From 731f74e51aa77d9f259aec39b221faaf860c23d0 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Thu, 9 Jul 2026 10:24:12 +0800 Subject: [PATCH] refactor: remove scheduled cron sync, manual trigger only --- apps/server/src/sync/schedule-sync.service.ts | 300 ++++++++++++++++++ apps/server/src/sync/sync.controller.ts | 26 ++ apps/server/src/sync/sync.module.ts | 25 +- apps/server/src/sync/sync.service.ts | 25 +- 4 files changed, 362 insertions(+), 14 deletions(-) create mode 100644 apps/server/src/sync/schedule-sync.service.ts diff --git a/apps/server/src/sync/schedule-sync.service.ts b/apps/server/src/sync/schedule-sync.service.ts new file mode 100644 index 0000000..eeac1f4 --- /dev/null +++ b/apps/server/src/sync/schedule-sync.service.ts @@ -0,0 +1,300 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository, In, Not, IsNull } from 'typeorm'; +import { + ClassSchedule, + UserDingMapping, + 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(UserDingMapping) + private readonly mappingRepo: 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映射 ── + const teacherIds = [...new Set(schedules.map((s) => s.teacherId!).filter(Boolean))]; + const mappings = await this.mappingRepo.find({ + where: { userId: In(teacherIds) }, + }); + const userIdToDingId = new Map(mappings.map((m) => [m.userId, m.dingUserId])); + + // ── 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.mappingRepo.find({ + where: { userId: In(teacherIds) }, + }); + return { + activeSchedules: schedules.length, + mappedTeachers: mappings.length, + totalTeachers: teacherIds.length, + }; + } +} diff --git a/apps/server/src/sync/sync.controller.ts b/apps/server/src/sync/sync.controller.ts index d146225..6738448 100644 --- a/apps/server/src/sync/sync.controller.ts +++ b/apps/server/src/sync/sync.controller.ts @@ -49,6 +49,32 @@ export class SyncController { return this.syncService.getLogs(platform, limit ? Number(limit) : 50); } + // ── 排班同步 ── + + /** 触发排班同步到钉钉考勤排班 */ + @Post('schedule/sync') + @RequirePermission('sync:trigger') + async syncSchedule( + @Query('dateFrom') dateFrom?: string, + @Query('days') days?: string, + ) { + const result = await this.syncService.syncScheduleToDingTalk( + dateFrom, + days ? parseInt(days, 10) : 30, + ); + return { success: true, data: result }; + } + + /** 查询钉钉排班状态(核对本地 vs 钉钉) */ + @Get('schedule/status') + @RequirePermission('sync:read') + async getScheduleStatus( + @Query('date') date?: string, + ) { + const status = await this.syncService.getScheduleSyncStatus(date); + return { success: true, data: status }; + } + private parseRootDeptId(rootDeptId: string): number { const parsed = parseInt(rootDeptId, 10); if (isNaN(parsed)) { diff --git a/apps/server/src/sync/sync.module.ts b/apps/server/src/sync/sync.module.ts index ac7c2ea..9c993f8 100644 --- a/apps/server/src/sync/sync.module.ts +++ b/apps/server/src/sync/sync.module.ts @@ -1,21 +1,36 @@ import { Module } from '@nestjs/common'; -import { ScheduleModule } from '@nestjs/schedule'; import { TypeOrmModule } from '@nestjs/typeorm'; import { IntegrationModule } from '../integration/integration.module'; import { AttendanceModule } from '../attendance/attendance.module'; -import { SyncLog, SyncState, UserDingMapping } from '../entities'; +import { + SyncLog, + SyncState, + UserDingMapping, + ClassSchedule, + Department, + UserDepartment, + ClassTeacher, +} from '../entities'; import { SyncService } from './sync.service'; import { SyncController } from './sync.controller'; +import { ScheduleSyncService } from './schedule-sync.service'; @Module({ imports: [ - ScheduleModule.forRoot(), - TypeOrmModule.forFeature([SyncLog, SyncState, UserDingMapping]), + TypeOrmModule.forFeature([ + SyncLog, + SyncState, + UserDingMapping, + ClassSchedule, + Department, + UserDepartment, + ClassTeacher, + ]), IntegrationModule, AttendanceModule, ], controllers: [SyncController], - providers: [SyncService], + providers: [SyncService, ScheduleSyncService], exports: [SyncService], }) export class SyncModule {} diff --git a/apps/server/src/sync/sync.service.ts b/apps/server/src/sync/sync.service.ts index 3323630..e874c98 100644 --- a/apps/server/src/sync/sync.service.ts +++ b/apps/server/src/sync/sync.service.ts @@ -1,5 +1,4 @@ import { Injectable, Logger } from '@nestjs/common'; -import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { SyncLog, SyncState, UserDingMapping } from '../entities'; @@ -7,6 +6,7 @@ import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.en import { DingTalkService } from '../integration/dingtalk.service'; import { WeComService } from '../integration/wecom.service'; import { AttendanceImportService } from '../attendance/attendance-import.service'; +import { ScheduleSyncService } from './schedule-sync.service'; @Injectable() export class SyncService { @@ -21,16 +21,10 @@ export class SyncService { private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, + private readonly scheduleSyncService: ScheduleSyncService, ) {} - // ── Scheduled cron: daily at 2 AM ── - @Cron('0 2 * * *') - async scheduledSync() { - this.logger.log('Starting scheduled sync job'); - await this.syncDingTalk(); - await this.syncWeCom(); - this.logger.log('Scheduled sync job completed'); - } + // ── Scheduled sync disabled — use manual trigger via UI ── // ── Sync DingTalk ── async syncDingTalk(rootDeptId = 1): Promise { @@ -98,6 +92,19 @@ export class SyncService { return this.dingTalkService.fetchOrgTree(rootDeptId); } + // ── 排班同步 ── + + /** 将本地排课同步到钉钉考勤排班 */ + async syncScheduleToDingTalk(dateFrom?: string, days = 30) { + return this.scheduleSyncService.syncAll(dateFrom, days); + } + + /** 获取排班同步状态(当前仅返回活跃排课统计) */ + async getScheduleSyncStatus(date?: string) { + const targetDate = date || new Date().toISOString().slice(0, 10); + return this.scheduleSyncService.getStatus(targetDate); + } + // ── Sync log queries ── async getLogs(platform?: SyncPlatform, limit: number = 50): Promise { const where: Record = {};