fix(server): 考勤自动结算支持空班级直接完成,补充自动匹配测试
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
const schedule = {
|
const schedule = {
|
||||||
id: 2,
|
id: 2,
|
||||||
@@ -17,6 +18,9 @@ const createService = () => {
|
|||||||
const scheduleRepo = { find: jest.fn() };
|
const scheduleRepo = { find: jest.fn() };
|
||||||
const sessionRepo = {
|
const sessionRepo = {
|
||||||
find: jest.fn(),
|
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 }),
|
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||||
};
|
};
|
||||||
const attendanceService = {
|
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 () => {
|
it('does not finalize when an import reports partial errors', async () => {
|
||||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
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 { Cron } from '@nestjs/schedule';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||||
@@ -150,13 +150,91 @@ export class AttendanceSettlementService {
|
|||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
} catch (error: unknown) {
|
} 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(
|
this.logger.error(
|
||||||
`课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(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(
|
private getEndedOccurrenceDate(
|
||||||
schedule: ClassSchedule,
|
schedule: ClassSchedule,
|
||||||
clock: { weekDay: number; minutes: number },
|
clock: { weekDay: number; minutes: number },
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { BatchCreateAttendanceDto } from './dto/attendance.dto';
|
|||||||
|
|
||||||
describe('AttendanceService — batchCreate', () => {
|
describe('AttendanceService — batchCreate', () => {
|
||||||
let service: AttendanceService;
|
let service: AttendanceService;
|
||||||
|
let mockDingRepo: { find: jest.Mock; save: jest.Mock };
|
||||||
|
let mockStudentDingMappingRepo: { find: jest.Mock };
|
||||||
|
|
||||||
const savedRecords: AttendanceRecord[] = [];
|
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 mockClassRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
const mockStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
// Reserved for future tests (auto-match, schedule-based attendance, etc.)
|
// Reserved for future tests (auto-match, schedule-based attendance, etc.)
|
||||||
const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
|
const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
const mockClassStudentRepo = { 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 mockAttendanceDeviceRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -132,10 +137,35 @@ describe('AttendanceService — batchCreate', () => {
|
|||||||
await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException);
|
await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => {
|
it('autoMatchDingRecords 通过 StudentDingMapping 匹配未匹配的钉钉原始记录', async () => {
|
||||||
// TODO: match dingtalk raw records to students via StudentDingMapping lookup,
|
const rawRecords = [
|
||||||
// then to class schedules → ClassStudent association, producing attendance records.
|
{
|
||||||
// Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
|
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