360 lines
14 KiB
TypeScript
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 ──
|
|
}
|