import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In } from 'typeorm'; import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; interface DailySchedulePeriod { startTime: string; endTime: string; scheduleId: number; } interface DailySchedulePlan { classId: number; date: string; shiftKey: string; periods: DailySchedulePeriod[]; } /** 单次排班同步的结果 */ export interface ScheduleSyncResult { /** 参与同步的排课记录数 */ scheduleCount: number; /** 创建/复用的班次数 */ shiftCount: number; /** 创建/复用的考勤组数 */ groupCount: number; /** 实际写入钉钉的排班条数 */ syncedItems: number; /** 因无学生或无钉钉映射而跳过的排课数 */ skippedNoMapping: number; /** 写入失败的排班批次数 */ failedBatchCount: number; /** 写入失败的排班条数 */ failedItems: number; /** 失败批次错误详情 */ errors: string[]; /** 按班级分组的详情 */ groups: Array<{ className: string; groupId: number; itemCount: number; }>; } /** * 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。 * * ## 同步流程(按班级学生) * 1. 查询活跃排课,按 classId 分组 * 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId * 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次) * 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次) * 5. 将排课展开为每个学生的每日排班,批量写入钉钉 * * ## 残余风险:同步窗口内已不存在的旧排班无法清理 * 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和 * `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口 * 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。 * 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机; * 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。 * * ## API 调用优化 * - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。 * - 排班写入按考勤组分批(钉钉单次最多 200 条)。 */ @Injectable() export class ScheduleSyncService { private readonly logger = new Logger(ScheduleSyncService.name); constructor( @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, @InjectRepository(ClassStudent) private readonly classStudentRepo: Repository, @InjectRepository(StudentDingMapping) private readonly mappingRepo: Repository, @InjectRepository(Class) private readonly classRepo: Repository, private readonly dingTalkService: DingTalkService, ) {} /** * 全量同步:将所有活跃排课同步到钉钉排班。 * @param dateFrom 起始日期(YYYY-MM-DD),默认今天 * @param days 同步天数,默认 30 * @param opUserId 钉钉操作人 userId * @param attendanceMachineOnly 是否关闭手机类打卡入口,仅使用考勤机 */ async syncAll( dateFrom?: string, days = 30, opUserId = 'manager', attendanceMachineOnly = false, ): Promise { const startDate = dateFrom || new Date().toISOString().slice(0, 10); const endDate = this.addDays(startDate, days); const empty: ScheduleSyncResult = { scheduleCount: 0, shiftCount: 0, groupCount: 0, syncedItems: 0, skippedNoMapping: 0, failedBatchCount: 0, failedItems: 0, errors: [], groups: [], }; // ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ── const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' }, }); const schedules = allSchedules.filter((s) => s.classId != null); if (schedules.length === 0) { this.logger.log('没有需要同步的活跃排课(无关联班级)'); return empty; } // ── Step 2: 班级 → 学生钉钉ID 映射 ── const classIds = [...new Set(schedules.map((s) => s.classId as number))]; const classDingUsers = await this.buildClassDingUserMap(classIds); const classNameMap = await this.loadClassNames(classIds); // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate); const uniqueShifts = new Map< string, { className: string; periods: DailySchedulePeriod[] } >(); const shiftPlanCount = new Map(); for (const plan of dailyPlans) { if (!uniqueShifts.has(plan.shiftKey)) { uniqueShifts.set(plan.shiftKey, { className: classNameMap.get(plan.classId) || `班级${plan.classId}`, periods: plan.periods, }); } shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1); } const existingShifts = await this.dingTalkService.queryShifts(opUserId); const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id])); const planToShiftId = new Map(); const errors: string[] = []; let failedBatchCount = 0; let failedItems = 0; let shiftCount = 0; for (const [key, { className, periods }] of uniqueShifts) { const periodLabel = periods .map((period) => `${period.startTime}-${period.endTime}`) .join('+'); const shiftName = `${className}_${periodLabel}`; try { let shiftId = shiftByName.get(shiftName); const shiftParams = { ...(shiftId === undefined ? {} : { id: shiftId }), name: shiftName, owner: opUserId, sections: periods.map((period) => ({ times: [ { check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${period.startTime}:00`, free_check: false, }, { check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${period.endTime}:00`, free_check: false, }, ], })), setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: Math.max( ...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)), ), }, }; shiftId = await this.dingTalkService.upsertShift(shiftParams); shiftByName.set(shiftName, shiftId); planToShiftId.set(key, shiftId); shiftCount++; } catch (e) { const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`; this.logger.error(msg); errors.push(msg); failedBatchCount++; failedItems += shiftPlanCount.get(key) || 0; } } // ── Step 4: 考勤组列表只查一次,供每个班级匹配 ── const existingGroups = await this.dingTalkService.queryAttendanceGroups(opUserId); const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id])); // ── Step 5: 按班级同步 ── const schedulesByClass = new Map(); for (const schedule of schedules) { const classId = schedule.classId as number; if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []); schedulesByClass.get(classId)!.push(schedule); } const dailyPlansByClass = new Map(); for (const plan of dailyPlans) { if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []); dailyPlansByClass.get(plan.classId)!.push(plan); } let syncedItems = 0; let skippedNoMapping = 0; let groupCount = 0; const groupDetails: ScheduleSyncResult['groups'] = []; for (const [classId, classSchedules] of schedulesByClass) { const className = classNameMap.get(classId) || `班级${classId}`; const dingUserIds = classDingUsers.get(classId) ?? []; if (dingUserIds.length === 0) { this.logger.warn(`班级 ${className} 无钉钉映射学生,跳过 ${classSchedules.length} 条排课`); skippedNoMapping += classSchedules.length; continue; } const classDailyPlans = dailyPlansByClass.get(classId) ?? []; // 该班级在同步日期范围内用到的合并班次 const classShiftIds = new Set(); for (const plan of classDailyPlans) { const shiftId = planToShiftId.get(plan.shiftKey); if (shiftId) classShiftIds.add(shiftId); } if (classShiftIds.size === 0) { this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure)`); continue; } // 先展开排班以计算受影响条数 const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); if (items.length === 0) { this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); skippedNoMapping += classSchedules.length; continue; } // 创建/匹配该班级的考勤组 const groupName = `排课_${className}`; let attendanceGroupId: number; try { const cached = groupByName.get(groupName); const groupParams = { name: groupName, type: 'TURN' as const, owner: opUserId, members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid, })), shift_ids: [...classShiftIds], enable_emp_select_class: true, disable_check_without_schedule: false, disable_check_when_rest: true, attendance_machine_only: attendanceMachineOnly, }; if (cached !== undefined) { attendanceGroupId = cached; await this.dingTalkService.updateAttendanceGroup({ ...groupParams, id: attendanceGroupId, }); } else { attendanceGroupId = await this.dingTalkService.createAttendanceGroup(groupParams); groupByName.set(groupName, attendanceGroupId); } groupCount++; } catch (e) { const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`; this.logger.error(msg); errors.push(msg); failedBatchCount++; failedItems += items.length; continue; } // 批量写入(单次≤200) let classItems = 0; for (let i = 0; i < items.length; i += 200) { const batch = items.slice(i, i + 200); try { await this.dingTalkService.scheduleUsers(attendanceGroupId, batch, opUserId); syncedItems += batch.length; classItems += batch.length; } catch (e) { const msg = `排班写入失败 (groupId=${attendanceGroupId}, batch=${Math.floor(i / 200) + 1}): ${(e as Error).message}`; this.logger.error(msg); failedBatchCount++; failedItems += batch.length; errors.push(msg); } } groupDetails.push({ className, groupId: attendanceGroupId, itemCount: classItems }); } this.logger.log( `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` + (failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''), ); return { scheduleCount: schedules.length, shiftCount, groupCount, syncedItems, skippedNoMapping, failedBatchCount, failedItems, errors, groups: groupDetails, }; } /** * 构建 classId → 学生钉钉 userId 列表。 * 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。 */ private async buildClassDingUserMap(classIds: number[]): Promise> { const result = new Map(); if (classIds.length === 0) return result; // 班级 → 活跃学生 const links = await this.classStudentRepo.find({ where: { classId: In(classIds), status: 'active' }, }); if (links.length === 0) return result; // 学生 → 钉钉 userId const studentIds = [...new Set(links.map((l) => l.studentId))]; const mappings = await this.mappingRepo.find({ where: { studentId: In(studentIds) }, }); const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId])); for (const link of links) { const dingId = studentToDing.get(link.studentId); if (!dingId) continue; if (!result.has(link.classId)) result.set(link.classId, []); const arr = result.get(link.classId)!; if (!arr.includes(dingId)) arr.push(dingId); } return result; } private async loadClassNames(classIds: number[]): Promise> { const map = new Map(); if (classIds.length === 0) return map; const classes = await this.classRepo.find({ where: { id: In(classIds) } }); for (const c of classes) map.set(c.id, c.name); return map; } /** * 把本地排课转换为“班级 + 日期”的日排班计划。 * 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。 */ private buildDailySchedulePlans( schedules: ClassSchedule[], syncFrom: string, syncTo: string, ): DailySchedulePlan[] { const periodMapByClassDate = new Map>(); const fromDate = new Date(syncFrom); const toDate = new Date(syncTo); for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) { const dateStr = date.toISOString().slice(0, 10); const weekDay = date.getDay() === 0 ? 7 : date.getDay(); for (const schedule of schedules) { if (schedule.classId == null || schedule.weekDay !== weekDay) continue; if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue; const classDateKey = `${schedule.classId}|${dateStr}`; if (!periodMapByClassDate.has(classDateKey)) { periodMapByClassDate.set(classDateKey, new Map()); } const periods = periodMapByClassDate.get(classDateKey)!; const periodKey = `${schedule.startTime}-${schedule.endTime}`; const existing = periods.get(periodKey); if (!existing || schedule.id < existing.scheduleId) { periods.set(periodKey, { startTime: schedule.startTime, endTime: schedule.endTime, scheduleId: schedule.id, }); } } } const plans: DailySchedulePlan[] = []; for (const [classDateKey, periodMap] of periodMapByClassDate) { const separator = classDateKey.indexOf('|'); const classId = Number(classDateKey.slice(0, separator)); const date = classDateKey.slice(separator + 1); const periods = [...periodMap.values()].sort( (left, right) => left.startTime.localeCompare(right.startTime) || left.endTime.localeCompare(right.endTime) || left.scheduleId - right.scheduleId, ); const periodSignature = periods .map((period) => `${period.startTime}-${period.endTime}`) .join('+'); plans.push({ classId, date, shiftKey: `${classId}|${periodSignature}`, periods, }); } return plans.sort( (left, right) => left.date.localeCompare(right.date) || left.classId - right.classId, ); } /** 每个学生每天仅生成一条钉钉排班,shift 内可包含多个课程卡段。 */ private expandDailySchedulePlans( plans: DailySchedulePlan[], dingUserIds: string[], planToShiftId: Map, ): DingTalkScheduleItem[] { const items: DingTalkScheduleItem[] = []; for (const plan of plans) { const shiftId = planToShiftId.get(plan.shiftKey); if (!shiftId) continue; const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime(); for (const userid of dingUserIds) { items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false }); } } return items; } private minutesBetween(startTime: string, endTime: string): number { const [startHour, startMinute] = startTime.split(':').map(Number); const [endHour, endMinute] = endTime.split(':').map(Number); const start = startHour * 60 + startMinute; let end = endHour * 60 + endMinute; if (end <= start) end += 24 * 60; return end - start; } private addDays(dateStr: string, days: number): string { const d = new Date(dateStr); d.setDate(d.getDate() + days); return d.toISOString().slice(0, 10); } /** 获取排班同步状态:活跃排课数、有钉钉映射学生的班级数 */ async getStatus(_targetDate: string): Promise<{ activeSchedules: number; mappedClasses: number; totalClasses: number; }> { const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } }); const schedules = allSchedules.filter((s) => s.classId != null); const classIds = [...new Set(schedules.map((s) => s.classId as number))]; const classDingUsers = await this.buildClassDingUserMap(classIds); const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length; return { activeSchedules: schedules.length, mappedClasses, totalClasses: classIds.length, }; } }