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.AttendancePeriodConfig,
Entities.DingAttendanceRaw,
Entities.DingLeaveRaw,
Entities.Notification,
Entities.StudentProfile,
Entities.StudentEnrollment,

View File

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

View File

@@ -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.

View File

@@ -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'>,

View File

@@ -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 () => {

View File

@@ -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,

View File

@@ -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,

View File

@@ -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();

View File

@@ -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,
],

View File

@@ -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,

View File

@@ -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,

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(
dataSource: DataSource,
logger: Logger,

View File

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

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 { 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';

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

View File

@@ -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 {