forked from wangziqi/gongxue-base
214 lines
7.9 KiB
TypeScript
214 lines
7.9 KiB
TypeScript
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<SyncLog>,
|
|
@InjectRepository(SyncState)
|
|
private readonly syncStateRepo: Repository<SyncState>,
|
|
@InjectRepository(UserDingMapping)
|
|
private readonly mappingRepo: Repository<UserDingMapping>,
|
|
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<SyncLog> {
|
|
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<SyncLog> {
|
|
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<SyncLog[]> {
|
|
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<SyncLog[]> {
|
|
const where: Record<string, SyncPlatform> = {};
|
|
if (platform) where.platform = platform;
|
|
return this.syncLogRepo.find({ where, order: { createdAt: 'DESC' }, take: limit });
|
|
}
|
|
|
|
async getLastSync(platform: SyncPlatform): Promise<SyncLog | null> {
|
|
return this.syncLogRepo.findOne({
|
|
where: { platform },
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
}
|
|
|
|
// ── Private helpers ──
|
|
|
|
private async determineSyncType(platform: SyncPlatform): Promise<SyncType> {
|
|
const state = await this.syncStateRepo.findOne({ where: { platform } });
|
|
return state?.lastSyncAt ? 'incremental' : 'full';
|
|
}
|
|
|
|
private async getLastSyncAt(platform: SyncPlatform): Promise<Date | null> {
|
|
const state = await this.syncStateRepo.findOne({ where: { platform } });
|
|
return state?.lastSyncAt ?? null;
|
|
}
|
|
|
|
private async updateLastSyncAt(platform: SyncPlatform): Promise<void> {
|
|
await this.syncStateRepo.upsert(
|
|
{ platform, lastSyncAt: new Date() },
|
|
['platform'],
|
|
);
|
|
}
|
|
|
|
private async createSyncLog(
|
|
platform: SyncPlatform,
|
|
syncType: SyncType,
|
|
status: SyncStatus,
|
|
): Promise<SyncLog> {
|
|
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<void> {
|
|
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<number> {
|
|
// 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<number> {
|
|
const result = await this.weComService.syncAll();
|
|
return result.deptCount + result.userCount;
|
|
}
|
|
}
|