import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository, In, Between } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw, DingLeaveRaw, Class, Student, ClassSchedule, ClassStudent, StudentDingMapping, ClassTeacher, AttendanceSession, AttendanceDevice, ScheduleType, } from '../entities'; import { SessionMutex } from './attendance-mutex'; import { attachAttendanceDeviceMappings } from './attendance-device'; import { getCourseClock, mapLessonScheduleTimeToSession, isClassStudentActiveOnDate } from './attendance-time'; import { getLessonAttendanceImportDateRange, getLessonAttendanceWindow, selectDingTalkRecordsForLesson, getLessonPunchMetadata, } from './attendance-dingtalk'; import { buildLessonRecord, resolveLessonStatus } from './attendance-lesson-status'; @Injectable() export class AttendanceLessonService { private readonly sessionMutex = new SessionMutex(); constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, @InjectRepository(DingLeaveRaw) private dingLeaveRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, @InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository, private dataSource: DataSource, ) {} async getClassStudentsForLesson( classId: number, lessonDate: string, relations: string[] = [], ): Promise { const classStudents = await this.classStudentRepo.find({ where: { classId, status: In(['active', 'left']) }, relations, }); return classStudents.filter((classStudent) => isClassStudentActiveOnDate(classStudent, lessonDate), ); } /** List classes the current user may select for DingTalk attendance import. */ private async getScheduleOccurrence(scheduleId: number, lessonDate: string) { const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('排课记录不存在'); if ((schedule.scheduleType as ScheduleType) !== ScheduleType.INTERNAL || schedule.status !== 'active') { throw new BadRequestException('该排课不能进行课程考勤'); } if (schedule.classId == null) throw new BadRequestException('该排课未关联班级'); if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) { throw new BadRequestException('所选日期不在排课有效期内'); } const date = new Date(`${lessonDate}T00:00:00`); const weekDay = date.getDay() === 0 ? 7 : date.getDay(); if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日'); return schedule; } async getLessonAttendance(scheduleId: number, lessonDate: string) { const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate); const session = await this.attendanceSessionRepo.findOne({ where: { scheduleId, lessonDate }, }); const records = session ? await this.attendanceRepo.find({ where: { attendanceSessionId: session.id }, relations: ['student'], order: { studentId: 'ASC' }, }) : []; return { schedule, session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, schedule.classId) }; } getLessonAttendanceImportDateRange( schedule: Pick, lessonDate: string, ): { startDate: string; endDate: string } { return getLessonAttendanceImportDateRange(schedule, lessonDate); } async createLessonAttendanceFromDingTalk( scheduleId: number, lessonDate: string, userId: number, finalize = false, ) { const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate); const now = new Date(); const courseClock = getCourseClock(now); const today = courseClock.date; if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤'); if (lessonDate === today) { const [hour, minute] = schedule.startTime.split(':').map(Number); const startMinute = hour * 60 + minute; const currentMinute = courseClock.minutes; if (currentMinute < startMinute) { throw new BadRequestException('课程尚未开始,不能拉取考勤'); } } const existing = await this.attendanceSessionRepo.findOne({ where: { scheduleId, lessonDate }, }); if (existing) { if ( existing.status !== 'in_progress' && existing.status !== 'completed' && !(finalize && existing.status === 'settling') ) { throw new BadRequestException('课程考勤正在结算'); } // Refresh latest DingTalk data even after automatic settlement; late-arriving punches // may legitimately change a DingTalk-generated absence to present. return this.dataSource.transaction(async (manager) => { const sessionRepo = manager.getRepository(AttendanceSession); const recordRepo = manager.getRepository(AttendanceRecord); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate); const effectiveFinalize = finalize || existing.status === 'completed'; const existingRecords = await recordRepo.find({ where: { attendanceSessionId: existing.id }, order: { studentId: 'ASC' }, }); const classStudents = await this.getClassStudentsForLesson( schedule.classId!, lessonDate, ['student'], ); const studentsById = new Map( classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]), ); const existingStudentIds = new Set(existingRecords.map((record) => record.studentId)); const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const updatedRecords = await Promise.all( existingRecords.map(async (record) => { record.student = studentsById.get(record.studentId)!; // Preserve manual corrections only while the lesson is still in progress. if (!finalize && record.source !== 'dingtalk') return record; const raw = selectDingTalkRecordsForLesson( rawByStudent.get(record.studentId) ?? [], schedule, lessonDate, ); const resolved = await resolveLessonStatus( this.dingLeaveRawRepo, record.studentId, raw, schedule, lessonDate, effectiveFinalize, ); record.status = resolved.status; Object.assign(record, getLessonPunchMetadata( raw, lessonDate, schedule.startTime, )); record.remark = resolved.remark ?? null; return record; }), ); for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; updatedRecords.push( await buildLessonRecord( recordRepo, this.dingLeaveRawRepo, rawByStudent, classStudent, schedule, lessonDate, lessonSessionKey, existing.id, scheduleId, effectiveFinalize, ), ); } const saved = await recordRepo.save(updatedRecords); if (finalize) { existing.status = 'completed'; existing.completedBy = userId; existing.completedAt = new Date(); await sessionRepo.save(existing); } return { schedule, session: existing, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) }; }); } // First pull: create session and records atomically return this.dataSource.transaction(async (manager) => { const sessionRepo = manager.getRepository(AttendanceSession); const recordRepo = manager.getRepository(AttendanceRecord); const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate); const classStudents = await this.getClassStudentsForLesson( schedule.classId!, lessonDate, ['student'], ); if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生'); let session: AttendanceSession; try { session = await sessionRepo.save( sessionRepo.create({ scheduleId, classId: schedule.classId!, lessonDate, status: 'in_progress', startedBy: userId, startedAt: new Date(), }), ); } catch (err: unknown) { const code = (err as Record).code; const errno = (err as Record).errno; // MySQL: ER_DUP_ENTRY or errno 1062 if (code === 'ER_DUP_ENTRY' || errno === 1062) { const existing = await sessionRepo.findOne({ where: { scheduleId, lessonDate }, }); if (existing) { session = existing; const existingRecords = await recordRepo.find({ where: { attendanceSessionId: session.id }, relations: ['student'], order: { studentId: 'ASC' }, }); return { schedule, session, records: await attachAttendanceDeviceMappings(existingRecords, this.attendanceDeviceRepo, schedule.classId) }; } } throw err; } const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const records = await Promise.all( classStudents.map((classStudent) => buildLessonRecord( recordRepo, this.dingLeaveRawRepo, rawByStudent, classStudent, schedule, lessonDate, lessonSessionKey, session.id, scheduleId, finalize, ), ), ); const saved = await recordRepo.save(records); if (finalize) { session.status = 'completed'; session.completedBy = userId; session.completedAt = new Date(); session = await sessionRepo.save(session); } return { schedule, session, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) }; }); } private async fetchDingTalkRawByStudent( classId: number, schedule: Pick, lessonDate: string, ): Promise> { const classStudents = await this.getClassStudentsForLesson(classId, lessonDate); if (classStudents.length === 0) return new Map(); const studentIds = classStudents.map((cs) => cs.studentId); const window = getLessonAttendanceWindow(schedule, lessonDate); const rawRecords = await this.dingRawRepo.find({ where: { attendanceDate: Between(window.dateFrom, window.dateTo), matchedStudentId: In(studentIds), }, }); const rawByStudent = new Map(); for (const raw of rawRecords) { if (raw.matchedStudentId == null) continue; const arr = rawByStudent.get(raw.matchedStudentId) ?? []; arr.push(raw); rawByStudent.set(raw.matchedStudentId, arr); } return rawByStudent; } async completeLessonAttendance(sessionId: number, userId: number) { return this.sessionMutex.runExclusive(sessionId, () => this.dataSource.transaction(async (manager) => { const sessionRepo = manager.getRepository(AttendanceSession); const recordRepo = manager.getRepository(AttendanceRecord); const session = await sessionRepo.findOne({ where: { id: sessionId } }); if (!session) throw new NotFoundException('课程考勤场次不存在'); // Re-check under lock: if already completed, return current state idempotently if (session.status === 'completed') { const records = await recordRepo.find({ where: { attendanceSessionId: sessionId }, relations: ['student'], order: { studentId: 'ASC' }, }); return { session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) }; } const pendingRecords = await recordRepo.count({ where: { attendanceSessionId: sessionId, status: 'pending' }, }); if (pendingRecords > 0) { throw new BadRequestException('存在未处理的考勤记录,无法完成考勤'); } session.status = 'completed'; session.completedBy = userId; session.completedAt = new Date(); const savedSession = await sessionRepo.save(session); const records = await recordRepo.find({ where: { attendanceSessionId: sessionId }, relations: ['student'], order: { studentId: 'ASC' }, }); return { session: savedSession, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) }; }), ); } async findAttendanceSession(id: number) { const session = await this.attendanceSessionRepo.findOne({ where: { id } }); if (!session) throw new NotFoundException('课程考勤场次不存在'); return session; } // ── Batch create attendance records ── }