feat: 考勤模块重构与钉钉考勤同步

This commit is contained in:
2026-08-05 17:11:23 +08:00
parent c622e40a12
commit e9c8a1085d
36 changed files with 5122 additions and 3458 deletions

View File

@@ -48,7 +48,16 @@ const createService = () => {
{} as never,
dataSource as unknown as DataSource,
);
return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource };
return {
service,
attendanceRepo,
dingRawRepo,
scheduleRepo,
classStudentRepo,
sessionRepo,
attendanceDeviceRepo,
dataSource,
};
};
const endedSchedule = {
@@ -128,9 +137,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
@@ -149,9 +156,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
@@ -167,7 +172,9 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
createService();
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
sessionRepo.findOne.mockResolvedValue(null);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } }]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '\u5F20\u4E09' } },
]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
@@ -201,10 +208,26 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 3, student: { id: 3, name: '王五' } },
]);
dingRawRepo.find.mockResolvedValue([
{ matchedStudentId: 1, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T08:40:00+08:00') },
{ matchedStudentId: 2, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:00+08:00') },
{ matchedStudentId: 3, attendanceType: 'OnDuty', checkInTime: new Date('2026-07-11T08:39:59+08:00') },
{ matchedStudentId: 3, attendanceType: 'OffDuty', checkOutTime: new Date('2026-07-11T10:00:01+08:00') },
{
matchedStudentId: 1,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T08:40:00+08:00'),
},
{
matchedStudentId: 2,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T10:00:00+08:00'),
},
{
matchedStudentId: 3,
attendanceType: 'OnDuty',
checkInTime: new Date('2026-07-11T08:39:59+08:00'),
},
{
matchedStudentId: 3,
attendanceType: 'OffDuty',
checkOutTime: new Date('2026-07-11T10:00:01+08:00'),
},
]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
@@ -218,10 +241,12 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
it('expands import dates when the pre-class window crosses midnight', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
)).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:15', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-11',
),
).toEqual({ startDate: '2026-07-10', endDate: '2026-07-11' });
});
it('creates local attendance after the lesson starts', async () => {
@@ -266,9 +291,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceRepo.find.mockResolvedValue([
{ id: 1, studentId: 1, attendanceSessionId: 90, status: 'absent', source: 'dingtalk' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([
{
matchedStudentId: 1,
@@ -306,8 +329,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ 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: 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);
@@ -326,7 +359,6 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(result.records).toHaveLength(2);
});
it('restores students missing from an existing empty session', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService();
@@ -373,8 +405,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
]);
dingRawRepo.find.mockResolvedValue([
{ 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') },
{
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);
@@ -404,9 +446,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
attendanceRepo.find.mockResolvedValue([
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
]);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
@@ -489,8 +529,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
{ studentId: 2, student: { id: 2, name: '李四' } },
]);
dingRawRepo.find.mockResolvedValue([
{ 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') },
{
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()
@@ -498,7 +548,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(updated.source).toBe('manual');
// Step 2: refresh in_progress session — manual record status must stay absent
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
const savedRecords = (attendanceRepo.save as jest.Mock).mock.calls[
(attendanceRepo.save as jest.Mock).mock.calls.length - 1
@@ -532,9 +582,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
errno: undefined,
}),
);
classStudentRepo.find.mockResolvedValue([
{ studentId: 1, student: { id: 1, name: '张三' } },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
dingRawRepo.find.mockResolvedValue([]);
attendanceRepo.find.mockResolvedValue([
{ id: 201, studentId: 1, attendanceSessionId: 77, status: 'present', source: 'dingtalk' },
@@ -555,9 +603,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
status: 'in_progress',
});
attendanceRepo.count.mockResolvedValue(0);
attendanceRepo.find.mockResolvedValue([
{ id: 1, status: 'present' },
]);
attendanceRepo.find.mockResolvedValue([{ id: 1, status: 'present' }]);
await service.completeLessonAttendance(90, 21);
@@ -615,15 +661,24 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
describe('AttendanceService — attendance window boundaries', () => {
it('crosses calendar boundaries only when the window requires it', () => {
const { service } = createService();
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 }, '2026-07-13',
)).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 30 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-13' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '00:30', endTime: '01:30', attendanceAdvanceMinutes: 31 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-12', endDate: '2026-07-13' });
expect(
service.getLessonAttendanceImportDateRange(
{ startTime: '22:00', endTime: '01:00', attendanceAdvanceMinutes: 30 },
'2026-07-13',
),
).toEqual({ startDate: '2026-07-13', endDate: '2026-07-14' });
});
it('uses Asia/Shanghai time when deciding whether todays lesson has started', async () => {
@@ -631,17 +686,23 @@ describe('AttendanceService — attendance window boundaries', () => {
process.env.TZ = 'UTC';
jest.useFakeTimers().setSystemTime(new Date('2026-07-13T01:00:00.000Z'));
try {
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } = createService();
const { service, scheduleRepo, sessionRepo, attendanceRepo, dingRawRepo, classStudentRepo } =
createService();
scheduleRepo.findOne.mockResolvedValue({
...endedSchedule, weekDay: 1, startTime: '08:30', endTime: '10:00',
startDate: '2026-07-13', endDate: '2026-07-13',
...endedSchedule,
weekDay: 1,
startTime: '08:30',
endTime: '10:00',
startDate: '2026-07-13',
endDate: '2026-07-13',
});
sessionRepo.findOne.mockResolvedValue({ id: 90, status: 'completed' });
attendanceRepo.find.mockResolvedValue([]);
dingRawRepo.find.mockResolvedValue([]);
classStudentRepo.find.mockResolvedValue([]);
await expect(service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21))
.resolves.toMatchObject({ records: [] });
await expect(
service.createLessonAttendanceFromDingTalk(4, '2026-07-13', 21),
).resolves.toMatchObject({ records: [] });
} finally {
jest.useRealTimers();
process.env.TZ = originalTz;