feat: settle course attendance automatically

This commit is contained in:
2026-07-13 10:26:38 +08:00
parent b9b295c997
commit 1c8bd08be4
11 changed files with 730 additions and 87 deletions

View File

@@ -74,8 +74,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
{
matchedStudentId: 1,
attendanceType: 'OnDuty',
timeResult: 'Normal',
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OnDuty',
timeResult: 'Late',
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
},
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
]);
@@ -91,13 +101,34 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 2, status: 'late', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'absent', source: 'dingtalk' }),
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
]);
expect(result.records).toHaveLength(4);
});
it('finalizes missing punches as absent and completes the session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'absent' }),
]);
expect(sessionRepo.save).toHaveBeenLastCalledWith(
expect.objectContaining({ status: 'completed', completedBy: 21 }),
);
expect(result.session.status).toBe('completed');
});
it('returns student relations after the first pull', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
@@ -211,8 +242,8 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
@@ -225,7 +256,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'present' }),
expect.objectContaining({ studentId: 2, status: 'late' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
expect(result.records).toHaveLength(2);
@@ -278,8 +309,8 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
@@ -289,12 +320,38 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(savedRecords).toEqual(
expect.arrayContaining([
expect.objectContaining({ studentId: 1, status: 'leave' }),
expect.objectContaining({ studentId: 2, status: 'late' }),
expect.objectContaining({ studentId: 2, status: 'present' }),
]),
);
expect(result.records).toHaveLength(2);
});
it('final settlement overrides interim manual status using the final punch result', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue({
id: 90,
scheduleId: 4,
classId: 8,
lessonDate: '2026-07-11',
status: 'in_progress',
});
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
dingRawRepo.find.mockResolvedValue([]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
expect(attendanceRepo.save).toHaveBeenCalledWith([
expect.objectContaining({ studentId: 1, status: 'absent' }),
]);
});
it('rejects completion when pending records exist', async () => {
const { service, sessionRepo, attendanceRepo } = createService();
sessionRepo.findOne.mockResolvedValue({
@@ -368,8 +425,8 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal' },
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
]);
// Step 1: update the record to absent via generic update()