From b882411f42e55e91945a31f6d963d862ecd5b7c9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 18:55:36 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E6=8B=86=E5=88=86=E8=80=83?= =?UTF-8?q?=E5=8B=A4=E6=9C=8D=E5=8A=A1=E6=96=87=E4=BB=B6=E5=B9=B6=E9=80=9A?= =?UTF-8?q?=E8=BF=87=20aislop=20=E5=85=A8=E9=A1=B9=E7=9B=AE=E6=89=AB?= =?UTF-8?q?=E6=8F=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/archive/archive-report.learning.ts | 2 +- .../attendance-import.service.spec.ts | 70 -------- .../attendance/attendance-import.service.ts | 134 +------------- .../attendance-leave-sync.service.spec.ts | 91 ++++++++++ .../attendance-leave-sync.service.ts | 166 ++++++++++++++++++ .../attendance/attendance-lesson-status.ts | 140 +++++++++++++++ .../attendance/attendance-lesson.service.ts | 127 +------------- .../attendance-settlement.service.spec.ts | 13 +- .../attendance-settlement.service.ts | 4 +- .../src/attendance/attendance.module.ts | 8 +- 10 files changed, 424 insertions(+), 331 deletions(-) create mode 100644 apps/server/src/attendance/attendance-leave-sync.service.spec.ts create mode 100644 apps/server/src/attendance/attendance-leave-sync.service.ts create mode 100644 apps/server/src/attendance/attendance-lesson-status.ts diff --git a/apps/server/src/archive/archive-report.learning.ts b/apps/server/src/archive/archive-report.learning.ts index db9c133..8125707 100644 --- a/apps/server/src/archive/archive-report.learning.ts +++ b/apps/server/src/archive/archive-report.learning.ts @@ -37,7 +37,7 @@ export function buildLearning(learnings: LearningRecord[], now: string): string `); } -export function buildResult(result: ResultArchive | null, now: string): string { +export function buildResult(result: ResultArchive | null, _now: string): string { if (!result) return ''; return sectionFrame(` diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index f545a42..1789c00 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -9,17 +9,10 @@ describe('AttendanceImportService', () => { findOne: jest.fn(), save: jest.fn(), }; - const dingLeaveRawRepo = { - find: jest.fn(), - findOne: jest.fn(), - create: jest.fn((value: Record) => value), - save: jest.fn(), - }; const studentRepo = { findOne: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), - fetchDailyLeaveStatus: jest.fn(), }; const attendanceService = { autoMatchDingRecords: jest.fn(), @@ -31,7 +24,6 @@ describe('AttendanceImportService', () => { jest.clearAllMocks(); service = new AttendanceImportService( dingRawRepo as never, - dingLeaveRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, @@ -313,66 +305,4 @@ describe('AttendanceImportService', () => { } }); - it('syncs approved DingTalk leaves per user per day and auto-matches them', async () => { - dingTalkService.fetchDailyLeaveStatus.mockImplementation( - async (userId: string, workDate: string) => [ - { - userId, - workDate, - procInstId: `leave-${userId}-${workDate}`, - tagName: '请假', - leaveType: '事假', - beginTime: new Date(`${workDate}T08:00:00+08:00`), - endTime: new Date(`${workDate}T12:00:00+08:00`), - approvedAt: new Date(`${workDate}T09:00:00+08:00`), - duration: '0.5', - durationUnit: 'day', - }, - ], - ); - dingLeaveRawRepo.findOne.mockResolvedValue(null); - dingLeaveRawRepo.save.mockImplementation(async (entities) => entities); - studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 7 }]); - dingLeaveRawRepo.find.mockResolvedValue([ - { dingId: 'leave-ding-1-2026-07-01', dingUserId: 'ding-1', matchStatus: 'unmatched' }, - ]); - - const result = await service.syncLeaveStatusForLesson({ - startDate: '2026-07-01', - endDate: '2026-07-02', - userIds: ['ding-1', 'ding-2'], - autoMatch: true, - }); - - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(4); - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( - 'ding-1', - '2026-07-01', - ); - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( - 'ding-2', - '2026-07-02', - ); - expect(dingLeaveRawRepo.save).toHaveBeenCalled(); - expect(result.synced).toBe(4); - expect(result.matched).toBe(1); - }); - - it('keeps syncing remaining users when one leave fetch fails', async () => { - dingTalkService.fetchDailyLeaveStatus - .mockRejectedValueOnce(new Error('DingTalk unavailable')) - .mockResolvedValue([]); - dingLeaveRawRepo.findOne.mockResolvedValue(null); - - const result = await service.syncLeaveStatusForLesson({ - startDate: '2026-07-01', - endDate: '2026-07-01', - userIds: ['ding-1', 'ding-2'], - autoMatch: false, - }); - - expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(2); - expect(result.errors).toHaveLength(1); - expect(result.synced).toBe(0); - }); }); diff --git a/apps/server/src/attendance/attendance-import.service.ts b/apps/server/src/attendance/attendance-import.service.ts index 87bc2f5..8895a17 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -4,15 +4,10 @@ import { Repository, In } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { DingAttendanceRaw, - DingLeaveRaw, Student, StudentDingMapping, } from '../entities'; -import { - DingTalkService, - DingTalkAttendanceResult, - DingTalkLeaveResult, -} from '../integration/dingtalk.service'; +import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service'; import { AttendanceService } from './attendance.service'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; @@ -38,8 +33,6 @@ export class AttendanceImportService { constructor( @InjectRepository(DingAttendanceRaw) private readonly dingRawRepo: Repository, - @InjectRepository(DingLeaveRaw) - private readonly dingLeaveRawRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) @@ -161,131 +154,6 @@ export class AttendanceImportService { } } - /** - * 拉取钉钉已审批通过的请假记录并落库。 - * - * 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表, - * 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求, - * 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。 - */ - async syncLeaveStatusForLesson(params: { - startDate: string; - endDate: string; - userIds?: string[]; - autoMatch?: boolean; - }): Promise<{ synced: number; matched: number; errors: string[] }> { - const userIds = [...new Set((params.userIds ?? []).filter(Boolean))]; - if (userIds.length === 0) return { synced: 0, matched: 0, errors: [] }; - if (params.startDate > params.endDate) { - throw new BadRequestException('开始日期不能晚于结束日期'); - } - - const errors: string[] = []; - let synced = 0; - - for (const date of this.enumerateDates(params.startDate, params.endDate)) { - for (const userId of userIds) { - try { - const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date); - for (const leave of leaves) { - await this.upsertLeave(leave); - synced++; - } - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - errors.push(`请假同步失败 ${userId} ${date}: ${msg}`); - this.logger.warn(`钉钉请假同步失败 userId=${userId} date=${date}: ${msg}`); - } - } - } - - const matched = params.autoMatch ? await this.autoMatchLeaveRecords() : 0; - if (synced > 0 || matched > 0) { - this.logger.log(`钉钉请假同步完成: 新增/更新 ${synced} 条, 匹配 ${matched} 条, 错误 ${errors.length} 条`); - } - return { synced, matched, errors }; - } - - private async upsertLeave(result: DingTalkLeaveResult): Promise { - const existing = await this.dingLeaveRawRepo.findOne({ - where: { dingId: result.procInstId }, - }); - if (existing) { - Object.assign(existing, { - dingUserId: result.userId, - workDate: result.workDate, - leaveType: result.leaveType, - tagName: result.tagName, - startTime: result.beginTime, - endTime: result.endTime, - approvedAt: result.approvedAt, - duration: result.duration, - durationUnit: result.durationUnit, - rawData: JSON.stringify(result), - }); - await this.dingLeaveRawRepo.save(existing); - return; - } - - const entity = this.dingLeaveRawRepo.create({ - dingUserId: result.userId, - userName: await this.resolveStudentName(result.userId), - workDate: result.workDate, - dingId: result.procInstId, - leaveType: result.leaveType, - tagName: result.tagName, - startTime: result.beginTime, - endTime: result.endTime, - approvedAt: result.approvedAt, - duration: result.duration, - durationUnit: result.durationUnit, - matchStatus: 'unmatched', - rawData: JSON.stringify(result), - }); - await this.dingLeaveRawRepo.save(entity); - } - - /** 通过 dingUserId → StudentDingMapping 自动匹配未匹配的请假记录。 */ - private async autoMatchLeaveRecords(): Promise { - const unmatched = await this.dingLeaveRawRepo.find({ - where: { matchStatus: 'unmatched' }, - }); - if (unmatched.length === 0) return 0; - - const mappings = await this.studentDingMappingRepo.find(); - const dingToStudentId = new Map(); - for (const mapping of mappings) { - dingToStudentId.set(mapping.dingUserId, mapping.studentId); - } - - let matched = 0; - const updates: DingLeaveRaw[] = []; - for (const record of unmatched) { - const studentId = dingToStudentId.get(record.dingUserId); - if (studentId == null) continue; - record.matchedStudentId = studentId; - record.matchStatus = 'matched'; - updates.push(record); - matched++; - } - if (updates.length > 0) { - await this.dingLeaveRawRepo.save(updates, { chunk: 50 }); - } - return matched; - } - - private enumerateDates(startDate: string, endDate: string): string[] { - const dates: string[] = []; - let cursor = this.parseDate(startDate); - const end = this.parseDate(endDate); - while (cursor.getTime() <= end.getTime()) { - dates.push(this.formatDate(cursor)); - cursor = new Date(cursor); - cursor.setUTCDate(cursor.getUTCDate() + 1); - } - return dates; - } - /** * DingTalk requires userIds, accepts at most 50 users per request, and * allows a maximum inclusive date range of 7 calendar days. diff --git a/apps/server/src/attendance/attendance-leave-sync.service.spec.ts b/apps/server/src/attendance/attendance-leave-sync.service.spec.ts new file mode 100644 index 0000000..a166a1c --- /dev/null +++ b/apps/server/src/attendance/attendance-leave-sync.service.spec.ts @@ -0,0 +1,91 @@ +import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; +import { DingTalkService } from '../integration/dingtalk.service'; + +describe('AttendanceLeaveSyncService', () => { + const dingLeaveRawRepo = { + find: jest.fn(), + findOne: jest.fn(), + create: jest.fn((value: Record) => value), + save: jest.fn(), + }; + const studentRepo = { findOne: jest.fn() }; + const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; + const dingTalkService = { + fetchDailyLeaveStatus: jest.fn(), + }; + + let service: AttendanceLeaveSyncService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new AttendanceLeaveSyncService( + dingLeaveRawRepo as never, + studentRepo as never, + studentDingMappingRepo as never, + dingTalkService as unknown as DingTalkService, + ); + }); + + it('syncs approved DingTalk leaves per user per day and auto-matches them', async () => { + dingTalkService.fetchDailyLeaveStatus.mockImplementation( + async (userId: string, workDate: string) => [ + { + userId, + workDate, + procInstId: `leave-${userId}-${workDate}`, + tagName: '请假', + leaveType: '事假', + beginTime: new Date(`${workDate}T08:00:00+08:00`), + endTime: new Date(`${workDate}T12:00:00+08:00`), + approvedAt: new Date(`${workDate}T09:00:00+08:00`), + duration: '0.5', + durationUnit: 'day', + }, + ], + ); + dingLeaveRawRepo.findOne.mockResolvedValue(null); + dingLeaveRawRepo.save.mockImplementation(async (entities) => entities); + studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 7 }]); + dingLeaveRawRepo.find.mockResolvedValue([ + { dingId: 'leave-ding-1-2026-07-01', dingUserId: 'ding-1', matchStatus: 'unmatched' }, + ]); + + const result = await service.syncLeaveStatusForLesson({ + startDate: '2026-07-01', + endDate: '2026-07-02', + userIds: ['ding-1', 'ding-2'], + autoMatch: true, + }); + + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(4); + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( + 'ding-1', + '2026-07-01', + ); + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith( + 'ding-2', + '2026-07-02', + ); + expect(dingLeaveRawRepo.save).toHaveBeenCalled(); + expect(result.synced).toBe(4); + expect(result.matched).toBe(1); + }); + + it('keeps syncing remaining users when one leave fetch fails', async () => { + dingTalkService.fetchDailyLeaveStatus + .mockRejectedValueOnce(new Error('DingTalk unavailable')) + .mockResolvedValue([]); + dingLeaveRawRepo.findOne.mockResolvedValue(null); + + const result = await service.syncLeaveStatusForLesson({ + startDate: '2026-07-01', + endDate: '2026-07-01', + userIds: ['ding-1', 'ding-2'], + autoMatch: false, + }); + + expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(2); + expect(result.errors).toHaveLength(1); + expect(result.synced).toBe(0); + }); +}); diff --git a/apps/server/src/attendance/attendance-leave-sync.service.ts b/apps/server/src/attendance/attendance-leave-sync.service.ts new file mode 100644 index 0000000..3a564af --- /dev/null +++ b/apps/server/src/attendance/attendance-leave-sync.service.ts @@ -0,0 +1,166 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { DingLeaveRaw, Student, StudentDingMapping } from '../entities'; +import { DingTalkService, DingTalkLeaveResult } from '../integration/dingtalk.service'; + +/** + * 钉钉请假数据同步服务。 + * + * 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表, + * 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求, + * 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。 + */ +@Injectable() +export class AttendanceLeaveSyncService { + private readonly logger = new Logger(AttendanceLeaveSyncService.name); + + constructor( + @InjectRepository(DingLeaveRaw) + private readonly dingLeaveRawRepo: Repository, + @InjectRepository(Student) + private readonly studentRepo: Repository, + @InjectRepository(StudentDingMapping) + private readonly studentDingMappingRepo: Repository, + private readonly dingTalkService: DingTalkService, + ) {} + + async syncLeaveStatusForLesson(params: { + startDate: string; + endDate: string; + userIds?: string[]; + autoMatch?: boolean; + }): Promise<{ synced: number; matched: number; errors: string[] }> { + const userIds = [...new Set((params.userIds ?? []).filter(Boolean))]; + if (userIds.length === 0) return { synced: 0, matched: 0, errors: [] }; + if (params.startDate > params.endDate) { + throw new BadRequestException('开始日期不能晚于结束日期'); + } + + const errors: string[] = []; + let synced = 0; + + for (const date of this.enumerateDates(params.startDate, params.endDate)) { + for (const userId of userIds) { + try { + const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date); + for (const leave of leaves) { + await this.upsertLeave(leave); + synced++; + } + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + errors.push(`请假同步失败 ${userId} ${date}: ${msg}`); + this.logger.warn(`钉钉请假同步失败 userId=${userId} date=${date}: ${msg}`); + } + } + } + + const matched = params.autoMatch ? await this.autoMatchLeaveRecords() : 0; + if (synced > 0 || matched > 0) { + this.logger.log(`钉钉请假同步完成: 新增/更新 ${synced} 条, 匹配 ${matched} 条, 错误 ${errors.length} 条`); + } + return { synced, matched, errors }; + } + + private async upsertLeave(result: DingTalkLeaveResult): Promise { + const existing = await this.dingLeaveRawRepo.findOne({ + where: { dingId: result.procInstId }, + }); + if (existing) { + Object.assign(existing, { + dingUserId: result.userId, + workDate: result.workDate, + leaveType: result.leaveType, + tagName: result.tagName, + startTime: result.beginTime, + endTime: result.endTime, + approvedAt: result.approvedAt, + duration: result.duration, + durationUnit: result.durationUnit, + rawData: JSON.stringify(result), + }); + await this.dingLeaveRawRepo.save(existing); + return; + } + + const entity = this.dingLeaveRawRepo.create({ + dingUserId: result.userId, + userName: await this.resolveStudentName(result.userId), + workDate: result.workDate, + dingId: result.procInstId, + leaveType: result.leaveType, + tagName: result.tagName, + startTime: result.beginTime, + endTime: result.endTime, + approvedAt: result.approvedAt, + duration: result.duration, + durationUnit: result.durationUnit, + matchStatus: 'unmatched', + rawData: JSON.stringify(result), + }); + await this.dingLeaveRawRepo.save(entity); + } + + /** 通过 dingUserId → StudentDingMapping 自动匹配未匹配的请假记录。 */ + private async autoMatchLeaveRecords(): Promise { + const unmatched = await this.dingLeaveRawRepo.find({ + where: { matchStatus: 'unmatched' }, + }); + if (unmatched.length === 0) return 0; + + const mappings = await this.studentDingMappingRepo.find(); + const dingToStudentId = new Map(); + for (const mapping of mappings) { + dingToStudentId.set(mapping.dingUserId, mapping.studentId); + } + + let matched = 0; + const updates: DingLeaveRaw[] = []; + for (const record of unmatched) { + const studentId = dingToStudentId.get(record.dingUserId); + if (studentId == null) continue; + record.matchedStudentId = studentId; + record.matchStatus = 'matched'; + updates.push(record); + matched++; + } + if (updates.length > 0) { + await this.dingLeaveRawRepo.save(updates, { chunk: 50 }); + } + return matched; + } + + private enumerateDates(startDate: string, endDate: string): string[] { + const dates: string[] = []; + let cursor = this.parseDate(startDate); + const end = this.parseDate(endDate); + while (cursor.getTime() <= end.getTime()) { + dates.push(this.formatDate(cursor)); + cursor = new Date(cursor); + cursor.setUTCDate(cursor.getUTCDate() + 1); + } + return dates; + } + + private parseDate(value: string): Date { + const date = new Date(`${value}T00:00:00.000Z`); + if (Number.isNaN(date.getTime())) { + throw new BadRequestException(`无效日期: ${value}`); + } + return date; + } + + private formatDate(value: Date): string { + return value.toISOString().slice(0, 10); + } + + private async resolveStudentName(dingUserId: string): Promise { + const mapping = await this.studentDingMappingRepo.findOne({ + where: { dingUserId }, + }); + if (!mapping) return ''; + const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } }); + return student?.name || ''; + } +} diff --git a/apps/server/src/attendance/attendance-lesson-status.ts b/apps/server/src/attendance/attendance-lesson-status.ts new file mode 100644 index 0000000..219def7 --- /dev/null +++ b/apps/server/src/attendance/attendance-lesson-status.ts @@ -0,0 +1,140 @@ +import { Repository } from 'typeorm'; +import { + AttendanceRecord, + ClassSchedule, + ClassStudent, + DingAttendanceRaw, + DingLeaveRaw, +} from '../entities'; +import { + getLessonAttendanceWindow, + getLessonPunchMetadata, + mapDingTalkStatus, + selectDingTalkRecordsForLesson, + type LessonScheduleLike, +} from './attendance-dingtalk'; + +type LessonRecordSchedule = Pick< + ClassSchedule, + 'classId' | 'startTime' | 'endTime' | 'attendanceAdvanceMinutes' +>; + +/** + * 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与 + * 本节课时间窗口重叠,则记为 leave,而不是缺勤。 + */ +export async function resolveLessonStatus( + dingLeaveRawRepo: Repository, + studentId: number, + raw: DingAttendanceRaw[], + schedule: LessonScheduleLike, + lessonDate: string, + finalize: boolean, +): Promise<{ status: string; remark?: string }> { + const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime); + if (!finalize) { + return { + status: mapDingTalkStatus(raw, false), + remark: hasPunch ? undefined : '未获取到钉钉打卡结果', + }; + } + if (hasPunch) return { status: 'present', remark: undefined }; + + const leave = await findApprovedLeaveForStudent(dingLeaveRawRepo, studentId, schedule, lessonDate); + if (leave) { + return { + status: 'leave', + remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`, + }; + } + return { status: 'absent', remark: '课程截止仍未打卡' }; +} + +export function createLessonRecord( + recordRepo: Repository, + classStudent: ClassStudent, + raw: DingAttendanceRaw[], + options: { + schedule: LessonRecordSchedule; + scheduleId: number; + lessonDate: string; + lessonSessionKey: string; + attendanceSessionId: number; + status: string; + remark?: string; + }, +): AttendanceRecord { + return recordRepo.create({ + studentId: classStudent.studentId, + student: classStudent.student, + classId: options.schedule.classId!, + scheduleId: options.scheduleId, + attendanceSessionId: options.attendanceSessionId, + attendanceDate: options.lessonDate, + session: options.lessonSessionKey, + status: options.status, + source: 'dingtalk', + ...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime), + remark: options.remark, + }); +} + +export async function buildLessonRecord( + recordRepo: Repository, + dingLeaveRawRepo: Repository, + rawByStudent: Map, + classStudent: ClassStudent, + schedule: LessonRecordSchedule, + lessonDate: string, + lessonSessionKey: string, + attendanceSessionId: number, + scheduleId: number, + finalize: boolean, +): Promise { + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(classStudent.studentId) ?? [], + schedule, + lessonDate, + ); + const resolved = await resolveLessonStatus( + dingLeaveRawRepo, + classStudent.studentId, + raw, + schedule, + lessonDate, + finalize, + ); + return createLessonRecord(recordRepo, classStudent, raw, { + schedule, + scheduleId, + lessonDate, + lessonSessionKey, + attendanceSessionId, + status: resolved.status, + remark: resolved.remark, + }); +} + +export async function findApprovedLeaveForStudent( + dingLeaveRawRepo: Repository, + studentId: number, + schedule: LessonScheduleLike, + lessonDate: string, +): Promise { + const leaves = await dingLeaveRawRepo.find({ + where: { matchedStudentId: studentId }, + }); + const window = getLessonAttendanceWindow(schedule, lessonDate); + const overlapping = leaves.filter( + (leave) => + leave.startTime && + leave.endTime && + leave.startTime.getTime() <= window.end && + leave.endTime.getTime() >= window.start, + ); + overlapping.sort( + (left, right) => + (right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0), + ); + return overlapping[0] ?? null; +} diff --git a/apps/server/src/attendance/attendance-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index ed8d4e6..845239d 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -22,9 +22,9 @@ import { getLessonAttendanceImportDateRange, getLessonAttendanceWindow, selectDingTalkRecordsForLesson, - mapDingTalkStatus, getLessonPunchMetadata, } from './attendance-dingtalk'; +import { buildLessonRecord, resolveLessonStatus } from './attendance-lesson-status'; @Injectable() export class AttendanceLessonService { @@ -163,7 +163,8 @@ export class AttendanceLessonService { schedule, lessonDate, ); - const resolved = await this.resolveLessonStatus( + const resolved = await resolveLessonStatus( + this.dingLeaveRawRepo, record.studentId, raw, schedule, @@ -183,8 +184,9 @@ export class AttendanceLessonService { for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; updatedRecords.push( - await this.buildLessonRecord( + await buildLessonRecord( recordRepo, + this.dingLeaveRawRepo, rawByStudent, classStudent, schedule, @@ -257,8 +259,9 @@ export class AttendanceLessonService { const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const records = await Promise.all( classStudents.map((classStudent) => - this.buildLessonRecord( + buildLessonRecord( recordRepo, + this.dingLeaveRawRepo, rawByStudent, classStudent, schedule, @@ -281,122 +284,6 @@ export class AttendanceLessonService { }); } - /** - * 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与 - * 本节课时间窗口重叠,则记为 leave,而不是缺勤。 - */ - private async resolveLessonStatus( - studentId: number, - raw: DingAttendanceRaw[], - schedule: Pick, - lessonDate: string, - finalize: boolean, - ): Promise<{ status: string; remark?: string }> { - const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime); - if (!finalize) { - return { - status: mapDingTalkStatus(raw, false), - remark: hasPunch ? undefined : '未获取到钉钉打卡结果', - }; - } - if (hasPunch) return { status: 'present', remark: undefined }; - - const leave = await this.findApprovedLeaveForStudent(studentId, schedule, lessonDate); - if (leave) { - return { - status: 'leave', - remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`, - }; - } - return { status: 'absent', remark: '课程截止仍未打卡' }; - } - - private createLessonRecord( - recordRepo: Repository, - classStudent: ClassStudent, - raw: DingAttendanceRaw[], - options: { - schedule: Pick; - scheduleId: number; - lessonDate: string; - lessonSessionKey: string; - attendanceSessionId: number; - status: string; - remark?: string; - }, - ): AttendanceRecord { - return recordRepo.create({ - studentId: classStudent.studentId, - student: classStudent.student, - classId: options.schedule.classId!, - scheduleId: options.scheduleId, - attendanceSessionId: options.attendanceSessionId, - attendanceDate: options.lessonDate, - session: options.lessonSessionKey, - status: options.status, - source: 'dingtalk', - ...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime), - remark: options.remark, - }); - } - - private async buildLessonRecord( - recordRepo: Repository, - rawByStudent: Map, - classStudent: ClassStudent, - schedule: Pick, - lessonDate: string, - lessonSessionKey: string, - attendanceSessionId: number, - scheduleId: number, - finalize: boolean, - ): Promise { - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(classStudent.studentId) ?? [], - schedule, - lessonDate, - ); - const resolved = await this.resolveLessonStatus( - classStudent.studentId, - raw, - schedule, - lessonDate, - finalize, - ); - return this.createLessonRecord(recordRepo, classStudent, raw, { - schedule, - scheduleId, - lessonDate, - lessonSessionKey, - attendanceSessionId, - status: resolved.status, - remark: resolved.remark, - }); - } - - private async findApprovedLeaveForStudent( - studentId: number, - schedule: Pick, - lessonDate: string, - ): Promise { - const leaves = await this.dingLeaveRawRepo.find({ - where: { matchedStudentId: studentId }, - }); - const window = getLessonAttendanceWindow(schedule, lessonDate); - const overlapping = leaves.filter( - (leave) => - leave.startTime && - leave.endTime && - leave.startTime.getTime() <= window.end && - leave.endTime.getTime() >= window.start, - ); - overlapping.sort( - (left, right) => - (right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0), - ); - return overlapping[0] ?? null; - } - private async fetchDingTalkRawByStudent( classId: number, schedule: Pick, diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index ac84445..eaa0aa8 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -37,6 +37,8 @@ const createService = () => { }; const importService = { importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), + }; + const leaveSyncService = { syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }), }; const service = new AttendanceSettlementService( @@ -44,13 +46,14 @@ const createService = () => { sessionRepo as never, attendanceService as never, importService as never, + leaveSyncService as never, ); - return { service, scheduleRepo, sessionRepo, attendanceService, importService }; + return { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService }; }; describe('AttendanceSettlementService', () => { it('pulls and finalizes an ended lesson once', async () => { - const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + const { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); @@ -69,7 +72,7 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 2, 2, '2026-07-13', 21, true, ); - expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ + expect(leaveSyncService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ startDate: '2026-07-13', endDate: '2026-07-13', userIds: ['ding-1'], @@ -78,10 +81,10 @@ describe('AttendanceSettlementService', () => { }); it('finalizes the lesson even when the leave sync fails', async () => { - const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + const { service, scheduleRepo, sessionRepo, attendanceService, leaveSyncService } = createService(); scheduleRepo.find.mockResolvedValue([schedule]); sessionRepo.find.mockResolvedValue([]); - importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); + leaveSyncService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 7472fae..a3fa8f1 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities'; +import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceService } from './attendance.service'; @@ -19,6 +20,7 @@ export class AttendanceSettlementService { private readonly sessionRepo: Repository, private readonly attendanceService: AttendanceService, private readonly importService: AttendanceImportService, + private readonly leaveSyncService: AttendanceLeaveSyncService, ) {} @Cron('* * * * *') @@ -130,7 +132,7 @@ export class AttendanceSettlementService { throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); } try { - await this.importService.syncLeaveStatusForLesson({ + await this.leaveSyncService.syncLeaveStatusForLesson({ ...importRange, userIds, autoMatch: true, diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index 348e813..323d161 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; import { AttendanceService } from './attendance.service'; +import { AttendanceLeaveSyncService } from './attendance-leave-sync.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceSettlementService } from './attendance-settlement.service'; import { AttendanceController } from './attendance.controller'; @@ -17,7 +18,12 @@ import { IntegrationModule } from '../integration/integration.module'; IntegrationModule, ], controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController], - providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService], + providers: [ + AttendanceService, + AttendanceImportService, + AttendanceLeaveSyncService, + AttendanceSettlementService, + ], exports: [AttendanceService, AttendanceImportService], }) export class AttendanceModule {}