fix: defer attendance settlement until lesson end

This commit is contained in:
2026-07-15 09:39:21 +08:00
parent b1f35f9d1a
commit fcde6caaaa
6 changed files with 213 additions and 3 deletions

View File

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

View File

@@ -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<T extends LessonAttendanceFilterRecord>(
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;

View File

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

View File

@@ -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<AttendanceRecordItem[]>([]);
const [recordLoading, setRecordLoading] = useState(false);
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('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 (
<div className="attendance-page teacher-attendance">
@@ -407,12 +417,39 @@ const TeacherAttendanceWorkspace: React.FC = () => {
/>
)}
<LessonCheckinSummaryStrip records={lessonRecords} />
<div className="lesson-record-filters">
<Input.Search
allowClear
value={studentKeyword}
placeholder="搜索学生姓名"
onChange={(event) => setStudentKeyword(event.target.value)}
className="lesson-record-search"
/>
<Select<LessonAttendanceFilter>
value={checkinFilter}
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
{ value: 'checked_in', label: '已打卡' },
{ value: 'not_checked_in', label: '未打卡' },
]}
className="lesson-record-filter-select"
/>
<span className="lesson-record-filter-count">
{filteredLessonRecords.length} / {lessonRecords.length}
</span>
</div>
<Table<AttendanceRecordItem>
rowKey="id"
loading={recordLoading}
dataSource={lessonRecords}
dataSource={filteredLessonRecords}
pagination={false}
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本节课尚未开始点名" /> }}
locale={{
emptyText: <Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
/>,
}}
columns={[
{
title: '学生', dataIndex: ['student', 'name'],

View File

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

View File

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