import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw, StudentDingMapping, AttendanceSession, } from '../entities'; import type { MatchDingRecordDto, UpdateAttendanceRecordDto } from './dto/attendance.dto'; import { SessionMutex } from './attendance-mutex'; @Injectable() export class AttendanceRecordMutationService { private readonly sessionMutex = new SessionMutex(); constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, private dataSource: DataSource, ) {} async findAttendanceRecord(id: number) { const record = await this.attendanceRepo.findOne({ where: { id } }); if (!record) { throw new NotFoundException(`AttendanceRecord ${id} not found`); } return record; } // ── Match a dingtalk record to a student ── async matchDingRecord(id: number, dto: MatchDingRecordDto) { const record = await this.dingRawRepo.findOne({ where: { id } }); if (!record) { throw new NotFoundException(`DingAttendanceRaw ${id} not found`); } record.matchedStudentId = dto.studentId; record.matchStatus = 'matched'; return this.dingRawRepo.save(record); } // ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ── async autoMatchDingRecords(): Promise<{ matched: number; total: number }> { const unmatched = await this.dingRawRepo.find({ where: { matchStatus: 'unmatched' }, }); if (unmatched.length === 0) return { matched: 0, total: 0 }; const mappings = await this.studentDingMappingRepo.find(); const dingToStudentId = new Map(); for (const m of mappings) { dingToStudentId.set(m.dingUserId, m.studentId); } let matched = 0; for (const record of unmatched) { const studentId = dingToStudentId.get(record.dingUserId); if (studentId == null) continue; record.matchedStudentId = studentId; record.matchStatus = 'matched'; await this.dingRawRepo.save(record); matched++; } return { matched, total: unmatched.length }; } // ── Update a single attendance record ── async update(id: number, dto: UpdateAttendanceRecordDto) { const record = await this.findAttendanceRecord(id); // Records without a lesson session keep original behaviour if (record.attendanceSessionId == null) { if (dto.status !== undefined) { record.status = dto.status; record.source = 'manual'; record.punchTime = null; record.punchSource = null; record.punchDeviceName = null; record.punchDeviceId = null; } if (dto.remark !== undefined) { record.remark = dto.remark; record.source = 'manual'; } return this.attendanceRepo.save(record); } return this.sessionMutex.runExclusive(record.attendanceSessionId, () => this.dataSource.transaction(async (manager) => { const recordRepo = manager.getRepository(AttendanceRecord); const sessionRepo = manager.getRepository(AttendanceSession); // Re-check session status inside the transaction while holding the lock const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } }); if (!session || session.status === 'completed') { throw new BadRequestException('已完成考勤的记录不允许修改或删除'); } const freshRecord = await recordRepo.findOne({ where: { id } }); if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`); if (dto.status !== undefined) { freshRecord.status = dto.status; freshRecord.source = 'manual'; freshRecord.punchTime = null; freshRecord.punchSource = null; freshRecord.punchDeviceName = null; freshRecord.punchDeviceId = null; } if (dto.remark !== undefined) { freshRecord.remark = dto.remark; freshRecord.source = 'manual'; } return recordRepo.save(freshRecord); }), ); } // ── Delete a single attendance record ── async remove(id: number) { const record = await this.findAttendanceRecord(id); // Records without a lesson session keep original behaviour if (record.attendanceSessionId == null) { await this.attendanceRepo.remove(record); return { deleted: true }; } return this.sessionMutex.runExclusive(record.attendanceSessionId, () => this.dataSource.transaction(async (manager) => { const recordRepo = manager.getRepository(AttendanceRecord); const sessionRepo = manager.getRepository(AttendanceSession); // Re-check session status inside the transaction while holding the lock const session = await sessionRepo.findOne({ where: { id: record.attendanceSessionId! } }); if (!session || session.status === 'completed') { throw new BadRequestException('已完成考勤的记录不允许修改或删除'); } const freshRecord = await recordRepo.findOne({ where: { id } }); if (!freshRecord) throw new NotFoundException(`AttendanceRecord ${id} not found`); await recordRepo.remove(freshRecord); return { deleted: true }; }), ); } }