feat: settle course attendance automatically
This commit is contained in:
193
apps/server/src/attendance/attendance-settlement.service.ts
Normal file
193
apps/server/src/attendance/attendance-settlement.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
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) {
|
||||
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 imported = await this.importService.importFromDingTalk({
|
||||
startDate: lessonDate,
|
||||
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
|
||||
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 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user