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'; /** 单次排班同步的结果 */ export interface ScheduleSyncResult { /** 参与同步的排课记录数 */ scheduleCount: number; /** 创建/复用的班次数 */ shiftCount: number; /** 创建/复用的考勤组数 */ groupCount: number; /** 实际写入钉钉的排班条数 */ syncedItems: number; /** 因无学生或无钉钉映射而跳过的排课数 */ skippedNoMapping: number; /** 按班级分组的详情 */ groups: Array<{ className: string; groupId: number; itemCount: number; }>; } /** * 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。 * * ## 同步流程(按班级学生) * 1. 查询活跃排课,按 classId 分组 * 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId * 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次) * 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次) * 5. 将排课展开为每个学生的每日排班,批量写入钉钉 * * ## 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 */ async syncAll( dateFrom?: string, days = 30, opUserId = 'manager', ): 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, 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); // ── Step 3: 班次(按时间段去重,班次列表只查一次) ── const shiftKey = (start: string, end: string) => `${start}-${end}`; const uniqueShifts = new Map(); for (const s of schedules) { const key = shiftKey(s.startTime, s.endTime); if (!uniqueShifts.has(key)) { uniqueShifts.set(key, { startTime: s.startTime, endTime: s.endTime }); } } const existingShifts = await this.dingTalkService.queryShifts(opUserId); const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id])); const timeToShiftId = new Map(); let shiftCount = 0; for (const [key, { startTime, endTime }] of uniqueShifts) { const shiftName = `排课_${startTime}-${endTime}`; try { let shiftId = shiftByName.get(shiftName); if (shiftId === undefined) { shiftId = await this.dingTalkService.upsertShift({ name: shiftName, owner: opUserId, sections: [{ times: [ { check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false }, { check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false }, ], }], setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 }, }); shiftByName.set(shiftName, shiftId); } timeToShiftId.set(key, shiftId); shiftCount++; } catch (e) { this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`); } } // ── 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 classNameMap = await this.loadClassNames(classIds); const schedulesByClass = new Map(); for (const s of schedules) { const cid = s.classId as number; if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []); schedulesByClass.get(cid)!.push(s); } 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 classShiftIds = new Set(); for (const s of classSchedules) { const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime)); if (sid) classShiftIds.add(sid); } if (classShiftIds.size === 0) { this.logger.warn(`班级 ${className} 无可用班次,跳过`); skippedNoMapping += classSchedules.length; continue; } // 创建/匹配该班级的考勤组 const groupName = `排课_${className}`; let attendanceGroupId: number; try { const cached = groupByName.get(groupName); if (cached !== undefined) { attendanceGroupId = cached; } else { attendanceGroupId = await this.dingTalkService.createAttendanceGroup({ name: groupName, type: 'TURN', owner: opUserId, members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid })), shift_ids: [...classShiftIds], enable_emp_select_class: true, disable_check_without_schedule: false, disable_check_when_rest: true, }); groupByName.set(groupName, attendanceGroupId); } groupCount++; } catch (e) { this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`); skippedNoMapping += classSchedules.length; continue; } // 展开为每个学生的每日排班 const items = this.expandSchedules( classSchedules, dingUserIds, timeToShiftId, startDate, endDate, ); // 批量写入(单次≤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) { this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`); } } groupDetails.push({ className, groupId: attendanceGroupId, itemCount: classItems }); } this.logger.log( `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`, ); return { scheduleCount: schedules.length, shiftCount, groupCount, syncedItems, skippedNoMapping, 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; } /** * 将排课记录展开为每个学生的每日排班数组。 * 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 → * 该日期范围内所有 weekDay 对应日期的排班。 */ private expandSchedules( schedules: ClassSchedule[], dingUserIds: string[], timeToShiftId: Map, syncFrom: string, syncTo: string, ): DingTalkScheduleItem[] { const items: DingTalkScheduleItem[] = []; const fromDate = new Date(syncFrom); const toDate = new Date(syncTo); // 预计算日期范围内每一天是星期几(周日=7) const dateWeekDays = new Map(); for (let d = new Date(fromDate); d <= toDate; d.setDate(d.getDate() + 1)) { const dateStr = d.toISOString().slice(0, 10); dateWeekDays.set(dateStr, d.getDay() === 0 ? 7 : d.getDay()); } for (const s of schedules) { const shiftId = timeToShiftId.get(`${s.startTime}-${s.endTime}`); if (!shiftId) continue; const scheduleStart = s.startDate > syncFrom ? s.startDate : syncFrom; const scheduleEnd = s.endDate < syncTo ? s.endDate : syncTo; for (const [dateStr, weekDay] of dateWeekDays) { if (dateStr < scheduleStart || dateStr > scheduleEnd) continue; if (weekDay !== s.weekDay) continue; const workDate = new Date(dateStr + '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 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, }; } }