forked from wangziqi/gongxue-base
fix: defer attendance settlement until lesson end
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
|
filterLessonAttendanceRecords,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
getPunchDisplayInfo,
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
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', () => {
|
describe('lesson punch device display', () => {
|
||||||
it('labels attendance machine punches with the machine name and id', () => {
|
it('labels attendance machine punches with the machine name and id', () => {
|
||||||
expect(
|
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 {
|
export interface PunchDisplayRecord {
|
||||||
status: string;
|
status: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
|||||||
@@ -286,6 +286,32 @@
|
|||||||
.is-leave { color: #2874c6 !important; background: #edf5ff; }
|
.is-leave { color: #2874c6 !important; background: #edf5ff; }
|
||||||
.is-pending { color: #667085 !important; background: #f1f3f6; }
|
.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 {
|
.attendance-status {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -414,6 +440,22 @@
|
|||||||
color: #a1a9b5;
|
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) {
|
@media (max-width: 900px) {
|
||||||
.attendance-hero,
|
.attendance-hero,
|
||||||
.archive-toolbar {
|
.archive-toolbar {
|
||||||
|
|||||||
@@ -40,11 +40,13 @@ import { usePermission } from '../../hooks/usePermission';
|
|||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
import {
|
import {
|
||||||
canPullAttendance,
|
canPullAttendance,
|
||||||
|
filterLessonAttendanceRecords,
|
||||||
getAttendanceExperience,
|
getAttendanceExperience,
|
||||||
getPunchDisplayInfo,
|
getPunchDisplayInfo,
|
||||||
getSchedulePhase,
|
getSchedulePhase,
|
||||||
summarizeLessonCheckins,
|
summarizeLessonCheckins,
|
||||||
type AttendanceSummary,
|
type AttendanceSummary,
|
||||||
|
type LessonAttendanceFilter,
|
||||||
type SchedulePhase,
|
type SchedulePhase,
|
||||||
} from './attendance-workspace';
|
} from './attendance-workspace';
|
||||||
import './attendance.css';
|
import './attendance.css';
|
||||||
@@ -261,6 +263,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
|
const [lessonRecords, setLessonRecords] = useState<AttendanceRecordItem[]>([]);
|
||||||
const [recordLoading, setRecordLoading] = useState(false);
|
const [recordLoading, setRecordLoading] = useState(false);
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
const [studentKeyword, setStudentKeyword] = useState('');
|
||||||
|
const [checkinFilter, setCheckinFilter] = useState<LessonAttendanceFilter>('all');
|
||||||
|
|
||||||
const loadWorkspace = useCallback(async () => {
|
const loadWorkspace = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -284,6 +288,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
|
|
||||||
|
|
||||||
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
|
||||||
|
setStudentKeyword('');
|
||||||
|
setCheckinFilter('all');
|
||||||
setSelectedSchedule(schedule);
|
setSelectedSchedule(schedule);
|
||||||
setDrawerOpen(true);
|
setDrawerOpen(true);
|
||||||
setRecordLoading(true);
|
setRecordLoading(true);
|
||||||
@@ -334,6 +340,10 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
|
||||||
);
|
);
|
||||||
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
const isAttendanceCompleted = lessonSession?.status === 'completed';
|
||||||
|
const filteredLessonRecords = useMemo(
|
||||||
|
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
|
||||||
|
[lessonRecords, studentKeyword, checkinFilter],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="attendance-page teacher-attendance">
|
<div className="attendance-page teacher-attendance">
|
||||||
@@ -407,12 +417,39 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LessonCheckinSummaryStrip records={lessonRecords} />
|
<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>
|
<Table<AttendanceRecordItem>
|
||||||
rowKey="id"
|
rowKey="id"
|
||||||
loading={recordLoading}
|
loading={recordLoading}
|
||||||
dataSource={lessonRecords}
|
dataSource={filteredLessonRecords}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
locale={{ emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="本节课尚未开始点名" /> }}
|
locale={{
|
||||||
|
emptyText: <Empty
|
||||||
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
|
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
||||||
|
/>,
|
||||||
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: '学生', dataIndex: ['student', 'name'],
|
title: '学生', dataIndex: ['student', 'name'],
|
||||||
|
|||||||
@@ -80,6 +80,49 @@ describe('AttendanceSettlementService', () => {
|
|||||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
|
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 () => {
|
it('continues with the next lesson when one settlement fails', async () => {
|
||||||
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
const { service, scheduleRepo, sessionRepo, attendanceService, importService } = createService();
|
||||||
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
|
scheduleRepo.find.mockResolvedValue([schedule, { ...schedule, id: 3 }]);
|
||||||
@@ -163,6 +206,32 @@ describe('AttendanceSettlementService', () => {
|
|||||||
expect(attendanceService.createLessonAttendanceFromDingTalk).not.toHaveBeenCalled();
|
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 () => {
|
it('settles an overnight lesson after its next-day end time', async () => {
|
||||||
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
|
const { service, scheduleRepo, sessionRepo, attendanceService } = createService();
|
||||||
scheduleRepo.find.mockResolvedValue([
|
scheduleRepo.find.mockResolvedValue([
|
||||||
|
|||||||
@@ -61,7 +61,11 @@ export class AttendanceSettlementService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const session of sessions) {
|
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}`, {
|
candidates.set(`${session.scheduleId}|${session.lessonDate}`, {
|
||||||
schedule: session.schedule,
|
schedule: session.schedule,
|
||||||
lessonDate: session.lessonDate,
|
lessonDate: session.lessonDate,
|
||||||
@@ -163,6 +167,18 @@ export class AttendanceSettlementService {
|
|||||||
return null;
|
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 {
|
private isOvernight(schedule: ClassSchedule): boolean {
|
||||||
return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime);
|
return this.toMinutes(schedule.endTime) <= this.toMinutes(schedule.startTime);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user