feat: 结算时同步钉钉已审批请假并标记为请假

This commit is contained in:
2026-08-05 18:43:02 +08:00
parent 32343b271b
commit c8316e9e8e
19 changed files with 746 additions and 58 deletions

View File

@@ -4,6 +4,7 @@ import { DataSource, Repository, In, Between } from 'typeorm';
import {
AttendanceRecord,
DingAttendanceRaw,
DingLeaveRaw,
Class,
Student,
ClassSchedule,
@@ -32,6 +33,7 @@ export class AttendanceLessonService {
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>,
@@ -150,29 +152,34 @@ export class AttendanceLessonService {
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const updatedRecords = existingRecords.map((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 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,
);
record.status = mapDingTalkStatus(raw, effectiveFinalize);
Object.assign(record, getLessonPunchMetadata(
raw,
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [],
schedule,
lessonDate,
schedule.startTime,
));
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
? null
: effectiveFinalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果';
return record;
});
);
const resolved = await this.resolveLessonStatus(
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;
const raw = selectDingTalkRecordsForLesson(
@@ -180,6 +187,13 @@ export class AttendanceLessonService {
schedule,
lessonDate,
);
const resolved = await this.resolveLessonStatus(
classStudent.studentId,
raw,
schedule,
lessonDate,
effectiveFinalize,
);
updatedRecords.push(
recordRepo.create({
studentId: classStudent.studentId,
@@ -189,18 +203,14 @@ export class AttendanceLessonService {
attendanceSessionId: existing.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: mapDingTalkStatus(raw, effectiveFinalize),
status: resolved.status,
source: 'dingtalk',
...getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: effectiveFinalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
remark: resolved.remark,
}),
);
}
@@ -263,34 +273,39 @@ export class AttendanceLessonService {
}
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const records = classStudents.map((classStudent) => {
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: mapDingTalkStatus(raw, finalize),
source: 'dingtalk',
...getLessonPunchMetadata(
raw,
const records = await Promise.all(
classStudents.map(async (classStudent) => {
const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(classStudent.studentId) ?? [],
schedule,
lessonDate,
schedule.startTime,
),
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
? undefined
: finalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
});
});
);
const resolved = await this.resolveLessonStatus(
classStudent.studentId,
raw,
schedule,
lessonDate,
finalize,
);
return recordRepo.create({
studentId: classStudent.studentId,
student: classStudent.student,
classId: schedule.classId!,
scheduleId,
attendanceSessionId: session.id,
attendanceDate: lessonDate,
session: lessonSessionKey,
status: resolved.status,
source: 'dingtalk',
...getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
),
remark: resolved.remark,
});
}),
);
const saved = await recordRepo.save(records);
if (finalize) {
session.status = 'completed';
@@ -302,6 +317,59 @@ export class AttendanceLessonService {
});
}
/**
* 结算finalize时无打卡的学生若当天存在钉钉已审批通过的请假且与
* 本节课时间窗口重叠,则记为 leave而不是缺勤。
*/
private async resolveLessonStatus(
studentId: number,
raw: DingAttendanceRaw[],
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
finalize: boolean,
): Promise<{ status: string; remark?: string }> {
const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime);
if (!finalize) {
return {
status: mapDingTalkStatus(raw, false),
remark: hasPunch ? undefined : '未获取到钉钉打卡结果',
};
}
if (hasPunch) return { status: 'present', remark: undefined };
const leave = await this.findApprovedLeaveForStudent(studentId, schedule, lessonDate);
if (leave) {
return {
status: 'leave',
remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'}`,
};
}
return { status: 'absent', remark: '课程截止仍未打卡' };
}
private async findApprovedLeaveForStudent(
studentId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): Promise<DingLeaveRaw | null> {
const leaves = await this.dingLeaveRawRepo.find({
where: { matchedStudentId: studentId },
});
const window = getLessonAttendanceWindow(schedule, lessonDate);
const overlapping = leaves.filter(
(leave) =>
leave.startTime &&
leave.endTime &&
leave.startTime.getTime() <= window.end &&
leave.endTime.getTime() >= window.start,
);
overlapping.sort(
(left, right) =>
(right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0),
);
return overlapping[0] ?? null;
}
private async fetchDingTalkRawByStudent(
classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,