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')}

今天,从课程开始

-

课表先同步到钉钉考勤;课程开始后,老师可随时拉取该节课的最新打卡结果并核对异常。

+

课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。

- ) : 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` 并完成场次。 +- 未截止课程不结算。 +- 重复扫描已完成课程不重复拉取或写入。 +- 单节课程失败不阻断其他课程,后续扫描可补偿。 +- 跨午夜课程在次日截止后结算。