feat: settle course attendance automatically

This commit is contained in:
2026-07-13 10:26:38 +08:00
parent b9b295c997
commit 1c8bd08be4
11 changed files with 730 additions and 87 deletions

View File

@@ -4,6 +4,7 @@ import {
getAttendanceExperience,
getSchedulePhase,
summarizeAttendance,
summarizeLessonCheckins,
} from './attendance-workspace';
describe('attendance role experience', () => {
@@ -51,3 +52,16 @@ describe('attendance summary', () => {
).toEqual({ total: 4, present: 2, late: 1, absent: 1, leave: 0, pending: 0 });
});
});
describe('lesson check-in summary', () => {
it('counts late punches as checked in and missing punches as not checked in', () => {
expect(
summarizeLessonCheckins([
{ status: 'present' },
{ status: 'late' },
{ status: 'pending' },
{ status: 'absent' },
]),
).toEqual({ total: 4, checkedIn: 2, notCheckedIn: 2 });
});
});

View File

@@ -63,3 +63,22 @@ export function summarizeAttendance(records: readonly { status: string }[]): Att
}
return summary;
}
export interface LessonCheckinSummary {
total: number;
checkedIn: number;
notCheckedIn: number;
}
export function summarizeLessonCheckins(
records: readonly { status: string }[],
): LessonCheckinSummary {
const checkedIn = records.filter(
(record) => record.status === 'present' || record.status === 'late',
).length;
return {
total: records.length,
checkedIn,
notCheckedIn: records.length - checkedIn,
};
}

View File

@@ -42,7 +42,7 @@ import {
canPullAttendance,
getAttendanceExperience,
getSchedulePhase,
summarizeAttendance,
summarizeLessonCheckins,
type AttendanceSummary,
type SchedulePhase,
} from './attendance-workspace';
@@ -215,6 +215,27 @@ function SummaryStrip({ summary }: { summary: AttendanceSummary }) {
);
}
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
const summary = summarizeLessonCheckins(records);
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
return (
<div className="attendance-summary-strip">
<div className="attendance-rate">
<Progress type="circle" percent={rate} size={64} strokeWidth={9} />
<div><span></span><strong>{summary.total} </strong></div>
</div>
<div className="attendance-summary-cell">
<span className="attendance-summary-icon is-present"></span>
<div><strong>{summary.checkedIn}</strong><span></span></div>
</div>
<div className="attendance-summary-cell">
<span className="attendance-summary-icon is-absent"></span>
<div><strong>{summary.notCheckedIn}</strong><span></span></div>
</div>
</div>
);
}
const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
@@ -235,7 +256,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [finishing, setFinishing] = useState(false);
const loadWorkspace = useCallback(async () => {
setLoading(true);
@@ -257,15 +277,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[workspace],
);
const loadLessonAttendance = useCallback(async (schedule: TodaySchedule) => {
const today = dayjs().format('YYYY-MM-DD');
const data = await api.get<LessonAttendanceResponse>(
`/attendance-lessons/schedules/${schedule.id}`,
{ params: { date: today } },
);
setLessonSession(data.session);
setLessonRecords(data.records);
}, []);
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setSelectedSchedule(schedule);
@@ -279,7 +290,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
);
setLessonSession(data.session);
setLessonRecords(data.records);
message.success('钉钉考勤已更新,请核对待确认和异常记录');
message.success('钉钉打卡已更新;课程截止后系统将自动结算');
} catch (error: unknown) {
setLessonSession(null);
setLessonRecords([]);
@@ -307,23 +318,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[],
);
const completeAttendance = useCallback(async () => {
if (!lessonSession || !selectedSchedule) return;
setFinishing(true);
try {
const result = await api.post<LessonAttendanceResponse>(
`/attendance-lessons/${lessonSession.id}/complete`,
);
setLessonSession(result.session);
setLessonRecords(result.records);
message.success('考勤核对已完成');
await loadLessonAttendance(selectedSchedule);
} catch (error: unknown) {
message.error((error as { message?: string })?.message || '完成点名失败');
} finally {
setFinishing(false);
}
}, [lessonSession, selectedSchedule, loadLessonAttendance]);
const now = new Date();
const schedules = workspace?.todaySchedules ?? [];
@@ -333,7 +327,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
const drawerSummary = summarizeAttendance(lessonRecords);
const isAttendanceCompleted = lessonSession?.status === 'completed';
return (
@@ -342,7 +335,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
<div>
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
<h1></h1>
<p></p>
<p></p>
</div>
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
@@ -403,18 +396,11 @@ const TeacherAttendanceWorkspace: React.FC = () => {
<Alert
type={isAttendanceCompleted ? 'success' : 'info'}
showIcon
title={isAttendanceCompleted ? '本节课考勤已核对完成;如有错误仍可直接修正' : '钉钉考勤已拉取,请核对待确认和异常学生'}
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
style={{ marginBottom: 16 }}
action={
!isAttendanceCompleted ? (
<Button type="primary" loading={finishing} onClick={() => void completeAttendance()}>
</Button>
) : undefined
}
/>
)}
<SummaryStrip summary={drawerSummary} />
<LessonCheckinSummaryStrip records={lessonRecords} />
<Table<AttendanceRecordItem>
rowKey="id"
loading={recordLoading}
@@ -427,24 +413,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
},
{
title: '考勤结果', dataIndex: 'status', width: 310,
render: (value: string, record: AttendanceRecordItem) => (
<div className="attendance-marking-actions">
{STATUS_OPTIONS.map((option) => (
title: '考勤结果', dataIndex: 'status', width: 230,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
<div className="attendance-marking-actions">
<Button
key={option.value}
size="small"
type={value === option.value ? 'primary' : 'default'}
danger={value === option.value && option.value === 'absent'}
onClick={() => void updateLessonRecord(record, option.value)}
type={checkedIn ? 'primary' : 'default'}
onClick={() => void updateLessonRecord(record, 'present')}
>
{option.label}
</Button>
))}
</div>
),
<Button
size="small"
type={!checkedIn ? 'primary' : 'default'}
danger={!checkedIn}
onClick={() => void updateLessonRecord(record, 'absent')}
>
</Button>
</div>
);
},
},
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value} /> },
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text"></span> },
]}
/>

View File

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

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

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

View File

@@ -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()

View File

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

View File

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