refactor: 拆分考勤服务文件并通过 aislop 全项目扫描
Some checks failed
CI / check (pull_request) Failing after 5m24s

This commit is contained in:
2026-08-05 18:55:36 +08:00
parent 6ba7f4e3d0
commit b882411f42
10 changed files with 424 additions and 331 deletions

View File

@@ -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 ''; if (!result) return '';
return sectionFrame(` return sectionFrame(`

View File

@@ -9,17 +9,10 @@ describe('AttendanceImportService', () => {
findOne: jest.fn(), findOne: jest.fn(),
save: 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 studentRepo = { findOne: jest.fn() };
const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() }; const studentDingMappingRepo = { findOne: jest.fn(), find: jest.fn() };
const dingTalkService = { const dingTalkService = {
fetchAttendanceResults: jest.fn(), fetchAttendanceResults: jest.fn(),
fetchDailyLeaveStatus: jest.fn(),
}; };
const attendanceService = { const attendanceService = {
autoMatchDingRecords: jest.fn(), autoMatchDingRecords: jest.fn(),
@@ -31,7 +24,6 @@ describe('AttendanceImportService', () => {
jest.clearAllMocks(); jest.clearAllMocks();
service = new AttendanceImportService( service = new AttendanceImportService(
dingRawRepo as never, dingRawRepo as never,
dingLeaveRawRepo as never,
studentRepo as never, studentRepo as never,
studentDingMappingRepo as never, studentDingMappingRepo as never,
dingTalkService as unknown as DingTalkService, 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);
});
}); });

View File

@@ -4,15 +4,10 @@ import { Repository, In } from 'typeorm';
import { Subject, Observable } from 'rxjs'; import { Subject, Observable } from 'rxjs';
import { import {
DingAttendanceRaw, DingAttendanceRaw,
DingLeaveRaw,
Student, Student,
StudentDingMapping, StudentDingMapping,
} from '../entities'; } from '../entities';
import { import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service';
DingTalkService,
DingTalkAttendanceResult,
DingTalkLeaveResult,
} from '../integration/dingtalk.service';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto'; import type { ImportProgressEvent, ImportResult } from './dto/dingtalk-import.dto';
@@ -38,8 +33,6 @@ export class AttendanceImportService {
constructor( constructor(
@InjectRepository(DingAttendanceRaw) @InjectRepository(DingAttendanceRaw)
private readonly dingRawRepo: Repository<DingAttendanceRaw>, private readonly dingRawRepo: Repository<DingAttendanceRaw>,
@InjectRepository(DingLeaveRaw)
private readonly dingLeaveRawRepo: Repository<DingLeaveRaw>,
@InjectRepository(Student) @InjectRepository(Student)
private readonly studentRepo: Repository<Student>, private readonly studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping) @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 * DingTalk requires userIds, accepts at most 50 users per request, and
* allows a maximum inclusive date range of 7 calendar days. * allows a maximum inclusive date range of 7 calendar days.

View File

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

View 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 || '';
}
}

View 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;
}

View File

@@ -22,9 +22,9 @@ import {
getLessonAttendanceImportDateRange, getLessonAttendanceImportDateRange,
getLessonAttendanceWindow, getLessonAttendanceWindow,
selectDingTalkRecordsForLesson, selectDingTalkRecordsForLesson,
mapDingTalkStatus,
getLessonPunchMetadata, getLessonPunchMetadata,
} from './attendance-dingtalk'; } from './attendance-dingtalk';
import { buildLessonRecord, resolveLessonStatus } from './attendance-lesson-status';
@Injectable() @Injectable()
export class AttendanceLessonService { export class AttendanceLessonService {
@@ -163,7 +163,8 @@ export class AttendanceLessonService {
schedule, schedule,
lessonDate, lessonDate,
); );
const resolved = await this.resolveLessonStatus( const resolved = await resolveLessonStatus(
this.dingLeaveRawRepo,
record.studentId, record.studentId,
raw, raw,
schedule, schedule,
@@ -183,8 +184,9 @@ export class AttendanceLessonService {
for (const classStudent of classStudents) { for (const classStudent of classStudents) {
if (existingStudentIds.has(classStudent.studentId)) continue; if (existingStudentIds.has(classStudent.studentId)) continue;
updatedRecords.push( updatedRecords.push(
await this.buildLessonRecord( await buildLessonRecord(
recordRepo, recordRepo,
this.dingLeaveRawRepo,
rawByStudent, rawByStudent,
classStudent, classStudent,
schedule, schedule,
@@ -257,8 +259,9 @@ export class AttendanceLessonService {
const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime); const lessonSessionKey = mapLessonScheduleTimeToSession(schedule.startTime);
const records = await Promise.all( const records = await Promise.all(
classStudents.map((classStudent) => classStudents.map((classStudent) =>
this.buildLessonRecord( buildLessonRecord(
recordRepo, recordRepo,
this.dingLeaveRawRepo,
rawByStudent, rawByStudent,
classStudent, classStudent,
schedule, 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( private async fetchDingTalkRawByStudent(
classId: number, classId: number,
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>, schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,

View File

@@ -37,6 +37,8 @@ const createService = () => {
}; };
const importService = { const importService = {
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }), importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
};
const leaveSyncService = {
syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }), syncLeaveStatusForLesson: jest.fn().mockResolvedValue({ synced: 0, matched: 0, errors: [] }),
}; };
const service = new AttendanceSettlementService( const service = new AttendanceSettlementService(
@@ -44,13 +46,14 @@ const createService = () => {
sessionRepo as never, sessionRepo as never,
attendanceService as never, attendanceService as never,
importService as never, importService as never,
leaveSyncService as never,
); );
return { service, scheduleRepo, sessionRepo, attendanceService, importService }; return { service, scheduleRepo, sessionRepo, attendanceService, importService, leaveSyncService };
}; };
describe('AttendanceSettlementService', () => { describe('AttendanceSettlementService', () => {
it('pulls and finalizes an ended lesson once', async () => { 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]); scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]); sessionRepo.find.mockResolvedValue([]);
@@ -69,7 +72,7 @@ describe('AttendanceSettlementService', () => {
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith( expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
2, 2, '2026-07-13', 21, true, 2, 2, '2026-07-13', 21, true,
); );
expect(importService.syncLeaveStatusForLesson).toHaveBeenCalledWith({ expect(leaveSyncService.syncLeaveStatusForLesson).toHaveBeenCalledWith({
startDate: '2026-07-13', startDate: '2026-07-13',
endDate: '2026-07-13', endDate: '2026-07-13',
userIds: ['ding-1'], userIds: ['ding-1'],
@@ -78,10 +81,10 @@ describe('AttendanceSettlementService', () => {
}); });
it('finalizes the lesson even when the leave sync fails', async () => { 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]); scheduleRepo.find.mockResolvedValue([schedule]);
sessionRepo.find.mockResolvedValue([]); 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')); await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));

View File

@@ -3,6 +3,7 @@ import { Cron } from '@nestjs/schedule';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; import { In, LessThan, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm';
import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities'; import { AttendanceSession, ClassSchedule, ScheduleType } from '../entities';
import { AttendanceLeaveSyncService } from './attendance-leave-sync.service';
import { AttendanceImportService } from './attendance-import.service'; import { AttendanceImportService } from './attendance-import.service';
import { AttendanceService } from './attendance.service'; import { AttendanceService } from './attendance.service';
@@ -19,6 +20,7 @@ export class AttendanceSettlementService {
private readonly sessionRepo: Repository<AttendanceSession>, private readonly sessionRepo: Repository<AttendanceSession>,
private readonly attendanceService: AttendanceService, private readonly attendanceService: AttendanceService,
private readonly importService: AttendanceImportService, private readonly importService: AttendanceImportService,
private readonly leaveSyncService: AttendanceLeaveSyncService,
) {} ) {}
@Cron('* * * * *') @Cron('* * * * *')
@@ -130,7 +132,7 @@ export class AttendanceSettlementService {
throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败'); throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败');
} }
try { try {
await this.importService.syncLeaveStatusForLesson({ await this.leaveSyncService.syncLeaveStatusForLesson({
...importRange, ...importRange,
userIds, userIds,
autoMatch: true, autoMatch: true,

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, DingLeaveRaw, 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 { AttendanceService } from './attendance.service';
import { AttendanceLeaveSyncService } from './attendance-leave-sync.service';
import { AttendanceImportService } from './attendance-import.service'; import { AttendanceImportService } from './attendance-import.service';
import { AttendanceSettlementService } from './attendance-settlement.service'; import { AttendanceSettlementService } from './attendance-settlement.service';
import { AttendanceController } from './attendance.controller'; import { AttendanceController } from './attendance.controller';
@@ -17,7 +18,12 @@ import { IntegrationModule } from '../integration/integration.module';
IntegrationModule, IntegrationModule,
], ],
controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController], controllers: [AttendanceController, AttendanceRecordsController, AttendanceImportController],
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService], providers: [
AttendanceService,
AttendanceImportService,
AttendanceLeaveSyncService,
AttendanceSettlementService,
],
exports: [AttendanceService, AttendanceImportService], exports: [AttendanceService, AttendanceImportService],
}) })
export class AttendanceModule {} export class AttendanceModule {}