import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { Bed } from '../entities/bed.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { RoomInspection } from '../entities/room-inspection.entity'; import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; interface InspectorIdentity { id?: number; username?: string; } @Injectable() export class RoomInspectionsService implements OnApplicationBootstrap { private readonly logger = new Logger(RoomInspectionsService.name); private settling = false; constructor( @InjectRepository(RoomInspection) private readonly inspectionRepo: Repository, @InjectRepository(RoomInspectionDetail) private readonly detailRepo: Repository, @InjectRepository(Room) private readonly roomRepo: Repository, @InjectRepository(Occupancy) private readonly occupancyRepo: Repository, private readonly dataSource: DataSource, private readonly operationLogs: OperationLogsService, ) {} async onApplicationBootstrap(): Promise { await this.settlePreviousDay().catch((error) => { this.logger.error('补记昨日宿舍查寝失败', error instanceof Error ? error.stack : String(error)); }); } @Cron('5 0 * * *', { timeZone: 'Asia/Shanghai' }) async settlePreviousDay(now = new Date()): Promise { if (this.settling) return; this.settling = true; try { const today = this.getChinaDate(now); const targetDate = this.shiftDate(today, -1); await this.settleDate(targetDate); } finally { this.settling = false; } } async submit( roomId: number, inspectionDate: string, presentOccupancyIds: number[], inspector: InspectorIdentity, ) { this.assertToday(inspectionDate); const uniquePresentIds = [...new Set(presentOccupancyIds)]; return this.dataSource.transaction(async (manager) => { const room = await this.lockRoom(manager, roomId, false); const occupancies = await this.findOccupanciesForDate(manager, roomId, inspectionDate); const allowedIds = new Set(occupancies.map((occupancy) => occupancy.id)); const invalidIds = uniquePresentIds.filter((id) => !allowedIds.has(id)); if (invalidIds.length > 0) { throw new BadRequestException(`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`); } const inspectionRepo = manager.getRepository(RoomInspection); const detailRepo = manager.getRepository(RoomInspectionDetail); let inspection = await inspectionRepo.findOne({ where: { roomId, inspectionDate }, }); const isUpdate = !!inspection; if (!inspection) { inspection = inspectionRepo.create({ roomId, inspectionDate }); } inspection.inspectorId = inspector.id ?? null; inspection.inspectorName = inspector.username || '未知用户'; inspection.source = 'manual'; inspection.submittedAt = new Date(); inspection = await inspectionRepo.save(inspection); await detailRepo.delete({ inspectionId: inspection.id }); const presentSet = new Set(uniquePresentIds); const details = occupancies.map((occupancy) => detailRepo.create({ inspectionId: inspection.id, occupancyId: occupancy.id, studentId: occupancy.studentId, bedId: occupancy.bedId ?? null, status: presentSet.has(occupancy.id) ? 'present' : 'absent', studentNameSnapshot: occupancy.student?.name || '未知学生', bedNumberSnapshot: occupancy.bed?.bedNumber || null, }), ); if (details.length > 0) await detailRepo.save(details); return { inspection: { ...inspection, details }, roomNumber: room.roomNumber, isUpdate, presentNames: details .filter((detail) => detail.status === 'present') .map((detail) => detail.studentNameSnapshot), absentNames: details .filter((detail) => detail.status === 'absent') .map((detail) => detail.studentNameSnapshot), }; }); } async getByRoomsAndDate(roomIds: number[], inspectionDate: string) { if (roomIds.length === 0) return new Map(); const inspections = await this.inspectionRepo .createQueryBuilder('inspection') .leftJoinAndSelect('inspection.details', 'detail') .where('inspection.roomId IN (:...roomIds)', { roomIds }) .andWhere('inspection.inspectionDate = :inspectionDate', { inspectionDate }) .getMany(); return new Map(inspections.map((inspection) => [inspection.roomId, inspection])); } async settleDate(inspectionDate: string): Promise { const existing = await this.inspectionRepo.find({ where: { inspectionDate } }); const existingRoomIds = new Set(existing.map((inspection) => inspection.roomId)); const occupancies = await this.findAllOccupanciesForDate(this.dataSource.manager, inspectionDate); const byRoom = new Map(); for (const occupancy of occupancies) { if (existingRoomIds.has(occupancy.roomId)) continue; const roomOccupancies = byRoom.get(occupancy.roomId) ?? []; roomOccupancies.push(occupancy); byRoom.set(occupancy.roomId, roomOccupancies); } if (byRoom.size === 0) return 0; const lastManualInspection = await this.inspectionRepo.findOne({ where: { inspectionDate, source: 'manual' }, order: { submittedAt: 'DESC' }, }); const inspectorId = lastManualInspection?.inspectorId ?? null; const inspectorName = lastManualInspection?.inspectorName || '系统自动判定'; let created = 0; for (const [roomId, roomOccupancies] of byRoom) { const result = await this.dataSource.transaction(async (manager) => { await this.lockRoom(manager, roomId, true); const inspectionRepo = manager.getRepository(RoomInspection); const detailRepo = manager.getRepository(RoomInspectionDetail); const duplicate = await inspectionRepo.findOne({ where: { roomId, inspectionDate } }); if (duplicate) return null; const inspection = await inspectionRepo.save( inspectionRepo.create({ inspectionDate, roomId, inspectorId, inspectorName, source: 'automatic', submittedAt: new Date(), }), ); const details = roomOccupancies.map((occupancy) => detailRepo.create({ inspectionId: inspection.id, occupancyId: occupancy.id, studentId: occupancy.studentId, bedId: occupancy.bedId ?? null, status: 'absent', studentNameSnapshot: occupancy.student?.name || '未知学生', bedNumberSnapshot: occupancy.bed?.bedNumber || null, }), ); await detailRepo.save(details); return { inspection, details }; }); if (!result) continue; created++; const room = await this.roomRepo.findOne({ where: { id: roomId } }); await this.operationLogs.log({ userId: inspectorId ?? undefined, username: inspectorName, module: '宿舍查寝', action: '自动补记缺勤', targetId: roomId, targetType: 'room', detail: `查寝日期: ${inspectionDate}, 宿舍: ${room?.roomNumber || roomId}, 缺勤: ${result.details.map((detail) => detail.studentNameSnapshot).join('、') || '无'}`, }); } return created; } private async lockRoom( manager: EntityManager, roomId: number, allowArchived: boolean, ): Promise { let query = manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId }); if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) { query = query.setLock('pessimistic_write'); } const room = await query.getOne(); if (!room) throw new BadRequestException('宿舍不存在'); if (!allowArchived && room.status === 'archived') { throw new BadRequestException('已归档宿舍不能查寝'); } return room; } private findOccupanciesForDate(manager: EntityManager, roomId: number, date: string) { return manager.getRepository(Occupancy).find({ where: [ { roomId, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() }, { roomId, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) }, ], relations: ['student', 'bed'], order: { id: 'ASC' }, }); } private findAllOccupanciesForDate(manager: EntityManager, date: string) { return manager.getRepository(Occupancy).find({ where: [ { checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() }, { checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) }, ], relations: ['student', 'bed'], order: { roomId: 'ASC', id: 'ASC' }, }); } private assertToday(date: string): void { if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('查寝日期格式错误'); const today = this.getChinaDate(new Date()); if (date < today) throw new BadRequestException('历史日期的查寝记录不可更改'); if (date > today) throw new BadRequestException('不能提前提交未来日期的查寝记录'); } private getChinaDate(now: Date): string { return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', }).format(now); } private shiftDate(date: string, days: number): string { const shifted = new Date(`${date}T12:00:00Z`); shifted.setUTCDate(shifted.getUTCDate() + days); return shifted.toISOString().slice(0, 10); } }