Files
gongxue-base/apps/server/src/rooms/rooms.controller.ts
wangziqi 00e9c5e45a fix: 修复多处边界条件问题
- rooms: parseRoomNumber 未知格式返回默认 capacity=4,防止 undefined 绕过入住容量检查
- rooms: 修复 parseInt() || undefined 导致楼层 0 被吞掉
- rooms: batchImport 中 capacity 使用 ?? 代替 ||,显式 0 不被覆盖
- occupancies: 所有 capacity 比较加 ?? 0 防守兜底,fail closed
- schedules: assertValidScheduleRange 增加 startTime > endTime 校验
- attendance: 时段重叠检查改为按 startTime 排序后再比较,消除漏检
- attendance: 移除 getScheduleOptionsForAttendance 中不可靠的 raw[index] fallback
- expenses: 个人附加费批量导入增加 assertPositiveAmount 校验
- expenses: 水电费导入增加 periodEnd >= periodStart 校验
2026-07-20 14:45:53 +08:00

372 lines
12 KiB
TypeScript

import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { RoomsService } from './rooms.service';
import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto';
import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.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('rooms')
export class RoomsController {
constructor(
private service: RoomsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('room:view')
findAll(
@Query('building') building?: string,
@Query('includeArchived') includeArchived?: string,
) {
return this.service.findAll({ building, includeArchived: includeArchived === 'true' });
}
@Get('overview')
@RequirePermission('room:view')
getOverview(@Query('includeArchived') includeArchived?: string) {
return this.service.getRoomOverview({ includeArchived: includeArchived === 'true' });
}
@Get('visual')
@RequirePermission('room:view')
getVisual(@Query('asOf') asOf?: string) {
return this.service.getRoomVisual(asOf);
}
@Get('template')
@RequirePermission('room:view')
async downloadTemplate(@Res() res: Response) {
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('宿舍导入模板');
ws.columns = [
{ header: '房间号', key: 'roomNumber', width: 12 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '额定人数', key: 'capacity', width: 10 },
{ header: '宿舍类型', key: 'roomType', width: 12 },
{ header: '租赁类型(long/short)', key: 'rentalCategory', width: 18 },
{ header: '月租金', key: 'monthlyRate', width: 10 },
];
ws.addRow({
roomNumber: '4-102',
building: '4号楼',
floor: 1,
capacity: 4,
roomType: '四人间',
rentalCategory: 'long',
monthlyRate: 800,
});
ws.addRow({
roomNumber: '2-201',
building: '2号楼',
floor: 2,
capacity: 1,
roomType: '单人间',
rentalCategory: 'short',
monthlyRate: 0,
});
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=room_template.xlsx');
await workbook.xlsx.write(res);
res.end();
}
@Get('export')
@RequirePermission('room:view')
async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response) {
const rooms = await this.service.getRoomOverview({
includeArchived: includeArchived === 'true',
});
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('宿舍列表');
ws.columns = [
{ header: '房间号', key: 'roomNumber', width: 12 },
{ header: '楼栋', key: 'building', width: 12 },
{ header: '楼层', key: 'floor', width: 8 },
{ header: '宿舍类型', key: 'roomType', width: 12 },
{ header: '额定人数', key: 'capacity', width: 10 },
{ header: '当前入住', key: 'currentCount', width: 10 },
{ header: '状态', key: 'status', width: 10 },
{ header: '租赁类型', key: 'rentalCategory', width: 12 },
{ header: '月租金', key: 'monthlyRate', width: 10 },
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = {
available: '可入住',
full: '已满',
maintenance: '维修中',
archived: '已归档',
};
for (const r of rooms) {
ws.addRow({
roomNumber: r.roomNumber,
building: r.building || '',
floor: r.floor || '',
roomType: r.roomType || '',
capacity: r.capacity,
currentCount: r.currentCount,
status: statusMap[r.status] || r.status,
rentalCategory: r.rentalCategory === 'long' ? '长租' : '短租',
monthlyRate: r.monthlyRate ?? '',
});
}
res!.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res!.setHeader('Content-Disposition', 'attachment; filename=rooms.xlsx');
await workbook.xlsx.write(res!);
res!.end();
}
// ── 床位管理 ──
@Get(':roomId/beds')
@RequirePermission('room:view')
getBeds(@Param('roomId') roomId: string) {
return this.service.getRoomBeds(+roomId);
}
@Get(':roomId/beds/available')
@RequirePermission('room:view')
getAvailableBeds(@Param('roomId') roomId: string) {
return this.service.getRoomAvailableBeds(+roomId);
}
@Post(':roomId/beds')
@RequirePermission('room:edit')
createBed(@Param('roomId') roomId: string, @Body() dto: CreateBedDto) {
return this.service.createBed(+roomId, dto);
}
@Put(':roomId/beds/:id')
@RequirePermission('room:edit')
updateBed(@Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateBedDto) {
return this.service.updateBed(+roomId, +id, dto);
}
@Delete(':roomId/beds/:id')
@RequirePermission('room:edit')
deleteBed(@Param('roomId') roomId: string, @Param('id') id: string) {
return this.service.deleteBed(+roomId, +id);
}
@Post(':roomId/beds/batch')
@RequirePermission('room:edit')
batchCreateBeds(@Param('roomId') roomId: string, @Body() dto: BatchCreateBedDto) {
return this.service.batchCreateBeds(+roomId, dto);
}
// ── 柜子管理 ──
@Get(':roomId/lockers')
@RequirePermission('room:view')
getLockers(@Param('roomId') roomId: string) {
return this.service.getRoomLockers(+roomId);
}
@Get(':roomId/lockers/available')
@RequirePermission('room:view')
getAvailableLockers(@Param('roomId') roomId: string) {
return this.service.getRoomAvailableLockers(+roomId);
}
@Post(':roomId/lockers')
@RequirePermission('room:edit')
createLocker(@Param('roomId') roomId: string, @Body() dto: CreateLockerDto) {
return this.service.createLocker(+roomId, dto);
}
@Put(':roomId/lockers/:id')
@RequirePermission('room:edit')
updateLocker(
@Param('roomId') roomId: string,
@Param('id') id: string,
@Body() dto: UpdateLockerDto,
) {
return this.service.updateLocker(+roomId, +id, dto);
}
@Delete(':roomId/lockers/:id')
@RequirePermission('room:edit')
deleteLocker(@Param('roomId') roomId: string, @Param('id') id: string) {
return this.service.deleteLocker(+roomId, +id);
}
@Post(':roomId/lockers/batch')
@RequirePermission('room:edit')
batchCreateLockers(@Param('roomId') roomId: string, @Body() dto: BatchCreateLockerDto) {
return this.service.batchCreateLockers(+roomId, dto);
}
@Get(':id')
@RequirePermission('room:view')
findOne(@Param('id') id: string) {
return this.service.findOneWithOccupants(+id);
}
@Post()
@RequirePermission('room:create')
async create(@Body() dto: CreateRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '添加宿舍',
detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}`,
ipAddress,
userAgent,
});
return result;
}
@Put(':id')
@RequirePermission('room:edit')
async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '编辑宿舍',
targetId: +id,
targetType: 'room',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@Delete(':id')
@RequirePermission('room:delete')
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: 'room',
ipAddress,
userAgent,
});
return result;
}
@Post('batch-delete')
@RequirePermission('room:delete')
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;
}
@Put(':id/restore')
@RequirePermission('room:edit')
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '恢复宿舍',
targetId: +id,
targetType: 'room',
ipAddress,
userAgent,
});
return result;
}
@Post('import')
@RequirePermission('room:create')
@UseInterceptors(FileInterceptor('file'))
async importExcel(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
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: {
roomNumber: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
rentalCategory?: string;
monthlyRate?: number;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
const rentalCategoryRaw = String(row.getCell(6).value || '')
.trim()
.toLowerCase();
const rentalCategory =
rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short'
? rentalCategoryRaw
: undefined;
const monthlyRateRaw = Number(row.getCell(7).value);
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
rows.push({
roomNumber: String(row.getCell(1).value || ''),
building: String(row.getCell(2).value || '') || undefined,
floor: (n => Number.isNaN(n) ? undefined : n)(Number(row.getCell(3).value)),
capacity: Number(row.getCell(4).value) || 4,
roomType: String(row.getCell(5).value || '').trim() || undefined,
rentalCategory,
monthlyRate,
});
});
const result = await this.service.batchImport(rows);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '批量导入',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
}