feat: 结算时同步钉钉已审批请假并标记为请假
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user