import { BadRequestException, Injectable, Logger } from '@nestjs/common'; import dayjs from '../common/dayjs'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities'; import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceService } from './attendance.service'; @Injectable() export class AttendanceSettlementService { private readonly logger = new Logger(AttendanceSettlementService.name); private readonly courseTimeZone = 'Asia/Shanghai'; private running = false; constructor( @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, @InjectRepository(AttendanceSession) private readonly sessionRepo: Repository, private readonly attendanceService: AttendanceService, private readonly importService: AttendanceImportService, private readonly leaveSyncService: AttendanceLeaveSyncService, ) {} @Cron('* * * * *') async settleEndedLessons(now = new Date()): Promise { if (this.running) return; this.running = true; try { const clock = this.getCourseClock(now); const staleClaimBefore = new Date(now.getTime() - 35 * 60 * 1000); await this.sessionRepo.update( { status: 'settling', updatedAt: LessThan(staleClaimBefore) }, { status: 'in_progress' }, ); const today = clock.date; const yesterday = this.shiftDate(today, -1); const [schedules, sessions] = await Promise.all([ this.scheduleRepo.find({ where: { scheduleType: ScheduleType.INTERNAL, status: 'active', startDate: LessThanOrEqual(today), endDate: MoreThanOrEqual(yesterday), }, }), this.sessionRepo.find({ where: { status: In(['in_progress', 'settling']) }, relations: ['schedule'], }), ]); const sessionByKey = new Map( sessions.map((session) => [`${session.scheduleId}|${session.lessonDate}`, session]), ); const candidates = new Map(); for (const schedule of schedules) { const lessonDate = this.getEndedOccurrenceDate(schedule, clock, today, yesterday); if (lessonDate) { const key = `${schedule.id}|${lessonDate}`; candidates.set(key, { schedule, lessonDate, session: sessionByKey.get(key) }); } } for (const session of sessions) { if ( session.status === 'in_progress' && session.schedule && this.hasOccurrenceEnded(session.schedule, session.lessonDate, clock) ) { candidates.set(`${session.scheduleId}|${session.lessonDate}`, { schedule: session.schedule, lessonDate: session.lessonDate, session, }); } } for (const candidate of candidates.values()) { await this.settleCandidate(candidate); } } finally { this.running = false; } } private async settleCandidate(candidate: { schedule: ClassSchedule; lessonDate: string; session?: AttendanceSession; }): Promise { const { schedule, lessonDate } = candidate; if (schedule.classId == null || schedule.teacherId == null) { this.logger.error(`课程${schedule.id} ${lessonDate}缺少班级或教师,无法自动结算`); return; } let session = candidate.session; try { if (!session) { const created = await this.attendanceService.createLessonAttendanceFromDingTalk( schedule.id, lessonDate, schedule.teacherId, false, ); session = created.session; } const claimed = await this.sessionRepo.update( { id: session.id, status: 'in_progress' }, { status: 'settling' }, ); if (claimed.affected !== 1) return; const userIds = await this.attendanceService.getTeacherClassDingUserIds( schedule.teacherId, schedule.classId, false, lessonDate, ); const importRange = this.attendanceService.getLessonAttendanceImportDateRange( schedule, lessonDate, ); const imported = await this.importService.importFromDingTalk({ ...importRange, userIds, autoMatch: true, userId: schedule.teacherId, }); if (!imported.success || imported.errors.length > 0) { throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); } try { await this.leaveSyncService.syncLeaveStatusForLesson({ ...importRange, userIds, autoMatch: true, }); } catch (error: unknown) { // 请假数据是补充信息,同步失败不应阻断结算;无请假的学生按缺勤处理。 this.logger.warn( `课程${schedule.id} ${lessonDate}钉钉请假同步失败: ${error instanceof Error ? error.message : String(error)}`, ); } await this.attendanceService.createLessonAttendanceFromDingTalk( schedule.id, lessonDate, schedule.teacherId, true, ); } catch (error: unknown) { if (this.isNoActiveStudentsError(error)) { try { await this.finalizeEmptyLesson(schedule, lessonDate, session); this.logger.log(`课程${schedule.id} ${lessonDate}班级无在读学生,已直接完成结算`); } catch (finalizeError: unknown) { this.logger.error( `课程${schedule.id} ${lessonDate}空班级完成结算落库失败: ${finalizeError instanceof Error ? finalizeError.message : String(finalizeError)}`, ); if (session) { await this.sessionRepo.update( { id: session.id, status: 'settling' }, { status: 'in_progress' }, ); } } return; } if (session) { await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' }); } this.logger.error( `课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(error)}`, ); } } private isNoActiveStudentsError(error: unknown): boolean { return ( error instanceof BadRequestException && error.message.includes('该班级暂无在读学生') ); } private async finalizeEmptyLesson( schedule: ClassSchedule, lessonDate: string, session?: AttendanceSession, ): Promise { const completedAt = new Date(); const completedBy = schedule.teacherId; if (session) { await this.sessionRepo.update( { id: session.id, status: 'settling' }, { status: 'completed', completedBy, completedAt }, ); return; } const existing = await this.sessionRepo.findOne({ where: { scheduleId: schedule.id, lessonDate }, }); if (existing) { if (existing.status !== 'completed') { await this.sessionRepo.update( { id: existing.id }, { status: 'completed', completedBy, completedAt }, ); } return; } try { await this.sessionRepo.save( this.sessionRepo.create({ scheduleId: schedule.id, classId: schedule.classId!, lessonDate, status: 'completed', startedBy: completedBy, startedAt: completedAt, completedBy, completedAt, }), ); } catch (error: unknown) { const code = (error as Record).code; const errno = (error as Record).errno; if (code !== 'ER_DUP_ENTRY' && errno !== 1062) throw error; await this.sessionRepo.update( { scheduleId: schedule.id, lessonDate, status: 'in_progress' }, { status: 'completed', completedBy, completedAt }, ); } } private getEndedOccurrenceDate( schedule: ClassSchedule, clock: { weekDay: number; minutes: number }, today: string, yesterday: string, ): string | null { const endMinutes = this.toMinutes(schedule.endTime); const overnight = this.isOvernight(schedule); const yesterdayWeekDay = clock.weekDay === 1 ? 7 : clock.weekDay - 1; if ( !overnight && schedule.weekDay === clock.weekDay && clock.minutes >= endMinutes && today >= schedule.startDate && today <= schedule.endDate ) return today; if ( overnight && schedule.weekDay === yesterdayWeekDay && clock.minutes >= endMinutes && yesterday >= schedule.startDate && yesterday <= schedule.endDate ) return yesterday; return null; } private hasOccurrenceEnded( schedule: ClassSchedule, lessonDate: string, clock: { date: string; minutes: number }, ): boolean { const occurrenceEndDate = this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate; if (clock.date !== occurrenceEndDate) return clock.date > occurrenceEndDate; return clock.minutes >= this.toMinutes(schedule.endTime); } private isOvernight(schedule: ClassSchedule): boolean { return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime); } private toMinutes(time: string): number { const [hour, minute] = time.split(':').map(Number); return hour * 60 + minute; } private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } { // Asia/Shanghai 无夏令时,固定 UTC+8 与 Intl 时区格式化等价 const c = dayjs.utc(date).add(8, 'hour'); const weekDay = c.day() === 0 ? 7 : c.day(); return { date: c.format('YYYY-MM-DD'), weekDay, minutes: c.hour() * 60 + c.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); } }