fix(server): 考勤自动结算支持空班级直接完成,补充自动匹配测试
This commit is contained in:
@@ -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]);
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user