feat: 考勤模块重构与钉钉考勤同步
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user