import { Injectable, Logger } from '@nestjs/common'; 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 { 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, ) {} @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, ); 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('; ') || '钉钉考勤拉取失败'); } await this.attendanceService.createLessonAttendanceFromDingTalk( schedule.id, lessonDate, schedule.teacherId, true, ); } catch (error: unknown) { 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 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 } { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone: this.courseTimeZone, year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23', }).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]), ); const weekDays: Record = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 }; return { date: `${parts.year}-${parts.month}-${parts.day}`, weekDay: weekDays[parts.weekday], 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); } }