Files
gongxue-base/apps/server/src/sync/schedule-sync.service.ts
wangziqi 86f126671c fix: repair 5 sites where StudentDingMapping.studentId was misused as User FK
- dingtalk.service.ts syncOneUser: query studentRepo (not userRepo) by mapping.studentId
- sync.service.ts importDingTalkUsers: skip User role lookup for existing mappings
- attendance.service.ts autoMatchDingRecords: use studentId directly, remove second-hop query
- schedule-sync.service.ts syncAll: skip teacher mapping block (deprecated)
- rbac.service.ts getUnboundUsers: return [] (method deleted in Task 4)
2026-07-09 17:00:00 +08:00

298 lines
9.8 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, Not, IsNull } from 'typeorm';
import {
ClassSchedule,
StudentDingMapping,
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(StudentDingMapping)
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
@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映射 ──
// ponytail: teacher scheduling deprecated; StudentDingMapping.studentId is Student FK, not User
const userIdToDingId = new Map<number, string>();
// ── 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.studentDingMappingRepo.find({
where: { studentId: In(teacherIds) },
});
return {
activeSchedules: schedules.length,
mappedTeachers: mappings.length,
totalTeachers: teacherIds.length,
};
}
}