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 '';
|
if (!result) return '';
|
||||||
|
|
||||||
return sectionFrame(`
|
return sectionFrame(`
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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,
|
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'>,
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user