Files
gongxue-base/apps/server/src/attendance/attendance-settlement.service.ts

213 lines
7.4 KiB
TypeScript

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<ClassSchedule>,
@InjectRepository(AttendanceSession)
private readonly sessionRepo: Repository<AttendanceSession>,
private readonly attendanceService: AttendanceService,
private readonly importService: AttendanceImportService,
) {}
@Cron('* * * * *')
async settleEndedLessons(now = new Date()): Promise<void> {
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<string, { schedule: ClassSchedule; lessonDate: string; session?: AttendanceSession }>();
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<void> {
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<string, number> = { 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);
}
}