forked from wangziqi/gongxue-base
- 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 校验
372 lines
12 KiB
TypeScript
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;
|
|
}
|
|
}
|