From fcde6caaaa242bc9753d910b572cbd60b21d91b4 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Wed, 15 Jul 2026 09:39:21 +0800 Subject: [PATCH] fix: defer attendance settlement until lesson end --- .../attendance-workspace.integration.test.ts | 22 ++++++ .../pages/Attendance/attendance-workspace.ts | 24 +++++++ .../admin/src/pages/Attendance/attendance.css | 42 +++++++++++ apps/admin/src/pages/Attendance/index.tsx | 41 ++++++++++- .../attendance-settlement.service.spec.ts | 69 +++++++++++++++++++ .../attendance-settlement.service.ts | 18 ++++- 6 files changed, 213 insertions(+), 3 deletions(-) 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 a8dd74e..440012a 100644 --- a/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts +++ b/apps/admin/src/pages/Attendance/attendance-workspace.integration.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { canPullAttendance, + filterLessonAttendanceRecords, getAttendanceExperience, getPunchDisplayInfo, getSchedulePhase, @@ -68,6 +69,27 @@ describe('lesson check-in summary', () => { }); +describe('lesson attendance filters', () => { + const records = [ + { id: 1, student: { name: '张三' }, status: 'present' }, + { id: 2, student: { name: '李四' }, status: 'late' }, + { id: 3, student: { name: '王五' }, status: 'pending' }, + { id: 4, student: { name: '赵六' }, status: 'absent' }, + ]; + + it('searches students by name and ignores surrounding whitespace', () => { + expect(filterLessonAttendanceRecords(records, ' 张 ', 'all').map((item) => item.id)).toEqual([1]); + }); + + it('groups present and late as checked in', () => { + expect(filterLessonAttendanceRecords(records, '', 'checked_in').map((item) => item.id)).toEqual([1, 2]); + }); + + it('groups pending and absent as not checked in and combines with search', () => { + expect(filterLessonAttendanceRecords(records, '王', 'not_checked_in').map((item) => item.id)).toEqual([3]); + }); +}); + describe('lesson punch device display', () => { it('labels attendance machine punches with the machine name and id', () => { expect( diff --git a/apps/admin/src/pages/Attendance/attendance-workspace.ts b/apps/admin/src/pages/Attendance/attendance-workspace.ts index a78250d..dcaf3b9 100644 --- a/apps/admin/src/pages/Attendance/attendance-workspace.ts +++ b/apps/admin/src/pages/Attendance/attendance-workspace.ts @@ -84,6 +84,30 @@ export function summarizeLessonCheckins( } +export type LessonAttendanceFilter = 'all' | 'checked_in' | 'not_checked_in'; + +export interface LessonAttendanceFilterRecord { + student: { name: string }; + status: string; +} + +export function filterLessonAttendanceRecords( + records: readonly T[], + keyword: string, + filter: LessonAttendanceFilter, +): T[] { + const normalizedKeyword = keyword.trim().toLocaleLowerCase('zh-CN'); + return records.filter((record) => { + const matchesKeyword = + !normalizedKeyword || + record.student.name.toLocaleLowerCase('zh-CN').includes(normalizedKeyword); + if (!matchesKeyword || filter === 'all') return matchesKeyword; + + const checkedIn = record.status === 'present' || record.status === 'late'; + return filter === 'checked_in' ? checkedIn : !checkedIn; + }); +} + export interface PunchDisplayRecord { status: string; source?: string; diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index c9745e4..f80a2de 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -286,6 +286,32 @@ .is-leave { color: #2874c6 !important; background: #edf5ff; } .is-pending { color: #667085 !important; background: #f1f3f6; } +.lesson-record-filters { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 14px; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: 12px; + background: #f8fafc; +} + +.lesson-record-search { + width: 260px; +} + +.lesson-record-filter-select { + width: 130px; +} + +.lesson-record-filter-count { + margin-left: auto; + color: var(--muted); + font-size: 12px; + white-space: nowrap; +} + .attendance-status { display: inline-flex; align-items: center; @@ -414,6 +440,22 @@ color: #a1a9b5; } +@media (max-width: 640px) { + .lesson-record-filters { + align-items: stretch; + flex-direction: column; + } + + .lesson-record-search, + .lesson-record-filter-select { + width: 100%; + } + + .lesson-record-filter-count { + margin-left: 0; + } +} + @media (max-width: 900px) { .attendance-hero, .archive-toolbar { diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 4c3a063..745a15a 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -40,11 +40,13 @@ import { usePermission } from '../../hooks/usePermission'; import { message } from '../../ui/app-message'; import { canPullAttendance, + filterLessonAttendanceRecords, getAttendanceExperience, getPunchDisplayInfo, getSchedulePhase, summarizeLessonCheckins, type AttendanceSummary, + type LessonAttendanceFilter, type SchedulePhase, } from './attendance-workspace'; import './attendance.css'; @@ -261,6 +263,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { const [lessonRecords, setLessonRecords] = useState([]); const [recordLoading, setRecordLoading] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); + const [studentKeyword, setStudentKeyword] = useState(''); + const [checkinFilter, setCheckinFilter] = useState('all'); const loadWorkspace = useCallback(async () => { setLoading(true); @@ -284,6 +288,8 @@ const TeacherAttendanceWorkspace: React.FC = () => { const openAttendance = useCallback(async (schedule: TodaySchedule) => { + setStudentKeyword(''); + setCheckinFilter('all'); setSelectedSchedule(schedule); setDrawerOpen(true); setRecordLoading(true); @@ -334,6 +340,10 @@ const TeacherAttendanceWorkspace: React.FC = () => { (item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended', ); const isAttendanceCompleted = lessonSession?.status === 'completed'; + const filteredLessonRecords = useMemo( + () => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter), + [lessonRecords, studentKeyword, checkinFilter], + ); return (
@@ -407,12 +417,39 @@ const TeacherAttendanceWorkspace: React.FC = () => { /> )} +
+ setStudentKeyword(event.target.value)} + className="lesson-record-search" + /> + + value={checkinFilter} + onChange={setCheckinFilter} + options={[ + { value: 'all', label: '全部学生' }, + { value: 'checked_in', label: '已打卡' }, + { value: 'not_checked_in', label: '未打卡' }, + ]} + className="lesson-record-filter-select" + /> + + 显示 {filteredLessonRecords.length} / {lessonRecords.length} 人 + +
rowKey="id" loading={recordLoading} - dataSource={lessonRecords} + dataSource={filteredLessonRecords} pagination={false} - locale={{ emptyText: }} + locale={{ + emptyText: , + }} columns={[ { title: '学生', dataIndex: ['student', 'name'], diff --git a/apps/server/src/attendance/attendance-settlement.service.spec.ts b/apps/server/src/attendance/attendance-settlement.service.spec.ts index 6c1bbce..1c17af6 100644 --- a/apps/server/src/attendance/attendance-settlement.service.spec.ts +++ b/apps/server/src/attendance/attendance-settlement.service.spec.ts @@ -80,6 +80,49 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); + it('does not settle an in-progress lesson before its end time', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([ + { + id: 90, + scheduleId: 2, + lessonDate: '2026-07-13', + status: 'in_progress', + schedule, + }, + ]); + + await service.settleEndedLessons(new Date('2026-07-13T09:30:00+08:00')); + + expect(importService.importFromDingTalk).not.toHaveBeenCalled(); + expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); + }); + + it('settles an in-progress lesson when its end time is reached', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService } = createService(); + scheduleRepo.find.mockResolvedValue([schedule]); + sessionRepo.find.mockResolvedValue([ + { + id: 90, + scheduleId: 2, + lessonDate: '2026-07-13', + status: 'in_progress', + schedule, + }, + ]); + + await service.settleEndedLessons(new Date('2026-07-13T10:00:00+08:00')); + + expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledTimes(1); + expect(attendanceService.createLessonAttendanceFromDingTalk).toHaveBeenCalledWith( + 2, + '2026-07-13', + 21, + true, + ); + }); + 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 }]); @@ -163,6 +206,32 @@ describe('AttendanceSettlementService', () => { expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled(); }); + it('does not settle an in-progress overnight lesson before its next-day end time', async () => { + const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService(); + const overnightSchedule = { + ...schedule, + id: 4, + weekDay: 7, + startTime: '22:00', + endTime: '01:00', + }; + scheduleRepo.find.mockResolvedValue([]); + sessionRepo.find.mockResolvedValue([ + { + id: 91, + scheduleId: 4, + lessonDate: '2026-07-12', + status: 'in_progress', + schedule: overnightSchedule, + }, + ]); + + await service.settleEndedLessons(new Date('2026-07-13T00:30: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([ diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index fda60e1..0c15735 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -61,7 +61,11 @@ export class AttendanceSettlementService { } } for (const session of sessions) { - if (session.status === 'in_progress' && session.schedule) { + if ( + session.status === 'in_progress' && + session.schedule && + this.hasOccurrenceEnded(session.schedule, session.lessonDate, clock) + ) { candidates.set(`${session.scheduleId}|${session.lessonDate}`, { schedule: session.schedule, lessonDate: session.lessonDate, @@ -163,6 +167,18 @@ export class AttendanceSettlementService { return null; } + private hasOccurrenceEnded( + schedule: ClassSchedule, + lessonDate: string, + clock: { date: string; minutes: number }, + ): boolean { + const occurrenceEndDate = this.isOvernight(schedule) + ? this.shiftDate(lessonDate, 1) + : lessonDate; + if (clock.date !== occurrenceEndDate) return clock.date > occurrenceEndDate; + return clock.minutes >= this.toMinutes(schedule.endTime); + } + private isOvernight(schedule: ClassSchedule): boolean { return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime); }