303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
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<ClassSchedule>,
|
|
@InjectRepository(AttendanceSession)
|
|
private readonly sessionRepo: Repository<AttendanceSession>,
|
|
private readonly attendanceService: AttendanceService,
|
|
private readonly importService: AttendanceImportService,
|
|
private readonly leaveSyncService: AttendanceLeaveSyncService,
|
|
) {}
|
|
|
|
@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,
|
|
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<void> {
|
|
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<string, unknown>).code;
|
|
const errno = (error as Record<string, unknown>).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);
|
|
}
|
|
}
|