refactor: remove scheduled cron sync, manual trigger only

This commit is contained in:
2026-07-09 10:24:12 +08:00
parent e8dec3b171
commit 731f74e51a
4 changed files with 362 additions and 14 deletions

View File

@@ -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<ClassSchedule>,
@InjectRepository(UserDingMapping)
private readonly mappingRepo: Repository<UserDingMapping>,
@InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(Department)
private readonly deptRepo: Repository<Department>,
@InjectRepository(UserDepartment)
private readonly userDeptRepo: Repository<UserDepartment>,
private readonly dingTalkService: DingTalkService,
) {}
/**
* 全量同步:将所有活跃排课同步到钉钉排班
* @param dateFrom 起始日期YYYY-MM-DD默认今天
* @param days 同步天数,默认 30
* @param opUserId 钉钉操作人 userId
*/
async syncAll(
dateFrom?: string,
days = 30,
opUserId = 'manager',
): Promise<ScheduleSyncResult> {
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<string, { startTime: string; endTime: string }>();
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<string, number>();
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<number> }
>();
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<number, string>(); // 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<number>();
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<number, string>,
timeToShiftId: Map<string, number>,
syncFrom: string,
syncTo: string,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo);
// 预计算日期范围内每一天是星期几
const dateWeekDays = new Map<string, number>();
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,
};
}
}

View File

@@ -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)) {

View File

@@ -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 {}

View File

@@ -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<SyncLog> {
@@ -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<SyncLog[]> {
const where: Record<string, SyncPlatform> = {};