feat: 结算时同步钉钉已审批请假并标记为请假
This commit is contained in:
@@ -9,10 +9,17 @@ describe('AttendanceImportService', () => {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const dingLeaveRawRepo = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn((value: Record<string, unknown>) => value),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn() };
|
||||
const studentDingMappingRepo = { findOne: jest.fn() };
|
||||
const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() };
|
||||
const dingTalkService = {
|
||||
fetchAttendanceResults: jest.fn(),
|
||||
fetchDailyLeaveStatus: jest.fn(),
|
||||
};
|
||||
const attendanceService = {
|
||||
autoMatchDingRecords: jest.fn(),
|
||||
@@ -24,6 +31,7 @@ describe('AttendanceImportService', () => {
|
||||
jest.clearAllMocks();
|
||||
service = new AttendanceImportService(
|
||||
dingRawRepo as never,
|
||||
dingLeaveRawRepo as never,
|
||||
studentRepo as never,
|
||||
studentDingMappingRepo as never,
|
||||
dingTalkService as unknown as DingTalkService,
|
||||
@@ -304,4 +312,67 @@ describe('AttendanceImportService', () => {
|
||||
expect(event.userId).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs approved DingTalk leaves per user per day and auto-matches them', async () => {
|
||||
dingTalkService.fetchDailyLeaveStatus.mockImplementation(
|
||||
async (userId: string, workDate: string) => [
|
||||
{
|
||||
userId,
|
||||
workDate,
|
||||
procInstId: `leave-${userId}-${workDate}`,
|
||||
tagName: '请假',
|
||||
leaveType: '事假',
|
||||
beginTime: new Date(`${workDate}T08:00:00+08:00`),
|
||||
endTime: new Date(`${workDate}T12:00:00+08:00`),
|
||||
approvedAt: new Date(`${workDate}T09:00:00+08:00`),
|
||||
duration: '0.5',
|
||||
durationUnit: 'day',
|
||||
},
|
||||
],
|
||||
);
|
||||
dingLeaveRawRepo.findOne.mockResolvedValue(null);
|
||||
dingLeaveRawRepo.save.mockImplementation(async (entities) => entities);
|
||||
studentDingMappingRepo.find.mockResolvedValue([{ dingUserId: 'ding-1', studentId: 7 }]);
|
||||
dingLeaveRawRepo.find.mockResolvedValue([
|
||||
{ dingId: 'leave-ding-1-2026-07-01', dingUserId: 'ding-1', matchStatus: 'unmatched' },
|
||||
]);
|
||||
|
||||
const result = await service.syncLeaveStatusForLesson({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(4);
|
||||
expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith(
|
||||
'ding-1',
|
||||
'2026-07-01',
|
||||
);
|
||||
expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledWith(
|
||||
'ding-2',
|
||||
'2026-07-02',
|
||||
);
|
||||
expect(dingLeaveRawRepo.save).toHaveBeenCalled();
|
||||
expect(result.synced).toBe(4);
|
||||
expect(result.matched).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps syncing remaining users when one leave fetch fails', async () => {
|
||||
dingTalkService.fetchDailyLeaveStatus
|
||||
.mockRejectedValueOnce(new Error('DingTalk unavailable'))
|
||||
.mockResolvedValue([]);
|
||||
dingLeaveRawRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.syncLeaveStatusForLesson({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: false,
|
||||
});
|
||||
|
||||
expect(dingTalkService.fetchDailyLeaveStatus).toHaveBeenCalledTimes(2);
|
||||
expect(result.errors).toHaveLength(1);
|
||||
expect(result.synced).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,10 +4,15 @@ import { Repository, In } from 'typeorm';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import {
|
||||
DingAttendanceRaw,
|
||||
DingLeaveRaw,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service';
|
||||
import {
|
||||
DingTalkService,
|
||||
DingTalkAttendanceResult,
|
||||
DingTalkLeaveResult,
|
||||
} from '../integration/dingtalk.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
|
||||
|
||||
@@ -33,6 +38,8 @@ export class AttendanceImportService {
|
||||
constructor(
|
||||
@InjectRepository(DingAttendanceRaw)
|
||||
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(DingLeaveRaw)
|
||||
private readonly dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
@@ -154,6 +161,131 @@ export class AttendanceImportService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取钉钉已审批通过的请假记录并落库。
|
||||
*
|
||||
* 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表,
|
||||
* 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求,
|
||||
* 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。
|
||||
*/
|
||||
async syncLeaveStatusForLesson(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
autoMatch?: boolean;
|
||||
}): Promise<{ synced: number; matched: number; errors: string[] }> {
|
||||
const userIds = [...new Set((params.userIds ?? []).filter(Boolean))];
|
||||
if (userIds.length === 0) return { synced: 0, matched: 0, errors: [] };
|
||||
if (params.startDate > params.endDate) {
|
||||
throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
}
|
||||
|
||||
const errors: string[] = [];
|
||||
let synced = 0;
|
||||
|
||||
for (const date of this.enumerateDates(params.startDate, params.endDate)) {
|
||||
for (const userId of userIds) {
|
||||
try {
|
||||
const leaves = await this.dingTalkService.fetchDailyLeaveStatus(userId, date);
|
||||
for (const leave of leaves) {
|
||||
await this.upsertLeave(leave);
|
||||
synced++;
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
errors.push(`请假同步失败 ${userId} ${date}: ${msg}`);
|
||||
this.logger.warn(`钉钉请假同步失败 userId=${userId} date=${date}: ${msg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const matched = params.autoMatch ? await this.autoMatchLeaveRecords() : 0;
|
||||
if (synced > 0 || matched > 0) {
|
||||
this.logger.log(`钉钉请假同步完成: 新增/更新 ${synced} 条, 匹配 ${matched} 条, 错误 ${errors.length} 条`);
|
||||
}
|
||||
return { synced, matched, errors };
|
||||
}
|
||||
|
||||
private async upsertLeave(result: DingTalkLeaveResult): Promise<void> {
|
||||
const existing = await this.dingLeaveRawRepo.findOne({
|
||||
where: { dingId: result.procInstId },
|
||||
});
|
||||
if (existing) {
|
||||
Object.assign(existing, {
|
||||
dingUserId: result.userId,
|
||||
workDate: result.workDate,
|
||||
leaveType: result.leaveType,
|
||||
tagName: result.tagName,
|
||||
startTime: result.beginTime,
|
||||
endTime: result.endTime,
|
||||
approvedAt: result.approvedAt,
|
||||
duration: result.duration,
|
||||
durationUnit: result.durationUnit,
|
||||
rawData: JSON.stringify(result),
|
||||
});
|
||||
await this.dingLeaveRawRepo.save(existing);
|
||||
return;
|
||||
}
|
||||
|
||||
const entity = this.dingLeaveRawRepo.create({
|
||||
dingUserId: result.userId,
|
||||
userName: await this.resolveStudentName(result.userId),
|
||||
workDate: result.workDate,
|
||||
dingId: result.procInstId,
|
||||
leaveType: result.leaveType,
|
||||
tagName: result.tagName,
|
||||
startTime: result.beginTime,
|
||||
endTime: result.endTime,
|
||||
approvedAt: result.approvedAt,
|
||||
duration: result.duration,
|
||||
durationUnit: result.durationUnit,
|
||||
matchStatus: 'unmatched',
|
||||
rawData: JSON.stringify(result),
|
||||
});
|
||||
await this.dingLeaveRawRepo.save(entity);
|
||||
}
|
||||
|
||||
/** 通过 dingUserId → StudentDingMapping 自动匹配未匹配的请假记录。 */
|
||||
private async autoMatchLeaveRecords(): Promise<number> {
|
||||
const unmatched = await this.dingLeaveRawRepo.find({
|
||||
where: { matchStatus: 'unmatched' },
|
||||
});
|
||||
if (unmatched.length === 0) return 0;
|
||||
|
||||
const mappings = await this.studentDingMappingRepo.find();
|
||||
const dingToStudentId = new Map<string, number>();
|
||||
for (const mapping of mappings) {
|
||||
dingToStudentId.set(mapping.dingUserId, mapping.studentId);
|
||||
}
|
||||
|
||||
let matched = 0;
|
||||
const updates: DingLeaveRaw[] = [];
|
||||
for (const record of unmatched) {
|
||||
const studentId = dingToStudentId.get(record.dingUserId);
|
||||
if (studentId == null) continue;
|
||||
record.matchedStudentId = studentId;
|
||||
record.matchStatus = 'matched';
|
||||
updates.push(record);
|
||||
matched++;
|
||||
}
|
||||
if (updates.length > 0) {
|
||||
await this.dingLeaveRawRepo.save(updates, { chunk: 50 });
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
private enumerateDates(startDate: string, endDate: string): string[] {
|
||||
const dates: string[] = [];
|
||||
let cursor = this.parseDate(startDate);
|
||||
const end = this.parseDate(endDate);
|
||||
while (cursor.getTime() <= end.getTime()) {
|
||||
dates.push(this.formatDate(cursor));
|
||||
cursor = new Date(cursor);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
/**
|
||||
* DingTalk requires userIds, accepts at most 50 users per request, and
|
||||
* allows a maximum inclusive date range of 7 calendar days.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { DataSource, Repository, In, Between } from 'typeorm';
|
||||
import {
|
||||
AttendanceRecord,
|
||||
DingAttendanceRaw,
|
||||
DingLeaveRaw,
|
||||
Class,
|
||||
Student,
|
||||
ClassSchedule,
|
||||
@@ -32,6 +33,7 @@ export class AttendanceLessonService {
|
||||
constructor(
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(DingLeaveRaw) private dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||
@@ -150,29 +152,34 @@ export class AttendanceLessonService {
|
||||
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
|
||||
|
||||
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const updatedRecords = existingRecords.map((record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manual corrections only while the lesson is still in progress.
|
||||
if (!finalize && record.source !== 'dingtalk') return record;
|
||||
const updatedRecords = await Promise.all(
|
||||
existingRecords.map(async (record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manual corrections only while the lesson is still in progress.
|
||||
if (!finalize && record.source !== 'dingtalk') return record;
|
||||
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(record.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
record.status = mapDingTalkStatus(raw, effectiveFinalize);
|
||||
Object.assign(record, getLessonPunchMetadata(
|
||||
raw,
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(record.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
));
|
||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? null
|
||||
: effectiveFinalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果';
|
||||
return record;
|
||||
});
|
||||
);
|
||||
const resolved = await this.resolveLessonStatus(
|
||||
record.studentId,
|
||||
raw,
|
||||
schedule,
|
||||
lessonDate,
|
||||
effectiveFinalize,
|
||||
);
|
||||
record.status = resolved.status;
|
||||
Object.assign(record, getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
));
|
||||
record.remark = resolved.remark ?? null;
|
||||
return record;
|
||||
}),
|
||||
);
|
||||
for (const classStudent of classStudents) {
|
||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
@@ -180,6 +187,13 @@ export class AttendanceLessonService {
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const resolved = await this.resolveLessonStatus(
|
||||
classStudent.studentId,
|
||||
raw,
|
||||
schedule,
|
||||
lessonDate,
|
||||
effectiveFinalize,
|
||||
);
|
||||
updatedRecords.push(
|
||||
recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
@@ -189,18 +203,14 @@ export class AttendanceLessonService {
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: lessonSessionKey,
|
||||
status: mapDingTalkStatus(raw, effectiveFinalize),
|
||||
status: resolved.status,
|
||||
source: 'dingtalk',
|
||||
...getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
),
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: effectiveFinalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果',
|
||||
remark: resolved.remark,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -263,34 +273,39 @@ export class AttendanceLessonService {
|
||||
}
|
||||
|
||||
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const records = classStudents.map((classStudent) => {
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: schedule.classId!,
|
||||
scheduleId,
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: lessonSessionKey,
|
||||
status: mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
...getLessonPunchMetadata(
|
||||
raw,
|
||||
const records = await Promise.all(
|
||||
classStudents.map(async (classStudent) => {
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
),
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果',
|
||||
});
|
||||
});
|
||||
);
|
||||
const resolved = await this.resolveLessonStatus(
|
||||
classStudent.studentId,
|
||||
raw,
|
||||
schedule,
|
||||
lessonDate,
|
||||
finalize,
|
||||
);
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: schedule.classId!,
|
||||
scheduleId,
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: lessonSessionKey,
|
||||
status: resolved.status,
|
||||
source: 'dingtalk',
|
||||
...getLessonPunchMetadata(
|
||||
raw,
|
||||
lessonDate,
|
||||
schedule.startTime,
|
||||
),
|
||||
remark: resolved.remark,
|
||||
});
|
||||
}),
|
||||
);
|
||||
const saved = await recordRepo.save(records);
|
||||
if (finalize) {
|
||||
session.status = 'completed';
|
||||
@@ -302,6 +317,59 @@ export class AttendanceLessonService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与
|
||||
* 本节课时间窗口重叠,则记为 leave,而不是缺勤。
|
||||
*/
|
||||
private async resolveLessonStatus(
|
||||
studentId: number,
|
||||
raw: DingAttendanceRaw[],
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
finalize: boolean,
|
||||
): Promise<{ status: string; remark?: string }> {
|
||||
const hasPunch = raw.some((item) => item.checkInTime || item.checkOutTime);
|
||||
if (!finalize) {
|
||||
return {
|
||||
status: mapDingTalkStatus(raw, false),
|
||||
remark: hasPunch ? undefined : '未获取到钉钉打卡结果',
|
||||
};
|
||||
}
|
||||
if (hasPunch) return { status: 'present', remark: undefined };
|
||||
|
||||
const leave = await this.findApprovedLeaveForStudent(studentId, schedule, lessonDate);
|
||||
if (leave) {
|
||||
return {
|
||||
status: 'leave',
|
||||
remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`,
|
||||
};
|
||||
}
|
||||
return { status: 'absent', remark: '课程截止仍未打卡' };
|
||||
}
|
||||
|
||||
private async findApprovedLeaveForStudent(
|
||||
studentId: number,
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
): Promise<DingLeaveRaw | null> {
|
||||
const leaves = await this.dingLeaveRawRepo.find({
|
||||
where: { matchedStudentId: studentId },
|
||||
});
|
||||
const window = getLessonAttendanceWindow(schedule, lessonDate);
|
||||
const overlapping = leaves.filter(
|
||||
(leave) =>
|
||||
leave.startTime &&
|
||||
leave.endTime &&
|
||||
leave.startTime.getTime() <= window.end &&
|
||||
leave.endTime.getTime() >= window.start,
|
||||
);
|
||||
overlapping.sort(
|
||||
(left, right) =>
|
||||
(right.approvedAt?.getTime() ?? 0) - (left.approvedAt?.getTime() ?? 0),
|
||||
);
|
||||
return overlapping[0] ?? null;
|
||||
}
|
||||
|
||||
private async fetchDingTalkRawByStudent(
|
||||
classId: number,
|
||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
|
||||
@@ -37,6 +37,7 @@ const createService = () => {
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
|
||||
syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }),
|
||||
};
|
||||
const service = new AttendanceSettlementService(
|
||||
scheduleRepo as never,
|
||||
@@ -68,6 +69,25 @@ describe('AttendanceSettlementService', () => {
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
|
||||
2, 2, '2026-07-13', 21, true,
|
||||
);
|
||||
expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-13',
|
||||
endDate: '2026-07-13',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('finalizes the lesson even when the leave sync fails', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable'));
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith(
|
||||
2, '2026-07-13', 21, true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not settle a lesson before its end time', async () => {
|
||||
|
||||
@@ -129,6 +129,18 @@ export class AttendanceSettlementService {
|
||||
if (!imported.success || imported.errors.length > 0) {
|
||||
throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败');
|
||||
}
|
||||
try {
|
||||
await this.importService.syncLeaveStatusForLesson({
|
||||
...importRange,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
// 请假数据是补充信息,同步失败不应阻断结算;无请假的学生按缺勤处理。
|
||||
this.logger.warn(
|
||||
`课程${schedule.id} ${lessonDate}钉钉请假同步失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
await this.attendanceService.createLessonAttendanceFromDingTalk(
|
||||
schedule.id,
|
||||
lessonDate,
|
||||
|
||||
@@ -28,6 +28,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
|
||||
return new AttendanceService(
|
||||
{} as never, // attendanceRepo
|
||||
{} as never, // dingRawRepo
|
||||
{} as never, // dingLeaveRawRepo
|
||||
{} as never, // classRepo
|
||||
{} as never, // studentRepo
|
||||
{} as never, // scheduleRepo
|
||||
@@ -217,6 +218,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', ()
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -277,6 +279,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', ()
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -339,6 +342,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', ()
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
||||
@@ -14,6 +14,7 @@ const createService = () => {
|
||||
count: jest.fn(),
|
||||
};
|
||||
const dingRawRepo = { find: jest.fn() };
|
||||
const dingLeaveRawRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const scheduleRepo = { findOne: jest.fn() };
|
||||
const classStudentRepo = { find: jest.fn() };
|
||||
const sessionRepo = {
|
||||
@@ -37,6 +38,7 @@ const createService = () => {
|
||||
const service = new AttendanceService(
|
||||
attendanceRepo as never,
|
||||
dingRawRepo as never,
|
||||
dingLeaveRawRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
scheduleRepo as never,
|
||||
@@ -52,6 +54,7 @@ const createService = () => {
|
||||
service,
|
||||
attendanceRepo,
|
||||
dingRawRepo,
|
||||
dingLeaveRawRepo,
|
||||
scheduleRepo,
|
||||
classStudentRepo,
|
||||
sessionRepo,
|
||||
@@ -151,6 +154,58 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
expect(result.session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('finalizes a missing punch as leave when an approved DingTalk leave overlaps the lesson', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
dingLeaveRawRepo.find.mockResolvedValue([
|
||||
{
|
||||
startTime: new Date('2026-07-11T08:00:00+08:00'),
|
||||
endTime: new Date('2026-07-11T12:00:00+08:00'),
|
||||
approvedAt: new Date('2026-07-10T15:00:00+08:00'),
|
||||
leaveType: '事假',
|
||||
tagName: '请假',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
studentId: 1,
|
||||
status: 'leave',
|
||||
remark: '钉钉请假已通过(事假)',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a leave student pending before the lesson is finalized', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, dingLeaveRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([{ studentId: 1, student: { id: 1, name: '张三' } }]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
dingLeaveRawRepo.find.mockResolvedValue([
|
||||
{
|
||||
startTime: new Date('2026-07-11T08:00:00+08:00'),
|
||||
endTime: new Date('2026-07-11T12:00:00+08:00'),
|
||||
approvedAt: new Date('2026-07-10T15:00:00+08:00'),
|
||||
leaveType: '事假',
|
||||
tagName: '请假',
|
||||
},
|
||||
]);
|
||||
|
||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'pending' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns student relations after the first pull', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
@@ -12,7 +12,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AttendanceSession } from '../entities/attendance-session.entity';
|
||||
import { AttendanceDevice } from '../entities/attendance-device.entity';
|
||||
import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity';
|
||||
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
|
||||
import { DingLeaveRaw } from '../entities/ding-leave-raw.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
@@ -50,6 +51,7 @@ describe('AttendanceService — batchCreate', () => {
|
||||
AttendanceService,
|
||||
{ provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo },
|
||||
{ provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo },
|
||||
{ provide: getRepositoryToken(DingLeaveRaw), useValue: { find: jest.fn().mockResolvedValue([]) } },
|
||||
{ provide: getRepositoryToken(Class), useValue: mockClassRepo },
|
||||
{ provide: getRepositoryToken(Student), useValue: mockStudentRepo },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
|
||||
@@ -156,6 +158,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classTeacherRepo as never,
|
||||
@@ -231,6 +234,7 @@ describe('AttendanceService — DingTalk raw query', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
{} as never,
|
||||
@@ -299,6 +303,7 @@ describe('AttendanceService — attendance device display mappings', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
attendanceDeviceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -408,6 +413,7 @@ describe('AttendanceService — session serialization', () => {
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ find: jest.fn().mockResolvedValue([]) } as never,
|
||||
{} as never,
|
||||
dataSourceMock as never,
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AttendanceDevice,
|
||||
AttendancePeriodConfig,
|
||||
DingAttendanceRaw,
|
||||
DingLeaveRaw,
|
||||
Class,
|
||||
Student,
|
||||
ClassSchedule,
|
||||
@@ -35,6 +36,8 @@ export class AttendanceService {
|
||||
private attendanceRepo: Repository<AttendanceRecord>,
|
||||
@InjectRepository(DingAttendanceRaw)
|
||||
private dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(DingLeaveRaw)
|
||||
private dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||||
@InjectRepository(Class)
|
||||
private classRepo: Repository<Class>,
|
||||
@InjectRepository(Student)
|
||||
@@ -69,6 +72,7 @@ export class AttendanceService {
|
||||
this.lessonService = new AttendanceLessonService(
|
||||
this.attendanceRepo,
|
||||
this.dingRawRepo,
|
||||
this.dingLeaveRawRepo,
|
||||
this.classRepo,
|
||||
this.studentRepo,
|
||||
this.scheduleRepo,
|
||||
|
||||
@@ -94,4 +94,73 @@ describe('DingTalkService — attendance records', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches approved leave approvals from the daily attendance data API', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({
|
||||
errcode: 0,
|
||||
errmsg: 'ok',
|
||||
result: {
|
||||
userid: 'ding-1',
|
||||
work_date: '2026-07-12 00:00:00',
|
||||
approve_list: [
|
||||
{
|
||||
procInst_id: 'PRO-LEAVE-1',
|
||||
tag_name: '请假',
|
||||
sub_type: '事假',
|
||||
biz_type: 3,
|
||||
begin_time: '2026-07-12 08:00:00',
|
||||
end_time: '2026-07-12 12:00:00',
|
||||
gmt_finished: '2026-07-11 18:00:00',
|
||||
duration: '0.5',
|
||||
duration_unit: 'day',
|
||||
},
|
||||
{
|
||||
// 审批中(无 gmt_finished)的请假不应返回
|
||||
procInst_id: 'PRO-LEAVE-2',
|
||||
tag_name: '请假',
|
||||
sub_type: '病假',
|
||||
biz_type: 3,
|
||||
begin_time: '2026-07-12 08:00:00',
|
||||
end_time: '2026-07-12 12:00:00',
|
||||
duration: '0.5',
|
||||
duration_unit: 'day',
|
||||
},
|
||||
{
|
||||
// 出差(biz_type=2)不应返回
|
||||
procInst_id: 'PRO-TRIP-1',
|
||||
tag_name: '出差',
|
||||
sub_type: '出差',
|
||||
biz_type: 2,
|
||||
begin_time: '2026-07-12 08:00:00',
|
||||
end_time: '2026-07-12 18:00:00',
|
||||
gmt_finished: '2026-07-11 18:00:00',
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
const leaves = await service.fetchDailyLeaveStatus('ding-1', '2026-07-12');
|
||||
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
|
||||
userid: 'ding-1',
|
||||
work_date: '2026-07-12 00:00:00',
|
||||
});
|
||||
expect(leaves).toHaveLength(1);
|
||||
expect(leaves[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
userId: 'ding-1',
|
||||
workDate: '2026-07-12',
|
||||
procInstId: 'PRO-LEAVE-1',
|
||||
leaveType: '事假',
|
||||
tagName: '请假',
|
||||
beginTime: new Date('2026-07-12T08:00:00+08:00'),
|
||||
endTime: new Date('2026-07-12T12:00:00+08:00'),
|
||||
approvedAt: new Date('2026-07-11T18:00:00+08:00'),
|
||||
duration: '0.5',
|
||||
durationUnit: 'day',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user