forked from wangziqi/gongxue-base
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.
345 lines
13 KiB
TypeScript
345 lines
13 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|