Files
gongxue-base/apps/server/src/sync/schedule-sync.service.ts

487 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
interface DailySchedulePeriod {
startTime: string;
endTime: string;
scheduleId: number;
}
interface DailySchedulePlan {
classId: number;
date: string;
shiftKey: string;
periods: DailySchedulePeriod[];
}
/** 单次排班同步的结果 */
export interface ScheduleSyncResult {
/** 参与同步的排课记录数 */
scheduleCount: number;
/** 创建/复用的班次数 */
shiftCount: number;
/** 创建/复用的考勤组数 */
groupCount: number;
/** 实际写入钉钉的排班条数 */
syncedItems: number;
/** 因无学生或无钉钉映射而跳过的排课数 */
skippedNoMapping: number;
/** 写入失败的排班批次数 */
failedBatchCount: number;
/** 写入失败的排班条数 */
failedItems: number;
/** 失败批次错误详情 */
errors: string[];
/** 按班级分组的详情 */
groups: Array<{
className: string;
groupId: number;
itemCount: number;
}>;
}
/**
* 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。
*
* ## 同步流程(按班级学生)
* 1. 查询活跃排课,按 classId 分组
* 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId
* 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次)
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
*
* ## 残余风险:同步窗口内已不存在的旧排班无法清理
* 钉钉开放平台未暴露排班删除接口(仅提供 `schedule/listbyusers` 查询和
* `group/schedule/async` 写入)。`queryScheduleByUsers` 受限于 7 天窗口
* 和每次 50 个用户,且无配套删除能力,无法在同步前清理旧排班。
* 当前产品流程为"排课后手动同步钉钉",依赖运营人员知晓同步时机;
* 若后续需要自动清理,需等钉钉开放排班删除 API 或改用考勤组覆盖策略。
*
* ## API 调用优化
* - 班次列表、考勤组列表各只查询一次,在内存中按名称匹配,避免每次 findOrCreate 都发一次查询。
* - 排班写入按考勤组分批(钉钉单次最多 200 条)。
*/
@Injectable()
export class ScheduleSyncService {
private readonly logger = new Logger(ScheduleSyncService.name);
constructor(
@InjectRepository(ClassSchedule)
private readonly scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(ClassStudent)
private readonly classStudentRepo: Repository<ClassStudent>,
@InjectRepository(StudentDingMapping)
private readonly mappingRepo: Repository<StudentDingMapping>,
@InjectRepository(Class)
private readonly classRepo: Repository<Class>,
private readonly dingTalkService: DingTalkService,
) {}
/**
* 全量同步:将所有活跃排课同步到钉钉排班。
* @param dateFrom 起始日期YYYY-MM-DD默认今天
* @param days 同步天数,默认 30
* @param opUserId 钉钉操作人 userId
* @param attendanceMachineOnly 是否关闭手机类打卡入口,仅使用考勤机
*/
async syncAll(
dateFrom?: string,
days = 30,
opUserId = 'manager',
attendanceMachineOnly = false,
): Promise<ScheduleSyncResult> {
const startDate = dateFrom || new Date().toISOString().slice(0, 10);
const endDate = this.addDays(startDate, days);
const empty: ScheduleSyncResult = {
scheduleCount: 0,
shiftCount: 0,
groupCount: 0,
syncedItems: 0,
skippedNoMapping: 0,
failedBatchCount: 0,
failedItems: 0,
errors: [],
groups: [],
};
// ── Step 1: 查询活跃排课(必须关联到班级才能取学生) ──
const allSchedules = await this.scheduleRepo.find({
where: { status: 'active' },
});
const schedules = allSchedules.filter((s) => s.classId != null);
if (schedules.length === 0) {
this.logger.log('没有需要同步的活跃排课(无关联班级)');
return empty;
}
// ── Step 2: 班级 → 学生钉钉ID 映射 ──
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await this.buildClassDingUserMap(classIds);
const classNameMap = await this.loadClassNames(classIds);
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate);
const uniqueShifts = new Map<
string,
{ className: string; periods: DailySchedulePeriod[] }
>();
const shiftPlanCount = new Map<string, number>();
for (const plan of dailyPlans) {
if (!uniqueShifts.has(plan.shiftKey)) {
uniqueShifts.set(plan.shiftKey, {
className: classNameMap.get(plan.classId) || `班级${plan.classId}`,
periods: plan.periods,
});
}
shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1);
}
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((shift) => [shift.name, shift.id]));
const planToShiftId = new Map<string, number>();
const errors: string[] = [];
let failedBatchCount = 0;
let failedItems = 0;
let shiftCount = 0;
for (const [key, { className, periods }] of uniqueShifts) {
const periodLabel = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
const shiftName = `${className}_${periodLabel}`;
try {
let shiftId = shiftByName.get(shiftName);
const shiftParams = {
...(shiftId === undefined ? {} : { id: shiftId }),
name: shiftName,
owner: opUserId,
sections: periods.map((period) => ({
times: [
{
check_type: 'OnDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.startTime}:00`,
free_check: false,
},
{
check_type: 'OffDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false,
},
],
})),
setting: {
is_flexible: false,
serious_late_minutes: -1,
absenteeism_late_minutes: Math.max(
...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)),
),
},
};
shiftId = await this.dingTalkService.upsertShift(shiftParams);
shiftByName.set(shiftName, shiftId);
planToShiftId.set(key, shiftId);
shiftCount++;
} catch (e) {
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += shiftPlanCount.get(key) || 0;
}
}
// ── Step 4: 考勤组列表只查一次,供每个班级匹配 ──
const existingGroups = await this.dingTalkService.queryAttendanceGroups(opUserId);
const groupByName = new Map(existingGroups.map((g) => [g.group_name, g.group_id]));
// ── Step 5: 按班级同步 ──
const schedulesByClass = new Map<number, ClassSchedule[]>();
for (const schedule of schedules) {
const classId = schedule.classId as number;
if (!schedulesByClass.has(classId)) schedulesByClass.set(classId, []);
schedulesByClass.get(classId)!.push(schedule);
}
const dailyPlansByClass = new Map<number, DailySchedulePlan[]>();
for (const plan of dailyPlans) {
if (!dailyPlansByClass.has(plan.classId)) dailyPlansByClass.set(plan.classId, []);
dailyPlansByClass.get(plan.classId)!.push(plan);
}
let syncedItems = 0;
let skippedNoMapping = 0;
let groupCount = 0;
const groupDetails: ScheduleSyncResult['groups'] = [];
for (const [classId, classSchedules] of schedulesByClass) {
const className = classNameMap.get(classId) || `班级${classId}`;
const dingUserIds = classDingUsers.get(classId) ?? [];
if (dingUserIds.length === 0) {
this.logger.warn(`班级 ${className} 无钉钉映射学生,跳过 ${classSchedules.length} 条排课`);
skippedNoMapping += classSchedules.length;
continue;
}
const classDailyPlans = dailyPlansByClass.get(classId) ?? [];
// 该班级在同步日期范围内用到的合并班次
const classShiftIds = new Set<number>();
for (const plan of classDailyPlans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (shiftId) classShiftIds.add(shiftId);
}
if (classShiftIds.size === 0) {
this.logger.warn(`班级 ${className} 无可用班次,跳过(班次创建已计入 failure`);
continue;
}
// 先展开排班以计算受影响条数
const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
skippedNoMapping += classSchedules.length;
continue;
}
// 创建/匹配该班级的考勤组
const groupName = `排课_${className}`;
let attendanceGroupId: number;
try {
const cached = groupByName.get(groupName);
const groupParams = {
name: groupName,
type: 'TURN' as const,
owner: opUserId,
members: dingUserIds.map((uid) => ({
role: 'Attendance',
type: 'StaffMember' as const,
user_id: uid,
})),
shift_ids: [...classShiftIds],
enable_emp_select_class: true,
disable_check_without_schedule: false,
disable_check_when_rest: true,
attendance_machine_only: attendanceMachineOnly,
};
if (cached !== undefined) {
attendanceGroupId = cached;
await this.dingTalkService.updateAttendanceGroup({
...groupParams,
id: attendanceGroupId,
});
} else {
attendanceGroupId = await this.dingTalkService.createAttendanceGroup(groupParams);
groupByName.set(groupName, attendanceGroupId);
}
groupCount++;
} catch (e) {
const msg = `考勤组 ${groupName} 创建/更新失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += items.length;
continue;
}
// 批量写入单次≤200
let classItems = 0;
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;
classItems += batch.length;
} catch (e) {
const msg = `排班写入失败 (groupId=${attendanceGroupId}, batch=${Math.floor(i / 200) + 1}): ${(e as Error).message}`;
this.logger.error(msg);
failedBatchCount++;
failedItems += batch.length;
errors.push(msg);
}
}
groupDetails.push({ className, groupId: attendanceGroupId, itemCount: classItems });
}
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
);
return {
scheduleCount: schedules.length,
shiftCount,
groupCount,
syncedItems,
skippedNoMapping,
failedBatchCount,
failedItems,
errors,
groups: groupDetails,
};
}
/**
* 构建 classId → 学生钉钉 userId 列表。
* 一次性查询所有班级的活跃学生与钉钉映射,避免 N+1。
*/
private async buildClassDingUserMap(classIds: number[]): Promise<Map<number, string[]>> {
const result = new Map<number, string[]>();
if (classIds.length === 0) return result;
// 班级 → 活跃学生
const links = await this.classStudentRepo.find({
where: { classId: In(classIds), status: 'active' },
});
if (links.length === 0) return result;
// 学生 → 钉钉 userId
const studentIds = [...new Set(links.map((l) => l.studentId))];
const mappings = await this.mappingRepo.find({
where: { studentId: In(studentIds) },
});
const studentToDing = new Map(mappings.map((m) => [m.studentId, m.dingUserId]));
for (const link of links) {
const dingId = studentToDing.get(link.studentId);
if (!dingId) continue;
if (!result.has(link.classId)) result.set(link.classId, []);
const arr = result.get(link.classId)!;
if (!arr.includes(dingId)) arr.push(dingId);
}
return result;
}
private async loadClassNames(classIds: number[]): Promise<Map<number, string>> {
const map = new Map<number, string>();
if (classIds.length === 0) return map;
const classes = await this.classRepo.find({ where: { id: In(classIds) } });
for (const c of classes) map.set(c.id, c.name);
return map;
}
/**
* 把本地排课转换为“班级 + 日期”的日排班计划。
* 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
*/
private buildDailySchedulePlans(
schedules: ClassSchedule[],
syncFrom: string,
syncTo: string,
): DailySchedulePlan[] {
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo);
for (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
const dateStr = date.toISOString().slice(0, 10);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
for (const schedule of schedules) {
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
if (dateStr < schedule.startDate || dateStr > schedule.endDate) continue;
const classDateKey = `${schedule.classId}|${dateStr}`;
if (!periodMapByClassDate.has(classDateKey)) {
periodMapByClassDate.set(classDateKey, new Map());
}
const periods = periodMapByClassDate.get(classDateKey)!;
const periodKey = `${schedule.startTime}-${schedule.endTime}`;
const existing = periods.get(periodKey);
if (!existing || schedule.id < existing.scheduleId) {
periods.set(periodKey, {
startTime: schedule.startTime,
endTime: schedule.endTime,
scheduleId: schedule.id,
});
}
}
}
const plans: DailySchedulePlan[] = [];
for (const [classDateKey, periodMap] of periodMapByClassDate) {
const separator = classDateKey.indexOf('|');
const classId = Number(classDateKey.slice(0, separator));
const date = classDateKey.slice(separator + 1);
const periods = [...periodMap.values()].sort(
(left, right) =>
left.startTime.localeCompare(right.startTime) ||
left.endTime.localeCompare(right.endTime) ||
left.scheduleId - right.scheduleId,
);
const periodSignature = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
plans.push({
classId,
date,
shiftKey: `${classId}|${periodSignature}`,
periods,
});
}
return plans.sort(
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
);
}
/** 每个学生每天仅生成一条钉钉排班shift 内可包含多个课程卡段。 */
private expandDailySchedulePlans(
plans: DailySchedulePlan[],
dingUserIds: string[],
planToShiftId: Map<string, number>,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
for (const plan of plans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (!shiftId) continue;
const workDate = new Date(`${plan.date}T00:00:00+08:00`).getTime();
for (const userid of dingUserIds) {
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
}
}
return items;
}
private minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number);
const [endHour, endMinute] = endTime.split(':').map(Number);
const start = startHour * 60 + startMinute;
let end = endHour * 60 + endMinute;
if (end <= start) end += 24 * 60;
return end - start;
}
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;
mappedClasses: number;
totalClasses: number;
}> {
const allSchedules = await this.scheduleRepo.find({ where: { status: 'active' } });
const schedules = allSchedules.filter((s) => s.classId != null);
const classIds = [...new Set(schedules.map((s) => s.classId as number))];
const classDingUsers = await this.buildClassDingUserMap(classIds);
const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length;
return {
activeSchedules: schedules.length,
mappedClasses,
totalClasses: classIds.length,
};
}
}