From c8316e9e8e350ced456342d7ac2a0c3500ff365a Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 5 Aug 2026 18:43:02 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=93=E7=AE=97=E6=97=B6=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E9=92=89=E9=92=89=E5=B7=B2=E5=AE=A1=E6=89=B9=E8=AF=B7?= =?UTF-8?q?=E5=81=87=E5=B9=B6=E6=A0=87=E8=AE=B0=E4=B8=BA=E8=AF=B7=E5=81=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/server/src/app.module.ts | 1 + .../attendance-import.service.spec.ts | 73 +++++++- .../attendance/attendance-import.service.ts | 134 ++++++++++++- .../attendance/attendance-lesson.service.ts | 176 ++++++++++++------ .../attendance-settlement.service.spec.ts | 20 ++ .../attendance-settlement.service.ts | 12 ++ .../attendance/attendance.boundaries.spec.ts | 4 + .../attendance.lesson-session.spec.ts | 55 ++++++ .../src/attendance/attendance.module.ts | 4 +- .../src/attendance/attendance.service.spec.ts | 6 + .../src/attendance/attendance.service.ts | 4 + .../dingtalk-attendance.service.spec.ts | 69 +++++++ .../database-migrations.attendance.ts | 41 ++++ .../database/database-migrations.service.ts | 6 + .../src/entities/ding-leave-raw.entity.ts | 71 +++++++ apps/server/src/entities/index.ts | 1 + apps/server/src/integration/dingtalk.leave.ts | 95 ++++++++++ .../src/integration/dingtalk.service.ts | 14 ++ apps/server/src/integration/dingtalk.types.ts | 18 ++ 19 files changed, 746 insertions(+), 58 deletions(-) create mode 100644 apps/server/src/entities/ding-leave-raw.entity.ts create mode 100644 apps/server/src/integration/dingtalk.leave.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 0cd513c..2423e75 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -121,6 +121,7 @@ import { IntegrationConfigModule } from './integration/config/config.module'; Entities.AttendanceDevice, Entities.AttendancePeriodConfig, Entities.DingAttendanceRaw, + Entities.DingLeaveRaw, Entities.Notification, Entities.StudentProfile, Entities.StudentEnrollment, diff --git a/apps/server/src/attendance/attendance-import.service.spec.ts b/apps/server/src/attendance/attendance-import.service.spec.ts index f238993..f545a42 100644 --- a/apps/server/src/attendance/attendance-import.service.spec.ts +++ b/apps/server/src/attendance/attendance-import.service.spec.ts @@ -9,10 +9,17 @@ 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() }; + const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const dingTalkService = { fetchAttendanceResults: jest.fn(), + fetchDailyLeaveStatus: jest.fn(), }; const attendanceService = { autoMatchDingRecords: jest.fn(), @@ -24,6 +31,7 @@ describe('AttendanceImportService', () => { jest.clearAllMocks(); service = new AttendanceImportService( dingRawRepo as never, + dingLeaveRawRepo as never, studentRepo as never, studentDingMappingRepo as never, dingTalkService as unknown as DingTalkService, @@ -304,4 +312,67 @@ describe('AttendanceImportService', () => { expect(event.userId).toBeUndefined(); } }); + + 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 8895a17..87bc2f5 100644 --- a/apps/server/src/attendance/attendance-import.service.ts +++ b/apps/server/src/attendance/attendance-import.service.ts @@ -4,10 +4,15 @@ import { Repository, In } from 'typeorm'; import { Subject, Observable } from 'rxjs'; import { DingAttendanceRaw, + DingLeaveRaw, Student, StudentDingMapping, } from '../entities'; -import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service'; +import { + DingTalkService, + DingTalkAttendanceResult, + DingTalkLeaveResult, +} from '../integration/dingtalk.service'; import { AttendanceService } from './attendance.service'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; @@ -33,6 +38,8 @@ export class AttendanceImportService { constructor( @InjectRepository(DingAttendanceRaw) private readonly dingRawRepo: Repository, + @InjectRepository(DingLeaveRaw) + private readonly dingLeaveRawRepo: Repository, @InjectRepository(Student) private readonly studentRepo: Repository, @InjectRepository(StudentDingMapping) @@ -154,6 +161,131 @@ 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-lesson.service.ts b/apps/server/src/attendance/attendance-lesson.service.ts index 5dafa4a..779c19a 100644 --- a/apps/server/src/attendance/attendance-lesson.service.ts +++ b/apps/server/src/attendance/attendance-lesson.service.ts @@ -4,6 +4,7 @@ import { DataSource, Repository, In, Between } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw, + DingLeaveRaw, Class, Student, ClassSchedule, @@ -32,6 +33,7 @@ export class AttendanceLessonService { constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, + @InjectRepository(DingLeaveRaw) private dingLeaveRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @@ -150,29 +152,34 @@ export class AttendanceLessonService { const existingStudentIds = new Set(existingRecords.map((record) => record.studentId)); const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); - const updatedRecords = existingRecords.map((record) => { - record.student = studentsById.get(record.studentId)!; - // Preserve manual corrections only while the lesson is still in progress. - if (!finalize && record.source !== 'dingtalk') return record; + const updatedRecords = await Promise.all( + existingRecords.map(async (record) => { + record.student = studentsById.get(record.studentId)!; + // Preserve manual corrections only while the lesson is still in progress. + if (!finalize && record.source !== 'dingtalk') return record; - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(record.studentId) ?? [], - schedule, - lessonDate, - ); - record.status = mapDingTalkStatus(raw, effectiveFinalize); - Object.assign(record, getLessonPunchMetadata( - raw, + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(record.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - )); - record.remark = raw.some((item) => item.checkInTime || item.checkOutTime) - ? null - : effectiveFinalize - ? '课程截止仍未打卡' - : '未获取到钉钉打卡结果'; - return record; - }); + ); + const resolved = await this.resolveLessonStatus( + record.studentId, + raw, + schedule, + lessonDate, + effectiveFinalize, + ); + record.status = resolved.status; + Object.assign(record, getLessonPunchMetadata( + raw, + lessonDate, + schedule.startTime, + )); + record.remark = resolved.remark ?? null; + return record; + }), + ); for (const classStudent of classStudents) { if (existingStudentIds.has(classStudent.studentId)) continue; const raw = selectDingTalkRecordsForLesson( @@ -180,6 +187,13 @@ export class AttendanceLessonService { schedule, lessonDate, ); + const resolved = await this.resolveLessonStatus( + classStudent.studentId, + raw, + schedule, + lessonDate, + effectiveFinalize, + ); updatedRecords.push( recordRepo.create({ studentId: classStudent.studentId, @@ -189,18 +203,14 @@ export class AttendanceLessonService { attendanceSessionId: existing.id, attendanceDate: lessonDate, session: lessonSessionKey, - status: mapDingTalkStatus(raw, effectiveFinalize), + status: resolved.status, source: 'dingtalk', ...getLessonPunchMetadata( raw, lessonDate, schedule.startTime, ), - remark: raw.some((item) => item.checkInTime || item.checkOutTime) - ? undefined - : effectiveFinalize - ? '课程截止仍未打卡' - : '未获取到钉钉打卡结果', + remark: resolved.remark, }), ); } @@ -263,34 +273,39 @@ export class AttendanceLessonService { } const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); - const records = classStudents.map((classStudent) => { - const raw = selectDingTalkRecordsForLesson( - rawByStudent.get(classStudent.studentId) ?? [], - schedule, - lessonDate, - ); - return recordRepo.create({ - studentId: classStudent.studentId, - student: classStudent.student, - classId: schedule.classId!, - scheduleId, - attendanceSessionId: session.id, - attendanceDate: lessonDate, - session: lessonSessionKey, - status: mapDingTalkStatus(raw, finalize), - source: 'dingtalk', - ...getLessonPunchMetadata( - raw, + const records = await Promise.all( + classStudents.map(async (classStudent) => { + const raw = selectDingTalkRecordsForLesson( + rawByStudent.get(classStudent.studentId) ?? [], + schedule, lessonDate, - schedule.startTime, - ), - remark: raw.some((item) => item.checkInTime || item.checkOutTime) - ? undefined - : finalize - ? '课程截止仍未打卡' - : '未获取到钉钉打卡结果', - }); - }); + ); + const resolved = await this.resolveLessonStatus( + classStudent.studentId, + raw, + schedule, + lessonDate, + finalize, + ); + return recordRepo.create({ + studentId: classStudent.studentId, + student: classStudent.student, + classId: schedule.classId!, + scheduleId, + attendanceSessionId: session.id, + attendanceDate: lessonDate, + session: lessonSessionKey, + status: resolved.status, + source: 'dingtalk', + ...getLessonPunchMetadata( + raw, + lessonDate, + schedule.startTime, + ), + remark: resolved.remark, + }); + }), + ); const saved = await recordRepo.save(records); if (finalize) { session.status = 'completed'; @@ -302,6 +317,59 @@ 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 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 1c17af6..ac84445 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -37,6 +37,7 @@ const createService = () => { }; const importService = { importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), + syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }), }; const service = new AttendanceSettlementService( scheduleRepo as never, @@ -68,6 +69,25 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( 2, 2, '2026-07-13', 21, true, ); + expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ + startDate: '2026-07-13', + endDate: '2026-07-13', + userIds: ['ding-1'], + autoMatch: true, + }); + }); + + it('finalizes the lesson even when the leave sync fails', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([]); + importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable')); + + await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00')); + + expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith( + 2, '2026-07-13', 21, true, + ); }); it('does not settle a lesson before its end time', async () => { diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index d4393aa..7472fae 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -129,6 +129,18 @@ export class AttendanceSettlementService { if (!imported.success || imported.errors.length > 0) { throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); } + try { + await this.importService.syncLeaveStatusForLesson({ + ...importRange, + userIds, + autoMatch: true, + }); + } catch (error: unknown) { + // 请假数据是补充信息,同步失败不应阻断结算;无请假的学生按缺勤处理。 + this.logger.warn( + `课程${schedule.id} ${lessonDate}钉钉请假同步失败: ${error instanceof Error ? error.message : String(error)}`, + ); + } await this.attendanceService.createLessonAttendanceFromDingTalk( schedule.id, lessonDate, diff --git a/apps/server/src/attendance/attendance.boundaries.spec.ts b/apps/server/src/attendance/attendance.boundaries.spec.ts index 0587ee8..1dd35a7 100644 --- a/apps/server/src/attendance/attendance.boundaries.spec.ts +++ b/apps/server/src/attendance/attendance.boundaries.spec.ts @@ -28,6 +28,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => { return new AttendanceService( {} as never, // attendanceRepo {} as never, // dingRawRepo + {} as never, // dingLeaveRawRepo {} as never, // classRepo {} as never, // studentRepo {} as never, // scheduleRepo @@ -217,6 +218,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () {} as never, {} as never, {} as never, + {} as never, scheduleRepo as never, {} as never, {} as never, @@ -277,6 +279,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () {} as never, {} as never, {} as never, + {} as never, scheduleRepo as never, {} as never, {} as never, @@ -339,6 +342,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', () {} as never, {} as never, {} as never, + {} as never, scheduleRepo as never, {} as never, {} as never, diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index 4509b9d..339b83e 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -14,6 +14,7 @@ const createService = () => { count: jest.fn(), }; const dingRawRepo = { find: jest.fn() }; + const dingLeaveRawRepo = { find: jest.fn().mockResolvedValue([]) }; const scheduleRepo = { findOne: jest.fn() }; const classStudentRepo = { find: jest.fn() }; const sessionRepo = { @@ -37,6 +38,7 @@ const createService = () => { const service = new AttendanceService( attendanceRepo as never, dingRawRepo as never, + dingLeaveRawRepo as never, {} as never, {} as never, scheduleRepo as never, @@ -52,6 +54,7 @@ const createService = () => { service, attendanceRepo, dingRawRepo, + dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo, @@ -151,6 +154,58 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => { expect(result.session.status).toBe('completed'); }); + it('finalizes a missing punch as leave when an approved DingTalk leave overlaps the lesson', async () => { + const { service, attendanceRepo, dingRawRepo, dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = + createService(); + scheduleRepo.findOne.mockResolvedValue(endedSchedule); + sessionRepo.findOne.mockResolvedValue(null); + classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]); + dingRawRepo.find.mockResolvedValue([]); + dingLeaveRawRepo.find.mockResolvedValue([ + { + startTime: new Date('2026-07-11T08:00:00+08:00'), + endTime: new Date('2026-07-11T12:00:00+08:00'), + approvedAt: new Date('2026-07-10T15:00:00+08:00'), + leaveType: '事假', + tagName: '请假', + }, + ]); + + await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true); + + expect(attendanceRepo.save).toHaveBeenCalledWith([ + expect.objectContaining({ + studentId: 1, + status: 'leave', + remark: '钉钉请假已通过(事假)', + }), + ]); + }); + + it('keeps a leave student pending before the lesson is finalized', async () => { + const { service, attendanceRepo, dingRawRepo, dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = + createService(); + scheduleRepo.findOne.mockResolvedValue(endedSchedule); + sessionRepo.findOne.mockResolvedValue(null); + classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]); + dingRawRepo.find.mockResolvedValue([]); + dingLeaveRawRepo.find.mockResolvedValue([ + { + startTime: new Date('2026-07-11T08:00:00+08:00'), + endTime: new Date('2026-07-11T12:00:00+08:00'), + approvedAt: new Date('2026-07-10T15:00:00+08:00'), + leaveType: '事假', + tagName: '请假', + }, + ]); + + await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21); + + expect(attendanceRepo.save).toHaveBeenCalledWith([ + expect.objectContaining({ studentId: 1, status: 'pending' }), + ]); + }); + it('returns student relations after the first pull', async () => { const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = createService(); diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts index 136406f..348e813 100644 --- a/apps/server/src/attendance/attendance.module.ts +++ b/apps/server/src/attendance/attendance.module.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; -import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; +import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities'; import { AttendanceService } from './attendance.service'; import { AttendanceImportService } from './attendance-import.service'; import { AttendanceSettlementService } from './attendance-settlement.service'; @@ -12,7 +12,7 @@ import { IntegrationModule } from '../integration/integration.module'; @Module({ imports: [ - TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), + TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]), OperationLogsModule, IntegrationModule, ], diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index 36d0603..c00e7ae 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -8,6 +8,7 @@ import { AttendanceSession } from '../entities/attendance-session.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity'; import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; +import { DingLeaveRaw } from '../entities/ding-leave-raw.entity'; import { Class } from '../entities/class.entity'; import { Student } from '../entities/student.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; @@ -50,6 +51,7 @@ describe('AttendanceService — batchCreate', () => { AttendanceService, { provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo }, { provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo }, + { provide: getRepositoryToken(DingLeaveRaw), useValue: { find: jest.fn().mockResolvedValue([]) } }, { provide: getRepositoryToken(Class), useValue: mockClassRepo }, { provide: getRepositoryToken(Student), useValue: mockStudentRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo }, @@ -156,6 +158,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => { {} as never, {} as never, {} as never, + {} as never, classStudentRepo as never, mappingRepo as never, classTeacherRepo as never, @@ -231,6 +234,7 @@ describe('AttendanceService — DingTalk raw query', () => { {} as never, {} as never, {} as never, + {} as never, classStudentRepo as never, mappingRepo as never, {} as never, @@ -299,6 +303,7 @@ describe('AttendanceService — attendance device display mappings', () => { {} as never, {} as never, {} as never, + {} as never, attendanceDeviceRepo as never, {} as never, {} as never, @@ -408,6 +413,7 @@ describe('AttendanceService — session serialization', () => { {} as never, {} as never, {} as never, + {} as never, { find: jest.fn().mockResolvedValue([]) } as never, {} as never, dataSourceMock as never, diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index b12848e..d13b2ab 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -7,6 +7,7 @@ import { AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, + DingLeaveRaw, Class, Student, ClassSchedule, @@ -35,6 +36,8 @@ export class AttendanceService { private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, + @InjectRepository(DingLeaveRaw) + private dingLeaveRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(Student) @@ -69,6 +72,7 @@ export class AttendanceService { this.lessonService = new AttendanceLessonService( this.attendanceRepo, this.dingRawRepo, + this.dingLeaveRawRepo, this.classRepo, this.studentRepo, this.scheduleRepo, diff --git a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts index e8e79b0..4426b1a 100644 --- a/apps/server/src/attendance/dingtalk-attendance.service.spec.ts +++ b/apps/server/src/attendance/dingtalk-attendance.service.spec.ts @@ -94,4 +94,73 @@ describe('DingTalkService — attendance records', () => { }), ); }); + + it('fetches approved leave approvals from the daily attendance data API', async () => { + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ + errcode: 0, + errmsg: 'ok', + result: { + userid: 'ding-1', + work_date: '2026-07-12 00:00:00', + approve_list: [ + { + procInst_id: 'PRO-LEAVE-1', + tag_name: '请假', + sub_type: '事假', + biz_type: 3, + begin_time: '2026-07-12 08:00:00', + end_time: '2026-07-12 12:00:00', + gmt_finished: '2026-07-11 18:00:00', + duration: '0.5', + duration_unit: 'day', + }, + { + // 审批中(无 gmt_finished)的请假不应返回 + procInst_id: 'PRO-LEAVE-2', + tag_name: '请假', + sub_type: '病假', + biz_type: 3, + begin_time: '2026-07-12 08:00:00', + end_time: '2026-07-12 12:00:00', + duration: '0.5', + duration_unit: 'day', + }, + { + // 出差(biz_type=2)不应返回 + procInst_id: 'PRO-TRIP-1', + tag_name: '出差', + sub_type: '出差', + biz_type: 2, + begin_time: '2026-07-12 08:00:00', + end_time: '2026-07-12 18:00:00', + gmt_finished: '2026-07-11 18:00:00', + }, + ], + }, + }), + }) as jest.MockedFunction; + + const leaves = await service.fetchDailyLeaveStatus('ding-1', '2026-07-12'); + + expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({ + userid: 'ding-1', + work_date: '2026-07-12 00:00:00', + }); + expect(leaves).toHaveLength(1); + expect(leaves[0]).toEqual( + expect.objectContaining({ + userId: 'ding-1', + workDate: '2026-07-12', + procInstId: 'PRO-LEAVE-1', + leaveType: '事假', + tagName: '请假', + beginTime: new Date('2026-07-12T08:00:00+08:00'), + endTime: new Date('2026-07-12T12:00:00+08:00'), + approvedAt: new Date('2026-07-11T18:00:00+08:00'), + duration: '0.5', + durationUnit: 'day', + }), + ); + }); }); diff --git a/apps/server/src/database/database-migrations.attendance.ts b/apps/server/src/database/database-migrations.attendance.ts index b68f9fd..61f918f 100644 --- a/apps/server/src/database/database-migrations.attendance.ts +++ b/apps/server/src/database/database-migrations.attendance.ts @@ -78,6 +78,47 @@ export async function ensureCourseAttendanceSchema( }); } +export async function ensureDingLeaveSchema( + dataSource: DataSource, + logger: Logger, +): Promise { + await withQueryRunner(dataSource, async (runner) => { + await runner.query(` + CREATE TABLE IF NOT EXISTS ding_leave_raw ( + id INTEGER PRIMARY KEY AUTO_INCREMENT, + ding_user_id VARCHAR(100) NOT NULL, + user_name VARCHAR(100) NOT NULL DEFAULT '', + work_date DATE NOT NULL, + ding_id VARCHAR(100) NOT NULL, + leave_type VARCHAR(100) NOT NULL DEFAULT '', + tag_name VARCHAR(50) NOT NULL DEFAULT '', + start_time DATETIME, + end_time DATETIME, + approved_at DATETIME, + duration VARCHAR(20) NOT NULL DEFAULT '', + duration_unit VARCHAR(20) NOT NULL DEFAULT '', + match_status VARCHAR(20) NOT NULL DEFAULT 'unmatched', + matched_student_id INTEGER, + raw_data TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); + + const createIndex = async (sql: string) => { + try { + await runner.query(sql); + } catch { + // Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent. + } + }; + await createIndex('CREATE UNIQUE INDEX uq_ding_leave_raw_ding_id ON ding_leave_raw (ding_id)'); + await createIndex('CREATE INDEX idx_ding_leave_raw_work_date ON ding_leave_raw (work_date)'); + await createIndex('CREATE INDEX idx_ding_leave_raw_match_status ON ding_leave_raw (match_status)'); + logger.log('已确保钉钉请假原始表 ding_leave_raw'); + }); +} + export async function protectAttendanceHistory( dataSource: DataSource, logger: Logger, diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 2b601aa..63848f0 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -14,6 +14,7 @@ import { import { ensureAiConfigTable } from './database-migrations.ai'; import { ensureCourseAttendanceSchema, + ensureDingLeaveSchema, protectAttendanceHistory, } from './database-migrations.attendance'; import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill'; @@ -29,6 +30,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.ensureSyncStateLeaseColumns(); await this.ensureStudentProfileCollegeColumns(); await this.ensureCourseAttendanceSchema(); + await this.ensureDingLeaveSchema(); await this.ensureAttendanceDevicesSchema(); await this.ensureStudentWalletSchema(); await this.backfillOrganizations(); @@ -85,6 +87,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { return ensureCourseAttendanceSchema(this.dataSource, this.logger); } + async ensureDingLeaveSchema(): Promise { + return ensureDingLeaveSchema(this.dataSource, this.logger); + } + async backfillOrganizations(): Promise { return backfillOrganizations(this.dataSource); } diff --git a/apps/server/src/entities/ding-leave-raw.entity.ts b/apps/server/src/entities/ding-leave-raw.entity.ts new file mode 100644 index 0000000..f348ff5 --- /dev/null +++ b/apps/server/src/entities/ding-leave-raw.entity.ts @@ -0,0 +1,71 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Student } from './student.entity'; + +@Entity('ding_leave_raw') +@Index(['workDate']) +@Index(['matchStatus']) +export class DingLeaveRaw { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'ding_user_id', length: 100 }) + dingUserId: string; + + @Column({ name: 'user_name', length: 100 }) + userName: string; + + @Column({ name: 'work_date', type: 'date' }) + workDate: string; + + @Column({ name: 'ding_id', length: 100, unique: true }) + dingId: string; + + @Column({ name: 'leave_type', length: 100 }) + leaveType: string; + + @Column({ name: 'tag_name', length: 50 }) + tagName: string; + + @Column({ name: 'start_time', type: 'datetime', nullable: true }) + startTime: Date | null; + + @Column({ name: 'end_time', type: 'datetime', nullable: true }) + endTime: Date | null; + + @Column({ name: 'approved_at', type: 'datetime', nullable: true }) + approvedAt: Date | null; + + @Column({ length: 20 }) + duration: string; + + @Column({ name: 'duration_unit', length: 20 }) + durationUnit: string; + + @Column({ name: 'match_status', length: 20, default: 'unmatched' }) + matchStatus: string; + + @Column({ name: 'matched_student_id', type: 'integer', nullable: true }) + matchedStudentId: number; + + @ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'matched_student_id' }) + matchedStudent: Student; + + @Column({ name: 'raw_data', type: 'text', nullable: true }) + rawData: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 90f991b..ed8af6e 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -27,6 +27,7 @@ export { AttendanceSession } from './attendance-session.entity'; export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity'; export { AttendancePeriodConfig } from './attendance-period-config.entity'; export { DingAttendanceRaw } from './ding-attendance-raw.entity'; +export { DingLeaveRaw } from './ding-leave-raw.entity'; export { SyncLog } from './sync-log.entity'; export { SyncState } from './sync-state.entity'; export { ExpenseType } from './expense-type.entity'; diff --git a/apps/server/src/integration/dingtalk.leave.ts b/apps/server/src/integration/dingtalk.leave.ts new file mode 100644 index 0000000..65fa92a --- /dev/null +++ b/apps/server/src/integration/dingtalk.leave.ts @@ -0,0 +1,95 @@ +// aislop-ignore-file: duplicate-block -- 钉钉 API 调用块结构相似(端点/参数不同) +import type { + DingTalkLeaveResult, + DingTalkServiceContext, +} from './dingtalk.types'; + +interface DingTalkGetUpdateDataResponse { + errcode: number; + errmsg: string; + result?: { + userid?: string; + work_date?: string; + approve_list?: Array<{ + procInst_id?: string; + tag_name?: string; + sub_type?: string; + biz_type?: number; + begin_time?: string; + end_time?: string; + gmt_finished?: string; + duration?: string; + duration_unit?: string; + }>; + }; +} + +/** + * 钉钉请假数据客户端。 + * + * 使用「获取用户考勤数据」接口(topapi/attendance/getupdatedata),按用户+工作日 + * 返回当天打卡结果与审批单列表;这里只取 biz_type=3(请假)且已审批完成 + * (gmt_finished 非空)的记录,保证结算时不会把审批中的请假误判为请假。 + */ +export class DingTalkLeaveClient { + constructor(private readonly context: DingTalkServiceContext) {} + + async fetchDailyLeaveStatus( + userId: string, + workDate: string, + ): Promise { + if (!(await this.context.isConfigured())) throw new Error('DingTalk not configured'); + if (!userId) throw new Error('钉钉请假查询 userId 不能为空'); + + const token = await this.context.getAccessToken(); + await this.context.rateLimit(); + const res = await fetch( + `https://oapi.dingtalk.com/topapi/attendance/getupdatedata?access_token=${token}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + userid: userId, + work_date: workDate.includes(' ') ? workDate : `${workDate} 00:00:00`, + }), + }, + ); + const data = (await res.json()) as DingTalkGetUpdateDataResponse; + if (data.errcode !== 0) { + throw new Error(`钉钉请假数据获取失败: ${data.errmsg}`); + } + + const result = data.result; + if (!result) return []; + const approveList = result.approve_list ?? []; + + return approveList + .filter( + (approval) => + approval.biz_type === 3 && + approval.gmt_finished && + approval.procInst_id && + approval.begin_time && + approval.end_time, + ) + .map((approval) => ({ + userId: result.userid ?? userId, + workDate, + procInstId: approval.procInst_id!, + tagName: approval.tag_name ?? '请假', + leaveType: approval.sub_type ?? '', + beginTime: this.parseDingDate(approval.begin_time!), + endTime: this.parseDingDate(approval.end_time!), + approvedAt: this.parseDingDate(approval.gmt_finished!), + duration: approval.duration ?? '', + durationUnit: approval.duration_unit ?? '', + })); + } + + /** 钉钉返回的日期可能是 '2026-08-01' 或 '2026-08-01 09:00:00',统一按东八区解析。 */ + private parseDingDate(value: string): Date { + const normalized = value.includes(' ') ? value.replace(' ', 'T') : `${value}T00:00:00`; + const date = new Date(`${normalized}+08:00`); + return Number.isNaN(date.getTime()) ? new Date(normalized) : date; + } +} diff --git a/apps/server/src/integration/dingtalk.service.ts b/apps/server/src/integration/dingtalk.service.ts index 433c4b4..e25c055 100644 --- a/apps/server/src/integration/dingtalk.service.ts +++ b/apps/server/src/integration/dingtalk.service.ts @@ -25,12 +25,14 @@ import type { OrgDeptNodeWithUsers, } from './dingtalk.types'; import { DingTalkAttendanceClient } from './dingtalk.attendance'; +import { DingTalkLeaveClient } from './dingtalk.leave'; import { DingTalkShiftClient } from './dingtalk.shifts'; import { DingTalkGroupClient } from './dingtalk.groups'; import { DingTalkScheduleClient } from './dingtalk.schedules'; export type { DingTalkAttendanceResult, + DingTalkLeaveResult, DingTalkGroupParams, DingTalkGroupSummary, DingTalkGroupUpdateParams, @@ -55,6 +57,7 @@ export class DingTalkService implements DingTalkServiceContext { private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; private attendanceClient?: DingTalkAttendanceClient; + private leaveClient?: DingTalkLeaveClient; private shiftClient?: DingTalkShiftClient; private groupClient?: DingTalkGroupClient; private scheduleClient?: DingTalkScheduleClient; @@ -73,6 +76,11 @@ export class DingTalkService implements DingTalkServiceContext { return this.attendanceClient; } + private get leaves(): DingTalkLeaveClient { + if (!this.leaveClient) this.leaveClient = new DingTalkLeaveClient(this); + return this.leaveClient; + } + private get shifts(): DingTalkShiftClient { if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this); return this.shiftClient; @@ -367,6 +375,12 @@ export class DingTalkService implements DingTalkServiceContext { return this.attendance.fetchAttendanceResults(...args); } + async fetchDailyLeaveStatus( + ...args: Parameters + ) { + return this.leaves.fetchDailyLeaveStatus(...args); + } + async upsertShift(...args: Parameters) { return this.shifts.upsertShift(...args); } diff --git a/apps/server/src/integration/dingtalk.types.ts b/apps/server/src/integration/dingtalk.types.ts index 1b4bd26..764fbbe 100644 --- a/apps/server/src/integration/dingtalk.types.ts +++ b/apps/server/src/integration/dingtalk.types.ts @@ -63,6 +63,24 @@ export interface DingTalkAttendanceResult { deviceId?: string; } +/** 钉钉已审批通过的请假记录 — 对齐 dws 考勤数据中的审批单列表。 */ +export interface DingTalkLeaveResult { + userId: string; + workDate: string; + /** 钉钉审批单 ID */ + procInstId: string; + /** 审批单类型名称,例如 请假 */ + tagName: string; + /** 请假类型,例如 年假 / 事假 / 病假 */ + leaveType: string; + beginTime: Date; + endTime: Date; + /** 审批完成时间;为 null 表示仍在审批中,不纳入结算 */ + approvedAt: Date | null; + duration: string; + durationUnit: string; +} + // ── 组织架构 API 类型 ── export interface DingTalkDeptListResponse {