- 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)
298 lines
9.8 KiB
TypeScript
298 lines
9.8 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|