由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复: - wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页 - financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等 - 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time) - rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项 Reviewed-by: OCR (open-codereview.ai)
317 lines
12 KiB
TypeScript
317 lines
12 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
|
||
import { DingTalkService } from '../integration/dingtalk.service';
|
||
import dayjs from '../common/dayjs';
|
||
import {
|
||
buildClassDingUserMap,
|
||
loadClassNames,
|
||
buildDailySchedulePlans,
|
||
expandDailySchedulePlans,
|
||
toMinutes,
|
||
minutesBetween,
|
||
addDays,
|
||
type DailySchedulePeriod,
|
||
type DailySchedulePlan,
|
||
type ScheduleSyncResult,
|
||
} from './schedule-sync.helpers';
|
||
|
||
@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 || dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||
const normalizedDays = Number.isFinite(days) ? Math.max(1, Math.floor(days)) : 30;
|
||
const endDate = addDays(startDate, normalizedDays - 1);
|
||
|
||
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 buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
|
||
const classNameMap = await loadClassNames(this.classRepo, classIds);
|
||
|
||
// 同名班级会串组:统计本次参与同步的班级名出现次数,
|
||
// 重名时在考勤组名/组标识中带上 classId 以避免共享同一个考勤组。
|
||
const classNameCounts = new Map<string, number>();
|
||
for (const name of classNameMap.values()) {
|
||
classNameCounts.set(name, (classNameCounts.get(name) || 0) + 1);
|
||
}
|
||
|
||
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
|
||
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
|
||
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
|
||
const dailyPlans = 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: toMinutes(period.endTime) <= toMinutes(period.startTime) ? 1 : 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) => 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 = expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
|
||
|
||
if (items.length === 0) {
|
||
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
|
||
skippedNoMapping += classSchedules.length;
|
||
continue;
|
||
}
|
||
|
||
// 创建/匹配该班级的考勤组;重名班级时追加 classId 后缀避免串组
|
||
const groupName =
|
||
(classNameCounts.get(className) ?? 0) > 1
|
||
? `排课_${classId}_${className}`
|
||
: `排课_${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。
|
||
*/
|
||
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 buildClassDingUserMap(this.classStudentRepo, this.mappingRepo, classIds);
|
||
const mappedClasses = [...classDingUsers.values()].filter((u) => u.length > 0).length;
|
||
|
||
return {
|
||
activeSchedules: schedules.length,
|
||
mappedClasses,
|
||
totalClasses: classIds.length,
|
||
};
|
||
}
|
||
}
|