fix(server): 考勤自动结算支持空班级直接完成,补充自动匹配测试

This commit is contained in:
2026-08-08 15:05:42 +08:00
parent a0829f17ce
commit f96c1c26c3
3 changed files with 168 additions and 8 deletions

View File

@@ -1,4 +1,5 @@
import { AttendanceSettlementService } from './attendance-settlement.service';
import { BadRequestException } from '@nestjs/common';
const schedule = {
id: 2,
@@ -17,6 +18,9 @@ const createService = () => {
const scheduleRepo = { find: jest.fn() };
const sessionRepo = {
find: jest.fn(),
findOne: jest.fn(),
create: jest.fn().mockImplementation((data) => data),
save: jest.fn().mockImplementation((entity) => Promise.resolve({ id: 99, ...entity })),
update: jest.fn().mockResolvedValue({ affected: 1 }),
};
const attendanceService = {
@@ -171,6 +175,54 @@ describe('AttendanceSettlementService', () => {
);
});
it('finalizes an empty completed session when the class has no students', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]);
sessionRepo.findOne.mockResolvedValue(null);
attendanceService.createLessonAttendanceFromDingTalk.mockRejectedValue(
new BadRequestException('该班级暂无在读学生'),
);
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(sessionRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
scheduleId: 2,
classId: 8,
lessonDate: '2026-07-13',
status: 'completed',
completedBy: 21,
}),
);
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
});
it('completes an in-progress session when the class has no students', 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,
},
]);
attendanceService.getTeacherClassDingUserIds.mockRejectedValue(
new BadRequestException('该班级暂无在读学生'),
);
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
expect(sessionRepo.update).toHaveBeenCalledWith(
{ id: 90, status: 'settling' },
expect.objectContaining({ status: 'completed', completedBy: 21 }),
);
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
});
it('does not finalize when an import reports partial errors', async () => {
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
scheduleRepo.find.mockResolvedValue([schedule]);

View File

@@ -1,4 +1,4 @@
import { Injectable, Logger } from '@nestjs/common';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
@@ -150,13 +150,91 @@ export class AttendanceSettlementService {
true,
);
} catch (error: unknown) {
if (session) await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' });
if (this.isNoActiveStudentsError(error)) {
try {
await this.finalizeEmptyLesson(schedule, lessonDate, session);
this.logger.log(`课程${schedule.id} ${lessonDate}班级无在读学生,已直接完成结算`);
} catch (finalizeError: unknown) {
this.logger.error(
`课程${schedule.id} ${lessonDate}空班级完成结算落库失败: ${finalizeError instanceof Error ? finalizeError.message : String(finalizeError)}`,
);
if (session) {
await this.sessionRepo.update(
{ id: session.id, status: 'settling' },
{ status: 'in_progress' },
);
}
}
return;
}
if (session) {
await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' });
}
this.logger.error(
`课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
private isNoActiveStudentsError(error: unknown): boolean {
return (
error instanceof BadRequestException &&
error.message.includes('该班级暂无在读学生')
);
}
private async finalizeEmptyLesson(
schedule: ClassSchedule,
lessonDate: string,
session?: AttendanceSession,
): Promise<void> {
const completedAt = new Date();
const completedBy = schedule.teacherId;
if (session) {
await this.sessionRepo.update(
{ id: session.id, status: 'settling' },
{ status: 'completed', completedBy, completedAt },
);
return;
}
const existing = await this.sessionRepo.findOne({
where: { scheduleId: schedule.id, lessonDate },
});
if (existing) {
if (existing.status !== 'completed') {
await this.sessionRepo.update(
{ id: existing.id },
{ status: 'completed', completedBy, completedAt },
);
}
return;
}
try {
await this.sessionRepo.save(
this.sessionRepo.create({
scheduleId: schedule.id,
classId: schedule.classId!,
lessonDate,
status: 'completed',
startedBy: completedBy,
startedAt: completedAt,
completedBy,
completedAt,
}),
);
} catch (error: unknown) {
const code = (error as Record<string, unknown>).code;
const errno = (error as Record<string, unknown>).errno;
if (code !== 'ER_DUP_ENTRY' && errno !== 1062) throw error;
await this.sessionRepo.update(
{ scheduleId: schedule.id, lessonDate, status: 'in_progress' },
{ status: 'completed', completedBy, completedAt },
);
}
}
private getEndedOccurrenceDate(
schedule: ClassSchedule,
clock: { weekDay: number; minutes: number },

View File

@@ -19,6 +19,8 @@ import { BatchCreateAttendanceDto } from './dto/attendance.dto';
describe('AttendanceService — batchCreate', () => {
let service: AttendanceService;
let mockDingRepo: { find: jest.Mock; save: jest.Mock };
let mockStudentDingMappingRepo: { find: jest.Mock };
const savedRecords: AttendanceRecord[] = [];
@@ -37,13 +39,16 @@ describe('AttendanceService — batchCreate', () => {
}),
};
const mockDingRepo = {};
mockDingRepo = {
find: jest.fn().mockResolvedValue([]),
save: jest.fn().mockImplementation((entity: DingAttendanceRaw) => Promise.resolve(entity)),
};
const mockClassRepo = { find: jest.fn().mockResolvedValue([]) };
const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) };
// Reserved for future tests (auto-match, schedule-based attendance, etc.)
const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) };
const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
const mockAttendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
const module: TestingModule = await Test.createTestingModule({
@@ -132,10 +137,35 @@ describe('AttendanceService — batchCreate', () => {
await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException);
});
it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => {
// TODO: match dingtalk raw records to students via StudentDingMapping lookup,
// then to class schedules → ClassStudent association, producing attendance records.
// Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
it('autoMatchDingRecords 通过 StudentDingMapping 匹配未匹配的钉钉原始记录', async () => {
const rawRecords = [
{
id: 1,
dingUserId: 'ding-1',
userName: '张三',
matchStatus: 'unmatched',
matchedStudentId: null,
},
{
id: 2,
dingUserId: 'ding-unknown',
userName: '李四',
matchStatus: 'unmatched',
matchedStudentId: null,
},
] as DingAttendanceRaw[];
mockDingRepo.find.mockResolvedValue(rawRecords);
mockStudentDingMappingRepo.find.mockResolvedValue([
{ studentId: 10, dingUserId: 'ding-1' },
]);
const result = await service.autoMatchDingRecords();
expect(result).toEqual({ matched: 1, total: 2 });
expect(mockDingRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ id: 1, matchedStudentId: 10, matchStatus: 'matched' }),
);
expect(rawRecords[1]).toMatchObject({ matchStatus: 'unmatched', matchedStudentId: null });
});
});