feat: improve attendance scheduling and API validation

This commit is contained in:
2026-07-14 23:12:14 +08:00
parent c75a08affe
commit e45da7f998
33 changed files with 869 additions and 297 deletions

View File

@@ -462,3 +462,90 @@ describe('ScheduleSyncService — all shifts fail', () => {
});
});
describe('ScheduleSyncService — multiple lessons per student per day', () => {
it('combines daily lessons into one DingTalk shift with multiple sections', async () => {
const scheduleRepo = {
find: jest.fn().mockResolvedValue([
{
id: 2,
classId: 10,
classroomId: 1,
weekDay: 1,
startTime: '22:10',
endTime: '23:10',
startDate: '2026-07-13',
endDate: '2026-07-13',
status: 'active',
} as ClassSchedule,
{
id: 1,
classId: 10,
classroomId: 2,
weekDay: 1,
startTime: '20:00',
endTime: '21:00',
startDate: '2026-07-13',
endDate: '2026-07-13',
status: 'active',
} as ClassSchedule,
]),
};
const classStudentRepo = {
find: jest.fn().mockResolvedValue([{ classId: 10, studentId: 20, status: 'active' }]),
};
const mappingRepo = {
find: jest.fn().mockResolvedValue([{ studentId: 20, dingUserId: 'student-1' }]),
};
const classRepo = {
find: jest.fn().mockResolvedValue([{ id: 10, name: '冲刺一班' }]),
};
const scheduleUsers = jest.fn().mockResolvedValue(undefined);
const upsertShift = jest.fn().mockResolvedValue(2022);
const dingTalkService = {
queryShifts: jest.fn().mockResolvedValue([]),
upsertShift,
queryAttendanceGroups: jest.fn().mockResolvedValue([
{ group_id: 1495610001, group_name: '排课_冲刺一班', type: 'TURN', member_count: 1 },
]),
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
createAttendanceGroup: jest.fn(),
scheduleUsers,
};
const service = new ScheduleSyncService(
scheduleRepo as never,
classStudentRepo as never,
mappingRepo as never,
classRepo as never,
dingTalkService as never,
);
const result = await service.syncAll('2026-07-13', 1);
expect(upsertShift).toHaveBeenCalledTimes(1);
expect(upsertShift).toHaveBeenCalledWith(expect.objectContaining({
name: '冲刺一班_20:00-21:00+22:10-23:10',
sections: [
expect.objectContaining({
times: expect.arrayContaining([
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 20:00:00' }),
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 21:00:00' }),
]),
}),
expect.objectContaining({
times: expect.arrayContaining([
expect.objectContaining({ check_type: 'OnDuty', check_time: '1970-01-01 22:10:00' }),
expect.objectContaining({ check_type: 'OffDuty', check_time: '1970-01-01 23:10:00' }),
]),
}),
],
}));
expect(scheduleUsers).toHaveBeenCalledTimes(1);
expect(scheduleUsers.mock.calls[0][1]).toEqual([
expect.objectContaining({ userid: 'student-1', shift_id: 2022 }),
]);
expect(result.syncedItems).toBe(1);
expect(result.failedBatchCount).toBe(0);
});
});

View File

@@ -1,14 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import {
ClassSchedule,
ClassStudent,
StudentDingMapping,
Class,
} from '../entities';
import { ClassSchedule, ClassStudent, StudentDingMapping, Class } from '../entities';
import { DingTalkService, DingTalkScheduleItem } from '../integration/dingtalk.service';
interface DailySchedulePeriod {
startTime: string;
endTime: string;
scheduleId: number;
}
interface DailySchedulePlan {
classId: number;
date: string;
shiftKey: string;
periods: DailySchedulePeriod[];
}
/** 单次排班同步的结果 */
export interface ScheduleSyncResult {
/** 参与同步的排课记录数 */
@@ -89,9 +97,14 @@ export class ScheduleSyncService {
const endDate = this.addDays(startDate, days);
const empty: ScheduleSyncResult = {
scheduleCount: 0, shiftCount: 0, groupCount: 0,
syncedItems: 0, skippedNoMapping: 0,
failedBatchCount: 0, failedItems: 0, errors: [],
scheduleCount: 0,
shiftCount: 0,
groupCount: 0,
syncedItems: 0,
skippedNoMapping: 0,
failedBatchCount: 0,
failedItems: 0,
errors: [],
groups: [],
};
@@ -110,63 +123,77 @@ export class ScheduleSyncService {
const classDingUsers = await this.buildClassDingUserMap(classIds);
const classNameMap = await this.loadClassNames(classIds);
// ── Step 3: 班次(按时间段去重,班次列表只查一次) ──
const shiftKey = (classId: number, start: string, end: string) =>
`${classId}|${start}-${end}`;
// ── Step 3: 将每天的多节课合并成一个钉钉班次 ──
// 钉钉要求每人每天只能写入一条排班,因此同一天的多节课必须作为
// 同一个班次的多个 sections 写入,不能拆成多条 schedule item。
const dailyPlans = this.buildDailySchedulePlans(schedules, startDate, endDate);
const uniqueShifts = new Map<
string,
{ className: string; startTime: string; endTime: string }
{ className: string; periods: DailySchedulePeriod[] }
>();
const shiftScheduleCount = new Map<string, number>();
for (const schedule of schedules) {
const classId = schedule.classId as number;
const key = shiftKey(classId, schedule.startTime, schedule.endTime);
if (!uniqueShifts.has(key)) {
uniqueShifts.set(key, {
className: classNameMap.get(classId) || `班级${classId}`,
startTime: schedule.startTime,
endTime: schedule.endTime,
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,
});
}
shiftScheduleCount.set(key, (shiftScheduleCount.get(key) || 0) + 1);
shiftPlanCount.set(plan.shiftKey, (shiftPlanCount.get(plan.shiftKey) || 0) + 1);
}
const existingShifts = await this.dingTalkService.queryShifts(opUserId);
const shiftByName = new Map(existingShifts.map((s) => [s.name, s.id]));
const timeToShiftId = new Map<string, number>();
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, startTime, endTime }] of uniqueShifts) {
const shiftName = `${className}_${startTime}-${endTime}`;
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: [{
sections: periods.map((period) => ({
times: [
{ check_type: 'OnDuty' as const, across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
{ check_type: 'OffDuty' as const, across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
{
check_type: 'OnDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.startTime}:00`,
free_check: false,
},
{
check_type: 'OffDuty' as const,
across: 0,
check_time: `1970-01-01 ${period.endTime}:00`,
free_check: false,
},
],
}],
})),
setting: {
is_flexible: false,
serious_late_minutes: -1,
absenteeism_late_minutes: this.minutesBetween(startTime, endTime),
absenteeism_late_minutes: Math.max(
...periods.map((period) => this.minutesBetween(period.startTime, period.endTime)),
),
},
};
shiftId = await this.dingTalkService.upsertShift(shiftParams);
shiftByName.set(shiftName, shiftId);
timeToShiftId.set(key, shiftId);
planToShiftId.set(key, shiftId);
shiftCount++;
} catch (e) {
const msg = `创建班次 ${shiftName} 失败: ${(e as Error).message}`;
this.logger.error(msg);
errors.push(msg);
failedBatchCount++;
failedItems += shiftScheduleCount.get(key) || 0;
failedItems += shiftPlanCount.get(key) || 0;
}
}
@@ -176,10 +203,15 @@ export class ScheduleSyncService {
// ── Step 5: 按班级同步 ──
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);
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;
@@ -196,11 +228,12 @@ export class ScheduleSyncService {
continue;
}
// 该班级用到的班次
const classDailyPlans = dailyPlansByClass.get(classId) ?? [];
// 该班级在同步日期范围内用到的合并班次
const classShiftIds = new Set<number>();
for (const s of classSchedules) {
const sid = timeToShiftId.get(shiftKey(classId, s.startTime, s.endTime));
if (sid) classShiftIds.add(sid);
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`);
@@ -208,9 +241,7 @@ export class ScheduleSyncService {
}
// 先展开排班以计算受影响条数
const items = this.expandSchedules(
classSchedules, dingUserIds, timeToShiftId, startDate, endDate,
);
const items = this.expandDailySchedulePlans(classDailyPlans, dingUserIds, planToShiftId);
if (items.length === 0) {
this.logger.warn(`班级 ${className} 无可用班次匹配,跳过`);
@@ -227,7 +258,11 @@ export class ScheduleSyncService {
name: groupName,
type: 'TURN' as const,
owner: opUserId,
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember' as const, user_id: uid })),
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,
@@ -276,8 +311,8 @@ export class ScheduleSyncService {
this.logger.log(
`排班同步完成: ${schedules.length} 条排课 → ${syncedItems} 条钉钉排班, ` +
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
`${shiftCount} 班次, ${groupCount} 考勤组, 跳过 ${skippedNoMapping} 条无映射` +
(failedBatchCount > 0 ? `, ${failedBatchCount} 批写入失败 (${failedItems} 条)` : ''),
);
return {
@@ -333,53 +368,87 @@ export class ScheduleSyncService {
}
/**
* 将排课记录展开为每个学生的每日排班数组
* 每条 ClassSchedule(weekDay, startDate-endDate) × 班级每个学生 →
* 该日期范围内所有 weekDay 对应日期的排班。
* 把本地排课转换为“班级 + 日期”的日排班计划
* 同一天相同时间段会去重,多节课按开始时间排序并合并为一个钉钉班次。
*/
private expandSchedules(
private buildDailySchedulePlans(
schedules: ClassSchedule[],
dingUserIds: string[],
timeToShiftId: Map<string, number>,
syncFrom: string,
syncTo: string,
): DingTalkScheduleItem[] {
const seen = new Set<string>();
const items: DingTalkScheduleItem[] = [];
): DailySchedulePlan[] {
const periodMapByClassDate = new Map<string, Map<string, DailySchedulePeriod>>();
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 (let date = new Date(fromDate); date <= toDate; date.setDate(date.getDate() + 1)) {
const dateStr = date.toISOString().slice(0, 10);
const weekDay = date.getDay() === 0 ? 7 : date.getDay();
for (const s of schedules) {
const shiftId = timeToShiftId.get(`${s.classId}|${s.startTime}-${s.endTime}`);
if (!shiftId) continue;
for (const schedule of schedules) {
if (schedule.classId == null || schedule.weekDay !== weekDay) continue;
if (dateStr < schedule.startDate || dateStr > schedule.endDate) 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) {
const dedupKey = `${userid}|${workDate}|${shiftId}`;
if (seen.has(dedupKey)) continue;
seen.add(dedupKey);
items.push({ userid, work_date: workDate, shift_id: shiftId, is_rest: false });
const classDateKey = `${schedule.classId}|${dateStr}`;
if (!periodMapByClassDate.has(classDateKey)) {
periodMapByClassDate.set(classDateKey, new Map());
}
const periods = periodMapByClassDate.get(classDateKey)!;
const periodKey = `${schedule.startTime}-${schedule.endTime}`;
const existing = periods.get(periodKey);
if (!existing || schedule.id < existing.scheduleId) {
periods.set(periodKey, {
startTime: schedule.startTime,
endTime: schedule.endTime,
scheduleId: schedule.id,
});
}
}
}
return items;
const plans: DailySchedulePlan[] = [];
for (const [classDateKey, periodMap] of periodMapByClassDate) {
const separator = classDateKey.indexOf('|');
const classId = Number(classDateKey.slice(0, separator));
const date = classDateKey.slice(separator + 1);
const periods = [...periodMap.values()].sort(
(left, right) =>
left.startTime.localeCompare(right.startTime) ||
left.endTime.localeCompare(right.endTime) ||
left.scheduleId - right.scheduleId,
);
const periodSignature = periods
.map((period) => `${period.startTime}-${period.endTime}`)
.join('+');
plans.push({
classId,
date,
shiftKey: `${classId}|${periodSignature}`,
periods,
});
}
return plans.sort(
(left, right) => left.date.localeCompare(right.date) || left.classId - right.classId,
);
}
/** 每个学生每天仅生成一条钉钉排班shift 内可包含多个课程卡段。 */
private expandDailySchedulePlans(
plans: DailySchedulePlan[],
dingUserIds: string[],
planToShiftId: Map<string, number>,
): DingTalkScheduleItem[] {
const items: DingTalkScheduleItem[] = [];
for (const plan of plans) {
const shiftId = planToShiftId.get(plan.shiftKey);
if (!shiftId) continue;
const workDate = new Date(`${plan.date}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 minutesBetween(startTime: string, endTime: string): number {
const [startHour, startMinute] = startTime.split(':').map(Number);