feat: settle course attendance automatically
This commit is contained in:
@@ -4,6 +4,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { TypeOrmModule, type TypeOrmModuleOptions } from '@nestjs/typeorm';
|
||||
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import {
|
||||
Student,
|
||||
Room,
|
||||
@@ -88,6 +89,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
},
|
||||
]),
|
||||
EventEmitterModule.forRoot(),
|
||||
ScheduleModule.forRoot(),
|
||||
TypeOrmModule.forRootAsync({
|
||||
imports: [ConfigModule],
|
||||
inject: [ConfigService],
|
||||
|
||||
205
apps/server/src/attendance/attendance-settlement.service.spec.ts
Normal file
205
apps/server/src/attendance/attendance-settlement.service.spec.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
|
||||
const schedule = {
|
||||
id: 2,
|
||||
classId: 8,
|
||||
teacherId: 21,
|
||||
weekDay: 1,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
scheduleType: 'INTERNAL',
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
const createService = () => {
|
||||
const scheduleRepo = { find: jest.fn() };
|
||||
const sessionRepo = {
|
||||
find: jest.fn(),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const attendanceService = {
|
||||
getTeacherClassDingUserIds: jest.fn().mockResolvedValue(['ding-1']),
|
||||
createLessonAttendanceFromDingTalk: jest.fn().mockImplementation(
|
||||
async (_scheduleId: number, lessonDate: string, userId: number, finalize: boolean) => ({
|
||||
session: { id: 90, lessonDate, startedBy: userId, status: finalize ? 'completed' : 'in_progress' },
|
||||
}),
|
||||
),
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn().mockResolvedValue({ success: true, errors: [] }),
|
||||
};
|
||||
const service = new AttendanceSettlementService(
|
||||
scheduleRepo as never,
|
||||
sessionRepo as never,
|
||||
attendanceService as never,
|
||||
importService as never,
|
||||
);
|
||||
return { service, scheduleRepo, sessionRepo, attendanceService, importService };
|
||||
};
|
||||
|
||||
describe('AttendanceSettlementService', () => {
|
||||
it('pulls and finalizes an ended lesson once', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-13',
|
||||
endDate: '2026-07-13',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: true,
|
||||
userId: 21,
|
||||
});
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
|
||||
1, 2, '2026-07-13', 21, false,
|
||||
);
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
|
||||
2, 2, '2026-07-13', 21, true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not settle a lesson before its end time', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('continues with the next lesson when one settlement fails', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
importService.importFromDingTalk
|
||||
.mockRejectedValueOnce(new Error('DingTalk unavailable'))
|
||||
.mockResolvedValueOnce({ success: true, errors: [] });
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
3,
|
||||
'2026-07-13',
|
||||
21,
|
||||
false,
|
||||
);
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenLastCalledWith(
|
||||
3,
|
||||
'2026-07-13',
|
||||
21,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not finalize when an import reports partial errors', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
errors: ['one batch failed'],
|
||||
});
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalledWith(
|
||||
2, '2026-07-13', 21, true,
|
||||
);
|
||||
});
|
||||
|
||||
it('retries an uncompleted daytime lesson on a later day', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
sessionRepo.find.mockResolvedValue([
|
||||
{
|
||||
scheduleId: 2,
|
||||
lessonDate: '2026-07-13',
|
||||
status: 'in_progress',
|
||||
schedule,
|
||||
},
|
||||
]);
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-15T10:01:00+08:00'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
2,
|
||||
'2026-07-13',
|
||||
21,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('skips final pull when another worker already claimed the session', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
sessionRepo.find.mockResolvedValue([
|
||||
{
|
||||
id: 90,
|
||||
scheduleId: 2,
|
||||
lessonDate: '2026-07-13',
|
||||
status: 'in_progress',
|
||||
schedule,
|
||||
},
|
||||
]);
|
||||
sessionRepo.update.mockResolvedValue({ affected: 0 });
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T10:01:00+08:00'));
|
||||
|
||||
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('settles an overnight lesson after its next-day end time', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([
|
||||
{ ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' },
|
||||
]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
4,
|
||||
'2026-07-12',
|
||||
21,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('pulls an overnight lesson through its next calendar date', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, importService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([
|
||||
{ ...schedule, id: 4, weekDay: 7, startTime: '22:00', endTime: '01:00' },
|
||||
]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T01:01:00+08:00'));
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ startDate: '2026-07-12', endDate: '2026-07-13' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('attendance settlement timezone', () => {
|
||||
it('uses Asia/Shanghai course time when the server runs in UTC', async () => {
|
||||
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
|
||||
scheduleRepo.find.mockResolvedValue([schedule]);
|
||||
sessionRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.settleEndedLessons(new Date('2026-07-13T02:01:00.000Z'));
|
||||
|
||||
expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith(
|
||||
2,
|
||||
'2026-07-13',
|
||||
21,
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
193
apps/server/src/attendance/attendance-settlement.service.ts
Normal file
193
apps/server/src/attendance/attendance-settlement.service.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
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 { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
@Injectable()
|
||||
export class AttendanceSettlementService {
|
||||
private readonly logger = new Logger(AttendanceSettlementService.name);
|
||||
private readonly courseTimeZone = 'Asia/Shanghai';
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ClassSchedule)
|
||||
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(AttendanceSession)
|
||||
private readonly sessionRepo: Repository<AttendanceSession>,
|
||||
private readonly attendanceService: AttendanceService,
|
||||
private readonly importService: AttendanceImportService,
|
||||
) {}
|
||||
|
||||
@Cron('* * * * *')
|
||||
async settleEndedLessons(now = new Date()): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
const clock = this.getCourseClock(now);
|
||||
const staleClaimBefore = new Date(now.getTime() - 35 * 60 * 1000);
|
||||
await this.sessionRepo.update(
|
||||
{ status: 'settling', updatedAt: LessThan(staleClaimBefore) },
|
||||
{ status: 'in_progress' },
|
||||
);
|
||||
const today = clock.date;
|
||||
const yesterday = this.shiftDate(today, -1);
|
||||
const [schedules, sessions] = await Promise.all([
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
scheduleType: ScheduleType.INTERNAL,
|
||||
status: 'active',
|
||||
startDate: LessThanOrEqual(today),
|
||||
endDate: MoreThanOrEqual(yesterday),
|
||||
},
|
||||
}),
|
||||
this.sessionRepo.find({
|
||||
where: { status: In(['in_progress', 'settling']) },
|
||||
relations: ['schedule'],
|
||||
}),
|
||||
]);
|
||||
const sessionByKey = new Map(
|
||||
sessions.map((session) => [`${session.scheduleId}|${session.lessonDate}`, session]),
|
||||
);
|
||||
const candidates = new Map<string, { schedule: ClassSchedule; lessonDate: string; session?: AttendanceSession }>();
|
||||
|
||||
for (const schedule of schedules) {
|
||||
const lessonDate = this.getEndedOccurrenceDate(schedule, clock, today, yesterday);
|
||||
if (lessonDate) {
|
||||
const key = `${schedule.id}|${lessonDate}`;
|
||||
candidates.set(key, { schedule, lessonDate, session: sessionByKey.get(key) });
|
||||
}
|
||||
}
|
||||
for (const session of sessions) {
|
||||
if (session.status === 'in_progress' && session.schedule) {
|
||||
candidates.set(`${session.scheduleId}|${session.lessonDate}`, {
|
||||
schedule: session.schedule,
|
||||
lessonDate: session.lessonDate,
|
||||
session,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of candidates.values()) {
|
||||
await this.settleCandidate(candidate);
|
||||
}
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async settleCandidate(candidate: {
|
||||
schedule: ClassSchedule;
|
||||
lessonDate: string;
|
||||
session?: AttendanceSession;
|
||||
}): Promise<void> {
|
||||
const { schedule, lessonDate } = candidate;
|
||||
if (schedule.classId == null || schedule.teacherId == null) {
|
||||
this.logger.error(`课程${schedule.id} ${lessonDate}缺少班级或教师,无法自动结算`);
|
||||
return;
|
||||
}
|
||||
|
||||
let session = candidate.session;
|
||||
try {
|
||||
if (!session) {
|
||||
const created = await this.attendanceService.createLessonAttendanceFromDingTalk(
|
||||
schedule.id,
|
||||
lessonDate,
|
||||
schedule.teacherId,
|
||||
false,
|
||||
);
|
||||
session = created.session;
|
||||
}
|
||||
const claimed = await this.sessionRepo.update(
|
||||
{ id: session.id, status: 'in_progress' },
|
||||
{ status: 'settling' },
|
||||
);
|
||||
if (claimed.affected !== 1) return;
|
||||
|
||||
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
|
||||
schedule.teacherId,
|
||||
schedule.classId,
|
||||
);
|
||||
const imported = await this.importService.importFromDingTalk({
|
||||
startDate: lessonDate,
|
||||
endDate: this.isOvernight(schedule) ? this.shiftDate(lessonDate, 1) : lessonDate,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
userId: schedule.teacherId,
|
||||
});
|
||||
if (!imported.success || imported.errors.length > 0) {
|
||||
throw new Error(imported.errors.join('; ') || '钉钉考勤拉取失败');
|
||||
}
|
||||
await this.attendanceService.createLessonAttendanceFromDingTalk(
|
||||
schedule.id,
|
||||
lessonDate,
|
||||
schedule.teacherId,
|
||||
true,
|
||||
);
|
||||
} catch (error: unknown) {
|
||||
if (session) await this.sessionRepo.update({ id: session.id, status: 'settling' }, { status: 'in_progress' });
|
||||
this.logger.error(
|
||||
`课程${schedule.id} ${lessonDate}自动结算失败: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private getEndedOccurrenceDate(
|
||||
schedule: ClassSchedule,
|
||||
clock: { weekDay: number; minutes: number },
|
||||
today: string,
|
||||
yesterday: string,
|
||||
): string | null {
|
||||
const endMinutes = this.toMinutes(schedule.endTime);
|
||||
const overnight = this.isOvernight(schedule);
|
||||
const yesterdayWeekDay = clock.weekDay === 1 ? 7 : clock.weekDay - 1;
|
||||
if (
|
||||
!overnight &&
|
||||
schedule.weekDay === clock.weekDay &&
|
||||
clock.minutes >= endMinutes &&
|
||||
today >= schedule.startDate &&
|
||||
today <= schedule.endDate
|
||||
) return today;
|
||||
if (
|
||||
overnight &&
|
||||
schedule.weekDay === yesterdayWeekDay &&
|
||||
clock.minutes >= endMinutes &&
|
||||
yesterday >= schedule.startDate &&
|
||||
yesterday <= schedule.endDate
|
||||
) return yesterday;
|
||||
return null;
|
||||
}
|
||||
|
||||
private isOvernight(schedule: ClassSchedule): boolean {
|
||||
return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime);
|
||||
}
|
||||
|
||||
private toMinutes(time: string): number {
|
||||
const [hour, minute] = time.split(':').map(Number);
|
||||
return hour * 60 + minute;
|
||||
}
|
||||
|
||||
private getCourseClock(date: Date): { date: string; weekDay: number; minutes: number } {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: this.courseTimeZone,
|
||||
year: 'numeric', month: '2-digit', day: '2-digit', weekday: 'short',
|
||||
hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
|
||||
}).formatToParts(date).filter((part) => part.type !== 'literal').map((part) => [part.type, part.value]),
|
||||
);
|
||||
const weekDays: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
|
||||
return {
|
||||
date: `${parts.year}-${parts.month}-${parts.day}`,
|
||||
weekDay: weekDays[parts.weekday],
|
||||
minutes: Number(parts.hour) * 60 + Number(parts.minute),
|
||||
};
|
||||
}
|
||||
|
||||
private shiftDate(date: string, days: number): string {
|
||||
const shifted = new Date(`${date}T00:00:00.000Z`);
|
||||
shifted.setUTCDate(shifted.getUTCDate() + days);
|
||||
return shifted.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
@@ -74,8 +74,18 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
{ studentId: 4, student: { id: 4, name: '\u8D75\u516D' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{
|
||||
matchedStudentId: 1,
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Normal',
|
||||
checkInTime: new Date('2026-07-11T08:55:00+08:00'),
|
||||
},
|
||||
{
|
||||
matchedStudentId: 2,
|
||||
attendanceType: 'OnDuty',
|
||||
timeResult: 'Late',
|
||||
checkInTime: new Date('2026-07-11T09:05:00+08:00'),
|
||||
},
|
||||
{ matchedStudentId: 3, attendanceType: 'OnDuty', timeResult: 'NotSigned' },
|
||||
]);
|
||||
|
||||
@@ -91,13 +101,34 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
);
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'present', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 3, status: 'absent', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'present', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 3, status: 'pending', source: 'dingtalk' }),
|
||||
expect.objectContaining({ studentId: 4, status: 'pending', source: 'dingtalk' }),
|
||||
]);
|
||||
expect(result.records).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('finalizes missing punches as absent and completes the session', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue(null);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
||||
]);
|
||||
expect(sessionRepo.save).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ status: 'completed', completedBy: 21 }),
|
||||
);
|
||||
expect(result.session.status).toBe('completed');
|
||||
});
|
||||
|
||||
it('returns student relations after the first pull', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
@@ -211,8 +242,8 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
@@ -225,7 +256,7 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'present' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'present' }),
|
||||
]),
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
@@ -278,8 +309,8 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
{ studentId: 2, student: { id: 2, name: '\u674E\u56DB' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late' },
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Late', checkInTime: new Date('2026-07-11T09:05:00+08:00') },
|
||||
]);
|
||||
|
||||
const result = await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21);
|
||||
@@ -289,12 +320,38 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
expect(savedRecords).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ studentId: 1, status: 'leave' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'late' }),
|
||||
expect.objectContaining({ studentId: 2, status: 'present' }),
|
||||
]),
|
||||
);
|
||||
expect(result.records).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('final settlement overrides interim manual status using the final punch result', async () => {
|
||||
const { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo } =
|
||||
createService();
|
||||
scheduleRepo.findOne.mockResolvedValue(endedSchedule);
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
id: 90,
|
||||
scheduleId: 4,
|
||||
classId: 8,
|
||||
lessonDate: '2026-07-11',
|
||||
status: 'in_progress',
|
||||
});
|
||||
attendanceRepo.find.mockResolvedValue([
|
||||
{ id: 101, studentId: 1, attendanceSessionId: 90, status: 'present', source: 'manual' },
|
||||
]);
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, student: { id: 1, name: '张三' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.createLessonAttendanceFromDingTalk(4, '2026-07-11', 21, true);
|
||||
|
||||
expect(attendanceRepo.save).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ studentId: 1, status: 'absent' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects completion when pending records exist', async () => {
|
||||
const { service, sessionRepo, attendanceRepo } = createService();
|
||||
sessionRepo.findOne.mockResolvedValue({
|
||||
@@ -368,8 +425,8 @@ describe('AttendanceService \u2014 DingTalk course attendance', () => {
|
||||
{ studentId: 2, student: { id: 2, name: '李四' } },
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal' },
|
||||
{ matchedStudentId: 1, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
|
||||
{ matchedStudentId: 2, attendanceType: 'OnDuty', timeResult: 'Normal', checkInTime: new Date('2026-07-11T08:55:00+08:00') },
|
||||
]);
|
||||
|
||||
// Step 1: update the record to absent via generic update()
|
||||
|
||||
@@ -3,6 +3,7 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, AttendanceSession, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceSettlementService } from './attendance-settlement.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { IntegrationModule } from '../integration/integration.module';
|
||||
@@ -14,7 +15,7 @@ import { IntegrationModule } from '../integration/integration.module';
|
||||
IntegrationModule,
|
||||
],
|
||||
controllers: [AttendanceController],
|
||||
providers: [AttendanceService, AttendanceImportService],
|
||||
providers: [AttendanceService, AttendanceImportService, AttendanceSettlementService],
|
||||
exports: [AttendanceService, AttendanceImportService],
|
||||
})
|
||||
export class AttendanceModule {}
|
||||
|
||||
@@ -195,24 +195,16 @@ export class AttendanceService {
|
||||
return timed.length > 0 ? timed : records.filter((record) => !record.checkInTime && !record.checkOutTime);
|
||||
}
|
||||
|
||||
private mapDingTalkStatus(records: DingAttendanceRaw[]): string {
|
||||
const results = new Set(records.map((record) => record.timeResult?.toLowerCase()));
|
||||
if (results.has('late') || results.has('seriouslate')) return 'late';
|
||||
if (
|
||||
results.has('notsigned') ||
|
||||
results.has('absenteeism') ||
|
||||
results.has('absent')
|
||||
) {
|
||||
return 'absent';
|
||||
}
|
||||
if (results.has('leave') || results.has('vacation')) return 'leave';
|
||||
if (results.has('normal')) return 'present';
|
||||
return 'pending';
|
||||
private mapDingTalkStatus(records: DingAttendanceRaw[], finalize = false): string {
|
||||
const hasPunch = records.some((record) => record.checkInTime || record.checkOutTime);
|
||||
if (hasPunch) return 'present';
|
||||
return finalize ? 'absent' : 'pending';
|
||||
}
|
||||
async createLessonAttendanceFromDingTalk(
|
||||
scheduleId: number,
|
||||
lessonDate: string,
|
||||
userId: number,
|
||||
finalize = false,
|
||||
) {
|
||||
const schedule = await this.getScheduleOccurrence(scheduleId, lessonDate);
|
||||
const now = new Date();
|
||||
@@ -244,9 +236,13 @@ export class AttendanceService {
|
||||
});
|
||||
return { schedule, session: existing, records };
|
||||
}
|
||||
if (existing.status !== 'in_progress' && !(finalize && existing.status === 'settling')) {
|
||||
throw new BadRequestException('课程考勤正在结算');
|
||||
}
|
||||
|
||||
// Refresh in_progress session from latest DingTalk data
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const sessionRepo = manager.getRepository(AttendanceSession);
|
||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, lessonDate);
|
||||
const existingRecords = await recordRepo.find({
|
||||
@@ -264,8 +260,8 @@ export class AttendanceService {
|
||||
|
||||
const updatedRecords = existingRecords.map((record) => {
|
||||
record.student = studentsById.get(record.studentId)!;
|
||||
// Preserve manually corrected records.
|
||||
if (record.source !== 'dingtalk') return record;
|
||||
// Preserve manual corrections only while the lesson is still in progress.
|
||||
if (!finalize && record.source !== 'dingtalk') return record;
|
||||
|
||||
const raw = this.selectDingTalkRecordsForLesson(
|
||||
rawByStudent.get(record.studentId) ?? [],
|
||||
@@ -273,8 +269,12 @@ export class AttendanceService {
|
||||
schedule.startTime,
|
||||
schedule.endTime,
|
||||
);
|
||||
record.status = this.mapDingTalkStatus(raw);
|
||||
record.remark = raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : null;
|
||||
record.status = this.mapDingTalkStatus(raw, finalize);
|
||||
record.remark = raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? null
|
||||
: finalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果';
|
||||
return record;
|
||||
});
|
||||
for (const classStudent of classStudents) {
|
||||
@@ -294,14 +294,24 @@ export class AttendanceService {
|
||||
attendanceSessionId: existing.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw),
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const saved = await recordRepo.save(updatedRecords);
|
||||
if (finalize) {
|
||||
existing.status = 'completed';
|
||||
existing.completedBy = userId;
|
||||
existing.completedAt = new Date();
|
||||
await sessionRepo.save(existing);
|
||||
}
|
||||
return { schedule, session: existing, records: saved };
|
||||
});
|
||||
}
|
||||
@@ -366,12 +376,22 @@ export class AttendanceService {
|
||||
attendanceSessionId: session.id,
|
||||
attendanceDate: lessonDate,
|
||||
session: this.mapScheduleTimeToSession(schedule.startTime),
|
||||
status: this.mapDingTalkStatus(raw),
|
||||
status: this.mapDingTalkStatus(raw, finalize),
|
||||
source: 'dingtalk',
|
||||
remark: raw.length === 0 ? '未获取到钉钉打卡结果,请老师确认' : undefined,
|
||||
remark: raw.some((item) => item.checkInTime || item.checkOutTime)
|
||||
? undefined
|
||||
: finalize
|
||||
? '课程截止仍未打卡'
|
||||
: '未获取到钉钉打卡结果',
|
||||
});
|
||||
});
|
||||
const saved = await recordRepo.save(records);
|
||||
if (finalize) {
|
||||
session.status = 'completed';
|
||||
session.completedBy = userId;
|
||||
session.completedAt = new Date();
|
||||
session = await sessionRepo.save(session);
|
||||
}
|
||||
return { schedule, session, records: saved };
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user