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

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