This commit is contained in:
@@ -37,7 +37,7 @@ export function buildLearning(learnings: LearningRecord[], now: string): string
|
||||
`);
|
||||
}
|
||||
|
||||
export function buildResult(result: ResultArchive | null, now: string): string {
|
||||
export function buildResult(result: ResultArchive | null, _now: string): string {
|
||||
if (!result) return '';
|
||||
|
||||
return sectionFrame(`
|
||||
|
||||
@@ -9,17 +9,10 @@ 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(), find: jest.fn() };
|
||||
const dingTalkService = {
|
||||
fetchAttendanceResults: jest.fn(),
|
||||
fetchDailyLeaveStatus: jest.fn(),
|
||||
};
|
||||
const attendanceService = {
|
||||
autoMatchDingRecords: jest.fn(),
|
||||
@@ -31,7 +24,6 @@ describe('AttendanceImportService', () => {
|
||||
jest.clearAllMocks();
|
||||
service = new AttendanceImportService(
|
||||
dingRawRepo as never,
|
||||
dingLeaveRawRepo as never,
|
||||
studentRepo as never,
|
||||
studentDingMappingRepo as never,
|
||||
dingTalkService as unknown as DingTalkService,
|
||||
@@ -313,66 +305,4 @@ describe('AttendanceImportService', () => {
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,15 +4,10 @@ import { Repository, In } from 'typeorm';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import {
|
||||
DingAttendanceRaw,
|
||||
DingLeaveRaw,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import {
|
||||
DingTalkService,
|
||||
DingTalkAttendanceResult,
|
||||
DingTalkLeaveResult,
|
||||
} from '../integration/dingtalk.service';
|
||||
import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
|
||||
|
||||
@@ -38,8 +33,6 @@ 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)
|
||||
@@ -161,131 +154,6 @@ 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.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { AttendanceLeaveSyncService } from './attendance-leave-sync.service';
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
|
||||
describe('AttendanceLeaveSyncService', () => {
|
||||
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(), find: jest.fn() };
|
||||
const dingTalkService = {
|
||||
fetchDailyLeaveStatus: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AttendanceLeaveSyncService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AttendanceLeaveSyncService(
|
||||
dingLeaveRawRepo as never,
|
||||
studentRepo as never,
|
||||
studentDingMappingRepo as never,
|
||||
dingTalkService as unknown as DingTalkService,
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
166
apps/server/src/attendance/attendance-leave-sync.service.ts
Normal file
166
apps/server/src/attendance/attendance-leave-sync.service.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
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 || '';
|
||||
}
|
||||
}
|
||||
140
apps/server/src/attendance/attendance-lesson-status.ts
Normal file
140
apps/server/src/attendance/attendance-lesson-status.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import {
|
||||
AttendanceRecord,
|
||||
ClassSchedule,
|
||||
ClassStudent,
|
||||
DingAttendanceRaw,
|
||||
DingLeaveRaw,
|
||||
} from '../entities';
|
||||
import {
|
||||
getLessonAttendanceWindow,
|
||||
getLessonPunchMetadata,
|
||||
mapDingTalkStatus,
|
||||
selectDingTalkRecordsForLesson,
|
||||
type LessonScheduleLike,
|
||||
} from './attendance-dingtalk';
|
||||
|
||||
type LessonRecordSchedule = Pick<
|
||||
ClassSchedule,
|
||||
'classId' | 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'
|
||||
>;
|
||||
|
||||
/**
|
||||
* 结算(finalize)时无打卡的学生,若当天存在钉钉已审批通过的请假且与
|
||||
* 本节课时间窗口重叠,则记为 leave,而不是缺勤。
|
||||
*/
|
||||
export async function resolveLessonStatus(
|
||||
dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||||
studentId: number,
|
||||
raw: DingAttendanceRaw[],
|
||||
schedule: LessonScheduleLike,
|
||||
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 findApprovedLeaveForStudent(dingLeaveRawRepo, studentId, schedule, lessonDate);
|
||||
if (leave) {
|
||||
return {
|
||||
status: 'leave',
|
||||
remark: `钉钉请假已通过(${leave.leaveType || leave.tagName || '请假'})`,
|
||||
};
|
||||
}
|
||||
return { status: 'absent', remark: '课程截止仍未打卡' };
|
||||
}
|
||||
|
||||
export function createLessonRecord(
|
||||
recordRepo: Repository<AttendanceRecord>,
|
||||
classStudent: ClassStudent,
|
||||
raw: DingAttendanceRaw[],
|
||||
options: {
|
||||
schedule: LessonRecordSchedule;
|
||||
scheduleId: number;
|
||||
lessonDate: string;
|
||||
lessonSessionKey: string;
|
||||
attendanceSessionId: number;
|
||||
status: string;
|
||||
remark?: string;
|
||||
},
|
||||
): AttendanceRecord {
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: options.schedule.classId!,
|
||||
scheduleId: options.scheduleId,
|
||||
attendanceSessionId: options.attendanceSessionId,
|
||||
attendanceDate: options.lessonDate,
|
||||
session: options.lessonSessionKey,
|
||||
status: options.status,
|
||||
source: 'dingtalk',
|
||||
...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime),
|
||||
remark: options.remark,
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildLessonRecord(
|
||||
recordRepo: Repository<AttendanceRecord>,
|
||||
dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||||
rawByStudent: Map<number, DingAttendanceRaw[]>,
|
||||
classStudent: ClassStudent,
|
||||
schedule: LessonRecordSchedule,
|
||||
lessonDate: string,
|
||||
lessonSessionKey: string,
|
||||
attendanceSessionId: number,
|
||||
scheduleId: number,
|
||||
finalize: boolean,
|
||||
): Promise<AttendanceRecord> {
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const resolved = await resolveLessonStatus(
|
||||
dingLeaveRawRepo,
|
||||
classStudent.studentId,
|
||||
raw,
|
||||
schedule,
|
||||
lessonDate,
|
||||
finalize,
|
||||
);
|
||||
return createLessonRecord(recordRepo, classStudent, raw, {
|
||||
schedule,
|
||||
scheduleId,
|
||||
lessonDate,
|
||||
lessonSessionKey,
|
||||
attendanceSessionId,
|
||||
status: resolved.status,
|
||||
remark: resolved.remark,
|
||||
});
|
||||
}
|
||||
|
||||
export async function findApprovedLeaveForStudent(
|
||||
dingLeaveRawRepo: Repository<DingLeaveRaw>,
|
||||
studentId: number,
|
||||
schedule: LessonScheduleLike,
|
||||
lessonDate: string,
|
||||
): Promise<DingLeaveRaw | null> {
|
||||
const leaves = await 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;
|
||||
}
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
getLessonAttendanceImportDateRange,
|
||||
getLessonAttendanceWindow,
|
||||
selectDingTalkRecordsForLesson,
|
||||
mapDingTalkStatus,
|
||||
getLessonPunchMetadata,
|
||||
} from './attendance-dingtalk';
|
||||
import { buildLessonRecord, resolveLessonStatus } from './attendance-lesson-status';
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceLessonService {
|
||||
@@ -163,7 +163,8 @@ export class AttendanceLessonService {
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const resolved = await this.resolveLessonStatus(
|
||||
const resolved = await resolveLessonStatus(
|
||||
this.dingLeaveRawRepo,
|
||||
record.studentId,
|
||||
raw,
|
||||
schedule,
|
||||
@@ -183,8 +184,9 @@ export class AttendanceLessonService {
|
||||
for (const classStudent of classStudents) {
|
||||
if (existingStudentIds.has(classStudent.studentId)) continue;
|
||||
updatedRecords.push(
|
||||
await this.buildLessonRecord(
|
||||
await buildLessonRecord(
|
||||
recordRepo,
|
||||
this.dingLeaveRawRepo,
|
||||
rawByStudent,
|
||||
classStudent,
|
||||
schedule,
|
||||
@@ -257,8 +259,9 @@ export class AttendanceLessonService {
|
||||
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
|
||||
const records = await Promise.all(
|
||||
classStudents.map((classStudent) =>
|
||||
this.buildLessonRecord(
|
||||
buildLessonRecord(
|
||||
recordRepo,
|
||||
this.dingLeaveRawRepo,
|
||||
rawByStudent,
|
||||
classStudent,
|
||||
schedule,
|
||||
@@ -281,122 +284,6 @@ 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 createLessonRecord(
|
||||
recordRepo: Repository<AttendanceRecord>,
|
||||
classStudent: ClassStudent,
|
||||
raw: DingAttendanceRaw[],
|
||||
options: {
|
||||
schedule: Pick<ClassSchedule, 'classId' | 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>;
|
||||
scheduleId: number;
|
||||
lessonDate: string;
|
||||
lessonSessionKey: string;
|
||||
attendanceSessionId: number;
|
||||
status: string;
|
||||
remark?: string;
|
||||
},
|
||||
): AttendanceRecord {
|
||||
return recordRepo.create({
|
||||
studentId: classStudent.studentId,
|
||||
student: classStudent.student,
|
||||
classId: options.schedule.classId!,
|
||||
scheduleId: options.scheduleId,
|
||||
attendanceSessionId: options.attendanceSessionId,
|
||||
attendanceDate: options.lessonDate,
|
||||
session: options.lessonSessionKey,
|
||||
status: options.status,
|
||||
source: 'dingtalk',
|
||||
...getLessonPunchMetadata(raw, options.lessonDate, options.schedule.startTime),
|
||||
remark: options.remark,
|
||||
});
|
||||
}
|
||||
|
||||
private async buildLessonRecord(
|
||||
recordRepo: Repository<AttendanceRecord>,
|
||||
rawByStudent: Map<number, DingAttendanceRaw[]>,
|
||||
classStudent: ClassStudent,
|
||||
schedule: Pick<ClassSchedule, 'classId' | 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||
lessonDate: string,
|
||||
lessonSessionKey: string,
|
||||
attendanceSessionId: number,
|
||||
scheduleId: number,
|
||||
finalize: boolean,
|
||||
): Promise<AttendanceRecord> {
|
||||
const raw = selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(classStudent.studentId) ?? [],
|
||||
schedule,
|
||||
lessonDate,
|
||||
);
|
||||
const resolved = await this.resolveLessonStatus(
|
||||
classStudent.studentId,
|
||||
raw,
|
||||
schedule,
|
||||
lessonDate,
|
||||
finalize,
|
||||
);
|
||||
return this.createLessonRecord(recordRepo, classStudent, raw, {
|
||||
schedule,
|
||||
scheduleId,
|
||||
lessonDate,
|
||||
lessonSessionKey,
|
||||
attendanceSessionId,
|
||||
status: resolved.status,
|
||||
remark: resolved.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'>,
|
||||
|
||||
@@ -37,6 +37,8 @@ const createService = () => {
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
|
||||
};
|
||||
const leaveSyncService = {
|
||||
syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }),
|
||||
};
|
||||
const service = new AttendanceSettlementService(
|
||||
@@ -44,13 +46,14 @@ const createService = () => {
|
||||
sessionRepo as never,
|
||||
attendanceService as never,
|
||||
importService as never,
|
||||
leaveSyncService as never,
|
||||
);
|
||||
return { service, scheduleRepo, sessionRepo, attendanceService, importService };
|
||||
return { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService };
|
||||
};
|
||||
|
||||
describe('AttendanceSettlementService', () => {
|
||||
it('pulls and finalizes an ended lesson once', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
|
||||
@@ -69,7 +72,7 @@ describe('AttendanceSettlementService', () => {
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
|
||||
2, 2, '2026-07-13', 21, true,
|
||||
);
|
||||
expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({
|
||||
expect(leaveSyncService.syncLeaveStatusForLesson).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-13',
|
||||
endDate: '2026-07-13',
|
||||
userIds: ['ding-1'],
|
||||
@@ -78,10 +81,10 @@ describe('AttendanceSettlementService', () => {
|
||||
});
|
||||
|
||||
it('finalizes the lesson even when the leave sync fails', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, leaveSyncService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
importService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable'));
|
||||
leaveSyncService.syncLeaveStatusForLesson.mockRejectedValue(new Error('DingTalk unavailable'));
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
|
||||
import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities';
|
||||
import { AttendanceLeaveSyncService } from './attendance-leave-sync.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
@@ -19,6 +20,7 @@ export class AttendanceSettlementService {
|
||||
private readonly sessionRepo: Repository<AttendanceSession>,
|
||||
private readonly attendanceService: AttendanceService,
|
||||
private readonly importService: AttendanceImportService,
|
||||
private readonly leaveSyncService: AttendanceLeaveSyncService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *')
|
||||
@@ -130,7 +132,7 @@ export class AttendanceSettlementService {
|
||||
throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败');
|
||||
}
|
||||
try {
|
||||
await this.importService.syncLeaveStatusForLesson({
|
||||
await this.leaveSyncService.syncLeaveStatusForLesson({
|
||||
...importRange,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceLeaveSyncService } from './attendance-leave-sync.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
@@ -17,7 +18,12 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
IntegrationModule,
|
||||
],
|
||||
controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController],
|
||||
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService],
|
||||
providers: [
|
||||
AttendanceService,
|
||||
AttendanceImportService,
|
||||
AttendanceLeaveSyncService,
|
||||
AttendanceSettlementService,
|
||||
],
|
||||
exports: [AttendanceService, AttendanceImportService],
|
||||
})
|
||||
export class AttendanceModule {}
|
||||
|
||||
Reference in New Issue
Block a user