import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual, DataSource } from 'typeorm'; import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, TeacherRoleType, ScheduleType, StudentDingMapping, } from '../entities'; import { BatchCreateAttendanceDto, AttendanceSummaryQueryDto, AttendanceCalendarQueryDto, QueryDingRawDto, MatchDingRecordDto, AttendanceReportQueryDto, UpdateAttendanceRecordDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, SaveAttendancePeriodConfigsDto, } from './dto/attendance.dto'; /** Keyed mutex serializing operations on the same attendance session. */ class SessionMutex { private queueTails = new Map>(); async runExclusive(sessionId: number, fn: () => Promise): Promise { const tail = this.queueTails.get(sessionId) ?? Promise.resolve(); let release!: () => void; const newTail = new Promise((resolve) => { release = resolve; }); this.queueTails.set(sessionId, newTail); await tail; try { return await fn(); } finally { release(); if (this.queueTails.get(sessionId) === newTail) { this.queueTails.delete(sessionId); } } } } @Injectable() export class AttendanceService { constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: 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, @InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository, private dataSource: DataSource, ) {} private sessionMutex = new SessionMutex(); private readonly defaultAttendancePeriods = [ { periodKey: 'morning_reading', label: '早自习', startTime: '07:30', endTime: '08:30', sortOrder: 1 }, { periodKey: 'morning', label: '早课', startTime: '09:00', endTime: '12:00', sortOrder: 2 }, { periodKey: 'afternoon', label: '晚课', startTime: '14:00', endTime: '17:00', sortOrder: 3 }, { periodKey: 'evening_study', label: '晚自习', startTime: '18:30', endTime: '21:00', sortOrder: 4 }, ] as const; private formatDeviceDetail(device: AttendanceDevice): string { const classroomName = device.classroom?.name; return classroomName ? `${device.deviceName} · ${classroomName}` : device.deviceName; } private async attachAttendanceDeviceMappings( records: T[], classroomId?: number | null, ): Promise { if (records.length === 0) return records; const sns = [...new Set(records.map((record) => record.punchDeviceId?.trim()).filter(Boolean) as string[])]; const devicesBySn = new Map(); if (sns.length > 0) { const devices = await this.attendanceDeviceRepo.find({ where: { deviceSn: In(sns) }, relations: ['classroom'], }); for (const device of devices) devicesBySn.set(device.deviceSn, device); } const classroomIds = [...new Set([ ...records.map((record) => record.classId).filter((id): id is number => id != null), ...(classroomId != null ? [classroomId] : []), ])]; const devicesByClassroom = new Map(); if (classroomIds.length > 0) { const devices = await this.attendanceDeviceRepo.find({ where: { classroomId: In(classroomIds), status: 'active' }, relations: ['classroom'], order: { id: 'ASC' }, }); for (const device of devices) { if (!devicesByClassroom.has(device.classroomId)) devicesByClassroom.set(device.classroomId, device); } } for (const record of records) { const sn = record.punchDeviceId?.trim(); const mappedBySn = sn ? devicesBySn.get(sn) : undefined; if (mappedBySn) { record.punchDeviceName = this.formatDeviceDetail(mappedBySn); record.punchDeviceId = mappedBySn.deviceSn; continue; } const source = (record.punchSource || '').trim().toUpperCase(); const isMachine = ['ATM', 'ATTENDANCE_MACHINE', 'MACHINE', 'DEVICE'].some( (value) => source === value || source.includes(value), ); const fallbackClassroomId = record.classId ?? classroomId ?? undefined; const mappedByClassroom = fallbackClassroomId ? devicesByClassroom.get(fallbackClassroomId) : undefined; if (isMachine && mappedByClassroom && !record.punchDeviceName) { record.punchDeviceName = this.formatDeviceDetail(mappedByClassroom); record.punchDeviceId = record.punchDeviceId || mappedByClassroom.deviceSn; } } return records; } async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise { if (canManageAll) return; const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } }); if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤'); } private isClassStudentActiveOnDate(classStudent: Pick, lessonDate: string): boolean { const status = classStudent.status ?? 'active'; if (!['active', 'left'].includes(status)) return false; if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false; if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false; return true; } private 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) => this.isClassStudentActiveOnDate(classStudent, lessonDate), ); } /** List classes the current user may select for DingTalk attendance import. */ async getImportableClasses(userId: number, isSuperAdmin = false) { if (isSuperAdmin) { const classes = await this.classRepo.find({ where: { isArchived: false }, order: { name: 'ASC' }, }); return classes.map((item) => ({ classId: item.id, className: item.name })); } const assignments = await this.classTeacherRepo.find({ where: { userId }, relations: ['class'], }); const classes = new Map(); for (const assignment of assignments) { if (assignment.class && !assignment.class.isArchived) { classes.set(assignment.classId, assignment.class.name); } } return [...classes.entries()] .map(([classId, className]) => ({ classId, className })) .sort((left, right) => left.className.localeCompare(right.className, 'zh-CN')); } /** Resolve the DingTalk users a teacher may import for one assigned class. */ async getTeacherClassDingUserIds( userId: number, classId: number, isSuperAdmin = false, lessonDate?: string, ): Promise { if (!isSuperAdmin) { const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId }, }); if (!assignment) { throw new BadRequestException('只能拉取自己任教班级的考勤记录'); } } else { const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) throw new NotFoundException(`Class ${classId} not found`); } const classStudents = lessonDate ? await this.getClassStudentsForLesson(classId, lessonDate) : await this.classStudentRepo.find({ where: { classId, status: 'active' }, }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) { throw new BadRequestException('该班级暂无在读学生'); } const mappings = await this.studentDingMappingRepo.find({ where: { studentId: In(studentIds) }, }); const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))]; if (userIds.length === 0) { throw new BadRequestException('该班级学生尚未同步钉钉账号'); } return userIds.sort(); } private async getScheduleOccurrence(scheduleId: number, lessonDate: string) { const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } }); if (!schedule) throw new NotFoundException('排课记录不存在'); if (schedule.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 this.attachAttendanceDeviceMappings(records, schedule.classId) }; } private getLessonAttendanceWindow( schedule: Pick, lessonDate: string, ): { start: number; end: number; dateFrom: string; dateTo: string } { const startMinuteOfDay = this.toMinutes(schedule.startTime); const endMinuteOfDay = this.toMinutes(schedule.endTime); const advanceMinutes = Math.max(0, schedule.attendanceAdvanceMinutes ?? 30); const lessonStart = new Date(`${lessonDate}T${schedule.startTime}:00+08:00`).getTime(); let lessonEnd = new Date(`${lessonDate}T${schedule.endTime}:00+08:00`).getTime(); const overnight = endMinuteOfDay <= startMinuteOfDay; if (overnight) lessonEnd += 24 * 60 * 60 * 1000; return { start: lessonStart - advanceMinutes * 60 * 1000, end: lessonEnd, dateFrom: advanceMinutes > startMinuteOfDay ? this.shiftDate(lessonDate, -1) : lessonDate, dateTo: overnight ? this.shiftDate(lessonDate, 1) : lessonDate, }; } getLessonAttendanceImportDateRange( schedule: Pick, lessonDate: string, ): { startDate: string; endDate: string } { const window = this.getLessonAttendanceWindow(schedule, lessonDate); return { startDate: window.dateFrom, endDate: window.dateTo }; } private selectDingTalkRecordsForLesson( records: DingAttendanceRaw[], schedule: Pick, lessonDate: string, ): DingAttendanceRaw[] { const window = this.getLessonAttendanceWindow(schedule, lessonDate); return records.filter((record) => { // 上班、下班打卡都有效,按原始记录中实际存在的时间判断。 const time = record.checkInTime ?? record.checkOutTime; return time && time.getTime() >= window.start && time.getTime() <= window.end; }); } private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string { const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime); if (hasPunch) return 'present'; return finalize ? 'absent' : 'pending'; } private getLessonPunchMetadata( records: DingAttendanceRaw[], lessonDate: string, startTime: string, ): Pick { const punches = records .map((record) => ({ record, time: record.checkInTime ?? record.checkOutTime })) .filter((item): item is { record: DingAttendanceRaw; time: Date } => !!item.time); if (punches.length === 0) { return { punchTime: null, punchSource: null, punchDeviceName: null, punchDeviceId: null, }; } const lessonStart = new Date(`${lessonDate}T${startTime}:00+08:00`).getTime(); punches.sort( (left, right) => Math.abs(left.time.getTime() - lessonStart) - Math.abs(right.time.getTime() - lessonStart), ); const primary = punches[0]; const metadataRecord = [...punches] .filter(({ record }) => !!(record.punchSource || record.punchDeviceName || record.punchDeviceId) || !['OnDuty', 'OffDuty'].includes(record.attendanceType), ) .sort( (left, right) => Math.abs(left.time.getTime() - primary.time.getTime()) - Math.abs(right.time.getTime() - primary.time.getTime()), )[0]?.record; const source = metadataRecord?.punchSource || (metadataRecord && !['OnDuty', 'OffDuty'].includes(metadataRecord.attendanceType) ? metadataRecord.attendanceType : primary.record.punchSource); return { punchTime: primary.time, punchSource: source || null, punchDeviceName: metadataRecord?.punchDeviceName || primary.record.punchDeviceName || null, punchDeviceId: metadataRecord?.punchDeviceId || primary.record.punchDeviceId || null, }; } async createLessonAttendanceFromDingTalk( scheduleId: number, lessonDate: string, userId: number, finalize = false, ) { const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate); const now = new Date(); const courseClock = this.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 === 'completed') { const records = await this.attendanceRepo.find({ where: { attendanceSessionId: existing.id }, relations: ['student'], order: { studentId: 'ASC' }, }); return { schedule, session: existing, records: await this.attachAttendanceDeviceMappings(records, schedule.classId) }; } if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) { throw new BadRequestException('课程考勤正在结算'); } // Refresh in_progress session from latest DingTalk data 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 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 = this.mapLessonScheduleTimeToSession(schedule.startTime); const updatedRecords = existingRecords.map((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 = this.selectDingTalkRecordsForLesson( rawByStudent.get(record.studentId) ?? [], schedule, lessonDate, ); record.status = this.mapDingTalkStatus(raw, finalize); Object.assign(record, this.getLessonPunchMetadata( raw, lessonDate, schedule.startTime, )); record.remark = raw.some((item) => item.checkInTime || item.checkOutTime) ? null : finalize ? '课程截止仍未打卡' : '未获取到钉钉打卡结果'; return record; }); for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; const raw = this.selectDingTalkRecordsForLesson( rawByStudent.get(classStudent.studentId) ?? [], schedule, lessonDate, ); updatedRecords.push( recordRepo.create({ studentId: classStudent.studentId, student: classStudent.student, classId: schedule.classId!, scheduleId, attendanceSessionId: existing.id, attendanceDate: lessonDate, session: lessonSessionKey, status: this.mapDingTalkStatus(raw, finalize), source: 'dingtalk', ...this.getLessonPunchMetadata( raw, lessonDate, schedule.startTime, ), remark: raw.some((item) => item.checkInTime || item.checkOutTime) ? undefined : finalize ? '课程截止仍未打卡' : '未获取到钉钉打卡结果', }), ); } 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 this.attachAttendanceDeviceMappings(saved, 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; SQLite: SQLITE_CONSTRAINT if (code === 'ER_DUP_ENTRY' || errno === 1062 || code === 'SQLITE_CONSTRAINT') { 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 this.attachAttendanceDeviceMappings(existingRecords, schedule.classId) }; } } throw err; } const lessonSessionKey = this.mapLessonScheduleTimeToSession(schedule.startTime); const records = classStudents.map((classStudent) => { const raw = this.selectDingTalkRecordsForLesson( rawByStudent.get(classStudent.studentId) ?? [], schedule, lessonDate, ); return recordRepo.create({ studentId: classStudent.studentId, student: classStudent.student, classId: schedule.classId!, scheduleId, attendanceSessionId: session.id, attendanceDate: lessonDate, session: lessonSessionKey, status: this.mapDingTalkStatus(raw, finalize), source: 'dingtalk', ...this.getLessonPunchMetadata( raw, lessonDate, schedule.startTime, ), remark: raw.some((item) => item.checkInTime || item.checkOutTime) ? undefined : 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 this.attachAttendanceDeviceMappings(saved, 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 = this.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 this.attachAttendanceDeviceMappings(records, 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 this.attachAttendanceDeviceMappings(records, 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 ── async batchCreate(dto: BatchCreateAttendanceDto) { if (!dto.records || dto.records.length === 0) { throw new BadRequestException('records array must not be empty'); } const entities = dto.records.map((r) => { const entity = this.attendanceRepo.create({ studentId: r.studentId, classId: r.classId ?? undefined, attendanceDate: r.attendanceDate, session: r.session, status: r.status, remark: r.remark, source: r.source || 'manual', }); return entity; }); const saved = await this.attendanceRepo.save(entities); return { count: saved.length, records: saved }; } // ── Generate attendance records from class schedules ── async generateAttendanceFromSchedules(dto: GenerateAttendanceFromSchedulesDto) { const { classId, dateFrom, dateTo } = dto; if (dateFrom > dateTo) { throw new BadRequestException('dateFrom must not be later than dateTo'); } const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) { throw new NotFoundException(`Class ${classId} not found`); } const schedules = await this.scheduleRepo.find({ where: { classId, scheduleType: ScheduleType.INTERNAL, status: 'active', startDate: LessThanOrEqual(dateTo), endDate: MoreThanOrEqual(dateFrom), }, }); const classStudents = await this.classStudentRepo.find({ where: { classId, status: In(['active', 'left']) }, relations: ['student'], }); if (schedules.length === 0 || classStudents.length === 0) { return { count: 0, records: [] }; } const existingRecords = await this.attendanceRepo.find({ where: { classId, attendanceDate: Between(dateFrom, dateTo) }, }); const existingKeys = new Set( existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`), ); const entities: AttendanceRecord[] = []; const end = new Date(dateTo); for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) { const dateStr = d.toISOString().slice(0, 10); const weekDay = d.getDay() === 0 ? 7 : d.getDay(); for (const sched of schedules) { if (sched.weekDay !== weekDay) continue; if (dateStr < sched.startDate || dateStr > sched.endDate) continue; const session = await this.mapScheduleTimeToSession(sched.startTime); const classStudentsForDate = classStudents.filter((cs) => this.isClassStudentActiveOnDate(cs, dateStr), ); for (const cs of classStudentsForDate) { const key = `${cs.studentId}|${dateStr}|${session}`; if (existingKeys.has(key)) continue; const entity = this.attendanceRepo.create({ studentId: cs.studentId, classId, attendanceDate: dateStr, session, status: 'pending', source: 'schedule', }); entities.push(entity); existingKeys.add(key); } } } const saved = await this.attendanceRepo.save(entities); return { count: saved.length, records: saved }; } // ── Generate attendance records from schedules (optional date range, defaults to current week) ── async generateFromSchedules( dto: GenerateFromSchedulesDto, ): Promise<{ count: number; records: AttendanceRecord[] }> { const { classId, startDate, endDate } = dto; // Default to current week (Monday–Sunday) const now = new Date(); const dayOfWeek = now.getDay(); const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const monday = new Date(now); monday.setDate(now.getDate() + mondayOffset); monday.setHours(0, 0, 0, 0); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); sunday.setHours(23, 59, 59, 999); const dateFrom = startDate ?? monday.toISOString().slice(0, 10); const dateTo = endDate ?? sunday.toISOString().slice(0, 10); return this.generateAttendanceFromSchedules({ classId, dateFrom, dateTo, }); } private toMinutes(time: string): number { const [hour, minute] = time.split(':').map(Number); return hour * 60 + minute; } private getCourseClock(date: Date): { date: string; minutes: number } { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), ); return { date: `${parts.year}-${parts.month}-${parts.day}`, minutes: Number(parts.hour) * 60 + Number(parts.minute), }; } private shiftDate(date: string, days: number): string { const shifted = new Date(`${date}T00:00:00.000Z`); shifted.setUTCDate(shifted.getUTCDate() + days); return shifted.toISOString().slice(0, 10); } private async ensureAttendancePeriodConfigs() { const count = await this.attendancePeriodConfigRepo.count(); if (count === 0) { await this.attendancePeriodConfigRepo.save( this.defaultAttendancePeriods.map((period) => this.attendancePeriodConfigRepo.create({ ...period, enabled: true, })), ); } return this.attendancePeriodConfigRepo.find({ order: { sortOrder: 'ASC', id: 'ASC' } }); } async getAttendancePeriodConfigs() { return this.ensureAttendancePeriodConfigs(); } async getRefreshableSchedules(date: string, classId?: number, session?: string, accessibleClassIds?: number[]) { const parsedDate = new Date(`${date}T00:00:00`); if (Number.isNaN(parsedDate.getTime())) throw new BadRequestException('无效日期'); const weekDay = parsedDate.getDay() === 0 ? 7 : parsedDate.getDay(); const qb = this.scheduleRepo .createQueryBuilder('schedule') .where('schedule.scheduleType = :scheduleType', { scheduleType: ScheduleType.INTERNAL }) .andWhere('schedule.status = :status', { status: 'active' }) .andWhere('schedule.classId IS NOT NULL') .andWhere('schedule.weekDay = :weekDay', { weekDay }) .andWhere('schedule.startDate <= :date', { date }) .andWhere('schedule.endDate >= :date', { date }); if (classId) { qb.andWhere('schedule.classId = :classId', { classId }); } else if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('schedule.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } const schedules = await qb.orderBy('schedule.startTime', 'ASC').getMany(); if (!session) return schedules; const matchedSchedules: ClassSchedule[] = []; for (const schedule of schedules) { if ((await this.mapScheduleTimeToSession(schedule.startTime)) === session) { matchedSchedules.push(schedule); } } return matchedSchedules; } async saveAttendancePeriodConfigs(dto: SaveAttendancePeriodConfigsDto) { const seen = new Set(); const normalized = dto.periods.map((period, index) => { const periodKey = period.periodKey.trim(); const label = period.label.trim(); if (!periodKey || !label) throw new BadRequestException('时段标识和名称不能为空'); if (seen.has(periodKey)) throw new BadRequestException(`时段标识 ${periodKey} 重复`); seen.add(periodKey); if (this.toMinutes(period.endTime) <= this.toMinutes(period.startTime)) { throw new BadRequestException(`${label} 的结束时间必须晚于开始时间`); } return { periodKey, label, startTime: period.startTime, endTime: period.endTime, sortOrder: period.sortOrder ?? index + 1, enabled: period.enabled ?? true, }; }).sort((left, right) => left.sortOrder - right.sortOrder); // 按开始时间排序后再检查重叠,避免 sortOrder 与时间顺序不一致时漏检 const sortedByTime = [...normalized].sort( (left, right) => this.toMinutes(left.startTime) - this.toMinutes(right.startTime), ); for (let index = 1; index < sortedByTime.length; index += 1) { const previous = sortedByTime[index - 1]; const current = sortedByTime[index]; if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) { throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`); } } await this.attendancePeriodConfigRepo.clear(); await this.attendancePeriodConfigRepo.save( normalized.map((period) => this.attendancePeriodConfigRepo.create(period)), ); return this.getAttendancePeriodConfigs(); } async resetAttendancePeriodConfigs() { await this.attendancePeriodConfigRepo.clear(); return this.ensureAttendancePeriodConfigs(); } private mapLessonScheduleTimeToSession(startTime: string): string { const hour = parseInt(startTime.slice(0, 2), 10); if (hour < 8) return 'morning_reading'; if (hour < 12) return 'morning'; if (hour < 17) return 'afternoon'; if (hour < 20) return 'evening_study'; return 'night_check'; } private async mapScheduleTimeToSession(startTime: string): Promise { const startMinutes = this.toMinutes(startTime); const periods = (await this.ensureAttendancePeriodConfigs()).filter((period) => period.enabled); const matched = periods.find((period) => { const periodStart = this.toMinutes(period.startTime); const periodEnd = this.toMinutes(period.endTime); return startMinutes >= periodStart && startMinutes < periodEnd; }); if (matched) return matched.periodKey; throw new BadRequestException(`课程开始时间 ${startTime} 未匹配到考勤时段,请先配置考勤时段`); } // ── Attendance summary ── async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) { const qb = this.attendanceRepo.createQueryBuilder('ar'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } if (query.scheduleId) { qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); } if (!query.classId && accessibleClassIds) { if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 }; qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } if (query.session) { qb.andWhere('ar.session = :session', { session: query.session }); } const rows = await qb.getMany(); const total = rows.length; const present = rows.filter((r) => r.status === 'present').length; const late = rows.filter((r) => r.status === 'late').length; const absent = rows.filter((r) => r.status === 'absent').length; const leave = rows.filter((r) => r.status === 'leave').length; const pending = rows.filter((r) => r.status === 'pending').length; const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0; return { total, present, late, absent, leave, pending, presentRate }; } // ── Attendance calendar ── async getCalendar(query: AttendanceCalendarQueryDto) { const { classId, weekStart } = query; if (!weekStart) { // Default to the Monday of the current week const now = new Date(); const day = now.getDay(); const diff = day === 0 ? -6 : 1 - day; // Monday offset const monday = new Date(now); monday.setDate(now.getDate() + diff); const mondayStr = monday.toISOString().slice(0, 10); return this.buildCalendar(classId, mondayStr); } return this.buildCalendar(classId, weekStart); } private getWeekDayForDate(date: string): number { const day = new Date(`${date}T00:00:00+08:00`).getUTCDay(); return day === 0 ? 7 : day; } async getScheduleOptionsForAttendance(classId: number, date: string) { const weekDay = this.getWeekDayForDate(date); const { entities, raw } = await this.scheduleRepo .createQueryBuilder('cs') .leftJoin('cs.teacher', 'teacher') .addSelect('cs.id', 'scheduleIdForTeacherMap') .addSelect('teacher.username', 'teacherUsername') .addSelect('teacher.name', 'teacherName') .where('cs.classId = :classId', { classId }) .andWhere('cs.weekDay = :weekDay', { weekDay }) .andWhere('cs.startDate <= :date', { date }) .andWhere('cs.endDate >= :date', { date }) .andWhere('cs.status = :status', { status: 'active' }) .orderBy('cs.startTime', 'ASC') .addOrderBy('cs.subject', 'ASC') .getRawAndEntities(); const teacherByScheduleId = new Map( raw.map((row) => [ Number(row.scheduleIdForTeacherMap), { teacherName: row.teacherName || null, teacherUsername: row.teacherUsername || null, }, ]), ); return entities.map((schedule) => { const teacher = teacherByScheduleId.get(schedule.id) ?? { teacherName: null, teacherUsername: null, }; return { ...schedule, ...teacher }; }); } private async buildCalendar(classId: number, weekStart: string) { // Compute weekEnd (Sunday = weekStart + 6 days) const start = new Date(weekStart); const end = new Date(start); end.setDate(start.getDate() + 6); const endStr = end.toISOString().slice(0, 10); // Fetch attendance records for the week const records = await this.attendanceRepo.find({ where: { classId, attendanceDate: Between(weekStart, endStr), }, relations: ['student'], order: { attendanceDate: 'ASC', session: 'ASC' }, }); // Group by studentId const studentMap = new Map< number, { studentId: number; studentName: string; days: Array<{ date: string; session: string; status: string }>; } >(); for (const r of records) { if (!studentMap.has(r.studentId)) { studentMap.set(r.studentId, { studentId: r.studentId, studentName: r.student?.name ?? `Student#${r.studentId}`, days: [], }); } studentMap.get(r.studentId)!.days.push({ date: r.attendanceDate, session: r.session, status: r.status, }); } return Array.from(studentMap.values()); } // ── List attendance records with filters ── async findAll( query: { classId?: number; scheduleId?: number; dateFrom?: string; dateTo?: string; session?: string; status?: string; source?: string; page?: number; pageSize?: number; }, accessibleClassIds?: number[], ) { const page = query.page || 1; const pageSize = query.pageSize || 20; const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class'); if (query.scheduleId) { qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); } if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } else if (accessibleClassIds) { if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize }; qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } if (query.session) { qb.andWhere('ar.session = :session', { session: query.session }); } if (query.status) { qb.andWhere('ar.status = :status', { status: query.status }); } if (query.source) { qb.andWhere('ar.source = :source', { source: query.source }); } qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC'); qb.skip((page - 1) * pageSize).take(pageSize); const [list, total] = await qb.getManyAndCount(); return { list: await this.attachAttendanceDeviceMappings(list), total, page, pageSize }; } // ── Get distinct classes with attendance records ── async getClasses(accessibleClassIds?: number[]) { const qb = this.attendanceRepo .createQueryBuilder('ar') .select('DISTINCT ar.classId', 'classId') .where('ar.classId IS NOT NULL'); const rows = accessibleClassIds ? accessibleClassIds.map((classId) => ({ classId })) : await qb.orderBy('ar.classId', 'ASC').getRawMany(); const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))]; if (classIds.length === 0) return []; const where = { id: In(classIds) }; const [classes, teachers] = await Promise.all([ this.classRepo.find({ where }), this.classTeacherRepo.find({ where: { classId: In(classIds), roleType: In([ TeacherRoleType.HEAD_TEACHER, TeacherRoleType.LIFE_TEACHER, TeacherRoleType.SUBJECT_TEACHER, ]), }, relations: ['user'], order: { roleType: 'ASC', id: 'ASC' }, }), ]); const nameMap = new Map(classes.map((c) => [c.id, c.name])); const teacherMap = new Map< number, Array<{ userId: number; username: string | null; name: string | null; roleType: string; subject: string | null; }> >(); for (const teacher of teachers) { const user = teacher.user as { username?: string | null; name?: string | null } | undefined; const items = teacherMap.get(teacher.classId) ?? []; items.push({ userId: teacher.userId, username: user?.username || null, name: user?.name || null, roleType: teacher.roleType, subject: teacher.subject || null, }); teacherMap.set(teacher.classId, items); } return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}`, teachers: teacherMap.get(id) ?? [], })); } // ── DingAttendance raw records ── async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) { const page = query.page || 1; const pageSize = query.pageSize || 20; const qb = this.dingRawRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent'); if (query.matchStatus) { qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds; if (scopedClassIds) { if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize }; const classStudents = await this.classStudentRepo.find({ where: { classId: In(scopedClassIds), status: 'active' }, }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) return { list: [], total: 0, page, pageSize }; const mappings = await this.studentDingMappingRepo.find({ where: { studentId: In(studentIds) }, }); const dingUserIds = [ ...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)), ]; if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize }; qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds }); } qb.orderBy('ar.attendanceDate', 'DESC') .addOrderBy('ar.checkInTime', 'ASC') .skip((page - 1) * pageSize) .take(pageSize); const [list, total] = await qb.getManyAndCount(); return { list, total, page, pageSize }; } // ── 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 }; // Build dingUserId → studentId map from the mapping table 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 }; } // ── Export all attendance records with filters (no pagination) ── async findAllForExport( query: { classId?: number; scheduleId?: number; dateFrom?: string; dateTo?: string; session?: string; status?: string; source?: string; }, accessibleClassIds?: number[], ) { const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class'); if (query.scheduleId) { qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); } if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } else if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } if (query.session) { qb.andWhere('ar.session = :session', { session: query.session }); } if (query.status) { qb.andWhere('ar.status = :status', { status: query.status }); } if (query.source) { qb.andWhere('ar.source = :source', { source: query.source }); } qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC'); const records = await qb.getMany(); return this.attachAttendanceDeviceMappings(records); } async findAttendanceRecord(id: number) { const record = await this.attendanceRepo.findOne({ where: { id } }); if (!record) { throw new NotFoundException(`AttendanceRecord ${id} not found`); } return record; } // ── 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 }; }), ); } // ── Class-based attendance report ── async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) { const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoin('ar.class', 'class') .select('class.id', 'classId') .addSelect('class.name', 'className') .addSelect('ar.status', 'status') .addSelect('COUNT(*)', 'count'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } else if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status'); const rawRows = await qb.getRawMany(); // Aggregate by class const classMap = new Map< number, { classId: number; className: string; present: number; absent: number; late: number; leave: number; } >(); for (const row of rawRows) { if (!row.classId) continue; if (!classMap.has(row.classId)) { classMap.set(row.classId, { classId: row.classId, className: row.className || `班级#${row.classId}`, present: 0, absent: 0, late: 0, leave: 0, }); } const entry = classMap.get(row.classId)!; const count = parseInt(row.count, 10); if (row.status === 'present') entry.present += count; else if (row.status === 'absent') entry.absent += count; else if (row.status === 'late') entry.late += count; else if (row.status === 'leave') entry.leave += count; } return Array.from(classMap.values()).map((entry) => { const total = entry.present + entry.absent + entry.late + entry.leave; return { ...entry, total, presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0', absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0', lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0', leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0', }; }); } // ── Attendance alerts: detect consecutive absences/late ── async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const cutoffStr = cutoff.toISOString().slice(0, 10); const qb = this.attendanceRepo .createQueryBuilder('a') .leftJoinAndSelect('a.student', 'student') .leftJoinAndSelect('a.class', 'class'); qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }).andWhere( 'a.status IN (:...statuses)', { statuses: ['absent', 'late'] }, ); if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } const records = await qb .orderBy('a.studentId', 'ASC') .addOrderBy('a.attendanceDate', 'DESC') .getMany(); const alerts: Array<{ studentId: number; studentName: string; className: string; type: string; count: number; lastDate: string; }> = []; let current: (typeof alerts)[0] | null = null; for (const r of records) { const name = (r.student as any)?.name || ''; const className = (r.class as any)?.name || ''; const status = r.status === 'absent' ? '缺勤' : '迟到'; if (current && current.studentId === r.studentId && current.type === status) { current.count++; if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate; } else { if (current && current.count >= threshold) alerts.push({ ...current }); current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate, }; } } if (current && current.count >= threshold) alerts.push(current); return alerts; } }