feat: improve attendance scheduling and API validation
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user