diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts
index f1d8262..eb42879 100644
--- a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts
+++ b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts
@@ -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 });
+ });
+});
diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.ts b/apps/admin/src/pages/Attendance/attendance-workspace.ts
index 6b4f4a9..42a6059 100644
--- a/apps/admin/src/pages/Attendance/attendance-workspace.ts
+++ b/apps/admin/src/pages/Attendance/attendance-workspace.ts
@@ -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,
+ };
+}
diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx
index e017b2e..7af49c3 100644
--- a/apps/admin/src/pages/Attendance/index.tsx
+++ b/apps/admin/src/pages/Attendance/index.tsx
@@ -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 (
+
+
+
+
打卡率{summary.total} 人
+
+
+
勤
+
{summary.checkedIn}已打卡
+
+
+
缺
+
{summary.notCheckedIn}未打卡
+
+
+ );
+}
+
const AttendancePage: React.FC = () => {
const { permissions, hasPermission } = usePermission();
const roles = useMemo(readCurrentRoles, []);
@@ -235,7 +256,6 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [lessonRecords, setLessonRecords] = useState([]);
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(
- `/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(
- `/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 = () => {
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
今天,从课程开始
-
课表先同步到钉钉考勤;课程开始后,老师可随时拉取该节课的最新打卡结果并核对异常。
+
课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
} onClick={() => void loadWorkspace()}>
刷新
@@ -403,18 +396,11 @@ const TeacherAttendanceWorkspace: React.FC = () => {
void completeAttendance()}>
- 完成核对
-
- ) : undefined
- }
/>
)}
-
+
rowKey="id"
loading={recordLoading}
@@ -427,24 +413,31 @@ const TeacherAttendanceWorkspace: React.FC = () => {
render: (name: string) => {name?.slice(0, 1)}{name || '-'} ,
},
{
- title: '考勤结果', dataIndex: 'status', width: 310,
- render: (value: string, record: AttendanceRecordItem) => (
-
- {STATUS_OPTIONS.map((option) => (
+ title: '考勤结果', dataIndex: 'status', width: 230,
+ render: (value: string, record: AttendanceRecordItem) => {
+ const checkedIn = value === 'present' || value === 'late';
+ return (
+
- ))}
-
- ),
+
+
+ );
+ },
},
- { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => },
+ { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => },
{ title: '备注', dataIndex: 'remark', render: (value: string | null) => value || — },
]}
/>
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index da113af..1814196 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -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],
diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts
new file mode 100644
index 0000000..13de319
--- /dev/null
+++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts
@@ -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,
+ );
+ });
+});
diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts
new file mode 100644
index 0000000..0d887b2
--- /dev/null
+++ b/apps/server/src/attendance/attendance-settlement.service.ts
@@ -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,
+ @InjectRepository(AttendanceSession)
+ private readonly sessionRepo: Repository,
+ private readonly attendanceService: AttendanceService,
+ private readonly importService: AttendanceImportService,
+ ) {}
+
+ @Cron('* * * * *')
+ async settleEndedLessons(now = new Date()): Promise {
+ 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();
+
+ 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 {
+ 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 = { 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);
+ }
+}
diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts
index e2d5421..b4a2c94 100644
--- a/apps/server/src/attendance/attendance.lesson-session.spec.ts
+++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts
@@ -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()
diff --git a/apps/server/src/attendance/attendance.module.ts b/apps/server/src/attendance/attendance.module.ts
index 8328daf..c411d68 100644
--- a/apps/server/src/attendance/attendance.module.ts
+++ b/apps/server/src/attendance/attendance.module.ts
@@ -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 {}
diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts
index a831687..8a8142f 100644
--- a/apps/server/src/attendance/attendance.service.ts
+++ b/apps/server/src/attendance/attendance.service.ts
@@ -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 };
});
}
diff --git a/docs/superpowers/plans/2026-07-13-binary-course-attendance.md b/docs/superpowers/plans/2026-07-13-binary-course-attendance.md
new file mode 100644
index 0000000..7d29e34
--- /dev/null
+++ b/docs/superpowers/plans/2026-07-13-binary-course-attendance.md
@@ -0,0 +1,76 @@
+# 课程二态考勤与截止自动结算 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** 教师课程考勤只显示已打卡/未打卡,并在课程截止后自动最终拉取、落库和完成场次。
+
+**Architecture:** 保留钉钉原始迟到状态;课程服务根据“是否存在实际打卡时间”生成临时二态结果和最终 `present/absent`。新增 Attendance 模块内的 NestJS 定时结算服务,每分钟扫描到期课程并复用导入与课程考勤服务,失败留待下一轮补偿。
+
+**Tech Stack:** NestJS 11、@nestjs/schedule 6、TypeORM 0.3、Jest、React 19、Ant Design 6。
+
+## Global Constraints
+
+- 仅调整排课关联的课程考勤。
+- 不迁移历史记录,不改变钉钉原始记录。
+- 不新增依赖、队列、兼容层或重复状态模型。
+- 课程截止后有实际打卡写 `present`,无实际打卡写 `absent`。
+- 单节失败不得阻断其他课程,后续扫描必须可补偿。
+
+---
+
+### Task 1: 课程二态映射
+
+**Files:**
+- Modify: `apps/server/src/attendance/attendance.service.ts`
+- Test: `apps/server/src/attendance/attendance.lesson-session.spec.ts`
+
+**Interfaces:**
+- Produces: `createLessonAttendanceFromDingTalk(scheduleId, lessonDate, userId, finalize?)`;`finalize=false` 返回临时二态,`finalize=true` 写最终二态并完成场次。
+
+- [ ] 添加失败测试:`Late` 且有 `checkInTime` 应为 `present`;无实际时间应为 `pending`;最终结算时无时间应为 `absent` 且 session 为 `completed`。
+- [ ] 运行 `npm test -- attendance.lesson-session.spec.ts --runInBand`,确认新增断言按预期失败。
+- [ ] 将课程状态映射改为只检查课程窗口内是否存在 `checkInTime` 或 `checkOutTime`;最终结算参数控制无打卡为 `absent`,并在同一事务完成场次。
+- [ ] 再次运行相同测试,确认通过。
+
+### Task 2: 截止自动结算
+
+**Files:**
+- Create: `apps/server/src/attendance/attendance-settlement.service.ts`
+- Create: `apps/server/src/attendance/attendance-settlement.service.spec.ts`
+- Modify: `apps/server/src/attendance/attendance.module.ts`
+- Modify: `apps/server/src/app.module.ts`
+
+**Interfaces:**
+- Consumes: `AttendanceImportService.importFromDingTalk(...)`、`AttendanceService.getTeacherClassDingUserIds(...)`、`AttendanceService.createLessonAttendanceFromDingTalk(..., true)`。
+- Produces: `AttendanceSettlementService.settleEndedLessons(now?: Date): Promise`,由 `@Cron('* * * * *')` 调用。
+
+- [ ] 添加失败测试:未截止不处理、已完成不处理、到期课程最终拉取并结算、一个课程失败后继续处理下一个、昨日跨午夜课程可结算。
+- [ ] 运行 `npm test -- attendance-settlement.service.spec.ts --runInBand`,确认因服务不存在而失败。
+- [ ] 实现每分钟扫描今天普通到期课程及昨日跨午夜到期课程;逐课程捕获异常并记录;使用课程 `teacherId` 作为自动导入审计用户。
+- [ ] 在 `AttendanceModule` 注册服务,在根模块启用 `ScheduleModule.forRoot()`。
+- [ ] 再次运行相同测试,确认通过。
+
+### Task 3: 教师二态界面
+
+**Files:**
+- Modify: `apps/admin/src/pages/Attendance/attendance-workspace.ts`
+- Modify: `apps/admin/src/pages/Attendance/index.tsx`
+- Test: `apps/admin/src/pages/Attendance/attendance-workspace.test.ts`
+
+**Interfaces:**
+- Produces: `summarizeLessonCheckins(records)`,将 `present`/`late` 归为已打卡,将其余归为未打卡。
+
+- [ ] 添加失败测试:`present` 与遗留 `late` 均计入已打卡,`pending`/`absent` 计入未打卡。
+- [ ] 运行 admin 的定向测试命令并确认失败。
+- [ ] 教师抽屉改为“已打卡 / 未打卡”标签、汇总和手动修改选项;管理员档案保持原五态。
+- [ ] 再次运行定向测试,确认通过。
+
+### Task 4: 聚焦验证
+
+**Files:**
+- Verify only; no planned production edits.
+
+- [ ] 运行 server 两个定向 Jest 测试文件。
+- [ ] 运行 server `npm run typecheck`。
+- [ ] 运行 admin 定向测试与 `npm run typecheck`。
+- [ ] 用现有数据库场景确认王子琪的 `Late` 在教师视图归为已打卡、陈浩无记录归为未打卡;不修改数据库数据。
diff --git a/docs/superpowers/specs/2026-07-13-binary-course-attendance-design.md b/docs/superpowers/specs/2026-07-13-binary-course-attendance-design.md
new file mode 100644
index 0000000..129607f
--- /dev/null
+++ b/docs/superpowers/specs/2026-07-13-binary-course-attendance-design.md
@@ -0,0 +1,63 @@
+# 课程二态考勤与截止自动结算设计
+
+## 目标
+
+课程考勤仅向教师展示“已打卡 / 未打卡”。迟到属于已打卡。每节课程到达截止时间后,系统自动从钉钉做最后一次拉取并将最终结果写入课程考勤记录;截止仍无实际打卡的学生记为缺勤。
+
+## 范围
+
+- 仅调整排课关联的课程考勤。
+- 不迁移历史记录。
+- 不改变后台其他考勤来源、迟到统计或钉钉原始数据。
+- 不引入队列或新依赖,复用 NestJS Schedule 与现有导入、匹配、课程考勤服务。
+
+## 状态规则
+
+### 教师当前课程页面
+
+- 存在课程时间窗口内的实际打卡时间:显示“已打卡”。
+- 不存在实际打卡时间:显示“未打卡”。
+- `Late`、`SeriousLate` 和 `Normal` 均显示为“已打卡”。
+- 页面汇总仅显示已打卡数、未打卡数和总人数。
+
+### 最终记录
+
+- 截止时存在实际打卡时间:`present`。
+- 截止时不存在实际打卡时间:`absent`。
+- 钉钉原始记录继续保留 `timeResult`,因此不会丢失迟到信息。
+- 自动结算完成后,课程考勤场次状态改为 `completed`,不再被后续拉取覆盖。
+
+## 自动结算
+
+后台任务每分钟扫描:
+
+1. 当天有效的内部课程;
+2. 当前时间已经达到课程 `endTime`;
+3. 对应日期的课程考勤场次尚未完成或尚未创建。
+
+对每节符合条件的课程:
+
+1. 获取该班在读学生的钉钉用户 ID;
+2. 拉取当天最终钉钉考勤并自动匹配;
+3. 创建或刷新课程考勤记录;
+4. 将有实际打卡的记录归为 `present`,其余归为 `absent`;
+5. 将场次标记为 `completed`。
+
+任务按课程独立处理。单节课拉取失败只记录错误,其他课程继续;下一分钟继续补偿失败课程。现有 `(scheduleId, lessonDate)` 唯一约束和完成状态保证重复扫描幂等。
+
+跨午夜课程以结束时间不晚于开始时间判断为次日截止;扫描同时覆盖昨日跨午夜课程。
+
+## 手动查看
+
+课程开始后,教师点击“查看当前考勤”仍会拉取最新数据。课程截止前结果是临时二态视图;课程截止后读取自动结算的最终记录。若自动任务尚未成功,手动查看可继续拉取,但只有自动结算或明确完成操作会冻结最终结果。
+
+## 测试
+
+- 迟到且存在实际打卡时间时,当前课程视图为“已打卡”。
+- 无实际打卡时间时,当前课程视图为“未打卡”。
+- 截止结算把迟到和正常打卡写为 `present`。
+- 截止结算把无打卡写为 `absent` 并完成场次。
+- 未截止课程不结算。
+- 重复扫描已完成课程不重复拉取或写入。
+- 单节课程失败不阻断其他课程,后续扫描可补偿。
+- 跨午夜课程在次日截止后结算。