From f96c1c26c3acecad2fcf3ee4e046749001a6f1b6 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sat, 8 Aug 2026 15:05:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E8=80=83=E5=8B=A4=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E7=BB=93=E7=AE=97=E6=94=AF=E6=8C=81=E7=A9=BA=E7=8F=AD?= =?UTF-8?q?=E7=BA=A7=E7=9B=B4=E6=8E=A5=E5=AE=8C=E6=88=90=EF=BC=8C=E8=A1=A5?= =?UTF-8?q?=E5=85=85=E8=87=AA=E5=8A=A8=E5=8C=B9=E9=85=8D=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../attendance-settlement.service.spec.ts | 52 ++++++++++++ .../attendance-settlement.service.ts | 82 ++++++++++++++++++- .../src/attendance/attendance.service.spec.ts | 42 ++++++++-- 3 files changed, 168 insertions(+), 8 deletions(-) diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index eaa0aa8..aace41d 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -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]); diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index a3fa8f1..c55086e 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -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 { + 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).code; + const errno = (error as Record).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 }, diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index c00e7ae..d8923e4 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -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 }); }); });