fix: defer attendance settlement until lesson end
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user