feat: 结算时同步钉钉已审批请假并标记为请假

This commit is contained in:
2026-08-05 18:43:02 +08:00
parent 32343b271b
commit c8316e9e8e
19 changed files with 746 additions and 58 deletions

View File

@@ -121,6 +121,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
Entities.AttendanceDevice, Entities.AttendanceDevice,
Entities.AttendancePeriodConfig, Entities.AttendancePeriodConfig,
Entities.DingAttendanceRaw, Entities.DingAttendanceRaw,
Entities.DingLeaveRaw,
Entities.Notification, Entities.Notification,
Entities.StudentProfile, Entities.StudentProfile,
Entities.StudentEnrollment, Entities.StudentEnrollment,

View File

@@ -9,10 +9,17 @@ describe('AttendanceImportService', () => {
findOne: jest.fn(), findOne: jest.fn(),
save: 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 studentRepo = { findOne: jest.fn() };
const studentDingMappingRepo = { findOne: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() };
const dingTalkService = { const dingTalkService = {
fetchAttendanceResults: jest.fn(), fetchAttendanceResults: jest.fn(),
fetchDailyLeaveStatus: jest.fn(),
}; };
const attendanceService = { const attendanceService = {
autoMatchDingRecords: jest.fn(), autoMatchDingRecords: jest.fn(),
@@ -24,6 +31,7 @@ describe('AttendanceImportService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
service = new AttendanceImportService( service = new AttendanceImportService(
dingRawRepo as never, dingRawRepo as never,
dingLeaveRawRepo as never,
studentRepo as never, studentRepo as never,
studentDingMappingRepo as never, studentDingMappingRepo as never,
dingTalkService as unknown as DingTalkService, dingTalkService as unknown as DingTalkService,
@@ -304,4 +312,67 @@ describe('AttendanceImportService', () => {
expect(event.userId).toBeUndefined(); 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);
});
}); });

View File

@@ -4,10 +4,15 @@ import { Repository, In } from 'typeorm';
import { Subject, Observable } from 'rxjs'; import { Subject, Observable } from 'rxjs';
import { import {
DingAttendanceRaw, DingAttendanceRaw,
DingLeaveRaw,
Student, Student,
StudentDingMapping, StudentDingMapping,
} from '../entities'; } from '../entities';
import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service'; import {
DingTalkService,
DingTalkAttendanceResult,
DingTalkLeaveResult,
} from '../integration/dingtalk.service';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
@@ -33,6 +38,8 @@ export class AttendanceImportService {
constructor( constructor(
@InjectRepository(DingAttendanceRaw) @InjectRepository(DingAttendanceRaw)
private readonly dingRawRepo: Repository<DingAttendanceRaw>, private readonly dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(DingLeaveRaw)
private readonly dingLeaveRawRepo: Repository<DingLeaveRaw>,
@InjectRepository(Student) @InjectRepository(Student)
private readonly studentRepo: Repository<Student>, private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping) @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 * DingTalk requires userIds, accepts at most 50 users per request, and
* allows a maximum inclusive date range of 7 calendar days. * allows a maximum inclusive date range of 7 calendar days.

View File

@@ -4,6 +4,7 @@ import { DataSource, Repository, In, Between } from 'typeorm';
import { import {
AttendanceRecord, AttendanceRecord,
DingAttendanceRaw, DingAttendanceRaw,
DingLeaveRaw,
Class, Class,
Student, Student,
ClassSchedule, ClassSchedule,
@@ -32,6 +33,7 @@ export class AttendanceLessonService {
constructor( constructor(
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(DingLeaveRaw) private dingLeaveRawRepo: Repository<DingLeaveRaw>,
@InjectRepository(Class) private classRepo: Repository<Class>, @InjectRepository(Class) private classRepo: Repository<Class>,
@InjectRepository(Student) private studentRepo: Repository<Student>, @InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>, @InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
@@ -150,29 +152,34 @@ export class AttendanceLessonService {
const existingStudentIds = new Set(existingRecords.map((record) => record.studentId)); const existingStudentIds = new Set(existingRecords.map((record) => record.studentId));
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const updatedRecords = existingRecords.map((record) => { const updatedRecords = await Promise.all(
record.student = studentsById.get(record.studentId)!; existingRecords.map(async (record) => {
// Preserve manual corrections only while the lesson is still in progress. record.student = studentsById.get(record.studentId)!;
if (!finalize && record.source !== 'dingtalk') return record; // Preserve manual corrections only while the lesson is still in progress.
if (!finalize && record.source !== 'dingtalk') return record;
const raw = selectDingTalkRecordsForLesson( const raw = selectDingTalkRecordsForLesson(
rawByStudent.get(record.studentId) ?? [], rawByStudent.get(record.studentId) ?? [],
schedule, schedule,
lessonDate,
);
record.status = mapDingTalkStatus(raw, effectiveFinalize);
Object.assign(record, getLessonPunchMetadata(
raw,
lessonDate, lessonDate,
schedule.startTime, );
)); const resolved = await this.resolveLessonStatus(
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime) record.studentId,
? null raw,
: effectiveFinalize schedule,
? '课程截止仍未打卡' lessonDate,
: '未获取到钉钉打卡结果'; effectiveFinalize,
return record; );
}); record.status = resolved.status;
Object.assign(record, getLessonPunchMetadata(
raw,
lessonDate,
schedule.startTime,
));
record.remark = resolved.remark ?? null;
return record;
}),
);
for (const classStudent of classStudents) { for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue; if (existingStudentIds.has(classStudent.studentId)) continue;
const raw = selectDingTalkRecordsForLesson( const raw = selectDingTalkRecordsForLesson(
@@ -180,6 +187,13 @@ export class AttendanceLessonService {
schedule, schedule,
lessonDate, lessonDate,
); );
const resolved = await this.resolveLessonStatus(
classStudent.studentId,
raw,
schedule,
lessonDate,
effectiveFinalize,
);
updatedRecords.push( updatedRecords.push(
recordRepo.create({ recordRepo.create({
studentId: classStudent.studentId, studentId: classStudent.studentId,
@@ -189,18 +203,14 @@ export class AttendanceLessonService {
attendanceSessionId: existing.id, attendanceSessionId: existing.id,
attendanceDate: lessonDate, attendanceDate: lessonDate,
session: lessonSessionKey, session: lessonSessionKey,
status: mapDingTalkStatus(raw, effectiveFinalize), status: resolved.status,
source: 'dingtalk', source: 'dingtalk',
...getLessonPunchMetadata( ...getLessonPunchMetadata(
raw, raw,
lessonDate, lessonDate,
schedule.startTime, schedule.startTime,
), ),
remark: raw.some((item) => item.checkInTime || item.checkOutTime) remark: resolved.remark,
? undefined
: effectiveFinalize
? '课程截止仍未打卡'
: '未获取到钉钉打卡结果',
}), }),
); );
} }
@@ -263,34 +273,39 @@ export class AttendanceLessonService {
} }
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const records = classStudents.map((classStudent) => { const records = await Promise.all(
const raw = selectDingTalkRecordsForLesson( classStudents.map(async (classStudent) => {
rawByStudent.get(classStudent.studentId) ?? [], const raw = selectDingTalkRecordsForLesson(
schedule, rawByStudent.get(classStudent.studentId) ?? [],
lessonDate, schedule,
);
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,
lessonDate, lessonDate,
schedule.startTime, );
), const resolved = await this.resolveLessonStatus(
remark: raw.some((item) => item.checkInTime || item.checkOutTime) classStudent.studentId,
? undefined raw,
: finalize 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); const saved = await recordRepo.save(records);
if (finalize) { if (finalize) {
session.status = 'completed'; 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( private async fetchDingTalkRawByStudent(
classId: number, classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>, schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,

View File

@@ -37,6 +37,7 @@ const createService = () => {
}; };
const importService = { const importService = {
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }),
}; };
const service = new AttendanceSettlementService( const service = new AttendanceSettlementService(
scheduleRepo as never, scheduleRepo as never,
@@ -68,6 +69,25 @@ describe('AttendanceSettlementService', () => {
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
2, 2, '2026-07-13', 21, true, 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 () => { it('does not settle a lesson before its end time', async () => {

View File

@@ -129,6 +129,18 @@ export class AttendanceSettlementService {
if (!imported.success || imported.errors.length > 0) { if (!imported.success || imported.errors.length > 0) {
throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); 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( await this.attendanceService.createLessonAttendanceFromDingTalk(
schedule.id, schedule.id,
lessonDate, lessonDate,

View File

@@ -28,6 +28,7 @@ describe('AttendanceService — saveAttendancePeriodConfigs boundaries', () => {
return new AttendanceService( return new AttendanceService(
{} as never, // attendanceRepo {} as never, // attendanceRepo
{} as never, // dingRawRepo {} as never, // dingRawRepo
{} as never, // dingLeaveRawRepo
{} as never, // classRepo {} as never, // classRepo
{} as never, // studentRepo {} as never, // studentRepo
{} as never, // scheduleRepo {} as never, // scheduleRepo
@@ -217,6 +218,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', ()
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
scheduleRepo as never, scheduleRepo as never,
{} as never, {} as never,
{} as never, {} as never,
@@ -277,6 +279,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', ()
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
scheduleRepo as never, scheduleRepo as never,
{} as never, {} as never,
{} as never, {} as never,
@@ -339,6 +342,7 @@ describe('AttendanceService — getScheduleOptionsForAttendance boundaries', ()
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
scheduleRepo as never, scheduleRepo as never,
{} as never, {} as never,
{} as never, {} as never,

View File

@@ -14,6 +14,7 @@ const createService = () => {
count: jest.fn(), count: jest.fn(),
}; };
const dingRawRepo = { find: jest.fn() }; const dingRawRepo = { find: jest.fn() };
const dingLeaveRawRepo = { find: jest.fn().mockResolvedValue([]) };
const scheduleRepo = { findOne: jest.fn() }; const scheduleRepo = { findOne: jest.fn() };
const classStudentRepo = { find: jest.fn() }; const classStudentRepo = { find: jest.fn() };
const sessionRepo = { const sessionRepo = {
@@ -37,6 +38,7 @@ const createService = () => {
const service = new AttendanceService( const service = new AttendanceService(
attendanceRepo as never, attendanceRepo as never,
dingRawRepo as never, dingRawRepo as never,
dingLeaveRawRepo as never,
{} as never, {} as never,
{} as never, {} as never,
scheduleRepo as never, scheduleRepo as never,
@@ -52,6 +54,7 @@ const createService = () => {
service, service,
attendanceRepo, attendanceRepo,
dingRawRepo, dingRawRepo,
dingLeaveRawRepo,
scheduleRepo, scheduleRepo,
classStudentRepo, classStudentRepo,
sessionRepo, sessionRepo,
@@ -151,6 +154,58 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
expect(result.session.status).toBe('completed'); 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 () => { it('returns student relations after the first pull', async () => {
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } = const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
createService(); createService();

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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 { AttendanceService } from './attendance.service';
import { AttendanceImportService } from './attendance-import.service'; import { AttendanceImportService } from './attendance-import.service';
import { AttendanceSettlementService } from './attendance-settlement.service'; import { AttendanceSettlementService } from './attendance-settlement.service';
@@ -12,7 +12,7 @@ import { IntegrationModule } from '../integration/integration.module';
@Module({ @Module({
imports: [ 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, OperationLogsModule,
IntegrationModule, IntegrationModule,
], ],

View File

@@ -8,6 +8,7 @@ import { AttendanceSession } from '../entities/attendance-session.entity';
import { AttendanceDevice } from '../entities/attendance-device.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity';
import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity'; import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity';
import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity';
import { DingLeaveRaw } from '../entities/ding-leave-raw.entity';
import { Class } from '../entities/class.entity'; import { Class } from '../entities/class.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { ClassSchedule } from '../entities/class-schedule.entity'; import { ClassSchedule } from '../entities/class-schedule.entity';
@@ -50,6 +51,7 @@ describe('AttendanceService — batchCreate', () => {
AttendanceService, AttendanceService,
{ provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo }, { provide: getRepositoryToken(AttendanceRecord), useValue: mockRepo },
{ provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo }, { provide: getRepositoryToken(DingAttendanceRaw), useValue: mockDingRepo },
{ provide: getRepositoryToken(DingLeaveRaw), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: getRepositoryToken(Class), useValue: mockClassRepo }, { provide: getRepositoryToken(Class), useValue: mockClassRepo },
{ provide: getRepositoryToken(Student), useValue: mockStudentRepo }, { provide: getRepositoryToken(Student), useValue: mockStudentRepo },
{ provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo }, { provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
@@ -156,6 +158,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
classStudentRepo as never, classStudentRepo as never,
mappingRepo as never, mappingRepo as never,
classTeacherRepo as never, classTeacherRepo as never,
@@ -231,6 +234,7 @@ describe('AttendanceService — DingTalk raw query', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
classStudentRepo as never, classStudentRepo as never,
mappingRepo as never, mappingRepo as never,
{} as never, {} as never,
@@ -299,6 +303,7 @@ describe('AttendanceService — attendance device display mappings', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
attendanceDeviceRepo as never, attendanceDeviceRepo as never,
{} as never, {} as never,
{} as never, {} as never,
@@ -408,6 +413,7 @@ describe('AttendanceService — session serialization', () => {
{} as never, {} as never,
{} as never, {} as never,
{} as never, {} as never,
{} as never,
{ find: jest.fn().mockResolvedValue([]) } as never, { find: jest.fn().mockResolvedValue([]) } as never,
{} as never, {} as never,
dataSourceMock as never, dataSourceMock as never,

View File

@@ -7,6 +7,7 @@ import {
AttendanceDevice, AttendanceDevice,
AttendancePeriodConfig, AttendancePeriodConfig,
DingAttendanceRaw, DingAttendanceRaw,
DingLeaveRaw,
Class, Class,
Student, Student,
ClassSchedule, ClassSchedule,
@@ -35,6 +36,8 @@ export class AttendanceService {
private attendanceRepo: Repository<AttendanceRecord>, private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(DingAttendanceRaw) @InjectRepository(DingAttendanceRaw)
private dingRawRepo: Repository<DingAttendanceRaw>, private dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(DingLeaveRaw)
private dingLeaveRawRepo: Repository<DingLeaveRaw>,
@InjectRepository(Class) @InjectRepository(Class)
private classRepo: Repository<Class>, private classRepo: Repository<Class>,
@InjectRepository(Student) @InjectRepository(Student)
@@ -69,6 +72,7 @@ export class AttendanceService {
this.lessonService = new AttendanceLessonService( this.lessonService = new AttendanceLessonService(
this.attendanceRepo, this.attendanceRepo,
this.dingRawRepo, this.dingRawRepo,
this.dingLeaveRawRepo,
this.classRepo, this.classRepo,
this.studentRepo, this.studentRepo,
this.scheduleRepo, this.scheduleRepo,

View File

@@ -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',
}),
);
});
}); });

View File

@@ -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( export async function protectAttendanceHistory(
dataSource: DataSource, dataSource: DataSource,
logger: Logger, logger: Logger,

View File

@@ -14,6 +14,7 @@ import {
import { ensureAiConfigTable } from './database-migrations.ai'; import { ensureAiConfigTable } from './database-migrations.ai';
import { import {
ensureCourseAttendanceSchema, ensureCourseAttendanceSchema,
ensureDingLeaveSchema,
protectAttendanceHistory, protectAttendanceHistory,
} from './database-migrations.attendance'; } from './database-migrations.attendance';
import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill'; import { backfillOrganizations, normalizeClassDates } from './database-migrations.backfill';
@@ -29,6 +30,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.ensureSyncStateLeaseColumns(); await this.ensureSyncStateLeaseColumns();
await this.ensureStudentProfileCollegeColumns(); await this.ensureStudentProfileCollegeColumns();
await this.ensureCourseAttendanceSchema(); await this.ensureCourseAttendanceSchema();
await this.ensureDingLeaveSchema();
await this.ensureAttendanceDevicesSchema(); await this.ensureAttendanceDevicesSchema();
await this.ensureStudentWalletSchema(); await this.ensureStudentWalletSchema();
await this.backfillOrganizations(); await this.backfillOrganizations();
@@ -85,6 +87,10 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
return ensureCourseAttendanceSchema(this.dataSource, this.logger); return ensureCourseAttendanceSchema(this.dataSource, this.logger);
} }
async ensureDingLeaveSchema(): Promise<void> {
return ensureDingLeaveSchema(this.dataSource, this.logger);
}
async backfillOrganizations(): Promise<void> { async backfillOrganizations(): Promise<void> {
return backfillOrganizations(this.dataSource); return backfillOrganizations(this.dataSource);
} }

View 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;
}

View File

@@ -27,6 +27,7 @@ export { AttendanceSession } from './attendance-session.entity';
export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity'; export { AttendanceDevice, AttendanceDeviceStatus } from './attendance-device.entity';
export { AttendancePeriodConfig } from './attendance-period-config.entity'; export { AttendancePeriodConfig } from './attendance-period-config.entity';
export { DingAttendanceRaw } from './ding-attendance-raw.entity'; export { DingAttendanceRaw } from './ding-attendance-raw.entity';
export { DingLeaveRaw } from './ding-leave-raw.entity';
export { SyncLog } from './sync-log.entity'; export { SyncLog } from './sync-log.entity';
export { SyncState } from './sync-state.entity'; export { SyncState } from './sync-state.entity';
export { ExpenseType } from './expense-type.entity'; export { ExpenseType } from './expense-type.entity';

View 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;
}
}

View File

@@ -25,12 +25,14 @@ import type {
OrgDeptNodeWithUsers, OrgDeptNodeWithUsers,
} from './dingtalk.types'; } from './dingtalk.types';
import { DingTalkAttendanceClient } from './dingtalk.attendance'; import { DingTalkAttendanceClient } from './dingtalk.attendance';
import { DingTalkLeaveClient } from './dingtalk.leave';
import { DingTalkShiftClient } from './dingtalk.shifts'; import { DingTalkShiftClient } from './dingtalk.shifts';
import { DingTalkGroupClient } from './dingtalk.groups'; import { DingTalkGroupClient } from './dingtalk.groups';
import { DingTalkScheduleClient } from './dingtalk.schedules'; import { DingTalkScheduleClient } from './dingtalk.schedules';
export type { export type {
DingTalkAttendanceResult, DingTalkAttendanceResult,
DingTalkLeaveResult,
DingTalkGroupParams, DingTalkGroupParams,
DingTalkGroupSummary, DingTalkGroupSummary,
DingTalkGroupUpdateParams, DingTalkGroupUpdateParams,
@@ -55,6 +57,7 @@ export class DingTalkService implements DingTalkServiceContext {
private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT; private static readonly MIN_INTERVAL = 1000 / DingTalkService.RATE_LIMIT;
private attendanceClient?: DingTalkAttendanceClient; private attendanceClient?: DingTalkAttendanceClient;
private leaveClient?: DingTalkLeaveClient;
private shiftClient?: DingTalkShiftClient; private shiftClient?: DingTalkShiftClient;
private groupClient?: DingTalkGroupClient; private groupClient?: DingTalkGroupClient;
private scheduleClient?: DingTalkScheduleClient; private scheduleClient?: DingTalkScheduleClient;
@@ -73,6 +76,11 @@ export class DingTalkService implements DingTalkServiceContext {
return this.attendanceClient; return this.attendanceClient;
} }
private get leaves(): DingTalkLeaveClient {
if (!this.leaveClient) this.leaveClient = new DingTalkLeaveClient(this);
return this.leaveClient;
}
private get shifts(): DingTalkShiftClient { private get shifts(): DingTalkShiftClient {
if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this); if (!this.shiftClient) this.shiftClient = new DingTalkShiftClient(this);
return this.shiftClient; return this.shiftClient;
@@ -367,6 +375,12 @@ export class DingTalkService implements DingTalkServiceContext {
return this.attendance.fetchAttendanceResults(...args); return this.attendance.fetchAttendanceResults(...args);
} }
async fetchDailyLeaveStatus(
...args: Parameters<DingTalkLeaveClient['fetchDailyLeaveStatus']>
) {
return this.leaves.fetchDailyLeaveStatus(...args);
}
async upsertShift(...args: Parameters<DingTalkShiftClient['upsertShift']>) { async upsertShift(...args: Parameters<DingTalkShiftClient['upsertShift']>) {
return this.shifts.upsertShift(...args); return this.shifts.upsertShift(...args);
} }

View File

@@ -63,6 +63,24 @@ export interface DingTalkAttendanceResult {
deviceId?: string; 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 类型 ── // ── 组织架构 API 类型 ──
export interface DingTalkDeptListResponse { export interface DingTalkDeptListResponse {