import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities'; import { DingTalkService } from '../integration/dingtalk.service'; import dayjs from '../common/dayjs'; import { buildClassDingUserMap, loadClassNames, buildDailySchedulePlans, expandDailySchedulePlans, toMinutes, minutesBetween, addDays, type DailySchedulePeriod, type DailySchedulePlan, type ScheduleSyncResult, } from './schedule-sync.helpers'; @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 || dayjs().utcOffset(8).format('YYYY-MM-DD'); const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30; const endDate = addDays(startDate, normalizedDays - 1); 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 buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); const classNameMap = await loadClassNames(this.classRepo, classIds); // 同名班级会串组:统计本次参与同步的班级名出现次数, // 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。 const classNameCounts = new Map(); for (const name of classNameMap.values()) { classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1); } // ── Step 3: 将每天的多节课合并成一个钉钉班次 ── // 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为 // 同一个班次的多个 sections 写入,不能拆成多条 schedule item。 const dailyPlans = 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: toMinutes(period.endTime) <= toMinutes(period.startTime) ? 1 : 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) => 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 = expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId); if (items.length === 0) { this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`); skippedNoMapping += classSchedules.length; continue; } // 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组 const groupName = (classNameCounts.get(className) ?? 0) > 1 ? `排课_${classId}_${className}` : `排课_${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。 */ 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 buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds); const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length; return { activeSchedules: schedules.length, mappedClasses, totalClasses: classIds.length, }; } }