256 lines
10 KiB
TypeScript
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);
|
|
}
|
|
}
|