import { Injectable, Logger } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { SyncLog, SyncState, UserDingMapping } from '../entities'; import type { SyncPlatform, SyncType, SyncStatus } from '../entities/sync-log.entity'; 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 { private readonly logger = new Logger(SyncService.name); constructor( @InjectRepository(SyncLog) private readonly syncLogRepo: Repository, @InjectRepository(SyncState) private readonly syncStateRepo: Repository, @InjectRepository(UserDingMapping) private readonly mappingRepo: Repository, private readonly dingTalkService: DingTalkService, private readonly weComService: WeComService, private readonly attendanceImportService: AttendanceImportService, private readonly scheduleSyncService: ScheduleSyncService, ) {} // ── Scheduled sync disabled — use manual trigger via UI ── // ── Sync DingTalk ── async syncDingTalk(rootDeptId = 1): Promise { const platform: SyncPlatform = 'dingtalk'; const syncType = await this.determineSyncType(platform); const log = await this.createSyncLog(platform, syncType, 'running'); try { const lastSyncAt = await this.getLastSyncAt(platform); this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`); // ── Call existing integration APIs ── // Integration hooks — extend here to call DingTalk APIs with lastSyncAt const recordsCount = await this.performDingTalkSync(lastSyncAt, rootDeptId); await this.updateLastSyncAt(platform); await this.finishSyncLog(log, 'success', recordsCount); this.logger.log(`DingTalk sync complete: ${recordsCount} records`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); await this.finishSyncLog(log, 'failed', 0, message); this.logger.error(`DingTalk sync failed: ${message}`, error instanceof Error ? error.stack : undefined); } return log; } // ── Sync WeCom ── async syncWeCom(): Promise { const platform: SyncPlatform = 'wecom'; const syncType = await this.determineSyncType(platform); const log = await this.createSyncLog(platform, syncType, 'running'); try { const lastSyncAt = await this.getLastSyncAt(platform); this.logger.log(`Syncing ${platform} (${syncType}), lastSyncAt: ${lastSyncAt}`); // ── Call existing integration APIs ── // Integration hooks — extend here to call WeCom APIs with lastSyncAt const recordsCount = await this.performWeComSync(lastSyncAt); await this.updateLastSyncAt(platform); await this.finishSyncLog(log, 'success', recordsCount); this.logger.log(`WeCom sync complete: ${recordsCount} records`); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); await this.finishSyncLog(log, 'failed', 0, message); this.logger.error(`WeCom sync failed: ${message}`, error instanceof Error ? error.stack : undefined); } return log; } // ── Manual trigger ── async triggerSync(platform?: SyncPlatform, rootDeptId = 1): Promise { if (platform === 'dingtalk') return [await this.syncDingTalk(rootDeptId)]; if (platform === 'wecom') return [await this.syncWeCom()]; return [await this.syncDingTalk(rootDeptId), await this.syncWeCom()]; } /** 获取钉钉组织部门树,供前端选择同步起点 */ async getDingTalkOrgTree(rootDeptId = 1) { 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 = {}; if (platform) where.platform = platform; return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit }); } async getLastSync(platform: SyncPlatform): Promise { return this.syncLogRepo.findOne({ where: { platform }, order: { createdAt: 'DESC' }, }); } // ── Private helpers ── private async determineSyncType(platform: SyncPlatform): Promise { const state = await this.syncStateRepo.findOne({ where: { platform } }); return state?.lastSyncAt ? 'incremental' : 'full'; } private async getLastSyncAt(platform: SyncPlatform): Promise { const state = await this.syncStateRepo.findOne({ where: { platform } }); return state?.lastSyncAt ?? null; } private async updateLastSyncAt(platform: SyncPlatform): Promise { await this.syncStateRepo.upsert( { platform, lastSyncAt: new Date() }, ['platform'], ); } private async createSyncLog( platform: SyncPlatform, syncType: SyncType, status: SyncStatus, ): Promise { const log = this.syncLogRepo.create({ platform, syncType, status, recordsCount: 0, startedAt: new Date(), }); return this.syncLogRepo.save(log); } private async finishSyncLog( log: SyncLog, status: SyncStatus, recordsCount: number, errorMessage?: string, ): Promise { log.status = status; log.recordsCount = recordsCount; log.finishedAt = new Date(); if (errorMessage) log.errorMessage = errorMessage; await this.syncLogRepo.save(log); } private async performDingTalkSync(lastSyncAt: Date | null, rootDeptId = 1): Promise { // Stage 1: Sync departments and users const result = await this.dingTalkService.syncAll(rootDeptId); let total = result.deptCount + result.userCount; // Stage 2: Import attendance data (last 7 days or since last sync) try { const endDate = new Date(); const startDate = new Date(); // If never synced, import last 7 days; otherwise import since last sync if (lastSyncAt) { startDate.setTime(lastSyncAt.getTime()); } else { startDate.setDate(startDate.getDate() - 7); } const start = startDate.toISOString().slice(0, 10); const end = endDate.toISOString().slice(0, 10); this.logger.log(`Importing DingTalk attendance: ${start} ~ ${end}`); const mappings = await this.mappingRepo.find(); const userIds = mappings.map((m) => m.dingUserId); const importResult = await this.attendanceImportService.importFromDingTalk({ startDate: start, endDate: end, userIds: userIds.length > 0 ? userIds : undefined, autoMatch: true, }); total += importResult.imported; this.logger.log(`DingTalk attendance import: ${importResult.imported} imported, ${importResult.skipped} skipped`); } catch (err: unknown) { // Attendance import failure should not block the sync const msg = err instanceof Error ? err.message : String(err); this.logger.warn(`DingTalk attendance import failed (non-fatal): ${msg}`); } return total; } private async performWeComSync(_lastSyncAt: Date | null): Promise { const result = await this.weComService.syncAll(); return result.deptCount + result.userCount; } }