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 '';
return sectionFrame(`

View File

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

View File

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

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,
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'>,

View File

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

View File

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

View File

@@ -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 {}