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

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);
}
}