diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css
index f80a2de..03befa0 100644
--- a/apps/admin/src/pages/Attendance/attendance.css
+++ b/apps/admin/src/pages/Attendance/attendance.css
@@ -66,6 +66,102 @@
opacity: 0.72;
}
+.teacher-topbar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+ margin-bottom: 14px;
+ padding: 12px 14px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+ box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
+}
+
+.teacher-topbar-select {
+ min-width: 160px;
+}
+
+.teacher-workspace-layout {
+ display: grid;
+ grid-template-columns: 168px minmax(0, 1fr);
+ gap: 16px;
+}
+
+.teacher-filter-rail {
+ position: sticky;
+ top: 16px;
+ align-self: start;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ padding: 14px;
+ border: 1px solid var(--line);
+ border-radius: 14px;
+ background: #fff;
+ box-shadow: 0 5px 18px rgb(23 32 51 / 4%);
+}
+
+.teacher-filter-title {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ color: var(--muted);
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.teacher-filter-select {
+ width: 100%;
+}
+
+.teacher-filter-hint {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.6;
+}
+
+.teacher-main-panel {
+ min-width: 0;
+}
+
+.current-lesson-card {
+ margin-bottom: 18px;
+ border: 1px solid #d8e6fb;
+ border-radius: 14px;
+ background: linear-gradient(120deg, #f7fbff 0%, #fff 70%);
+}
+
+.current-lesson-card .ant-card-body {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+}
+
+.current-lesson-copy > span {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.current-lesson-copy h2 {
+ margin: 4px 0;
+ font-size: 22px;
+}
+
+.current-lesson-copy p {
+ margin: 0;
+ color: var(--muted);
+}
+
+.current-lesson-actions {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 10px;
+}
+
.teacher-overview {
margin-bottom: 30px;
}
@@ -458,7 +554,8 @@
@media (max-width: 900px) {
.attendance-hero,
- .archive-toolbar {
+ .archive-toolbar,
+ .teacher-topbar {
align-items: flex-start;
flex-direction: column;
}
@@ -494,6 +591,23 @@
grid-column: 2 / -1;
padding: 14px 0 0;
}
+
+ .teacher-workspace-layout {
+ grid-template-columns: 1fr;
+ }
+
+ .teacher-filter-rail {
+ position: static;
+ }
+
+ .current-lesson-card .ant-card-body {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .current-lesson-actions {
+ justify-content: flex-start;
+ }
}
@media (max-width: 576px) {
@@ -566,3 +680,502 @@
color: #8c8c8c;
font-size: 12px;
}
+
+/* Student attendance center — adapted from the provided class overview reference */
+.student-attendance-center {
+ --student-bg: #f4f7f7;
+ --student-surface: #ffffff;
+ --student-soft: #f7faf9;
+ --student-ink: #17201e;
+ --student-muted: #66736f;
+ --student-quiet: #8a9692;
+ --student-line: #e1e8e5;
+ --student-primary: #157a65;
+ --student-primary-soft: #e8f4f0;
+ min-height: calc(100vh - 64px);
+ margin: -24px;
+ padding: 0 24px 28px;
+ background: var(--student-bg);
+ color: var(--student-ink);
+}
+
+.student-center-topbar {
+ position: sticky;
+ top: 0;
+ z-index: 12;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ min-height: 60px;
+ margin: 0 -24px 18px;
+ padding: 0 24px;
+ border-bottom: 1px solid var(--student-line);
+ background: rgb(255 255 255 / 96%);
+ backdrop-filter: blur(10px);
+}
+
+.student-center-title {
+ display: flex;
+ align-items: baseline;
+ gap: 12px;
+}
+
+.student-center-title h1 {
+ margin: 0;
+ font-size: 18px;
+ letter-spacing: 0;
+}
+
+.student-center-title span,
+.student-sync-status {
+ color: var(--student-quiet);
+ font-size: 12px;
+}
+
+.student-center-actions {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.student-sync-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-right: 6px;
+}
+
+.student-sync-status i {
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+ background: #2f9c78;
+}
+
+.student-filter-panel {
+ display: grid;
+ grid-template-columns: minmax(260px, 1.4fr) repeat(4, minmax(150px, 1fr)) auto;
+ gap: 12px;
+ align-items: end;
+ margin-bottom: 16px;
+ padding: 16px;
+ border: 1px solid var(--student-line);
+ border-radius: 10px;
+ background: var(--student-surface);
+}
+
+.student-filter-field {
+ display: grid;
+ gap: 6px;
+ min-width: 0;
+}
+
+.student-filter-field label {
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
+.student-filter-field .ant-picker,
+.student-filter-field .ant-select {
+ width: 100%;
+}
+
+.student-filter-actions {
+ display: flex;
+ gap: 8px;
+}
+
+.student-class-overview {
+ display: grid;
+ grid-template-columns: minmax(320px, .9fr) minmax(0, 1.1fr);
+ gap: 16px;
+ margin-bottom: 16px;
+}
+
+.student-class-identity,
+.student-metric-strip,
+.student-workspace,
+.student-record-card {
+ border: 1px solid var(--student-line);
+ border-radius: 10px;
+ background: var(--student-surface);
+}
+
+.student-class-identity {
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ min-height: 156px;
+ padding: 18px;
+}
+
+.student-class-identity h2 {
+ margin: 0 0 5px;
+ font-size: 22px;
+}
+
+.student-class-identity span {
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
+.student-teacher-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 10px;
+ margin-top: 20px;
+}
+
+.student-teacher-item {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ min-width: 150px;
+ padding: 9px;
+ border-radius: 8px;
+ background: var(--student-soft);
+}
+
+.student-teacher-item .ant-avatar {
+ color: var(--student-primary);
+ background: var(--student-primary-soft);
+}
+
+.student-teacher-item strong,
+.student-teacher-item span {
+ display: block;
+}
+
+.student-teacher-item strong {
+ margin-top: 2px;
+ font-size: 13px;
+}
+
+.student-metric-strip {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(82px, 1fr));
+ overflow: hidden;
+}
+
+.student-metric-card {
+ appearance: none;
+ display: grid;
+ align-content: center;
+ gap: 6px;
+ min-height: 156px;
+ padding: 16px 12px;
+ border: 0;
+ border-right: 1px solid var(--student-line);
+ background: #fff;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.student-metric-card:last-child {
+ border-right: 0;
+}
+
+.student-metric-card:hover,
+.student-metric-card.active {
+ background: var(--student-primary-soft);
+}
+
+.student-metric-icon {
+ display: inline-grid;
+ width: max-content;
+ min-width: 34px;
+ height: 26px;
+ place-items: center;
+ padding: 0 7px;
+ border-radius: 999px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.student-metric-card strong {
+ font-size: 24px;
+ line-height: 1;
+}
+
+.student-metric-card small {
+ color: var(--student-muted);
+}
+
+.student-workspace {
+ margin-bottom: 16px;
+ padding: 0 18px 20px;
+}
+
+.student-workspace-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 18px;
+ min-height: 70px;
+ border-bottom: 1px solid var(--student-line);
+}
+
+.student-workspace-header h3 {
+ margin: 0 0 4px;
+ font-size: 17px;
+}
+
+.student-workspace-header span {
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
+.student-workspace-tools {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.student-workspace-tools .ant-input-search {
+ width: 240px;
+}
+
+.student-legend {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 12px 0;
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
+.student-legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ margin-right: 12px;
+}
+
+.student-legend i {
+ width: 9px;
+ height: 9px;
+ border-radius: 2px;
+}
+
+.student-legend i.is-present { background: #2f9c78; }
+.student-legend i.is-late { background: #d78a18; }
+.student-legend i.is-leave { background: #397bbf; }
+.student-legend i.is-absent { background: #d44d4d; }
+.student-legend i.is-pending { background: #8a9692; }
+
+.student-legend em {
+ color: var(--student-quiet);
+ font-style: normal;
+}
+
+.student-card-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(184px, 1fr));
+ gap: 10px;
+}
+
+.student-attendance-card {
+ appearance: none;
+ display: grid;
+ gap: 12px;
+ min-height: 96px;
+ padding: 13px;
+ border: 1px solid var(--student-line);
+ border-radius: 8px;
+ background: #fff;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+ transition: border-color 160ms ease, box-shadow 160ms ease, transform 160ms ease;
+}
+
+.student-attendance-card:hover,
+.student-attendance-card.selected {
+ transform: translateY(-1px);
+ border-color: #91c8b9;
+ box-shadow: 0 8px 20px rgb(24 44 38 / 8%);
+}
+
+.student-attendance-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.student-attendance-head strong {
+ font-size: 15px;
+}
+
+.student-attendance-head small {
+ color: var(--student-quiet);
+}
+
+.student-status-blocks {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 6px;
+}
+
+.student-status-block {
+ display: grid;
+ min-height: 28px;
+ place-items: center;
+ border-radius: 5px;
+ font-size: 12px;
+ font-weight: 700;
+}
+
+.student-record-card {
+ overflow: hidden;
+ margin-bottom: 16px;
+}
+
+.student-record-card .ant-card-body {
+ padding: 0 18px 14px;
+}
+
+.student-detail-panel {
+ display: grid;
+ gap: 18px;
+}
+
+.student-detail-profile {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--student-line);
+}
+
+.student-detail-profile h4 {
+ margin: 0 0 4px;
+ font-size: 18px;
+}
+
+.student-detail-profile p,
+.student-detail-rate span {
+ margin: 0;
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
+.student-detail-rate {
+ text-align: right;
+}
+
+.student-detail-rate strong {
+ display: block;
+ color: var(--student-primary);
+ font-size: 24px;
+}
+
+.student-detail-rates {
+ display: grid;
+ grid-template-columns: repeat(4, 1fr);
+ gap: 8px;
+}
+
+.student-detail-rates > div {
+ padding: 12px 8px;
+ border-radius: 8px;
+ background: var(--student-soft);
+ text-align: center;
+}
+
+.student-detail-rates strong,
+.student-detail-rates span {
+ display: block;
+}
+
+.student-detail-rates span {
+ margin-top: 4px;
+ color: var(--student-muted);
+ font-size: 12px;
+}
+
+.student-detail-section h5 {
+ margin: 0 0 10px;
+ font-size: 14px;
+}
+
+.student-detail-timeline {
+ display: grid;
+ gap: 8px;
+}
+
+.student-detail-timeline > div {
+ display: grid;
+ grid-template-columns: 64px 96px 1fr auto;
+ gap: 8px;
+ align-items: center;
+ padding: 10px;
+ border: 1px solid var(--student-line);
+ border-radius: 8px;
+}
+
+.student-detail-timeline > div > span:first-child {
+ color: var(--student-muted);
+}
+
+.student-trend-bars {
+ display: flex;
+ align-items: end;
+ gap: 8px;
+ height: 110px;
+ padding: 12px;
+ border-radius: 8px;
+ background: var(--student-soft);
+}
+
+.student-trend-bars i {
+ flex: 1;
+ min-height: 18px;
+ border-radius: 5px 5px 0 0;
+ background: linear-gradient(180deg, #56bea3, #157a65);
+}
+
+@media (max-width: 1180px) {
+ .student-filter-panel,
+ .student-class-overview {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .student-filter-actions {
+ grid-column: 1 / -1;
+ justify-content: flex-end;
+ }
+
+ .student-metric-strip {
+ grid-template-columns: repeat(3, 1fr);
+ }
+}
+
+@media (max-width: 760px) {
+ .student-attendance-center {
+ margin: -16px;
+ padding: 0 16px 22px;
+ }
+
+ .student-center-topbar,
+ .student-workspace-header,
+ .student-center-actions,
+ .student-legend {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .student-filter-panel,
+ .student-class-overview {
+ grid-template-columns: 1fr;
+ }
+
+ .student-workspace-tools,
+ .student-workspace-tools .ant-input-search {
+ width: 100%;
+ }
+}
diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx
index 3c43313..a047553 100644
--- a/apps/admin/src/pages/Attendance/index.tsx
+++ b/apps/admin/src/pages/Attendance/index.tsx
@@ -27,7 +27,9 @@ import {
ClockCircleOutlined,
EditOutlined,
ExportOutlined,
+ CalendarOutlined,
FileSearchOutlined,
+ FilterOutlined,
ReloadOutlined,
ScheduleOutlined,
TeamOutlined,
@@ -183,45 +185,6 @@ function AttendanceStatusTag({ status }: { status: string }) {
);
}
-function SummaryStrip({ summary }: { summary: AttendanceSummary }) {
- const rate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
- return (
-
-
-
-
- 出勤率
- {summary.total} 条记录
-
-
- {[
- ['present', summary.present],
- ['late', summary.late],
- ['absent', summary.absent],
- ['leave', summary.leave],
- ].map(([status, value]) => {
- const meta = STATUS_META[String(status)];
- return (
-
-
{meta.short}
-
- {value}
- {meta.label}
-
-
- );
- })}
-
- );
-}
-
function LessonCheckinSummaryStrip({ records }: { records: readonly AttendanceRecordItem[] }) {
const summary = summarizeLessonCheckins(records);
const rate = summary.total > 0 ? Math.round((summary.checkedIn / summary.total) * 100) : 0;
@@ -265,6 +228,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const [drawerOpen, setDrawerOpen] = useState(false);
const [studentKeyword, setStudentKeyword] = useState('');
const [checkinFilter, setCheckinFilter] = useState('all');
+ const [selectedClassId, setSelectedClassId] = useState('all');
+ const [phaseFilter, setPhaseFilter] = useState('all');
const loadWorkspace = useCallback(async () => {
setLoading(true);
@@ -286,6 +251,17 @@ const TeacherAttendanceWorkspace: React.FC = () => {
[workspace],
);
+ const classFilterOptions = useMemo(
+ () => [
+ { value: 'all' as const, label: '全部教学班' },
+ ...(workspace?.assignedClasses.map((item) => ({
+ value: item.classId,
+ label: item.className,
+ })) ?? []),
+ ],
+ [workspace],
+ );
+
const openAttendance = useCallback(async (schedule: TodaySchedule) => {
setStudentKeyword('');
@@ -324,7 +300,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
setLessonRecords((items) =>
items.map((item) => (item.id === record.id ? { ...item, status: previous } : item)),
);
- message.error((error as { message?: string })?.message || '更新考勤失败');
+ message.error((error as { message?: string })?.message || '更新课堂考勤失败');
}
},
[],
@@ -339,6 +315,16 @@ const TeacherAttendanceWorkspace: React.FC = () => {
const nextSchedule = schedules.find(
(item) => getSchedulePhase(item.startTime, item.endTime, now) !== 'ended',
);
+ const filteredSchedules = schedules.filter((item) => {
+ const phase = getSchedulePhase(item.startTime, item.endTime, now);
+ const matchesClass = selectedClassId === 'all' || item.classId === selectedClassId;
+ const matchesPhase = phaseFilter === 'all' || phase === phaseFilter;
+ return matchesClass && matchesPhase;
+ });
+ const currentFocusSchedule = nextSchedule ?? schedules.at(-1) ?? null;
+ const currentFocusClassName = currentFocusSchedule
+ ? classNameById.get(currentFocusSchedule.classId) || `班级 ${currentFocusSchedule.classId}`
+ : '暂无教学班';
const isAttendanceCompleted = lessonSession?.status === 'completed';
const filteredLessonRecords = useMemo(
() => filterLessonAttendanceRecords(lessonRecords, studentKeyword, checkinFilter),
@@ -347,72 +333,135 @@ const TeacherAttendanceWorkspace: React.FC = () => {
return (
+
+
+ }>{dayjs().format('YYYY-MM-DD')}
+
+ } onClick={() => void loadWorkspace()}>
+ 刷新课程
+
+
+
-
TEACHING DAY · {dayjs().format('MM月DD日 dddd')}
-
今天,从课程开始
-
课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。
+
LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}
+
任课老师课堂考勤
+
围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。
- } onClick={() => void loadWorkspace()}>
- 刷新
+ } onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)} disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'}>
+ 查看本节考勤
- 今日课程{schedules.length}节
+ 今日课程{schedules.length}节待查看
- 已开始{startedCount}节,可查看考勤
+ 已开始课程{startedCount}节,可处理课堂考勤
- 下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
+ 当前/下一节{nextSchedule ? nextSchedule.startTime : '—'}{nextSchedule?.subject || '今天没有更多课程'}
-
-
今日教学节奏
我的课程
-
课程开始后可拉取钉钉考勤记录
-
+
+
-
- {schedules.length === 0 ? (
-
- 今天还没有课程请联系教务管理员安排课程。
}
- />
+
+
+
+
主页主功能区
+
{currentFocusSchedule?.subject || '暂无待处理课程'}
+
+ {currentFocusSchedule
+ ? `${currentFocusClassName} · ${currentFocusSchedule.startTime}-${currentFocusSchedule.endTime} · 教室 ${currentFocusSchedule.classroomId}`
+ : '今天没有课程,可切换日期查看其他课堂考勤。'}
+
+
+
+
+ } disabled>导出本节课记录
+
- ) : (
-
- {schedules.map((schedule, index) => {
- const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
- return (
-
void openAttendance(schedule)}
- />
- );
- })}
+
+
+
课堂考勤列表
我的课程
+
课程开始后可查看本节课学生签到和异常
- )}
-
+
+
+ {schedules.length === 0 ? (
+
+ 今天还没有课程请联系教务管理员安排课程。
}
+ />
+
+ ) : filteredSchedules.length === 0 ? (
+
+ 没有符合筛选条件的课程请调整左侧教学班级或课程状态。
}
+ />
+
+ ) : (
+
+ {filteredSchedules.map((schedule, index) => {
+ const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
+ return (
+ void openAttendance(schedule)}
+ />
+ );
+ })}
+
+ )}
+
+
+
setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
LESSON ATTENDANCE
-
{selectedSchedule?.subject || '课程考勤'}
-
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}
+
{selectedSchedule?.subject || '本节课考勤'}
+
{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · 本节课 {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}
{lessonSession && (
)}
@@ -430,8 +479,8 @@ const TeacherAttendanceWorkspace: React.FC = () => {
onChange={setCheckinFilter}
options={[
{ value: 'all', label: '全部学生' },
- { value: 'checked_in', label: '已打卡' },
- { value: 'not_checked_in', label: '未打卡' },
+ { value: 'checked_in', label: '已到学生' },
+ { value: 'not_checked_in', label: '未签到/旷课' },
]}
className="lesson-record-filter-select"
/>
@@ -447,7 +496,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
locale={{
emptyText: ,
}}
columns={[
@@ -456,7 +505,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
render: (name: string) => {name?.slice(0, 1)}{name || '-'} ,
},
{
- title: '考勤结果', dataIndex: 'status', width: 230,
+ title: '课堂考勤操作', dataIndex: 'status', width: 250,
render: (value: string, record: AttendanceRecordItem) => {
const checkedIn = value === 'present' || value === 'late';
return (
@@ -466,7 +515,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
type={checkedIn ? 'primary' : 'default'}
onClick={() => void updateLessonRecord(record, 'present')}
>
- 已打卡
+ 标记已到
);
},
},
- { title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => },
+ { title: '当前状态', dataIndex: 'status', width: 118, render: (value: string) => },
{
- title: '打卡设备',
+ title: '签到来源',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
@@ -496,7 +545,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
);
},
},
- { title: '备注', dataIndex: 'remark', render: (value: string | null) => value || — },
+ { title: '备注/异常处理', dataIndex: 'remark', render: (value: string | null) => value || 暂无备注 },
]}
/>
@@ -530,7 +579,7 @@ const LessonCard: React.FC<{
) : (
)}
@@ -539,6 +588,71 @@ const LessonCard: React.FC<{
};
+interface AdminStudentPanel {
+ key: string;
+ studentId: number;
+ studentName: string;
+ className: string;
+ records: AttendanceRecordItem[];
+ statusBySession: Partial>;
+ primaryStatus: string;
+ rate: number;
+ latestDate: string;
+}
+
+const ADMIN_PERIODS = [
+ { key: 'morning_reading', label: '早读' },
+ { key: 'morning', label: '上午' },
+ { key: 'afternoon', label: '下午' },
+ { key: 'evening_study', label: '晚自习' },
+];
+
+const ADMIN_METRIC_META = [
+ { key: 'all', label: '出勤率', short: '率' },
+ { key: 'present', label: '正常', short: '正常' },
+ { key: 'late', label: '迟到', short: '迟到' },
+ { key: 'leave', label: '请假', short: '请假' },
+ { key: 'absent', label: '缺勤', short: '缺勤' },
+ { key: 'pending', label: '未打卡', short: '待核' },
+];
+
+function pickPrimaryStatus(records: AttendanceRecordItem[]) {
+ const priority = ['absent', 'late', 'leave', 'pending', 'present'];
+ return priority.find((item) => records.some((record) => record.status === item)) || 'pending';
+}
+
+function buildAdminStudentPanels(records: AttendanceRecordItem[]): AdminStudentPanel[] {
+ const map = new Map();
+ for (const record of records) {
+ const current = map.get(record.studentId) ?? {
+ key: String(record.studentId),
+ studentId: record.studentId,
+ studentName: record.student?.name || '未知学生',
+ className: record.class?.name || '未关联班级',
+ records: [],
+ statusBySession: {},
+ primaryStatus: 'pending',
+ rate: 0,
+ latestDate: record.attendanceDate,
+ };
+ current.records.push(record);
+ if (!current.statusBySession[record.session]) current.statusBySession[record.session] = record;
+ if (dayjs(record.attendanceDate).isAfter(dayjs(current.latestDate))) {
+ current.latestDate = record.attendanceDate;
+ }
+ map.set(record.studentId, current);
+ }
+
+ return Array.from(map.values()).map((item) => {
+ const checked = item.records.filter((record) => record.status === 'present' || record.status === 'late').length;
+ return {
+ ...item,
+ primaryStatus: pickPrimaryStatus(item.records),
+ rate: item.records.length > 0 ? Math.round((checked / item.records.length) * 100) : 0,
+ };
+ });
+}
+
const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => {
const [records, setRecords] = useState([]);
const [summary, setSummary] = useState(EMPTY_SUMMARY);
@@ -546,15 +660,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
const [classOptions, setClassOptions] = useState([]);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
- const [pageSize, setPageSize] = useState(20);
+ const [pageSize, setPageSize] = useState(48);
const [total, setTotal] = useState(0);
const [classId, setClassId] = useState();
- const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([
- dayjs().subtract(30, 'day'),
- dayjs(),
- ]);
+ const [dateRange, setDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]);
const [status, setStatus] = useState();
const [session, setSession] = useState();
+ const [metricFilter, setMetricFilter] = useState('all');
+ const [studentSearch, setStudentSearch] = useState('');
+ const [selectedStudent, setSelectedStudent] = useState(null);
const [editRecord, setEditRecord] = useState(null);
const [editForm] = Form.useForm();
@@ -587,7 +701,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
setTotal(recordData.total);
setSummary({ ...EMPTY_SUMMARY, ...summaryData });
} catch (error: unknown) {
- message.error((error as { message?: string })?.message || '加载历史考勤失败');
+ message.error((error as { message?: string })?.message || '加载学生考勤失败');
} finally {
setLoading(false);
}
@@ -606,9 +720,11 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
const resetFilters = () => {
setClassId(undefined);
- setDateRange([dayjs().subtract(30, 'day'), dayjs()]);
+ setDateRange([dayjs(), dayjs()]);
setStatus(undefined);
setSession(undefined);
+ setMetricFilter('all');
+ setStudentSearch('');
setPage(1);
};
@@ -627,7 +743,7 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
- anchor.download = `历史考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
+ anchor.download = `学生考勤-${dayjs().format('YYYYMMDD')}.xlsx`;
anchor.click();
URL.revokeObjectURL(url);
})
@@ -648,6 +764,28 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
}
};
+ const studentPanels = useMemo(() => buildAdminStudentPanels(records), [records]);
+ const visibleStudents = useMemo(() => {
+ const query = studentSearch.trim().toLocaleLowerCase('zh-CN');
+ return studentPanels.filter((student) => {
+ const matchesQuery =
+ !query ||
+ student.studentName.toLocaleLowerCase('zh-CN').includes(query) ||
+ String(student.studentId).includes(query);
+ const matchesMetric =
+ metricFilter === 'all' || student.records.some((record) => record.status === metricFilter);
+ return matchesQuery && matchesMetric;
+ });
+ }, [metricFilter, studentPanels, studentSearch]);
+
+ const selectedClass = classId
+ ? classOptions.find((item) => item.classId === classId)?.className || `班级 ${classId}`
+ : '全部班级';
+ const attendanceRate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0;
+ const dateLabel = dateRange?.[0]?.isSame(dateRange?.[1], 'day')
+ ? dateRange?.[0]?.format('YYYY-MM-DD')
+ : `${dateRange?.[0]?.format('YYYY-MM-DD') || '开始日期'} 至 ${dateRange?.[1]?.format('YYYY-MM-DD') || '结束日期'}`;
+
const columns: ColumnsType = [
{
title: '学生',
@@ -672,19 +810,13 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
render: (value: string) => SESSION_MAP[value] || value,
},
{
- title: '结果',
+ title: '状态',
dataIndex: 'status',
- width: 110,
+ width: 105,
render: (value: string) => ,
},
{
- title: '记录来源',
- dataIndex: 'source',
- width: 110,
- render: (value: string) => (value === 'dingtalk' ? '钉钉同步' : value === 'schedule' ? '课程生成' : value === 'lesson' ? '课堂点名' : '人工记录'),
- },
- {
- title: '打卡设备',
+ title: '签到来源',
width: 220,
render: (_: unknown, record: AttendanceRecordItem) => {
const info = getPunchDisplayInfo(record);
@@ -704,12 +836,6 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
ellipsis: true,
render: (value: string | null) => value || —,
},
- {
- title: '归档时间',
- dataIndex: 'createdAt',
- width: 165,
- render: (value: string) => dayjs(value).format('YYYY-MM-DD HH:mm'),
- },
...(canEdit
? [
{
@@ -735,24 +861,109 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
];
return (
-
-
-
-
ATTENDANCE ARCHIVE
-
历史考勤档案
-
面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。
+
+
+
+
+
+
+ {
+ setDateRange(value as [Dayjs, Dayjs] | null);
+ setPage(1);
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } onClick={() => void loadRecords()}>
+ 查询
+
- }
- size="large"
- onClick={handleExport}
- >
- 导出当前结果
-
-
+
+
+
+
{selectedClass}
+ {dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录
+
+
+
+
+
任当前任课{session ? SESSION_MAP[session] : '全部时段'}
+
+
+
+ {ADMIN_METRIC_META.map((metric) => {
+ const value = metric.key === 'all'
+ ? `${attendanceRate}%`
+ : summary[metric.key as keyof AttendanceSummary] ?? 0;
+ const meta = STATUS_META[metric.key] ?? { className: 'is-present' };
+ return (
+
+ );
+ })}
+
+
{alerts.length > 0 && (
@@ -767,70 +978,76 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
)}
-
-
-
-
-
- 档案检索
- 默认查看最近 30 天
-
+
+
+
+ 正常
+ 迟到
+ 请假
+ 缺勤
+ 未打卡
+ 每名学生依次显示:早读 / 上午 / 下午 / 晚自习
+
+ {visibleStudents.length === 0 ? (
+
+ ) : (
+
+ {visibleStudents.map((student) => (
+
+ ))}
+
+ )}
+
+
+
rowKey="id"
columns={columns}
dataSource={records}
loading={loading}
- scroll={{ x: 1050 }}
+ scroll={{ x: 950 }}
pagination={{
current: page,
pageSize,
@@ -850,6 +1067,70 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
/>
+ setSelectedStudent(null)}
+ width={520}
+ title="学生考勤明细"
+ className="student-detail-drawer"
+ >
+ {selectedStudent && (
+
+
+ {selectedStudent.studentName.slice(0, 1)}
+
+
{selectedStudent.studentName}
+
{selectedStudent.className} · 学号 {selectedStudent.studentId}
+
+ {selectedStudent.rate}%累计出勤率
+
+
+ {ADMIN_PERIODS.map((period) => {
+ const record = selectedStudent.statusBySession[period.key];
+ const normal = record?.status === 'present' || record?.status === 'late';
+ return {record ? (normal ? '100%' : '0%') : '—'}{period.label}
;
+ })}
+
+
+ 当天打卡时间
+
+ {ADMIN_PERIODS.map((period) => {
+ const record = selectedStudent.statusBySession[period.key];
+ return (
+
+
{period.label}
+
+
{record?.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}
+ {canEdit && record && (
+
+ )}
+
+ );
+ })}
+
+
+
+ 近 7 日趋势
+
+ {Array.from({ length: 7 }).map((_, index) => {
+ const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8));
+ return ;
+ })}
+
+
+
+ )}
+
+
= {
@@ -93,6 +94,7 @@ const BillsPage: React.FC = () => {
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
+ operationId: newOperationId(),
billingMonth: values.billingMonth.format('YYYY-MM'),
});
message.success(res.message || '生成成功');
@@ -128,7 +130,7 @@ const BillsPage: React.FC = () => {
okText: '确认取消', cancelText: '返回',
onOk: async () => {
if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
- await api.post(`/bills/${id}/cancel`, { reason: reason.trim() });
+ await api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回');
fetchData();
},
diff --git a/apps/admin/src/pages/Wallets/index.tsx b/apps/admin/src/pages/Wallets/index.tsx
index 9ac926f..9792c43 100644
--- a/apps/admin/src/pages/Wallets/index.tsx
+++ b/apps/admin/src/pages/Wallets/index.tsx
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
+import { newOperationId } from '../../utils/operation-id';
interface WalletRow {
studentId: number;
@@ -59,7 +60,7 @@ const WalletsPage: React.FC = () => {
const values = await form.validateFields();
setSaving(true);
try {
- const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values });
+ const result: any = await api.post('/wallets/change-balance', { operationId: newOperationId(), studentId: selected.studentId, ...values });
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null);
@@ -73,6 +74,7 @@ const WalletsPage: React.FC = () => {
setSaving(true);
try {
const result: any = await api.post('/wallets/batch-change-balance', {
+ operationId: newOperationId(),
studentIds: selectedRowKeys,
...values,
});
diff --git a/apps/admin/src/utils/operation-id.ts b/apps/admin/src/utils/operation-id.ts
new file mode 100644
index 0000000..f92dbe4
--- /dev/null
+++ b/apps/admin/src/utils/operation-id.ts
@@ -0,0 +1 @@
+export const newOperationId = () => crypto.randomUUID();
diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts
index f3f34f1..b86de8d 100644
--- a/apps/server/src/app.module.ts
+++ b/apps/server/src/app.module.ts
@@ -46,6 +46,7 @@ import {
AiConfig,
StudentWallet,
WalletTransaction,
+ FinancialOperation,
} from './entities';
import { AuthModule } from './auth/auth.module';
import { AuthorizationModule } from './authorization';
@@ -76,6 +77,7 @@ import { DatabaseMigrationsModule } from './database/database-migrations.module'
import { AgentToolsModule } from './agent-tools';
import { AiConfigModule } from './ai-config/ai-config.module';
import { WalletsModule } from './wallets/wallets.module';
+import { FinancialOperationsModule } from './financial-operations/financial-operations.module';
import {
IntegrationConfig,
@@ -143,6 +145,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
AiConfig,
StudentWallet,
WalletTransaction,
+ FinancialOperation,
];
if (dbType === 'mysql') {
return {
@@ -177,6 +180,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
OperationLogsModule,
DepositsModule,
WalletsModule,
+ FinancialOperationsModule,
ClassroomsModule,
AttendanceModule,
AttendanceDevicesModule,
diff --git a/apps/server/src/bills/bills.service.spec.ts b/apps/server/src/bills/bills.service.spec.ts
index eee388a..3c38bb7 100644
--- a/apps/server/src/bills/bills.service.spec.ts
+++ b/apps/server/src/bills/bills.service.spec.ts
@@ -284,7 +284,7 @@ describe('BillsService — generateBills', () => {
// Bug-exposing tests
// ============================================================
- it.skip('BUG: long-term multi-month period → monthlyRate not multiplied by months', async () => {
+ it('long-term multi-month period multiplies and prorates monthly rent', async () => {
// 3-month period: Jan–Mar 2026
const THREE_MONTHS = { periodStart: '2026-01-01', periodEnd: '2026-03-31' };
@@ -328,7 +328,7 @@ describe('BillsService — generateBills', () => {
expect(actual).toBeCloseTo(expected, 0);
});
- it.skip('BUG: long-term partial month → full monthlyRate charged instead of prorated', async () => {
+ it('long-term partial month prorates by calendar days', async () => {
// Student occupies only Jun 15–30 (16 days out of 30), monthlyRate 600
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder([
diff --git a/apps/server/src/bills/bills.service.ts b/apps/server/src/bills/bills.service.ts
index 545949a..be1fb7f 100644
--- a/apps/server/src/bills/bills.service.ts
+++ b/apps/server/src/bills/bills.service.ts
@@ -1,4 +1,4 @@
-import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
+import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource, EntityManager } from 'typeorm';
import { Bill } from '../entities/bill.entity';
@@ -10,6 +10,7 @@ import { Room } from '../entities/room.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
+import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
@Injectable()
@@ -23,12 +24,22 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository,
private dataSource: DataSource,
private walletsService: WalletsService,
+ @Optional()
+ private financialOperations?: FinancialOperationsService,
) {}
/**
* 核心计费引擎:按"人天数"加权分摊
*/
async generateBills(dto: GenerateBillsDto) {
+ const { operationId, ...request } = dto;
+ const work = () => this.generateBillsOnce(request as GenerateBillsDto);
+ return this.financialOperations
+ ? this.financialOperations.run(operationId, 'bill.generate', work)
+ : work();
+ }
+
+ private async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
@@ -37,55 +48,30 @@ export class BillsService {
}
const pStart = new Date(`${periodStart}T00:00:00Z`);
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
-
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) {
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
}
-
- const existingDrafts: Bill[] = [];
- if (existingDrafts.length > 0) {
- const draftIds = existingDrafts.map((b) => b.id);
- await this.personalExpRepo
- .createQueryBuilder()
- .update()
- .set({ billId: null })
- .where('billId IN (:...ids)', { ids: draftIds })
- .execute();
- await this.itemRepo
- .createQueryBuilder()
- .delete()
- .where('billId IN (:...ids)', { ids: draftIds })
- .execute();
- await this.billRepo
- .createQueryBuilder()
- .delete()
- .where('id IN (:...ids)', { ids: draftIds })
- .execute();
- }
-
- // 获取所有有费用的宿舍
const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e')
- .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
- periodStart,
- periodEnd,
- })
+ .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd })
.andWhere('e.status = :status', { status: 'active' })
.getMany();
-
- // 按宿舍分组费用
+ const longTermOccupancies: Occupancy[] = [];
const roomExpMap = new Map();
- for (const exp of roomExpenses) {
- if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
- roomExpMap.get(exp.roomId)!.push(exp);
+ for (const expense of roomExpenses) {
+ const expenses = roomExpMap.get(expense.roomId) || [];
+ expenses.push(expense);
+ roomExpMap.set(expense.roomId, expenses);
}
+ const roomIds = new Set([
+ ...roomExpMap.keys(),
+ ...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId),
+ ]);
+ const studentBillData = new Map> }>();
- // 计算每个学生的分摊费用
- const studentBillData = new Map();
-
- for (const [roomId, expenses] of roomExpMap) {
- // 获取该宿舍在此周期内的所有入住记录
+ for (const roomId of roomIds) {
+ const expenses = roomExpMap.get(roomId) || [];
const occupancies = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
@@ -94,112 +80,92 @@ export class BillsService {
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
+ const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
+ const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
-
- // 分离长租与短租入住记录
- const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
- const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
-
- // 长租:按月租费独立计费,不参与人天数分摊
- for (const occ of longTermOccs) {
- const monthlyRate = Number(occ.room?.monthlyRate || 0);
- if (!studentBillData.has(occ.studentId)) {
- studentBillData.set(occ.studentId, { shared: 0, items: [] });
- }
- const data = studentBillData.get(occ.studentId)!;
- data.shared += monthlyRate;
+ for (const occupancy of longTermOccs) {
+ const rent = this.calculateLongTermRent(
+ occupancy,
+ periodStart,
+ periodEnd,
+ Number(occupancy.room?.monthlyRate || 0),
+ );
+ if (rent <= 0) continue;
+ const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] };
+ data.shared += rent;
data.items.push({
roomId,
expenseType: 'rent',
- description: `长租月租费 (${occ.room?.roomNumber || '未知房间'})`,
+ description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
- roomTotalAmount: monthlyRate,
- studentAmount: monthlyRate,
+ roomTotalAmount: rent,
+ studentAmount: rent,
});
+ studentBillData.set(occupancy.studentId, data);
}
- // 短租:原人天数加权分摊逻辑
- if (shortTermOccs.length === 0) continue;
-
- // 计算每个学生的计费天数
- const studentDays: { studentId: number; days: number }[] = [];
- let totalDays = 0;
-
- for (const occ of shortTermOccs) {
- const start = new Date(
- Math.max(new Date(occ.billingStartDate).getTime(), pStart.getTime()),
- );
- const end = occ.billingEndDate
- ? new Date(Math.min(new Date(occ.billingEndDate).getTime(), pEnd.getTime()))
+ const studentDays = shortTermOccs.map((occupancy) => {
+ const start = new Date(Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()));
+ const end = occupancy.billingEndDate
+ ? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime()))
: pEnd;
- const days = Math.max(
- 0,
- Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1,
- );
- studentDays.push({ studentId: occ.studentId, days });
- totalDays += days;
- }
-
+ const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1);
+ return { studentId: occupancy.studentId, days };
+ });
+ const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
- // 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。
for (const expense of expenses) {
- const eligibleDays = studentDays.filter((sd) => sd.days > 0);
+ const eligibleDays = studentDays.filter((entry) => entry.days > 0);
const expenseTotal = Number(Number(expense.amount).toFixed(2));
let allocated = 0;
- for (const [index, sd] of eligibleDays.entries()) {
+ for (const [index, entry] of eligibleDays.entries()) {
const amount = index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
- : Number(((sd.days / totalDays) * expenseTotal).toFixed(2));
+ : Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
- if (!studentBillData.has(sd.studentId)) {
- studentBillData.set(sd.studentId, { shared: 0, items: [] });
- }
- const data = studentBillData.get(sd.studentId)!;
+ const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] };
data.shared += amount;
data.items.push({
+ roomExpenseId: expense.id,
roomId,
expenseType: expense.expenseType,
description: `${expense.expenseType} 分摊`,
- days: sd.days,
+ days: entry.days,
totalRoomDays: totalDays,
roomTotalAmount: expense.amount,
studentAmount: amount,
});
+ studentBillData.set(entry.studentId, data);
}
}
}
- // 获取个人附加费
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
- .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
- periodStart,
- periodEnd,
- })
+ .where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd })
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
-
const personalMap = new Map();
- const personalItems = new Map();
- for (const pe of personalExps) {
- personalMap.set(pe.studentId, (personalMap.get(pe.studentId) || 0) + Number(pe.amount));
- if (!personalItems.has(pe.studentId)) personalItems.set(pe.studentId, []);
- personalItems.get(pe.studentId)!.push({
- roomId: pe.roomId,
- expenseType: pe.expenseType,
- description: `个人费用: ${pe.description || pe.expenseType}`,
+ const personalItems = new Map>>();
+ for (const expense of personalExps) {
+ personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount));
+ const items = personalItems.get(expense.studentId) || [];
+ items.push({
+ personalExpenseId: expense.id,
+ roomId: expense.roomId,
+ expenseType: expense.expenseType,
+ description: `个人费用: ${expense.description || expense.expenseType}`,
days: 0,
totalRoomDays: 0,
- roomTotalAmount: pe.amount,
- studentAmount: pe.amount,
+ roomTotalAmount: expense.amount,
+ studentAmount: expense.amount,
});
+ personalItems.set(expense.studentId, items);
}
-
- // 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
const bills = await this.dataSource.transaction(async (manager) => {
const generated: Bill[] = [];
@@ -207,31 +173,23 @@ export class BillsService {
const shared = studentBillData.get(studentId)?.shared || 0;
const personal = personalMap.get(studentId) || 0;
const total = Number((shared + personal).toFixed(2));
- let bill = await manager.save(
- manager.create(Bill, {
- studentId,
- periodStart,
- periodEnd,
- sharedAmount: Number(shared.toFixed(2)),
- personalAmount: personal,
- totalAmount: total,
- source: 'batch',
- paidAmount: 0,
- outstandingAmount: total,
- status: 'unpaid',
- }),
- );
- const items = [
- ...(studentBillData.get(studentId)?.items || []),
- ...(personalItems.get(studentId) || []),
- ];
- for (const item of items) {
- await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
- }
+ let bill = await manager.save(manager.create(Bill, {
+ studentId,
+ periodStart,
+ periodEnd,
+ sharedAmount: Number(shared.toFixed(2)),
+ personalAmount: personal,
+ totalAmount: total,
+ source: 'batch',
+ paidAmount: 0,
+ outstandingAmount: total,
+ status: 'unpaid',
+ }));
+ const items = [...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || [])];
+ for (const item of items) await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
- await manager
- .createQueryBuilder()
+ await manager.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
@@ -242,10 +200,31 @@ export class BillsService {
}
return generated;
});
-
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
}
+ private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) {
+ const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
+ const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
+ ? occupancy.billingEndDate
+ : periodEnd;
+ if (activeEnd < activeStart || monthlyRate <= 0) return 0;
+ const [startYear, startMonth] = activeStart.split('-').map(Number);
+ const [endYear, endMonth] = activeEnd.split('-').map(Number);
+ let total = 0;
+ for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) {
+ const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
+ const prefix = `${year}-${String(month).padStart(2, '0')}-`;
+ const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`;
+ const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`;
+ const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd;
+ const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1;
+ total += monthlyRate * days / daysInMonth;
+ if (++month > 12) { month = 1; year++; }
+ }
+ return Number(total.toFixed(2));
+ }
+
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
@@ -275,6 +254,7 @@ export class BillsService {
recordedBy?: number,
) {
return this.dataSource.transaction(async (manager) => {
+ expense = await manager.save(manager.create(PersonalExpense, expense));
let bill = await manager.save(
manager.create(Bill, {
studentId: expense.studentId,
@@ -292,6 +272,7 @@ export class BillsService {
await manager.save(
manager.create(BillItem, {
billId: bill.id,
+ personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
@@ -304,7 +285,7 @@ export class BillsService {
expense.billId = bill.id;
await manager.save(expense);
bill = await this.walletsService.debitBill(manager, bill, recordedBy);
- return bill;
+ return { expense, bill };
});
}
@@ -382,13 +363,19 @@ export class BillsService {
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
const reason = dto.reason?.trim();
if (!reason) throw new BadRequestException('取消原因不能为空');
- return this.dataSource.transaction(async (manager) => {
- const bill = await manager.findOne(Bill, { where: { id } });
+ const work = () => this.dataSource.transaction(async (manager) => {
+ const bill = await manager.createQueryBuilder(Bill, 'bill')
+ .where('bill.id = :id', { id })
+ .setLock('pessimistic_write')
+ .getOne();
if (!bill) throw new NotFoundException('账单不存在');
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
await manager.update(PersonalExpense, { billId: id }, { billId: null });
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
});
+ return this.financialOperations
+ ? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work)
+ : work();
}
async remove(id: number) {
diff --git a/apps/server/src/bills/dto/bill.dto.ts b/apps/server/src/bills/dto/bill.dto.ts
index c4355c1..7d3d67d 100644
--- a/apps/server/src/bills/dto/bill.dto.ts
+++ b/apps/server/src/bills/dto/bill.dto.ts
@@ -1,6 +1,11 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class GenerateBillsDto {
+ @IsOptional()
+ @IsString()
+ @Matches(/^[\w-]{8,64}$/)
+ operationId?: string;
+
@IsString()
@Matches(/^\d{4}-\d{2}$/)
billingMonth: string;
@@ -20,6 +25,11 @@ export class UpdateBillStatusDto {
}
export class CancelBillDto {
+ @IsOptional()
+ @IsString()
+ @Matches(/^[\w-]{8,64}$/)
+ operationId?: string;
+
@IsString()
@IsNotEmpty()
@Matches(/\S/)
diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts
index d181a09..9a3c6e4 100644
--- a/apps/server/src/database/database-migrations.service.ts
+++ b/apps/server/src/database/database-migrations.service.ts
@@ -117,6 +117,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
description VARCHAR(300), recorded_by INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
+ await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations (
+ id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL,
+ status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500),
+ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
+ )`);
+ const walletTransactions = await runner.getTable('wallet_transactions');
+ if (walletTransactions) {
+ const columns = new Set(walletTransactions.columns.map((column) => column.name));
+ if (!columns.has('operation_id')) {
+ await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)');
+ }
+ }
+ const billItems = await runner.getTable('bill_items');
+ if (billItems) {
+ const columns = new Set(billItems.columns.map((column) => column.name));
+ for (const [name, definition] of [
+ ['room_expense_id', 'INTEGER'],
+ ['personal_expense_id', 'INTEGER'],
+ ]) {
+ if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`);
+ }
+ }
+ const roomExpenses = await runner.getTable('room_expenses');
+ if (roomExpenses) {
+ const columns = new Set(roomExpenses.columns.map((column) => column.name));
+ if (!columns.has('import_key')) {
+ await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)');
+ }
+ const refreshedRoomExpenses = await runner.getTable('room_expenses');
+ const hasImportKey = refreshedRoomExpenses?.indices.some((index) =>
+ index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key');
+ if (!hasImportKey) {
+ await runner.query(isMySQL
+ ? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)'
+ : 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)');
+ }
+ }
const bills = await runner.getTable('bills');
if (bills) {
const columns = new Set(bills.columns.map((column) => column.name));
diff --git a/apps/server/src/entities/bill-item.entity.ts b/apps/server/src/entities/bill-item.entity.ts
index 2e92d60..0bb2b9d 100644
--- a/apps/server/src/entities/bill-item.entity.ts
+++ b/apps/server/src/entities/bill-item.entity.ts
@@ -9,6 +9,12 @@ export class BillItem {
@Column({ name: 'bill_id' })
billId: number;
+ @Column({ name: 'room_expense_id', type: 'integer', nullable: true })
+ roomExpenseId: number | null;
+
+ @Column({ name: 'personal_expense_id', type: 'integer', nullable: true })
+ personalExpenseId: number | null;
+
@Column({ name: 'room_id', nullable: true })
roomId: number;
diff --git a/apps/server/src/entities/financial-operation.entity.ts b/apps/server/src/entities/financial-operation.entity.ts
new file mode 100644
index 0000000..ba3d8b5
--- /dev/null
+++ b/apps/server/src/entities/financial-operation.entity.ts
@@ -0,0 +1,31 @@
+import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
+
+export type FinancialOperationStatus = 'running' | 'completed' | 'failed';
+
+@Entity('financial_operations')
+@Index(['operationId'], { unique: true })
+export class FinancialOperation {
+ @PrimaryGeneratedColumn()
+ id: number;
+
+ @Column({ name: 'operation_id', type: 'varchar', length: 64, unique: true })
+ operationId: string;
+
+ @Column({ type: 'varchar', length: 64 })
+ type: string;
+
+ @Column({ type: 'varchar', length: 20, default: 'running' })
+ status: FinancialOperationStatus;
+
+ @Column({ name: 'result_json', type: 'text', nullable: true })
+ resultJson: string | null;
+
+ @Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true })
+ errorMessage: string | null;
+
+ @CreateDateColumn({ name: 'created_at' })
+ createdAt: Date;
+
+ @UpdateDateColumn({ name: 'updated_at' })
+ updatedAt: Date;
+}
diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts
index 8d21c6a..b0e6d31 100644
--- a/apps/server/src/entities/index.ts
+++ b/apps/server/src/entities/index.ts
@@ -39,3 +39,4 @@ export { AiConfig } from '../ai-config/ai-config.entity';
export * from './student-wallet.entity';
export * from './wallet-transaction.entity';
+export * from './financial-operation.entity';
diff --git a/apps/server/src/entities/room-expense.entity.ts b/apps/server/src/entities/room-expense.entity.ts
index 2d41b16..4870466 100644
--- a/apps/server/src/entities/room-expense.entity.ts
+++ b/apps/server/src/entities/room-expense.entity.ts
@@ -5,10 +5,12 @@ import {
CreateDateColumn,
ManyToOne,
JoinColumn,
+ Index,
} from 'typeorm';
import { Room } from './room.entity';
@Entity('room_expenses')
+@Index(['importKey'], { unique: true })
export class RoomExpense {
@PrimaryGeneratedColumn()
id: number;
@@ -34,6 +36,9 @@ export class RoomExpense {
@Column({ name: 'recorded_by', nullable: true })
recordedBy: number;
+ @Column({ name: 'import_key', type: 'varchar', length: 120, nullable: true, unique: true })
+ importKey: string | null;
+
@Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived';
diff --git a/apps/server/src/entities/wallet-transaction.entity.ts b/apps/server/src/entities/wallet-transaction.entity.ts
index ab81c9d..067448c 100644
--- a/apps/server/src/entities/wallet-transaction.entity.ts
+++ b/apps/server/src/entities/wallet-transaction.entity.ts
@@ -12,6 +12,9 @@ export class WalletTransaction {
@Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null;
+ @Column({ name: 'operation_id', type: 'varchar', length: 64, nullable: true })
+ operationId: string | null;
+
@Column({ type: 'varchar', length: 30 })
type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund';
diff --git a/apps/server/src/expenses/expenses.service.ts b/apps/server/src/expenses/expenses.service.ts
index f4a8e10..d818e33 100644
--- a/apps/server/src/expenses/expenses.service.ts
+++ b/apps/server/src/expenses/expenses.service.ts
@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
-import { Repository, In } from 'typeorm';
+import { DataSource, In, Repository } from 'typeorm';
import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Room } from '../entities/room.entity';
@@ -23,6 +23,7 @@ export class ExpensesService {
@InjectRepository(Room) private roomRepo: Repository,
@InjectRepository(Student) private studentRepo: Repository,
private billsService: BillsService,
+ private dataSource: DataSource,
) {}
async getFormLookups() {
@@ -87,6 +88,8 @@ export class ExpensesService {
async deleteRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
+ const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
+ if (billed) throw new BadRequestException('已计入账单的宿舍费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.roomExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
@@ -96,6 +99,8 @@ export class ExpensesService {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
+ const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: In(uniqueIds) } });
+ if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用');
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const result = await this.roomExpRepo
.createQueryBuilder()
@@ -108,6 +113,8 @@ export class ExpensesService {
async updateRoomExpense(id: number, dto: Partial) {
const e = await this.roomExpRepo.findOne({ where: { id } });
+ const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
+ if (billed) throw new BadRequestException('已计入账单的宿舍费用不能修改,请先取消账单');
if (!e) throw new NotFoundException('费用记录不存在');
const periodStart = dto.periodStart ?? e.periodStart;
const periodEnd = dto.periodEnd ?? e.periodEnd;
@@ -145,24 +152,16 @@ export class ExpensesService {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
- const expense = await this.personalExpRepo.save(
- this.personalExpRepo.create({
- studentId: dto.studentId,
- expenseType: dto.expenseType,
- amount: dto.amount,
- expenseDate: dto.periodEnd,
- description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
- recordedBy: userId,
- billId: null,
- }),
- );
- try {
- const bill = await this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
- return { expense, bill };
- } catch (error) {
- await this.personalExpRepo.delete(expense.id);
- throw error;
- }
+ const expense = {
+ studentId: dto.studentId,
+ expenseType: dto.expenseType,
+ amount: dto.amount,
+ expenseDate: dto.periodEnd,
+ description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
+ recordedBy: userId,
+ billId: null,
+ } as PersonalExpense;
+ return this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
}
// 个人附加费
@@ -303,45 +302,50 @@ export class ExpensesService {
continue;
}
- // 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
- await this.roomExpRepo
- .createQueryBuilder()
- .delete()
- .where('roomId = :roomId', { roomId: room.id })
- .andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
- .andWhere('expenseType IN (:...types)', { types: ['water', 'electricity'] })
- .execute();
+ const existing = await this.roomExpRepo.find({
+ where: [
+ { importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` },
+ { importKey: `${room.id}:${periodStart}:${periodEnd}:water` },
+ ],
+ });
+ const byType = new Map(existing.map((expense) => [expense.expenseType, expense]));
let savedAny = false;
// 导入电费
if (row.electricityFee > 0) {
- await this.roomExpRepo.save(
- this.roomExpRepo.create({
- roomId: room.id,
- expenseType: 'electricity',
- amount: row.electricityFee,
- periodStart,
- periodEnd,
- description: `电量${row.electricityAmount}kWh`,
- recordedBy: userId,
- }),
- );
+ const expense = byType.get('electricity') || this.roomExpRepo.create({
+ roomId: room.id,
+ expenseType: 'electricity',
+ periodStart,
+ periodEnd,
+ importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`,
+ });
+ if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
+ throw new BadRequestException('该周期电费已计入账单,不能覆盖');
+ }
+ expense.amount = row.electricityFee;
+ expense.description = `电量${row.electricityAmount}kWh`;
+ expense.recordedBy = userId!;
+ await this.roomExpRepo.save(expense);
savedAny = true;
}
// 导入水费
if (row.waterFee > 0) {
- await this.roomExpRepo.save(
- this.roomExpRepo.create({
- roomId: room.id,
- expenseType: 'water',
- amount: row.waterFee,
- periodStart,
- periodEnd,
- description: `用水${row.waterAmount}吨`,
- recordedBy: userId,
- }),
- );
+ const expense = byType.get('water') || this.roomExpRepo.create({
+ roomId: room.id,
+ expenseType: 'water',
+ periodStart,
+ periodEnd,
+ importKey: `${room.id}:${periodStart}:${periodEnd}:water`,
+ });
+ if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
+ throw new BadRequestException('该周期水费已计入账单,不能覆盖');
+ }
+ expense.amount = row.waterFee;
+ expense.description = `用水${row.waterAmount}吨`;
+ expense.recordedBy = userId!;
+ await this.roomExpRepo.save(expense);
savedAny = true;
}
diff --git a/apps/server/src/financial-operations/financial-operations.module.ts b/apps/server/src/financial-operations/financial-operations.module.ts
new file mode 100644
index 0000000..35e6522
--- /dev/null
+++ b/apps/server/src/financial-operations/financial-operations.module.ts
@@ -0,0 +1,12 @@
+import { Global, Module } from '@nestjs/common';
+import { TypeOrmModule } from '@nestjs/typeorm';
+import { FinancialOperation } from '../entities/financial-operation.entity';
+import { FinancialOperationsService } from './financial-operations.service';
+
+@Global()
+@Module({
+ imports: [TypeOrmModule.forFeature([FinancialOperation])],
+ providers: [FinancialOperationsService],
+ exports: [FinancialOperationsService],
+})
+export class FinancialOperationsModule {}
diff --git a/apps/server/src/financial-operations/financial-operations.service.ts b/apps/server/src/financial-operations/financial-operations.service.ts
new file mode 100644
index 0000000..99c5964
--- /dev/null
+++ b/apps/server/src/financial-operations/financial-operations.service.ts
@@ -0,0 +1,55 @@
+import { ConflictException, Injectable } from '@nestjs/common';
+import { InjectRepository } from '@nestjs/typeorm';
+import { Repository } from 'typeorm';
+import { FinancialOperation } from '../entities/financial-operation.entity';
+
+@Injectable()
+export class FinancialOperationsService {
+ constructor(
+ @InjectRepository(FinancialOperation)
+ private readonly repo: Repository,
+ ) {}
+
+ async run(operationId: string | undefined, type: string, work: () => Promise): Promise {
+ if (!operationId) return work();
+ if (!/^[\w-]{8,64}$/.test(operationId)) throw new ConflictException('operationId 格式无效');
+
+ const existing = await this.repo.findOne({ where: { operationId } });
+ if (existing) {
+ if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作');
+ if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T;
+ if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交');
+ }
+
+ let operation = existing;
+ if (!operation) {
+ try {
+ operation = await this.repo.save(this.repo.create({ operationId, type, status: 'running' }));
+ } catch (error) {
+ const concurrent = await this.repo.findOne({ where: { operationId } });
+ if (concurrent?.status === 'completed' && concurrent.resultJson) {
+ return JSON.parse(concurrent.resultJson) as T;
+ }
+ throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error });
+ }
+ } else {
+ operation.status = 'running';
+ operation.errorMessage = null;
+ operation.resultJson = null;
+ await this.repo.save(operation);
+ }
+
+ try {
+ const result = await work();
+ operation.status = 'completed';
+ operation.resultJson = JSON.stringify(result);
+ await this.repo.save(operation);
+ return result;
+ } catch (error) {
+ operation.status = 'failed';
+ operation.errorMessage = error instanceof Error ? error.message.slice(0, 500) : '未知错误';
+ await this.repo.save(operation);
+ throw error;
+ }
+ }
+}
diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts
index 9c95640..cb6c339 100644
--- a/apps/server/src/occupancies/occupancies.service.ts
+++ b/apps/server/src/occupancies/occupancies.service.ts
@@ -49,125 +49,106 @@ export class OccupanciesService {
async checkIn(dto: CheckInDto, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
+ return this.dataSource.transaction(async (manager) => {
+ const existing = await manager.createQueryBuilder(Occupancy, 'occupancy')
+ .where('occupancy.studentId = :studentId', { studentId: dto.studentId })
+ .andWhere('occupancy.checkOutDate IS NULL')
+ .setLock('pessimistic_write')
+ .getOne();
+ if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿');
- // 检查学生是否已有活跃入住
- const existing = await this.repo.findOne({
- where: { studentId: dto.studentId, checkOutDate: IsNull() },
- });
- if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿');
+ const room = await manager.createQueryBuilder(Room, 'room')
+ .where('room.id = :roomId', { roomId: dto.roomId })
+ .setLock('pessimistic_write')
+ .getOne();
+ if (!room) throw new NotFoundException('宿舍不存在');
+ if (room.status === 'archived' || room.status === 'maintenance') {
+ throw new BadRequestException('该宿舍当前不可入住');
+ }
+ const count = await manager.count(Occupancy, { where: { roomId: dto.roomId, checkOutDate: IsNull() } });
+ if (count >= room.capacity) throw new BadRequestException('宿舍已满');
+ const student = await manager.findOne(Student, { where: { id: dto.studentId } });
+ if (!student) throw new NotFoundException('学生不存在');
- // 检查宿舍容量
- const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
- if (!room) throw new NotFoundException('宿舍不存在');
- if (room.status === 'archived' || room.status === 'maintenance') {
- throw new BadRequestException('该宿舍当前不可入住');
- }
- const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
- if (count >= room.capacity) throw new BadRequestException('宿舍已满');
+ if (dto.bedId) {
+ const bed = await manager.createQueryBuilder(Bed, 'bed')
+ .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.bedId, roomId: dto.roomId })
+ .setLock('pessimistic_write')
+ .getOne();
+ if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍');
+ if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中');
+ }
+ if (dto.lockerId) {
+ const locker = await manager.createQueryBuilder(Locker, 'locker')
+ .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.lockerId, roomId: dto.roomId })
+ .setLock('pessimistic_write')
+ .getOne();
+ if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
+ if (locker.status !== 'available') throw new BadRequestException('柜子已被占用或维修中');
+ }
- const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
- if (!student) throw new NotFoundException('学生不存在');
+ const saved = await manager.save(manager.create(Occupancy, {
+ studentId: dto.studentId,
+ roomId: dto.roomId,
+ checkInDate: dto.checkInDate,
+ billingStartDate: dto.billingStartDate || dto.checkInDate,
+ stayType: dto.stayType,
+ responsibleOrganizationId: student.organizationId,
+ notes: dto.notes,
+ bedId: dto.bedId,
+ lockerId: dto.lockerId,
+ }));
+ if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' });
+ if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' });
+ if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' });
- // 床位校验
- if (dto.bedId) {
- const bed = await this.bedRepo.findOne({ where: { id: dto.bedId, roomId: dto.roomId } });
- if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍');
- if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中');
- }
-
- // 柜子校验
- if (dto.lockerId) {
- const locker = await this.lockerRepo.findOne({
- where: { id: dto.lockerId, roomId: dto.roomId },
- });
- if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
- if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中');
- }
-
- const occ = this.repo.create({
- studentId: dto.studentId,
- roomId: dto.roomId,
- checkInDate: dto.checkInDate,
- billingStartDate: dto.billingStartDate || dto.checkInDate,
- stayType: dto.stayType,
- responsibleOrganizationId: student.organizationId,
- notes: dto.notes,
- bedId: dto.bedId,
- lockerId: dto.lockerId,
- });
- const saved = await this.repo.save(occ);
-
- // 更新床位/柜子状态
- if (dto.bedId) {
- await this.bedRepo.update(dto.bedId, { status: 'occupied' });
- }
- if (dto.lockerId) {
- await this.lockerRepo.update(dto.lockerId, { status: 'occupied' });
- }
-
- // 更新宿舍状态
- if (count + 1 >= room.capacity) {
- await this.roomRepo.update(room.id, { status: 'full' });
- }
-
- if (dto.collectDeposit) {
- const existingDeposit = await this.depositRepo.findOne({
- where: { studentId: dto.studentId },
- });
- if (existingDeposit) {
- existingDeposit.amount = Number(
- (Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2),
- );
- existingDeposit.status = 'paid';
- existingDeposit.paidDate = dto.checkInDate;
- existingDeposit.recordedBy = userId ?? null;
- existingDeposit.notes = '入住登记自动收取';
- await this.depositRepo.save(existingDeposit);
- } else {
- await this.depositRepo.save(
- this.depositRepo.create({
+ if (dto.collectDeposit) {
+ let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } });
+ if (deposit) {
+ deposit.amount = Number((Number(deposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2));
+ deposit.status = 'paid';
+ deposit.paidDate = dto.checkInDate;
+ deposit.recordedBy = userId ?? null;
+ deposit.notes = '入住登记自动收取';
+ } else {
+ deposit = manager.create(Deposit, {
studentId: dto.studentId,
amount: dto.depositAmount ?? 500,
paidDate: dto.checkInDate,
status: 'paid',
recordedBy: userId,
notes: '入住登记自动收取',
- }),
- );
+ });
+ }
+ await manager.save(deposit);
}
- }
-
- return saved;
+ return saved;
+ });
}
async checkOut(occupancyId: number, dto: CheckOutDto) {
- const occ = await this.repo.findOne({ where: { id: occupancyId } });
- if (!occ) throw new NotFoundException('入住记录不存在');
- if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
- this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
- this.assertDateOrder(
- occ.billingStartDate || occ.checkInDate,
- dto.billingEndDate || dto.checkOutDate,
- '计费截止日不能早于计费起始日',
- );
-
- occ.checkOutDate = dto.checkOutDate;
- occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
- occ.checkOutReason = dto.checkOutReason || '';
- await this.repo.save(occ);
-
- // 释放床位/柜子
- if (occ.bedId) {
- await this.bedRepo.update(occ.bedId, { status: 'available' });
- }
- if (occ.lockerId) {
- await this.lockerRepo.update(occ.lockerId, { status: 'available' });
- }
-
- // 更新宿舍状态
- await this.roomRepo.update(occ.roomId, { status: 'available' });
-
- return occ;
+ return this.dataSource.transaction(async (manager) => {
+ const occ = await manager.createQueryBuilder(Occupancy, 'occupancy')
+ .where('occupancy.id = :id', { id: occupancyId })
+ .setLock('pessimistic_write')
+ .getOne();
+ if (!occ) throw new NotFoundException('入住记录不存在');
+ if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
+ this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
+ this.assertDateOrder(
+ occ.billingStartDate || occ.checkInDate,
+ dto.billingEndDate || dto.checkOutDate,
+ '计费截止日不能早于计费起始日',
+ );
+ occ.checkOutDate = dto.checkOutDate;
+ occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
+ occ.checkOutReason = dto.checkOutReason || '';
+ await manager.save(occ);
+ if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });
+ if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });
+ await manager.update(Room, occ.roomId, { status: 'available' });
+ return occ;
+ });
}
async transferRoom(occupancyId: number, dto: TransferRoomDto) {
@@ -175,7 +156,10 @@ export class OccupanciesService {
await runner.connect();
await runner.startTransaction();
try {
- const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } });
+ const oldOcc = await runner.manager.createQueryBuilder(Occupancy, 'occupancy')
+ .where('occupancy.id = :id', { id: occupancyId })
+ .setLock('pessimistic_write')
+ .getOne();
if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId)
@@ -201,7 +185,10 @@ export class OccupanciesService {
}
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 检查新房容量
- const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
+ const newRoom = await runner.manager.createQueryBuilder(Room, 'room')
+ .where('room.id = :roomId', { roomId: dto.newRoomId })
+ .setLock('pessimistic_write')
+ .getOne();
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住');
@@ -213,16 +200,18 @@ export class OccupanciesService {
// 新床位校验
if (dto.newBedId) {
- const newBed = await runner.manager.findOne(Bed, {
- where: { id: dto.newBedId, roomId: dto.newRoomId },
- });
+ const newBed = await runner.manager.createQueryBuilder(Bed, 'bed')
+ .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.newBedId, roomId: dto.newRoomId })
+ .setLock('pessimistic_write')
+ .getOne();
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
- const newLocker = await runner.manager.findOne(Locker, {
- where: { id: dto.newLockerId, roomId: dto.newRoomId },
- });
+ const newLocker = await runner.manager.createQueryBuilder(Locker, 'locker')
+ .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.newLockerId, roomId: dto.newRoomId })
+ .setLock('pessimistic_write')
+ .getOne();
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
}
diff --git a/apps/server/src/wallets/dto/wallet.dto.ts b/apps/server/src/wallets/dto/wallet.dto.ts
index f15557c..97a1973 100644
--- a/apps/server/src/wallets/dto/wallet.dto.ts
+++ b/apps/server/src/wallets/dto/wallet.dto.ts
@@ -1,7 +1,12 @@
import { Type } from 'class-transformer';
-import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator';
+import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, MaxLength, NotEquals } from 'class-validator';
export class ChangeWalletBalanceDto {
+ @IsOptional()
+ @IsString()
+ @Matches(/^[\w-]{8,64}$/)
+ operationId?: string;
+
@IsInt()
studentId: number;
@@ -20,6 +25,11 @@ export class ChangeWalletBalanceDto {
export class BatchChangeWalletBalanceDto {
+ @IsOptional()
+ @IsString()
+ @Matches(/^[\w-]{8,64}$/)
+ operationId?: string;
+
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
diff --git a/apps/server/src/wallets/wallets.service.ts b/apps/server/src/wallets/wallets.service.ts
index 489c230..1523fb8 100644
--- a/apps/server/src/wallets/wallets.service.ts
+++ b/apps/server/src/wallets/wallets.service.ts
@@ -7,6 +7,7 @@ import { StudentWallet } from '../entities/student-wallet.entity';
import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { In } from 'typeorm';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
+import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -17,6 +18,7 @@ export class WalletsService {
@InjectRepository(WalletTransaction) private transactionRepo: Repository,
@InjectRepository(Student) private studentRepo: Repository,
private dataSource: DataSource,
+ private financialOperations?: FinancialOperationsService,
) {}
async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
@@ -61,6 +63,18 @@ export class WalletsService {
}
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
+ const { operationId, ...change } = dto;
+ return this.financialOperations
+ ? this.financialOperations.run(operationId, 'wallet.change_balance', () => this.changeBalanceOnce(change, recordedBy, operationId))
+ : this.changeBalanceOnce(change, recordedBy, operationId);
+ }
+
+ private async changeBalanceOnce(
+ dto: Omit,
+ recordedBy?: number,
+ operationId?: string,
+ transactionManager?: EntityManager,
+ ) {
const amount = money(dto.amount);
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
throw new BadRequestException('调账金额最多保留两位小数');
@@ -69,8 +83,8 @@ export class WalletsService {
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
- return this.dataSource.transaction(async (manager) => {
- const wallet = await this.getOrCreateWallet(manager, dto.studentId);
+ const work = async (manager: EntityManager) => {
+ const wallet = await this.getOrCreateWallet(manager, dto.studentId, true);
const nextBalance = money(Number(wallet.balance) + amount);
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
wallet.balance = nextBalance;
@@ -79,6 +93,7 @@ export class WalletsService {
manager.create(WalletTransaction, {
studentId: dto.studentId,
billId: null,
+ operationId: operationId ?? null,
type: dto.type,
amount,
balanceAfter: nextBalance,
@@ -89,21 +104,28 @@ export class WalletsService {
const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
return { wallet: finalWallet, payments };
- });
+ };
+ return transactionManager ? work(transactionManager) : this.dataSource.transaction(work);
}
async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) {
- const uniqueStudentIds = Array.from(new Set(dto.studentIds));
- const results: Awaited>[] = [];
- for (const studentId of uniqueStudentIds) {
- results.push(await this.changeBalance({
- studentId,
- amount: dto.amount,
- type: dto.type,
- description: dto.description,
- }, recordedBy));
- }
- return { count: uniqueStudentIds.length, results };
+ const { operationId, ...batch } = dto;
+ const work = () => this.dataSource.transaction(async (manager) => {
+ const uniqueStudentIds = Array.from(new Set(batch.studentIds));
+ const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = [];
+ for (const studentId of uniqueStudentIds) {
+ results.push(await this.changeBalanceOnce({
+ studentId,
+ amount: batch.amount,
+ type: batch.type,
+ description: batch.description,
+ }, recordedBy, operationId ? `${operationId}:${studentId}` : undefined, manager));
+ }
+ return { count: uniqueStudentIds.length, results };
+ });
+ return this.financialOperations
+ ? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work)
+ : work();
}
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
@@ -194,9 +216,27 @@ export class WalletsService {
return settled;
}
- private async getOrCreateWallet(manager: EntityManager, studentId: number) {
- let wallet = await manager.findOne(StudentWallet, { where: { studentId } });
- if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
+ private async getOrCreateWallet(manager: EntityManager, studentId: number, lock = false) {
+ const find = async () => {
+ if (!lock || !manager.createQueryBuilder) {
+ return manager.findOne(StudentWallet, { where: { studentId } });
+ }
+ return manager.createQueryBuilder(StudentWallet, 'wallet')
+ .where('wallet.studentId = :studentId', { studentId })
+ .setLock('pessimistic_write')
+ .getOne();
+ };
+ let wallet = await find();
+ if (!wallet) {
+ try {
+ if (manager.insert) await manager.insert(StudentWallet, { studentId, balance: 0 });
+ else wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
+ } catch {
+ // A concurrent request may have inserted the one wallet row.
+ }
+ wallet ||= await find();
+ }
+ if (!wallet) throw new NotFoundException('学生钱包创建失败');
return wallet;
}
}