213 lines
7.4 KiB
TypeScript
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);
|
|
}
|
|
}
|