Files
gongxue-base/apps/server/src/sync/schedule-sync.service.ts
wangziqi 55881863c1 feat(sync): sync class schedules to DingTalk students
Group active schedules by class, resolve enrolled students through DingTalk mappings, reuse shifts and attendance groups, and expose class-based sync status in the admin UI.
2026-07-10 14:11:39 +08:00

345 lines
13 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';
/** 单次排班同步的结果 */
export interface ScheduleSyncResult {
/** 参与同步的排课记录数 */
scheduleCount: number;
/** 创建/复用的班次数 */
shiftCount: number;
/** 创建/复用的考勤组数 */
groupCount: number;
/** 实际写入钉钉的排班条数 */
syncedItems: number;
/** 因无学生或无钉钉映射而跳过的排课数 */
skippedNoMapping: number;
/** 按班级分组的详情 */
groups: Array<{
className: string;
groupId: number;
itemCount: number;
}>;
}
/**
* 排班同步服务 — 将本地 ClassSchedule 同步到钉钉考勤排班。
*
* ## 同步流程(按班级学生)
* 1. 查询活跃排课,按 classId 分组
* 2. 通过 ClassStudent + StudentDingMapping 拿到每个班级学生的钉钉 userId
* 3. 按 (startTime, endTime) 创建/匹配钉钉班次(班次列表只拉一次)
* 4. 每个班级创建/匹配一个排班制考勤组(考勤组列表只拉一次)
* 5. 将排课展开为每个学生的每日排班,批量写入钉钉
*
* ## 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
*/
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);
const empty: ScheduleSyncResult = {
scheduleCount: 0, shiftCount: 0, groupCount: 0,
syncedItems: 0, skippedNoMapping: 0, 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);
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
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 existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
const timeToShiftId = new Map<string, number>();
let shiftCount = 0;
for (const [key, { startTime, endTime }] of uniqueShifts) {
const shiftName = `排课_${startTime}-${endTime}`;
try {
let shiftId = shiftByName.get(shiftName);
if (shiftId === undefined) {
shiftId = await this.dingTalkService.upsertShift({
name: shiftName,
owner: opUserId,
sections: [{
times: [
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
],
}],
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 },
});
shiftByName.set(shiftName, shiftId);
}
timeToShiftId.set(key, shiftId);
shiftCount++;
} catch (e) {
this.logger.error(`创建班次 ${shiftName} 失败: ${(e as Error).message}`);
}
}
// ── 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 classNameMap = await this.loadClassNames(classIds);
const schedulesByClass = new Map<number, ClassSchedule[]>();
for (const s of schedules) {
const cid = s.classId as number;
if (!schedulesByClass.has(cid)) schedulesByClass.set(cid, []);
schedulesByClass.get(cid)!.push(s);
}
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 classShiftIds = new Set<number>();
for (const s of classSchedules) {
const sid = timeToShiftId.get(shiftKey(s.startTime, s.endTime));
if (sid) classShiftIds.add(sid);
}
if (classShiftIds.size === 0) {
this.logger.warn(`班级 ${className} 无可用班次,跳过`);
skippedNoMapping += classSchedules.length;
continue;
}
// 创建/匹配该班级的考勤组
const groupName = `排课_${className}`;
let attendanceGroupId: number;
try {
const cached = groupByName.get(groupName);
if (cached !== undefined) {
attendanceGroupId = cached;
} else {
attendanceGroupId = await this.dingTalkService.createAttendanceGroup({
name: groupName,
type: 'TURN',
owner: opUserId,
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid })),
shift_ids: [...classShiftIds],
enable_emp_select_class: true,
disable_check_without_schedule: false,
disable_check_when_rest: true,
});
groupByName.set(groupName, attendanceGroupId);
}
groupCount++;
} catch (e) {
this.logger.error(`创建考勤组 ${groupName} 失败: ${(e as Error).message}`);
skippedNoMapping += classSchedules.length;
continue;
}
// 展开为每个学生的每日排班
const items = this.expandSchedules(
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
// 批量写入单次≤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) {
this.logger.error(`排班写入失败 (groupId=${attendanceGroupId}, offset=${i}): ${(e as Error).message}`);
}
}
groupDetails.push({ className, groupId: attendanceGroupId, itemCount: classItems });
}
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射`,
);
return {
scheduleCount: schedules.length,
shiftCount,
groupCount,
syncedItems,
skippedNoMapping,
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;
}
/**
* 将排课记录展开为每个学生的每日排班数组。
* 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 →
* 该日期范围内所有 weekDay 对应日期的排班。
*/
private expandSchedules(
schedules: ClassSchedule[],
dingUserIds: string[],
timeToShiftId: Map<string, number>,
syncFrom: string,
syncTo: string,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
const fromDate = new Date(syncFrom);
const toDate = new Date(syncTo);
// 预计算日期范围内每一天是星期几(周日=7
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());
}
for (const s of schedules) {
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();
for (const userid of dingUserIds) {
items.push({ userid, 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;
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,
};
}
}