forked from wangziqi/gongxue-base
167 lines
5.8 KiB
TypeScript
167 lines
5.8 KiB
TypeScript
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository } from 'typeorm';
|
||
import { DingLeaveRaw, Student, StudentDingMapping } from '../entities';
|
||
import { DingTalkService, DingTalkLeaveResult } from '../integration/dingtalk.service';
|
||
|
||
/**
|
||
* 钉钉请假数据同步服务。
|
||
*
|
||
* 钉钉「获取用户考勤数据」接口按 用户 × 工作日 返回当天审批单列表,
|
||
* 这里只保留 biz_type=3(请假)且已审批完成的数据。逐用户逐日请求,
|
||
* 单条失败只记录错误、不中断整批,避免请假数据缺失阻断课程结算。
|
||
*/
|
||
@Injectable()
|
||
export class AttendanceLeaveSyncService {
|
||
private readonly logger = new Logger(AttendanceLeaveSyncService.name);
|
||
|
||
constructor(
|
||
@InjectRepository(DingLeaveRaw)
|
||
private readonly dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||
@InjectRepository(Student)
|
||
private readonly studentRepo: Repository<Student>,
|
||
@InjectRepository(StudentDingMapping)
|
||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||
private readonly dingTalkService: DingTalkService,
|
||
) {}
|
||
|
||
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;
|
||
}
|
||
|
||
private parseDate(value: string): Date {
|
||
const date = new Date(`${value}T00:00:00.000Z`);
|
||
if (Number.isNaN(date.getTime())) {
|
||
throw new BadRequestException(`无效日期: ${value}`);
|
||
}
|
||
return date;
|
||
}
|
||
|
||
private formatDate(value: Date): string {
|
||
return value.toISOString().slice(0, 10);
|
||
}
|
||
|
||
private async resolveStudentName(dingUserId: string): Promise<string> {
|
||
const mapping = await this.studentDingMappingRepo.findOne({
|
||
where: { dingUserId },
|
||
});
|
||
if (!mapping) return '';
|
||
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
|
||
return student?.name || '';
|
||
}
|
||
}
|