Files
gongxue-base/apps/server/src/rooms/room-inspections.service.ts
2026-07-22 10:55:48 +08:00

256 lines
10 KiB
TypeScript

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<RoomInspection>,
@InjectRepository(RoomInspectionDetail)
private readonly detailRepo: Repository<RoomInspectionDetail>,
@InjectRepository(Room)
private readonly roomRepo: Repository<Room>,
@InjectRepository(Occupancy)
private readonly occupancyRepo: Repository<Occupancy>,
private readonly dataSource: DataSource,
private readonly operationLogs: OperationLogsService,
) {}
async onApplicationBootstrap(): Promise<void> {
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<void> {
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<number, RoomInspection>();
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<number> {
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<number, Occupancy[]>();
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<Room> {
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);
}
}