355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import {
|
||
Controller,
|
||
Get,
|
||
Post,
|
||
Put,
|
||
Delete,
|
||
Param,
|
||
Body,
|
||
Query,
|
||
UseGuards,
|
||
Request,
|
||
Res,
|
||
UseInterceptors,
|
||
UploadedFile,
|
||
} from '@nestjs/common';
|
||
import { FileInterceptor } from '@nestjs/platform-express';
|
||
import type { Response } from 'express';
|
||
import { OccupanciesService } from './occupancies.service';
|
||
import { NotificationsService } from '../notifications/notifications.service';
|
||
import { NotificationType } from '../entities/notification.entity';
|
||
import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto } from './dto/occupancy.dto';
|
||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||
import { extractRequestInfo } from '../common/request-utils';
|
||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||
import * as ExcelJS from 'exceljs';
|
||
|
||
@UseGuards(JwtAuthGuard)
|
||
@Controller('occupancies')
|
||
export class OccupanciesController {
|
||
constructor(
|
||
private service: OccupanciesService,
|
||
private logService: OperationLogsService,
|
||
private readonly notificationsService: NotificationsService,
|
||
) {}
|
||
|
||
@Get()
|
||
@RequirePermission('occupancy:view')
|
||
findAll(
|
||
@Query('roomId') roomId?: string,
|
||
@Query('studentId') studentId?: string,
|
||
@Query('active') active?: string,
|
||
) {
|
||
return this.service.findAll({
|
||
roomId: roomId ? +roomId : undefined,
|
||
studentId: studentId ? +studentId : undefined,
|
||
active: active === 'true',
|
||
});
|
||
}
|
||
|
||
@Post('batch-check-out')
|
||
@RequirePermission('occupancy:checkout')
|
||
async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const result = await this.service.batchCheckOut(dto);
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '批量退宿',
|
||
detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`,
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
@Post('check-in')
|
||
@RequirePermission('occupancy:checkin')
|
||
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const result = await this.service.checkIn(dto);
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '办理入住',
|
||
targetId: result.id,
|
||
targetType: 'occupancy',
|
||
detail: `学生${dto.studentId} 入住房间${dto.roomId}`,
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
// TODO: Send notification for check_in — studentId→userId mapping unavailable
|
||
return result;
|
||
}
|
||
|
||
@Put(':id/check-out')
|
||
@RequirePermission('occupancy:checkout')
|
||
async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const result = await this.service.checkOut(+id, dto);
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '办理退宿',
|
||
targetId: +id,
|
||
targetType: 'occupancy',
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
// TODO: Send notification for check_out — studentId→userId mapping unavailable
|
||
return result;
|
||
}
|
||
|
||
@Put(':id/transfer')
|
||
@RequirePermission('occupancy:transfer')
|
||
async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const result = await this.service.transferRoom(+id, dto);
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '调换宿舍',
|
||
targetId: +id,
|
||
targetType: 'occupancy',
|
||
detail: `换到房间${dto.newRoomId}`,
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
@Delete(':id')
|
||
@RequirePermission('occupancy:view')
|
||
async remove(@Param('id') id: string, @Request() req: any) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const result = await this.service.remove(+id);
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '删除入住记录',
|
||
targetId: +id,
|
||
targetType: 'occupancy',
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
@Post('batch-delete')
|
||
@RequirePermission('occupancy:view')
|
||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const result = await this.service.batchRemove(body.ids || []);
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '批量删除入住记录',
|
||
detail: `IDs: ${(body.ids || []).join(',')}`,
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
return result;
|
||
}
|
||
|
||
@Get('export')
|
||
@RequirePermission('occupancy:view')
|
||
async exportExcel(@Query('active') active?: string, @Res() res?: Response) {
|
||
const records = await this.service.findAll({ active: active === 'true' });
|
||
const workbook = new ExcelJS.Workbook();
|
||
const ws = workbook.addWorksheet('入住记录');
|
||
ws.columns = [
|
||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||
{ header: '楼栋', key: 'building', width: 12 },
|
||
{ header: '学生姓名', key: 'studentName', width: 12 },
|
||
{ header: '性别', key: 'gender', width: 8 },
|
||
{ header: '电话', key: 'phone', width: 18 },
|
||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||
{ header: '所属机构', key: 'organization', width: 18 },
|
||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||
{ header: '入住日期', key: 'checkInDate', width: 14 },
|
||
{ header: '退宿日期', key: 'checkOutDate', width: 14 },
|
||
{ header: '计费起始', key: 'billingStartDate', width: 14 },
|
||
{ header: '计费截止', key: 'billingEndDate', width: 14 },
|
||
{ header: '退宿原因', key: 'checkOutReason', width: 12 },
|
||
];
|
||
ws.getRow(1).font = { bold: true };
|
||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||
for (const r of records) {
|
||
ws.addRow({
|
||
roomNumber: r.room?.roomNumber || '',
|
||
building: r.room?.building || '',
|
||
studentName: r.student?.name || '',
|
||
gender: r.student?.gender || '',
|
||
phone: r.student?.phone || '',
|
||
idNumber: r.student?.idNumber || '',
|
||
organization: r.student?.organization || '',
|
||
supervisor: r.student?.supervisor || '',
|
||
checkInDate: r.checkInDate || '',
|
||
checkOutDate: r.checkOutDate || '',
|
||
billingStartDate: r.billingStartDate || '',
|
||
billingEndDate: r.billingEndDate || '',
|
||
checkOutReason: r.checkOutReason || '',
|
||
});
|
||
}
|
||
res!.setHeader(
|
||
'Content-Type',
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
);
|
||
res!.setHeader('Content-Disposition', 'attachment; filename=occupancies.xlsx');
|
||
await workbook.xlsx.write(res!);
|
||
res!.end();
|
||
}
|
||
|
||
@Get('template')
|
||
@RequirePermission('occupancy:view')
|
||
async downloadTemplate(@Res() res: Response) {
|
||
const workbook = new ExcelJS.Workbook();
|
||
const ws = workbook.addWorksheet('入住名单导入模板');
|
||
ws.columns = [
|
||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||
{ header: '床位号', key: 'bedNumber', width: 8 },
|
||
{ header: '姓名', key: 'name', width: 12 },
|
||
{ header: '性别', key: 'gender', width: 8 },
|
||
{ header: '民族', key: 'ethnicity', width: 10 },
|
||
{ header: '电话', key: 'phone', width: 15 },
|
||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||
{ header: '入住时间', key: 'checkInDate', width: 14 },
|
||
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
|
||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||
{ header: '所属机构', key: 'organization', width: 18 },
|
||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||
];
|
||
ws.getRow(1).font = { bold: true };
|
||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||
// 添加说明行
|
||
ws.addRow({
|
||
roomNumber: '4-102',
|
||
bedNumber: 1,
|
||
name: '张三',
|
||
gender: '男',
|
||
ethnicity: '汉族',
|
||
phone: '13800138000',
|
||
idNumber: '2024001',
|
||
checkInDate: '2026-04-21',
|
||
checkOutDate: '',
|
||
emergencyContact: '张父',
|
||
emergencyPhone: '13900000000',
|
||
organization: '',
|
||
supervisor: '',
|
||
});
|
||
ws.addRow({
|
||
roomNumber: '4-102',
|
||
bedNumber: 2,
|
||
name: '李四',
|
||
gender: '男',
|
||
ethnicity: '汉族',
|
||
phone: '13800138001',
|
||
idNumber: '2024002',
|
||
checkInDate: '2026-04-21',
|
||
checkOutDate: '',
|
||
emergencyContact: '',
|
||
emergencyPhone: '',
|
||
organization: 'XXX教育科技',
|
||
supervisor: '王老师',
|
||
});
|
||
// 添加使用说明sheet
|
||
const helpWs = workbook.addWorksheet('使用说明');
|
||
helpWs.getColumn(1).width = 60;
|
||
helpWs.addRow(['【入住名单导入说明】']);
|
||
helpWs.addRow(['1. 导入入住名单会自动创建不存在的学生和宿舍,无需单独导入学生或宿舍']);
|
||
helpWs.addRow(['2. 宿舍号会智能解析楼栋、楼层和房间类型(如4-102自动识别为4号楼1层四人间)']);
|
||
helpWs.addRow(['3. 同一宿舍号的多个学生可合并宿舍号单元格,系统会自动继承上一行的宿舍号']);
|
||
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
|
||
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
|
||
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
|
||
helpWs.addRow(['7. 性别约束:同一宿舍只能住同性别学生,首位入住者确定宿舍性别']);
|
||
helpWs.addRow(['8. 床位号仅做标识参考,不影响入住逻辑']);
|
||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||
res.setHeader(
|
||
'Content-Type',
|
||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||
);
|
||
res.setHeader('Content-Disposition', 'attachment; filename=checkin_template.xlsx');
|
||
await workbook.xlsx.write(res);
|
||
res.end();
|
||
}
|
||
|
||
@Post('import')
|
||
@RequirePermission('occupancy:checkin')
|
||
@UseInterceptors(FileInterceptor('file'))
|
||
async importCheckIn(
|
||
@UploadedFile() file: Express.Multer.File,
|
||
@Request() req: any,
|
||
@Query('autoDeposit') autoDeposit?: string,
|
||
@Query('depositAmount') depositAmount?: string,
|
||
) {
|
||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||
const workbook = new ExcelJS.Workbook();
|
||
await workbook.xlsx.load(file.buffer as any);
|
||
const ws = workbook.worksheets[0];
|
||
const rows: any[] = [];
|
||
let lastRoomNumber = '';
|
||
|
||
ws.eachRow((row, idx) => {
|
||
if (idx === 1) return; // 跳过表头
|
||
|
||
// 宿舍号可能是合并单元格,需要继承上一行
|
||
const roomNumberVal = row.getCell(1).value;
|
||
const roomNumber = roomNumberVal ? String(roomNumberVal).trim() : '';
|
||
if (roomNumber) lastRoomNumber = roomNumber;
|
||
|
||
const name = String(row.getCell(3).value || '').trim();
|
||
if (!name) return; // 无姓名则跳过空行
|
||
|
||
// 解析日期
|
||
const parseDate = (cell: any): string => {
|
||
const val = cell.value;
|
||
if (!val) return '';
|
||
if (val instanceof Date) return val.toISOString().split('T')[0];
|
||
const s = String(val).trim();
|
||
// 处理 "YYYY/MM/DD" 或 "YYYY-MM-DD" 或 "YYYY.MM.DD"
|
||
const m = s.match(/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/);
|
||
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
|
||
return s;
|
||
};
|
||
|
||
rows.push({
|
||
name,
|
||
roomNumber: lastRoomNumber,
|
||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
||
phone: String(row.getCell(6).value || '').trim() || undefined,
|
||
idNumber: String(row.getCell(7).value || '').trim() || undefined,
|
||
checkInDate: parseDate(row.getCell(8)),
|
||
checkOutDate: parseDate(row.getCell(9)) || undefined,
|
||
emergencyContact: String(row.getCell(10).value || '').trim() || undefined,
|
||
emergencyPhone: String(row.getCell(11).value || '').trim() || undefined,
|
||
organization: String(row.getCell(12).value || '').trim() || undefined,
|
||
supervisor: String(row.getCell(13).value || '').trim() || undefined,
|
||
});
|
||
});
|
||
const result = await this.service.batchImportCheckIn(rows, {
|
||
autoDeposit: autoDeposit === 'true',
|
||
depositAmount: depositAmount ? +depositAmount : undefined,
|
||
});
|
||
await this.logService.log({
|
||
userId: req.user?.id,
|
||
username: req.user?.username,
|
||
module: '入住管理',
|
||
action: '批量导入入住',
|
||
detail: result.message,
|
||
ipAddress,
|
||
userAgent,
|
||
});
|
||
return result;
|
||
}
|
||
}
|