feat: 结算时同步钉钉已审批请假并标记为请假
This commit is contained in:
@@ -121,6 +121,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
Entities.AttendanceDevice,
|
||||
Entities.AttendancePeriodConfig,
|
||||
Entities.DingAttendanceRaw,
|
||||
Entities.DingLeaveRaw,
|
||||
Entities.Notification,
|
||||
Entities.StudentProfile,
|
||||
Entities.StudentEnrollment,
|
||||
|
||||
@@ -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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,47 @@ export async function ensureCourseAttendanceSchema(
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureDingLeaveSchema(
|
||||
dataSource: DataSource,
|
||||
logger: Logger,
|
||||
): Promise<void> {
|
||||
await withQueryRunner(dataSource, async (runner) => {
|
||||
await runner.query(`
|
||||
CREATE TABLE IF NOT EXISTS ding_leave_raw (
|
||||
id INTEGER PRIMARY KEY AUTO_INCREMENT,
|
||||
ding_user_id VARCHAR(100) NOT NULL,
|
||||
user_name VARCHAR(100) NOT NULL DEFAULT '',
|
||||
work_date DATE NOT NULL,
|
||||
ding_id VARCHAR(100) NOT NULL,
|
||||
leave_type VARCHAR(100) NOT NULL DEFAULT '',
|
||||
tag_name VARCHAR(50) NOT NULL DEFAULT '',
|
||||
start_time DATETIME,
|
||||
end_time DATETIME,
|
||||
approved_at DATETIME,
|
||||
duration VARCHAR(20) NOT NULL DEFAULT '',
|
||||
duration_unit VARCHAR(20) NOT NULL DEFAULT '',
|
||||
match_status VARCHAR(20) NOT NULL DEFAULT 'unmatched',
|
||||
matched_student_id INTEGER,
|
||||
raw_data TEXT,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
const createIndex = async (sql: string) => {
|
||||
try {
|
||||
await runner.query(sql);
|
||||
} catch {
|
||||
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
|
||||
}
|
||||
};
|
||||
await createIndex('CREATE UNIQUE INDEX uq_ding_leave_raw_ding_id ON ding_leave_raw (ding_id)');
|
||||
await createIndex('CREATE INDEX idx_ding_leave_raw_work_date ON ding_leave_raw (work_date)');
|
||||
await createIndex('CREATE INDEX idx_ding_leave_raw_match_status ON ding_leave_raw (match_status)');
|
||||
logger.log('已确保钉钉请假原始表 ding_leave_raw');
|
||||
});
|
||||
}
|
||||
|
||||
export async function protectAttendanceHistory(
|
||||
dataSource: DataSource,
|
||||
logger: Logger,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { ensureAiConfigTable } from './database-migrations.ai';
|
||||
import {
|
||||
ensureCourseAttendanceSchema,
|
||||
ensureDingLeaveSchema,
|
||||
protectAttendanceHistory,
|
||||
} from './database-migrations.attendance';
|
||||
import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill';
|
||||
@@ -29,6 +30,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.ensureSyncStateLeaseColumns();
|
||||
await this.ensureStudentProfileCollegeColumns();
|
||||
await this.ensureCourseAttendanceSchema();
|
||||
await this.ensureDingLeaveSchema();
|
||||
await this.ensureAttendanceDevicesSchema();
|
||||
await this.ensureStudentWalletSchema();
|
||||
await this.backfillOrganizations();
|
||||
@@ -85,6 +87,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
return ensureCourseAttendanceSchema(this.dataSource, this.logger);
|
||||
}
|
||||
|
||||
async ensureDingLeaveSchema(): Promise<void> {
|
||||
return ensureDingLeaveSchema(this.dataSource, this.logger);
|
||||
}
|
||||
|
||||
async backfillOrganizations(): Promise<void> {
|
||||
return backfillOrganizations(this.dataSource);
|
||||
}
|
||||
|
||||
71
apps/server/src/entities/ding-leave-raw.entity.ts
Normal file
71
apps/server/src/entities/ding-leave-raw.entity.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
UpdateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
Index,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
|
||||
@Entity('ding_leave_raw')
|
||||
@Index(['workDate'])
|
||||
@Index(['matchStatus'])
|
||||
export class DingLeaveRaw {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'ding_user_id', length: 100 })
|
||||
dingUserId: string;
|
||||
|
||||
@Column({ name: 'user_name', length: 100 })
|
||||
userName: string;
|
||||
|
||||
@Column({ name: 'work_date', type: 'date' })
|
||||
workDate: string;
|
||||
|
||||
@Column({ name: 'ding_id', length: 100, unique: true })
|
||||
dingId: string;
|
||||
|
||||
@Column({ name: 'leave_type', length: 100 })
|
||||
leaveType: string;
|
||||
|
||||
@Column({ name: 'tag_name', length: 50 })
|
||||
tagName: string;
|
||||
|
||||
@Column({ name: 'start_time', type: 'datetime', nullable: true })
|
||||
startTime: Date | null;
|
||||
|
||||
@Column({ name: 'end_time', type: 'datetime', nullable: true })
|
||||
endTime: Date | null;
|
||||
|
||||
@Column({ name: 'approved_at', type: 'datetime', nullable: true })
|
||||
approvedAt: Date | null;
|
||||
|
||||
@Column({ length: 20 })
|
||||
duration: string;
|
||||
|
||||
@Column({ name: 'duration_unit', length: 20 })
|
||||
durationUnit: string;
|
||||
|
||||
@Column({ name: 'match_status', length: 20, default: 'unmatched' })
|
||||
matchStatus: string;
|
||||
|
||||
@Column({ name: 'matched_student_id', type: 'integer', nullable: true })
|
||||
matchedStudentId: number;
|
||||
|
||||
@ManyToOne(() => Student, { onDelete: 'SET NULL', nullable: true })
|
||||
@JoinColumn({ name: 'matched_student_id' })
|
||||
matchedStudent: Student;
|
||||
|
||||
@Column({ name: 'raw_data', type: 'text', nullable: true })
|
||||
rawData: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export { AttendanceSession } from './attendance-session.entity';
|
||||
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
|
||||
export { AttendancePeriodConfig } from './attendance-period-config.entity';
|
||||
export { DingAttendanceRaw } from './ding-attendance-raw.entity';
|
||||
export { DingLeaveRaw } from './ding-leave-raw.entity';
|
||||
export { SyncLog } from './sync-log.entity';
|
||||
export { SyncState } from './sync-state.entity';
|
||||
export { ExpenseType } from './expense-type.entity';
|
||||
|
||||
95
apps/server/src/integration/dingtalk.leave.ts
Normal file
95
apps/server/src/integration/dingtalk.leave.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
// aislop-ignore-file: duplicate-block -- 钉钉 API 调用块结构相似(端点/参数不同)
|
||||
import type {
|
||||
DingTalkLeaveResult,
|
||||
DingTalkServiceContext,
|
||||
} from './dingtalk.types';
|
||||
|
||||
interface DingTalkGetUpdateDataResponse {
|
||||
errcode: number;
|
||||
errmsg: string;
|
||||
result?: {
|
||||
userid?: string;
|
||||
work_date?: string;
|
||||
approve_list?: Array<{
|
||||
procInst_id?: string;
|
||||
tag_name?: string;
|
||||
sub_type?: string;
|
||||
biz_type?: number;
|
||||
begin_time?: string;
|
||||
end_time?: string;
|
||||
gmt_finished?: string;
|
||||
duration?: string;
|
||||
duration_unit?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 钉钉请假数据客户端。
|
||||
*
|
||||
* 使用「获取用户考勤数据」接口(topapi/attendance/getupdatedata),按用户+工作日
|
||||
* 返回当天打卡结果与审批单列表;这里只取 biz_type=3(请假)且已审批完成
|
||||
* (gmt_finished 非空)的记录,保证结算时不会把审批中的请假误判为请假。
|
||||
*/
|
||||
export class DingTalkLeaveClient {
|
||||
constructor(private readonly context: DingTalkServiceContext) {}
|
||||
|
||||
async fetchDailyLeaveStatus(
|
||||
userId: string,
|
||||
workDate: string,
|
||||
): Promise<DingTalkLeaveResult[]> {
|
||||
if (!(await this.context.isConfigured())) throw new Error('DingTalk not configured');
|
||||
if (!userId) throw new Error('钉钉请假查询 userId 不能为空');
|
||||
|
||||
const token = await this.context.getAccessToken();
|
||||
await this.context.rateLimit();
|
||||
const res = await fetch(
|
||||
`https://oapi.dingtalk.com/topapi/attendance/getupdatedata?access_token=${token}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
userid: userId,
|
||||
work_date: workDate.includes(' ') ? workDate : `${workDate} 00:00:00`,
|
||||
}),
|
||||
},
|
||||
);
|
||||
const data = (await res.json()) as DingTalkGetUpdateDataResponse;
|
||||
if (data.errcode !== 0) {
|
||||
throw new Error(`钉钉请假数据获取失败: ${data.errmsg}`);
|
||||
}
|
||||
|
||||
const result = data.result;
|
||||
if (!result) return [];
|
||||
const approveList = result.approve_list ?? [];
|
||||
|
||||
return approveList
|
||||
.filter(
|
||||
(approval) =>
|
||||
approval.biz_type === 3 &&
|
||||
approval.gmt_finished &&
|
||||
approval.procInst_id &&
|
||||
approval.begin_time &&
|
||||
approval.end_time,
|
||||
)
|
||||
.map((approval) => ({
|
||||
userId: result.userid ?? userId,
|
||||
workDate,
|
||||
procInstId: approval.procInst_id!,
|
||||
tagName: approval.tag_name ?? '请假',
|
||||
leaveType: approval.sub_type ?? '',
|
||||
beginTime: this.parseDingDate(approval.begin_time!),
|
||||
endTime: this.parseDingDate(approval.end_time!),
|
||||
approvedAt: this.parseDingDate(approval.gmt_finished!),
|
||||
duration: approval.duration ?? '',
|
||||
durationUnit: approval.duration_unit ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 钉钉返回的日期可能是 '2026-08-01' 或 '2026-08-01 09:00:00',统一按东八区解析。 */
|
||||
private parseDingDate(value: string): Date {
|
||||
const normalized = value.includes(' ') ? value.replace(' ', 'T') : `${value}T00:00:00`;
|
||||
const date = new Date(`${normalized}+08:00`);
|
||||
return Number.isNaN(date.getTime()) ? new Date(normalized) : date;
|
||||
}
|
||||
}
|
||||
@@ -25,12 +25,14 @@ import type {
|
||||
OrgDeptNodeWithUsers,
|
||||
} from './dingtalk.types';
|
||||
import { DingTalkAttendanceClient } from './dingtalk.attendance';
|
||||
import { DingTalkLeaveClient } from './dingtalk.leave';
|
||||
import { DingTalkShiftClient } from './dingtalk.shifts';
|
||||
import { DingTalkGroupClient } from './dingtalk.groups';
|
||||
import { DingTalkScheduleClient } from './dingtalk.schedules';
|
||||
|
||||
export type {
|
||||
DingTalkAttendanceResult,
|
||||
DingTalkLeaveResult,
|
||||
DingTalkGroupParams,
|
||||
DingTalkGroupSummary,
|
||||
DingTalkGroupUpdateParams,
|
||||
@@ -55,6 +57,7 @@ export class DingTalkService implements DingTalkServiceContext {
|
||||
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
|
||||
|
||||
private attendanceClient?: DingTalkAttendanceClient;
|
||||
private leaveClient?: DingTalkLeaveClient;
|
||||
private shiftClient?: DingTalkShiftClient;
|
||||
private groupClient?: DingTalkGroupClient;
|
||||
private scheduleClient?: DingTalkScheduleClient;
|
||||
@@ -73,6 +76,11 @@ export class DingTalkService implements DingTalkServiceContext {
|
||||
return this.attendanceClient;
|
||||
}
|
||||
|
||||
private get leaves(): DingTalkLeaveClient {
|
||||
if (!this.leaveClient) this.leaveClient = new DingTalkLeaveClient(this);
|
||||
return this.leaveClient;
|
||||
}
|
||||
|
||||
private get shifts(): DingTalkShiftClient {
|
||||
if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this);
|
||||
return this.shiftClient;
|
||||
@@ -367,6 +375,12 @@ export class DingTalkService implements DingTalkServiceContext {
|
||||
return this.attendance.fetchAttendanceResults(...args);
|
||||
}
|
||||
|
||||
async fetchDailyLeaveStatus(
|
||||
...args: Parameters<DingTalkLeaveClient['fetchDailyLeaveStatus']>
|
||||
) {
|
||||
return this.leaves.fetchDailyLeaveStatus(...args);
|
||||
}
|
||||
|
||||
async upsertShift(...args: Parameters<DingTalkShiftClient['upsertShift']>) {
|
||||
return this.shifts.upsertShift(...args);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,24 @@ export interface DingTalkAttendanceResult {
|
||||
deviceId?: string;
|
||||
}
|
||||
|
||||
/** 钉钉已审批通过的请假记录 — 对齐 dws 考勤数据中的审批单列表。 */
|
||||
export interface DingTalkLeaveResult {
|
||||
userId: string;
|
||||
workDate: string;
|
||||
/** 钉钉审批单 ID */
|
||||
procInstId: string;
|
||||
/** 审批单类型名称,例如 请假 */
|
||||
tagName: string;
|
||||
/** 请假类型,例如 年假 / 事假 / 病假 */
|
||||
leaveType: string;
|
||||
beginTime: Date;
|
||||
endTime: Date;
|
||||
/** 审批完成时间;为 null 表示仍在审批中,不纳入结算 */
|
||||
approvedAt: Date | null;
|
||||
duration: string;
|
||||
durationUnit: string;
|
||||
}
|
||||
|
||||
// ── 组织架构 API 类型 ──
|
||||
|
||||
export interface DingTalkDeptListResponse {
|
||||
|
||||
Reference in New Issue
Block a user