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

@@ -2,7 +2,6 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { getDataSourceToken } from '@nestjs/typeorm';
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { Repository } from 'typeorm';
import { AttendanceService } from './attendance.service';
import { AttendanceRecord } from '../entities/attendance-record.entity';
import { AttendanceSession } from '../entities/attendance-session.entity';
@@ -19,7 +18,6 @@ import { BatchCreateAttendanceDto } from './dto/attendance.dto';
describe('AttendanceService — batchCreate', () => {
let service: AttendanceService;
let attendanceRepo: jest.Mocked<Pick<Repository<AttendanceRecord>, 'create' | 'save'>>;
const savedRecords: AttendanceRecord[] = [];
@@ -28,14 +26,14 @@ describe('AttendanceService — batchCreate', () => {
const mockRepo = {
create: jest
.fn()
.mockImplementation((data: Partial<AttendanceRecord>) => ({ id: 1, ...data } as AttendanceRecord)),
save: jest
.fn()
.mockImplementation((entities: AttendanceRecord[]) => {
const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
savedRecords.push(...result);
return Promise.resolve(result);
}),
.mockImplementation(
(data: Partial<AttendanceRecord>) => ({ id: 1, ...data }) as AttendanceRecord,
),
save: jest.fn().mockImplementation((entities: AttendanceRecord[]) => {
const result = entities.map((e, i) => ({ ...e, id: i + 1 }));
savedRecords.push(...result);
return Promise.resolve(result);
}),
};
const mockDingRepo = {};
@@ -66,15 +64,32 @@ describe('AttendanceService — batchCreate', () => {
}).compile();
service = module.get<AttendanceService>(AttendanceService);
attendanceRepo = module.get(getRepositoryToken(AttendanceRecord));
});
it('valid batch with morning_reading, evening_study, and night_check sessions → succeeds', async () => {
const dto: BatchCreateAttendanceDto = {
records: [
{ studentId: 1, classId: 10, attendanceDate: '2026-07-05', session: 'morning_reading', status: 'present' },
{ studentId: 2, classId: 10, attendanceDate: '2026-07-05', session: 'evening_study', status: 'present' },
{ studentId: 3, classId: 10, attendanceDate: '2026-07-05', session: 'night_check', status: 'present' },
{
studentId: 1,
classId: 10,
attendanceDate: '2026-07-05',
session: 'morning_reading',
status: 'present',
},
{
studentId: 2,
classId: 10,
attendanceDate: '2026-07-05',
session: 'evening_study',
status: 'present',
},
{
studentId: 3,
classId: 10,
attendanceDate: '2026-07-05',
session: 'night_check',
status: 'present',
},
],
};
@@ -92,7 +107,12 @@ describe('AttendanceService — batchCreate', () => {
const invalidPayload = {
records: [
{ studentId: 1, attendanceDate: '2026-07-05', session: 'invalid_session', status: 'present' },
{
studentId: 1,
attendanceDate: '2026-07-05',
session: 'invalid_session',
status: 'present',
},
],
};
@@ -151,11 +171,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
it('returns only mapped active students for a class assigned to the teacher', async () => {
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
classStudentRepo.find.mockResolvedValue([
{ studentId: 2 },
{ studentId: 1 },
{ studentId: 2 },
]);
classStudentRepo.find.mockResolvedValue([{ studentId: 2 }, { studentId: 1 }, { studentId: 2 }]);
mappingRepo.find.mockResolvedValue([
{ studentId: 1, dingUserId: 'ding-1' },
{ studentId: 2, dingUserId: 'ding-2' },
@@ -206,7 +222,9 @@ describe('AttendanceService — DingTalk raw query', () => {
};
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]),
};
const service = new AttendanceService(
{} as never,
dingRepo as never,
@@ -237,7 +255,6 @@ describe('AttendanceService — DingTalk raw query', () => {
});
});
describe('AttendanceService — attendance device display mappings', () => {
function createHistoryQueryBuilder(records: AttendanceRecord[]) {
return {
@@ -337,8 +354,6 @@ describe('AttendanceService — attendance device display mappings', () => {
});
});
// ── Session serialization tests ──
function deferred<T>(): {
promise: Promise<T>;
@@ -400,7 +415,8 @@ describe('AttendanceService — session serialization', () => {
}
function makeTxManager(sessionStatus: string) {
const session = sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const session =
sessionStatus === 'completed' ? { ...sessionCompleted } : { ...sessionInProgress };
const sessionRepo = {
findOne: jest.fn().mockResolvedValue(session),
@@ -528,9 +544,7 @@ describe('AttendanceService — session serialization', () => {
it('complete is idempotent: returns current state when session already completed', async () => {
const manager = makeTxManager('completed');
const txMock = jest
.fn()
.mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const txMock = jest.fn().mockImplementation((cb: (m: unknown) => unknown) => cb(manager));
const svc = makeService({ transaction: txMock });