import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Not, IsNull } from 'typeorm'; import { ClassSchedule, StudentDingMapping, ClassStudent, ClassTeacher, Department, UserDepartment, } from '../entities'; import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service'; /** 单次排班同步的结果 */ export interface ScheduleSyncResult { /** 同步的排课记录数 */ scheduleCount: number; /** 创建的班次数 */ shiftCount: number; /** 创建/使用的考勤组数 */ groupCount: number; /** 实际发送的排班条数 */ syncedItems: number; /** 跳过的记录数(无钉钉映射的用户) */ skippedNoMapping: number; /** 按部门分组的详情 */ groups: Array<{ deptName: string; groupId: number; itemCount: number; }>; } /** * 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。 * * ## 同步流程 * 1. 查询活跃排课 + 关联教师 * 2. 按 (startTime, endTime) 创建/匹配钉钉班次 * 3. 按部门创建/匹配钉钉排班制考勤组 * 4. 将排课展开为每日排班,批量写入钉钉 */ @Injectable() export class ScheduleSyncService { private readonly logger = new Logger(ScheduleSyncService.name); constructor( @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, @InjectRepository(StudentDingMapping) private readonly studentDingMappingRepo: Repository, @InjectRepository(ClassTeacher) private readonly classTeacherRepo: Repository, @InjectRepository(Department) private readonly deptRepo: Repository, @InjectRepository(UserDepartment) private readonly userDeptRepo: 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); // ── Step 1: 查询活跃排课 + 关联教师 ── const schedules = await this.scheduleRepo.find({ where: { status: 'active', teacherId: Not(IsNull()), }, relations: ['department'], }); if (schedules.length === 0) { this.logger.log('没有需要同步的活跃排课'); return { scheduleCount: 0, shiftCount: 0, groupCount: 0, syncedItems: 0, skippedNoMapping: 0, groups: [] }; } // ── Step 2: 获取教师→钉钉用户ID映射 ── // ponytail: teacher scheduling deprecated; StudentDingMapping.studentId is Student FK, not User const userIdToDingId = new Map(); // ── Step 3: 按 (startTime, endTime) 创建/匹配班次 ── 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 timeToShiftId = new Map(); let shiftCount = 0; for (const [key, { startTime, endTime }] of uniqueShifts) { const shiftName = `排课_${startTime}-${endTime}`; try { const shiftId = await this.dingTalkService.findOrCreateShift(shiftName, startTime, endTime, opUserId); timeToShiftId.set(key, shiftId); shiftCount++; } catch (e) { this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`); } } // ── Step 4: 按部门分组 ── const deptGroups = new Map< number, { deptName: string; schedules: ClassSchedule[]; teacherIds: Set } >(); for (const s of schedules) { const deptId = s.departmentId || 0; if (!deptGroups.has(deptId)) { deptGroups.set(deptId, { deptName: (s.department as Department)?.name || `部门${deptId}`, schedules: [], teacherIds: new Set(), }); } const group = deptGroups.get(deptId)!; group.schedules.push(s); if (s.teacherId) group.teacherIds.add(s.teacherId); } // ── Step 5: 每个部门 → 考勤组 → 排班 ── let syncedItems = 0; let skippedNoMapping = 0; let groupCount = 0; const groupDetails: ScheduleSyncResult['groups'] = []; for (const [deptId, group] of deptGroups) { // 获取该部门教师的钉钉 userIds const dingUserIds: string[] = []; const teacherDingMap = new Map(); // local teacherId → dingUserId for (const tid of group.teacherIds) { const dingId = userIdToDingId.get(tid); if (dingId) { dingUserIds.push(dingId); teacherDingMap.set(tid, dingId); } } if (dingUserIds.length === 0) { skippedNoMapping += group.schedules.length; this.logger.warn(`部门 ${group.deptName}: 无钉钉用户映射,跳过`); continue; } // 该部门使用的班次 IDs const deptShiftIds = new Set(); for (const s of group.schedules) { const key = shiftKey(s.startTime, s.endTime); const sid = timeToShiftId.get(key); if (sid) deptShiftIds.add(sid); } // 创建/匹配考勤组 const groupName = `排课_${group.deptName}`; let attendanceGroupId: number; try { attendanceGroupId = await this.dingTalkService.findOrCreateAttendanceGroup( groupName, opUserId, dingUserIds, [...deptShiftIds], ); groupCount++; } catch (e) { this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`); continue; } // 展开排课为每日排班 const items = this.expandSchedules( group.schedules, teacherDingMap, timeToShiftId, startDate, endDate, ); skippedNoMapping += group.schedules.length - new Set(items.map((i) => i.userid)).size; // 分批写入(每次最多200条) 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; } catch (e) { this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`); } } groupDetails.push({ deptName: group.deptName, groupId: attendanceGroupId, itemCount: items.length, }); } this.logger.log( `排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` + `${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`, ); return { scheduleCount: schedules.length, shiftCount, groupCount, syncedItems, skippedNoMapping, groups: groupDetails, }; } /** * 将排课记录展开为每日排班数组。 * 每条 ClassSchedule(weekDay, startDate-endDate) → 该日期范围内所有 weekDay 对应日期的排班 */ private expandSchedules( schedules: ClassSchedule[], teacherDingMap: Map, timeToShiftId: Map, syncFrom: string, syncTo: string, ): DingTalkScheduleItem[] { const items: DingTalkScheduleItem[] = []; const fromDate = new Date(syncFrom); const toDate = new Date(syncTo); // 预计算日期范围内每一天是星期几 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()); // 周日=7 } for (const s of schedules) { const dingUserId = s.teacherId ? teacherDingMap.get(s.teacherId) : undefined; if (!dingUserId) continue; 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(); items.push({ userid: dingUserId, 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; mappedTeachers: number; totalTeachers: number }> { const schedules = await this.scheduleRepo.find({ where: { status: 'active', teacherId: Not(IsNull()) }, }); const teacherIds = [...new Set(schedules.map((s) => s.teacherId!).filter(Boolean))]; const mappings = await this.studentDingMappingRepo.find({ where: { studentId: In(teacherIds) }, }); return { activeSchedules: schedules.length, mappedTeachers: mappings.length, totalTeachers: teacherIds.length, }; } }