Files
gongxue-base/apps/server/src/attendance/attendance-lesson.service.ts
wangziqi b882411f42
Some checks failed
CI / check (pull_request) Failing after 5m24s
refactor: 拆分考勤服务文件并通过 aislop 全项目扫描
2026-08-05 18:55:36 +08:00

360 lines
14 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository, In, Between } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
DingLeaveRaw,
Class,
Student,
ClassSchedule,
ClassStudent,
StudentDingMapping,
ClassTeacher,
AttendanceSession,
AttendanceDevice,
ScheduleType,
} from '../entities';
import { SessionMutex } from './attendance-mutex';
import { attachAttendanceDeviceMappings } from './attendance-device';
import { getCourseClock, mapLessonScheduleTimeToSession, isClassStudentActiveOnDate } from './attendance-time';
import {
getLessonAttendanceImportDateRange,
getLessonAttendanceWindow,
selectDingTalkRecordsForLesson,
getLessonPunchMetadata,
} from './attendance-dingtalk';
import { buildLessonRecord, resolveLessonStatus } from './attendance-lesson-status';
@Injectable()
export class AttendanceLessonService {
private readonly sessionMutex = new SessionMutex();
constructor(
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(DingLeaveRaw) private dingLeaveRawRepo: Repository<DingLeaveRaw>,
@InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository<StudentDingMapping>,
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository<AttendanceSession>,
@InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository<AttendanceDevice>,
private dataSource: DataSource,
) {}
async getClassStudentsForLesson(
classId: number,
lessonDate: string,
relations: string[] = [],
): Promise<ClassStudent[]> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: In(['active', 'left']) },
relations,
});
return classStudents.filter((classStudent) =>
isClassStudentActiveOnDate(classStudent, lessonDate),
);
}
/** List classes the current user may select for DingTalk attendance import. */
private async getScheduleOccurrence(scheduleId: number, lessonDate: string) {
const schedule = await this.scheduleRepo.findOne({ where: { id: scheduleId } });
if (!schedule) throw new NotFoundException('排课记录不存在');
if ((schedule.scheduleType as ScheduleType) !== ScheduleType.INTERNAL || schedule.status !== 'active') {
throw new BadRequestException('该排课不能进行课程考勤');
}
if (schedule.classId == null) throw new BadRequestException('该排课未关联班级');
if (lessonDate < schedule.startDate || lessonDate > schedule.endDate) {
throw new BadRequestException('所选日期不在排课有效期内');
}
const date = new Date(`${lessonDate}T00:00:00`);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
if (weekDay !== schedule.weekDay) throw new BadRequestException('所选日期不是该课程的上课日');
return schedule;
}
async getLessonAttendance(scheduleId: number, lessonDate: string) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const session = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
const records = session
? await this.attendanceRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
})
: [];
return { schedule, session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, schedule.classId) };
}
getLessonAttendanceImportDateRange(
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): { startDate: string; endDate: string } {
return getLessonAttendanceImportDateRange(schedule, lessonDate);
}
async createLessonAttendanceFromDingTalk(
scheduleId: number,
lessonDate: string,
userId: number,
finalize = false,
) {
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
const now = new Date();
const courseClock = getCourseClock(now);
const today = courseClock.date;
if (lessonDate > today) throw new BadRequestException('课程尚未开始,不能拉取考勤');
if (lessonDate === today) {
const [hour, minute] = schedule.startTime.split(':').map(Number);
const startMinute = hour * 60 + minute;
const currentMinute = courseClock.minutes;
if (currentMinute < startMinute) {
throw new BadRequestException('课程尚未开始,不能拉取考勤');
}
}
const existing = await this.attendanceSessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
if (
existing.status !== 'in_progress' &&
existing.status !== 'completed' &&
!(finalize && existing.status === 'settling')
) {
throw new BadRequestException('课程考勤正在结算');
}
// Refresh latest DingTalk data even after automatic settlement; late-arriving punches
// may legitimately change a DingTalk-generated absence to present.
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const effectiveFinalize = finalize || existing.status === 'completed';
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const updatedRecords = await Promise.all(
existingRecords.map(async (record) => {
record.student = studentsById.get(record.studentId)!;
// Preserve manual corrections only while the lesson is still in progress.
if (!finalize && record.source !== 'dingtalk') return record;
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate,
);
const resolved = await resolveLessonStatus(
this.dingLeaveRawRepo,
record.studentId,
raw,
schedule,
lessonDate,
effectiveFinalize,
);
record.status = resolved.status;
Object.assign(record, getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = resolved.remark ?? null;
return record;
}),
);
for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue;
updatedRecords.push(
await buildLessonRecord(
recordRepo,
this.dingLeaveRawRepo,
rawByStudent,
classStudent,
schedule,
lessonDate,
lessonSessionKey,
existing.id,
scheduleId,
effectiveFinalize,
),
);
}
const saved = await recordRepo.save(updatedRecords);
if (finalize) {
existing.status = 'completed';
existing.completedBy = userId;
existing.completedAt = new Date();
await sessionRepo.save(existing);
}
return { schedule, session: existing, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) };
});
}
// First pull: create session and records atomically
return this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
let session: AttendanceSession;
try {
session = await sessionRepo.save(
sessionRepo.create({
scheduleId,
classId: schedule.classId!,
lessonDate,
status: 'in_progress',
startedBy: userId,
startedAt: new Date(),
}),
);
} catch (err: unknown) {
const code = (err as Record<string, unknown>).code;
const errno = (err as Record<string, unknown>).errno;
// MySQL: ER_DUP_ENTRY or errno 1062
if (code === 'ER_DUP_ENTRY' || errno === 1062) {
const existing = await sessionRepo.findOne({
where: { scheduleId, lessonDate },
});
if (existing) {
session = existing;
const existingRecords = await recordRepo.find({
where: { attendanceSessionId: session.id },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { schedule, session, records: await attachAttendanceDeviceMappings(existingRecords, this.attendanceDeviceRepo, schedule.classId) };
}
}
throw err;
}
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const records = await Promise.all(
classStudents.map((classStudent) =>
buildLessonRecord(
recordRepo,
this.dingLeaveRawRepo,
rawByStudent,
classStudent,
schedule,
lessonDate,
lessonSessionKey,
session.id,
scheduleId,
finalize,
),
),
);
const saved = await recordRepo.save(records);
if (finalize) {
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
session = await sessionRepo.save(session);
}
return { schedule, session, records: await attachAttendanceDeviceMappings(saved, this.attendanceDeviceRepo, schedule.classId) };
});
}
private async fetchDingTalkRawByStudent(
classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const window = getLessonAttendanceWindow(schedule, lessonDate);
const rawRecords = await this.dingRawRepo.find({
where: {
attendanceDate: Between(window.dateFrom, window.dateTo),
matchedStudentId: In(studentIds),
},
});
const rawByStudent = new Map<number, DingAttendanceRaw[]>();
for (const raw of rawRecords) {
if (raw.matchedStudentId == null) continue;
const arr = rawByStudent.get(raw.matchedStudentId) ?? [];
arr.push(raw);
rawByStudent.set(raw.matchedStudentId, arr);
}
return rawByStudent;
}
async completeLessonAttendance(sessionId: number, userId: number) {
return this.sessionMutex.runExclusive(sessionId, () =>
this.dataSource.transaction(async (manager) => {
const sessionRepo = manager.getRepository(AttendanceSession);
const recordRepo = manager.getRepository(AttendanceRecord);
const session = await sessionRepo.findOne({ where: { id: sessionId } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
// Re-check under lock: if already completed, return current state idempotently
if (session.status === 'completed') {
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) };
}
const pendingRecords = await recordRepo.count({
where: { attendanceSessionId: sessionId, status: 'pending' },
});
if (pendingRecords > 0) {
throw new BadRequestException('存在未处理的考勤记录,无法完成考勤');
}
session.status = 'completed';
session.completedBy = userId;
session.completedAt = new Date();
const savedSession = await sessionRepo.save(session);
const records = await recordRepo.find({
where: { attendanceSessionId: sessionId },
relations: ['student'],
order: { studentId: 'ASC' },
});
return { session: savedSession, records: await attachAttendanceDeviceMappings(records, this.attendanceDeviceRepo, session.classId) };
}),
);
}
async findAttendanceSession(id: number) {
const session = await this.attendanceSessionRepo.findOne({ where: { id } });
if (!session) throw new NotFoundException('课程考勤场次不存在');
return session;
}
// ── Batch create attendance records ──
}