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

@@ -0,0 +1,205 @@
import { AttendanceSettlementService } from './attendance-settlement.service';
const schedule = {
id: 2,
classId: 8,
teacherId: 21,
weekDay: 1,
startTime: '09:00',
endTime: '10:00',
startDate: '2026-07-01',
endDate: '2026-07-31',
scheduleType: 'INTERNAL',
status: 'active',
};
const createService = () => {
const scheduleRepo = { find: jest.fn() };
const sessionRepo = {
find: jest.fn(),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const attendanceService = {
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
session: { id: 90, lessonDate, startedBy: userId, status: finalize ? 'completed' : 'in_progress' },
}),
),
};
const importService = {
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
};
const service = new AttendanceSettlementService(
scheduleRepo as never,
sessionRepo as never,
attendanceService as never,
importService as never,
);
return { service, scheduleRepo, sessionRepo, attendanceService, importService };
};
describe('AttendanceSettlementService', () => {
it('pulls and finalizes an ended lesson once', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
startDate: '2026-07-13',
endDate: '2026-07-13',
userIds: ['ding-1'],
autoMatch: true,
userId: 21,
});
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
1, 2, '2026-07-13', 21, false,
);
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
2, 2, '2026-07-13', 21, true,
);
});
it('does not settle a lesson before its end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('continues with the next lesson when one settlement fails', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
sessionRepo.find.mockResolvedValue([]);
importService.importFromDingTalk
.mockRejectedValueOnce(new Error('DingTalk unavailable'))
.mockResolvedValueOnce({ success: true, errors: [] });
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
2,
3,
'2026-07-13',
21,
false,
);
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith(
3,
'2026-07-13',
21,
true,
);
});
it('does not finalize when an import reports partial errors', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
importService.importFromDingTalk.mockResolvedValue({
success: true,
errors: ['one batch failed'],
});
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalledWith(
2, '2026-07-13', 21, true,
);
});
it('retries an uncompleted daytime lesson on a later day', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([]);
sessionRepo.find.mockResolvedValue([
{
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
await service.settleEndedLessons(new Date('2026-07-15T10:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
2,
'2026-07-13',
21,
true,
);
});
it('skips final pull when another worker already claimed the session', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([]);
sessionRepo.find.mockResolvedValue([
{
id: 90,
scheduleId: 2,
lessonDate: '2026-07-13',
status: 'in_progress',
schedule,
},
]);
sessionRepo.update.mockResolvedValue({ affected: 0 });
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
});
it('settles an overnight lesson after its next-day end time', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([
{ ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' },
]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
4,
'2026-07-12',
21,
true,
);
});
it('pulls an overnight lesson through its next calendar date', async () => {
const { service, scheduleRepo, sessionRepo, importService } = createService();
scheduleRepo.find.mockResolvedValue([
{ ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' },
]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00'));
expect(importService.importFromDingTalk).toHaveBeenCalledWith(
expect.objectContaining({ startDate: '2026-07-12', endDate: '2026-07-13' }),
);
});
});
describe('attendance settlement timezone', () => {
it('uses Asia/Shanghai course time when the server runs in UTC', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
await service.settleEndedLessons(new Date('2026-07-13T02:01:00.000Z'));
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
2,
'2026-07-13',
21,
true,
);
});
});