forked from wangziqi/gongxue-base
fix permissions and teacher attendance workflows
This commit is contained in:
120
apps/server/src/sync/schedule-sync.service.spec.ts
Normal file
120
apps/server/src/sync/schedule-sync.service.spec.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { ScheduleSyncService } from './schedule-sync.service';
|
||||
import { ClassSchedule } from '../entities';
|
||||
|
||||
describe('ScheduleSyncService — absence threshold', () => {
|
||||
it('updates an existing 16:00-17:00 shift so checking in before 17:00 is not absent', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 5,
|
||||
startTime: '16:00',
|
||||
endTime: '17:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
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 dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_16:00-17:00' }]),
|
||||
upsertShift: jest.fn().mockResolvedValue(456),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
await service.syncAll('2026-07-10', 1);
|
||||
|
||||
expect(dingTalkService.upsertShift).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 456,
|
||||
name: '排课_16:00-17:00',
|
||||
setting: expect.objectContaining({ absenteeism_late_minutes: 60 }),
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScheduleSyncService — attendance machine only', () => {
|
||||
it('updates a reused attendance group with machine-only restrictions', async () => {
|
||||
const scheduleRepo = {
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{
|
||||
id: 1,
|
||||
classId: 10,
|
||||
classroomId: 1,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '16:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
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 dingTalkService = {
|
||||
queryShifts: jest.fn().mockResolvedValue([{ id: 456, name: '排课_09:00-16:00' }]),
|
||||
upsertShift: jest.fn().mockResolvedValue(456),
|
||||
queryAttendanceGroups: jest.fn().mockResolvedValue([
|
||||
{ group_id: 123, group_name: '排课_冲刺班', type: 'TURN', member_count: 1 },
|
||||
]),
|
||||
updateAttendanceGroup: jest.fn().mockResolvedValue(undefined),
|
||||
createAttendanceGroup: jest.fn(),
|
||||
scheduleUsers: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
|
||||
const service = new ScheduleSyncService(
|
||||
scheduleRepo as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classRepo as never,
|
||||
dingTalkService as never,
|
||||
);
|
||||
|
||||
await (service.syncAll as unknown as (
|
||||
dateFrom: string,
|
||||
days: number,
|
||||
opUserId: string,
|
||||
attendanceMachineOnly: boolean,
|
||||
) => Promise<unknown>)('2026-07-13', 1, 'manager', true);
|
||||
|
||||
expect(dingTalkService.updateAttendanceGroup).toHaveBeenCalledWith(expect.objectContaining({
|
||||
id: 123,
|
||||
name: '排课_冲刺班',
|
||||
owner: 'manager',
|
||||
shift_ids: [456],
|
||||
attendance_machine_only: true,
|
||||
}));
|
||||
expect(dingTalkService.createAttendanceGroup).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -64,11 +64,13 @@ export class ScheduleSyncService {
|
||||
* @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 || new Date().toISOString().slice(0, 10);
|
||||
const endDate = this.addDays(startDate, days);
|
||||
@@ -110,20 +112,24 @@ export class ScheduleSyncService {
|
||||
const shiftName = `排课_${startTime}-${endTime}`;
|
||||
try {
|
||||
let shiftId = shiftByName.get(shiftName);
|
||||
if (shiftId === undefined) {
|
||||
shiftId = await this.dingTalkService.upsertShift({
|
||||
name: shiftName,
|
||||
owner: opUserId,
|
||||
sections: [{
|
||||
times: [
|
||||
{ check_type: 'OnDuty', across: 0, check_time: `1970-01-01 ${startTime}:00`, free_check: false },
|
||||
{ check_type: 'OffDuty', across: 0, check_time: `1970-01-01 ${endTime}:00`, free_check: false },
|
||||
],
|
||||
}],
|
||||
setting: { is_flexible: false, serious_late_minutes: -1, absenteeism_late_minutes: -1 },
|
||||
});
|
||||
shiftByName.set(shiftName, shiftId);
|
||||
}
|
||||
const shiftParams = {
|
||||
...(shiftId === undefined ? {} : { id: shiftId }),
|
||||
name: shiftName,
|
||||
owner: opUserId,
|
||||
sections: [{
|
||||
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 },
|
||||
],
|
||||
}],
|
||||
setting: {
|
||||
is_flexible: false,
|
||||
serious_late_minutes: -1,
|
||||
absenteeism_late_minutes: this.minutesBetween(startTime, endTime),
|
||||
},
|
||||
};
|
||||
shiftId = await this.dingTalkService.upsertShift(shiftParams);
|
||||
shiftByName.set(shiftName, shiftId);
|
||||
timeToShiftId.set(key, shiftId);
|
||||
shiftCount++;
|
||||
} catch (e) {
|
||||
@@ -176,19 +182,25 @@ export class ScheduleSyncService {
|
||||
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;
|
||||
} else {
|
||||
attendanceGroupId = await this.dingTalkService.createAttendanceGroup({
|
||||
name: groupName,
|
||||
type: 'TURN',
|
||||
owner: opUserId,
|
||||
members: dingUserIds.map((uid) => ({ role: 'Attendance', type: 'StaffMember', user_id: uid })),
|
||||
shift_ids: [...classShiftIds],
|
||||
enable_emp_select_class: true,
|
||||
disable_check_without_schedule: false,
|
||||
disable_check_when_rest: true,
|
||||
await this.dingTalkService.updateAttendanceGroup({
|
||||
...groupParams,
|
||||
id: attendanceGroupId,
|
||||
});
|
||||
} else {
|
||||
attendanceGroupId = await this.dingTalkService.createAttendanceGroup(groupParams);
|
||||
groupByName.set(groupName, attendanceGroupId);
|
||||
}
|
||||
groupCount++;
|
||||
@@ -317,6 +329,15 @@ export class ScheduleSyncService {
|
||||
return items;
|
||||
}
|
||||
|
||||
private minutesBetween(startTime: string, endTime: string): number {
|
||||
const [startHour, startMinute] = startTime.split(':').map(Number);
|
||||
const [endHour, endMinute] = endTime.split(':').map(Number);
|
||||
const start = startHour * 60 + startMinute;
|
||||
let end = endHour * 60 + endMinute;
|
||||
if (end <= start) end += 24 * 60;
|
||||
return end - start;
|
||||
}
|
||||
|
||||
private addDays(dateStr: string, days: number): string {
|
||||
const d = new Date(dateStr);
|
||||
d.setDate(d.getDate() + days);
|
||||
|
||||
22
apps/server/src/sync/sync.controller.spec.ts
Normal file
22
apps/server/src/sync/sync.controller.spec.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { SyncController } from './sync.controller';
|
||||
|
||||
describe('SyncController — schedule sync options', () => {
|
||||
it('forwards the attendance-machine-only option', async () => {
|
||||
const syncService = {
|
||||
syncScheduleToDingTalk: jest.fn().mockResolvedValue({ syncedItems: 0 }),
|
||||
};
|
||||
const controller = new SyncController(syncService as never);
|
||||
|
||||
await (controller.syncSchedule as unknown as (
|
||||
dateFrom?: string,
|
||||
days?: string,
|
||||
attendanceMachineOnly?: string,
|
||||
) => Promise<unknown>)('2026-07-10', '30', 'true');
|
||||
|
||||
expect(syncService.syncScheduleToDingTalk).toHaveBeenCalledWith(
|
||||
'2026-07-10',
|
||||
30,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -67,10 +67,12 @@ export class SyncController {
|
||||
async syncSchedule(
|
||||
@Query('dateFrom') dateFrom?: string,
|
||||
@Query('days') days?: string,
|
||||
@Query('attendanceMachineOnly') attendanceMachineOnly?: string,
|
||||
) {
|
||||
const result = await this.syncService.syncScheduleToDingTalk(
|
||||
dateFrom,
|
||||
days ? parseInt(days, 10) : 30,
|
||||
attendanceMachineOnly === 'true',
|
||||
);
|
||||
return { success: true, data: result };
|
||||
}
|
||||
|
||||
@@ -101,8 +101,12 @@ export class SyncService {
|
||||
// ── 排班同步 ──
|
||||
|
||||
/** 将本地排课同步到钉钉考勤排班 */
|
||||
async syncScheduleToDingTalk(dateFrom?: string, days = 30) {
|
||||
return this.scheduleSyncService.syncAll(dateFrom, days);
|
||||
async syncScheduleToDingTalk(
|
||||
dateFrom?: string,
|
||||
days = 30,
|
||||
attendanceMachineOnly = false,
|
||||
) {
|
||||
return this.scheduleSyncService.syncAll(dateFrom, days, 'manager', attendanceMachineOnly);
|
||||
}
|
||||
|
||||
/** 获取排班同步状态(当前仅返回活跃排课统计) */
|
||||
|
||||
Reference in New Issue
Block a user