fix: merge financial safety and attendance workspace #22
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="attendance-summary-strip">
|
||||
<div className="attendance-rate">
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={rate}
|
||||
size={64}
|
||||
strokeWidth={9}
|
||||
strokeColor="#1677ff"
|
||||
trailColor="#e8eef7"
|
||||
/>
|
||||
<div>
|
||||
<span>出勤率</span>
|
||||
<strong>{summary.total} 条记录</strong>
|
||||
</div>
|
||||
</div>
|
||||
{[
|
||||
['present', summary.present],
|
||||
['late', summary.late],
|
||||
['absent', summary.absent],
|
||||
['leave', summary.leave],
|
||||
].map(([status, value]) => {
|
||||
const meta = STATUS_META[String(status)];
|
||||
return (
|
||||
<div className="attendance-summary-cell" key={String(status)}>
|
||||
<span className={`attendance-summary-icon ${meta.className}`}>{meta.short}</span>
|
||||
<div>
|
||||
<strong>{value}</strong>
|
||||
<span>{meta.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<LessonAttendanceFilter>('all');
|
||||
const [selectedClassId, setSelectedClassId] = useState<number | 'all'>('all');
|
||||
const [phaseFilter, setPhaseFilter] = useState<SchedulePhase | 'all'>('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 (
|
||||
<div className="attendance-page teacher-attendance">
|
||||
<section className="teacher-topbar" aria-label="任课老师课堂考勤导航">
|
||||
<Space size={10} wrap>
|
||||
<Button icon={<CalendarOutlined />}>{dayjs().format('YYYY-MM-DD')}</Button>
|
||||
<Select<number | 'all'>
|
||||
value={selectedClassId}
|
||||
onChange={setSelectedClassId}
|
||||
options={classFilterOptions}
|
||||
className="teacher-topbar-select"
|
||||
/>
|
||||
<Button type="primary">课堂考勤</Button>
|
||||
<Button onClick={() => setPhaseFilter('all')}>今日课程</Button>
|
||||
<Button onClick={() => setPhaseFilter('upcoming')}>我的课表</Button>
|
||||
<Button onClick={() => setPhaseFilter('ended')}>考勤统计</Button>
|
||||
</Space>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||||
刷新课程
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<section className="attendance-hero attendance-hero--teacher">
|
||||
<div>
|
||||
<span className="attendance-eyebrow">TEACHING DAY · {dayjs().format('MM月DD日 dddd')}</span>
|
||||
<h1>今天,从课程开始</h1>
|
||||
<p>课程开始后可查看最新打卡结果;课程截止时系统自动拉取并结算缺勤。</p>
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE · {dayjs().format('MM月DD日 dddd')}</span>
|
||||
<h1>任课老师课堂考勤</h1>
|
||||
<p>围绕“我的课程、本节课、教学班级”查看学生签到,课程开始后可拉取最新结果并处理未签到学生。</p>
|
||||
</div>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadWorkspace()}>
|
||||
刷新
|
||||
<Button type="primary" ghost icon={<FileSearchOutlined />} onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)} disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'}>
|
||||
查看本节考勤
|
||||
</Button>
|
||||
</section>
|
||||
|
||||
<Row gutter={[16, 16]} className="teacher-overview">
|
||||
<Col xs={24} md={8}>
|
||||
<div className="teacher-kpi"><span>今日课程</span><strong>{schedules.length}</strong><small>节</small></div>
|
||||
<div className="teacher-kpi"><span>今日课程</span><strong>{schedules.length}</strong><small>节待查看</small></div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div className="teacher-kpi"><span>已开始</span><strong>{startedCount}</strong><small>节,可查看考勤</small></div>
|
||||
<div className="teacher-kpi"><span>已开始课程</span><strong>{startedCount}</strong><small>节,可处理课堂考勤</small></div>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<div className="teacher-kpi teacher-kpi--next"><span>下一节</span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
|
||||
<div className="teacher-kpi teacher-kpi--next"><span>当前/下一节</span><strong>{nextSchedule ? nextSchedule.startTime : '—'}</strong><small>{nextSchedule?.subject || '今天没有更多课程'}</small></div>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<div className="attendance-section-heading">
|
||||
<div><span>今日教学节奏</span><h2>我的课程</h2></div>
|
||||
<span className="attendance-section-note">课程开始后可拉取钉钉考勤记录</span>
|
||||
</div>
|
||||
<div className="teacher-workspace-layout">
|
||||
<aside className="teacher-filter-rail">
|
||||
<div className="teacher-filter-title"><FilterOutlined /> 左侧筛选</div>
|
||||
<Button block type={phaseFilter === 'all' && selectedClassId === 'all' ? 'primary' : 'default'} onClick={() => { setPhaseFilter('all'); setSelectedClassId('all'); }}>今日课程</Button>
|
||||
<Button block type={phaseFilter === 'ongoing' ? 'primary' : 'default'} onClick={() => setPhaseFilter('ongoing')}>进行中</Button>
|
||||
<Button block type={phaseFilter === 'ended' ? 'primary' : 'default'} onClick={() => setPhaseFilter('ended')}>已结束</Button>
|
||||
<Button block type={phaseFilter === 'upcoming' ? 'primary' : 'default'} onClick={() => setPhaseFilter('upcoming')}>待上课</Button>
|
||||
<Select<number | 'all'>
|
||||
value={selectedClassId}
|
||||
onChange={setSelectedClassId}
|
||||
options={classFilterOptions}
|
||||
className="teacher-filter-select"
|
||||
/>
|
||||
<div className="teacher-filter-hint">按教学班级、课堂状态快速定位需要处理的学生考勤。</div>
|
||||
</aside>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{schedules.length === 0 ? (
|
||||
<Card className="attendance-empty-card">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<div><strong>今天还没有课程</strong><p>请联系教务管理员安排课程。</p></div>}
|
||||
/>
|
||||
<main className="teacher-main-panel">
|
||||
<Card className="current-lesson-card">
|
||||
<div className="current-lesson-copy">
|
||||
<span>主页主功能区</span>
|
||||
<h2>{currentFocusSchedule?.subject || '暂无待处理课程'}</h2>
|
||||
<p>
|
||||
{currentFocusSchedule
|
||||
? `${currentFocusClassName} · ${currentFocusSchedule.startTime}-${currentFocusSchedule.endTime} · 教室 ${currentFocusSchedule.classroomId}`
|
||||
: '今天没有课程,可切换日期查看其他课堂考勤。'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="current-lesson-actions">
|
||||
<Button type="primary" disabled={!currentFocusSchedule || getSchedulePhase(currentFocusSchedule.startTime, currentFocusSchedule.endTime, now) === 'upcoming'} onClick={() => currentFocusSchedule && void openAttendance(currentFocusSchedule)}>
|
||||
查看本节考勤 / 发起签到
|
||||
</Button>
|
||||
<Button icon={<ExportOutlined />} disabled>导出本节课记录</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="lesson-timeline">
|
||||
{schedules.map((schedule, index) => {
|
||||
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
|
||||
return (
|
||||
<LessonCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
phase={phase}
|
||||
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
|
||||
index={index + 1}
|
||||
onOpen={() => void openAttendance(schedule)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<div className="attendance-section-heading">
|
||||
<div><span>课堂考勤列表</span><h2>我的课程</h2></div>
|
||||
<span className="attendance-section-note">课程开始后可查看本节课学生签到和异常</span>
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
<Spin spinning={loading}>
|
||||
{schedules.length === 0 ? (
|
||||
<Card className="attendance-empty-card">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<div><strong>今天还没有课程</strong><p>请联系教务管理员安排课程。</p></div>}
|
||||
/>
|
||||
</Card>
|
||||
) : filteredSchedules.length === 0 ? (
|
||||
<Card className="attendance-empty-card">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={<div><strong>没有符合筛选条件的课程</strong><p>请调整左侧教学班级或课程状态。</p></div>}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="lesson-timeline">
|
||||
{filteredSchedules.map((schedule, index) => {
|
||||
const phase = getSchedulePhase(schedule.startTime, schedule.endTime, now);
|
||||
return (
|
||||
<LessonCard
|
||||
key={schedule.id}
|
||||
schedule={schedule}
|
||||
phase={phase}
|
||||
className={classNameById.get(schedule.classId) || `班级 ${schedule.classId}`}
|
||||
index={index + 1}
|
||||
onOpen={() => void openAttendance(schedule)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} width={960} title={null} className="attendance-drawer">
|
||||
<div className="lesson-record-header">
|
||||
<span className="attendance-eyebrow">LESSON ATTENDANCE</span>
|
||||
<h2>{selectedSchedule?.subject || '课程考勤'}</h2>
|
||||
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
||||
<h2>{selectedSchedule?.subject || '本节课考勤'}</h2>
|
||||
<p>{selectedSchedule ? classNameById.get(selectedSchedule.classId) : ''} · 本节课 {selectedSchedule?.startTime}–{selectedSchedule?.endTime} · {dayjs().format('YYYY-MM-DD')}</p>
|
||||
</div>
|
||||
{lessonSession && (
|
||||
<Alert
|
||||
type={isAttendanceCompleted ? 'success' : 'info'}
|
||||
showIcon
|
||||
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前打卡结果;课程截止后将自动做最终结算'}
|
||||
title={isAttendanceCompleted ? '本节课考勤已结算' : '当前为本节课实时签到结果;课程截止后将自动做最终结算'}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
@@ -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: <Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={lessonRecords.length === 0 ? '本节课尚未开始点名' : '没有符合条件的学生'}
|
||||
description={lessonRecords.length === 0 ? '本节课尚未产生签到记录' : '没有符合条件的学生'}
|
||||
/>,
|
||||
}}
|
||||
columns={[
|
||||
@@ -456,7 +505,7 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
render: (name: string) => <div className="student-cell"><Avatar size={32}>{name?.slice(0, 1)}</Avatar><strong>{name || '-'}</strong></div>,
|
||||
},
|
||||
{
|
||||
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')}
|
||||
>
|
||||
已打卡
|
||||
标记已到
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
@@ -474,15 +523,15 @@ const TeacherAttendanceWorkspace: React.FC = () => {
|
||||
danger={!checkedIn}
|
||||
onClick={() => void updateLessonRecord(record, 'absent')}
|
||||
>
|
||||
未打卡
|
||||
标记未签
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '当前状态', dataIndex: 'status', width: 105, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||
{ title: '当前状态', dataIndex: 'status', width: 118, render: (value: string) => <AttendanceStatusTag status={value === 'present' || value === 'late' ? 'present' : 'absent'} /> },
|
||||
{
|
||||
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 || <span className="muted-text">—</span> },
|
||||
{ title: '备注/异常处理', dataIndex: 'remark', render: (value: string | null) => value || <span className="muted-text">暂无备注</span> },
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
@@ -530,7 +579,7 @@ const LessonCard: React.FC<{
|
||||
<Tooltip title="课程尚未开始"><Button disabled>等待上课</Button></Tooltip>
|
||||
) : (
|
||||
<Button type="primary" onClick={onOpen}>
|
||||
{phase === 'ongoing' ? '查看当前考勤' : '拉取 / 查看考勤'} <ArrowRightOutlined />
|
||||
{phase === 'ongoing' ? '查看本节考勤' : '拉取 / 查看本节考勤'} <ArrowRightOutlined />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -539,6 +588,71 @@ const LessonCard: React.FC<{
|
||||
};
|
||||
|
||||
|
||||
interface AdminStudentPanel {
|
||||
key: string;
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
records: AttendanceRecordItem[];
|
||||
statusBySession: Partial<Record<string, AttendanceRecordItem>>;
|
||||
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<number, AdminStudentPanel>();
|
||||
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<AttendanceRecordItem[]>([]);
|
||||
const [summary, setSummary] = useState<AttendanceSummary>(EMPTY_SUMMARY);
|
||||
@@ -546,15 +660,15 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
const [classOptions, setClassOptions] = useState<ClassOption[]>([]);
|
||||
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<number>();
|
||||
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<string>();
|
||||
const [session, setSession] = useState<string>();
|
||||
const [metricFilter, setMetricFilter] = useState('all');
|
||||
const [studentSearch, setStudentSearch] = useState('');
|
||||
const [selectedStudent, setSelectedStudent] = useState<AdminStudentPanel | null>(null);
|
||||
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(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<AttendanceRecordItem> = [
|
||||
{
|
||||
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) => <AttendanceStatusTag status={value} />,
|
||||
},
|
||||
{
|
||||
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 || <span className="muted-text">—</span>,
|
||||
},
|
||||
{
|
||||
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 (
|
||||
<div className="attendance-page admin-attendance">
|
||||
<section className="attendance-hero attendance-hero--admin">
|
||||
<div>
|
||||
<span className="attendance-eyebrow">ATTENDANCE ARCHIVE</span>
|
||||
<h1>历史考勤档案</h1>
|
||||
<p>面向管理人员的历史检索、异常追踪与数据归档,不承载实时上课操作。</p>
|
||||
<div className="attendance-page admin-attendance student-attendance-center">
|
||||
<header className="student-center-topbar">
|
||||
<div className="student-center-title">
|
||||
<h1>学生考勤中心</h1>
|
||||
<span>班级考勤总览</span>
|
||||
</div>
|
||||
<div className="student-center-actions">
|
||||
<span className="student-sync-status"><i />数据已更新 {dayjs().format('HH:mm')}</span>
|
||||
<Button icon={<ReloadOutlined />} onClick={() => void loadRecords()}>刷新</Button>
|
||||
<PermissionButton permission="attendance:export" icon={<ExportOutlined />} onClick={handleExport}>
|
||||
导出当前报表
|
||||
</PermissionButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="student-filter-panel" aria-label="考勤筛选">
|
||||
<div className="student-filter-field student-filter-field--date">
|
||||
<label>日期</label>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(value) => {
|
||||
setDateRange(value as [Dayjs, Dayjs] | null);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>班级</label>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部班级"
|
||||
value={classId}
|
||||
onChange={(value) => {
|
||||
setClassId(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>科目</label>
|
||||
<Select disabled placeholder="全部科目" />
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>任课老师</label>
|
||||
<Select disabled placeholder="全部老师" />
|
||||
</div>
|
||||
<div className="student-filter-field">
|
||||
<label>时段</label>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="全部时段"
|
||||
value={session}
|
||||
onChange={(value) => {
|
||||
setSession(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={SESSION_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="student-filter-actions">
|
||||
<Button onClick={resetFilters}>重置</Button>
|
||||
<Button type="primary" icon={<FileSearchOutlined />} onClick={() => void loadRecords()}>
|
||||
查询
|
||||
</Button>
|
||||
</div>
|
||||
<PermissionButton
|
||||
permission="attendance:export"
|
||||
icon={<ExportOutlined />}
|
||||
size="large"
|
||||
onClick={handleExport}
|
||||
>
|
||||
导出当前结果
|
||||
</PermissionButton>
|
||||
</section>
|
||||
|
||||
<SummaryStrip summary={summary} />
|
||||
<section className="student-class-overview" aria-label="班级考勤汇总">
|
||||
<div className="student-class-identity">
|
||||
<div>
|
||||
<h2>{selectedClass}</h2>
|
||||
<span>{dateLabel} · 当前展示 {visibleStudents.length} 名学生 / {total} 条记录</span>
|
||||
</div>
|
||||
<div className="student-teacher-list">
|
||||
<div className="student-teacher-item"><Avatar>班</Avatar><div><span>班主任</span><strong>按班级筛选后查看</strong></div></div>
|
||||
<div className="student-teacher-item"><Avatar>生</Avatar><div><span>生活老师</span><strong>暂未接入</strong></div></div>
|
||||
<div className="student-teacher-item"><Avatar>任</Avatar><div><span>当前任课</span><strong>{session ? SESSION_MAP[session] : '全部时段'}</strong></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="student-metric-strip">
|
||||
{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 (
|
||||
<button
|
||||
type="button"
|
||||
key={metric.key}
|
||||
className={`student-metric-card ${metricFilter === metric.key ? 'active' : ''}`}
|
||||
onClick={() => setMetricFilter(metric.key)}
|
||||
>
|
||||
<span className={`student-metric-icon ${meta.className}`}>{metric.short}</span>
|
||||
<strong>{value}</strong>
|
||||
<small>{metric.label}</small>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{alerts.length > 0 && (
|
||||
<div className="archive-alert">
|
||||
@@ -767,70 +978,76 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="archive-card" bordered={false}>
|
||||
<div className="archive-toolbar">
|
||||
<div className="archive-toolbar__title">
|
||||
<FileSearchOutlined />
|
||||
<div>
|
||||
<strong>档案检索</strong>
|
||||
<span>默认查看最近 30 天</span>
|
||||
</div>
|
||||
<section className="student-workspace">
|
||||
<header className="student-workspace-header">
|
||||
<div>
|
||||
<h3>班级学生考勤</h3>
|
||||
<span>{metricFilter === 'all' ? `显示全部 ${visibleStudents.length} 名学生` : `筛出 ${ADMIN_METRIC_META.find((item) => item.key === metricFilter)?.label || ''}相关 ${visibleStudents.length} 名学生`}</span>
|
||||
</div>
|
||||
<Space wrap size={10}>
|
||||
<Select
|
||||
<div className="student-workspace-tools">
|
||||
<Input.Search
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="全部班级"
|
||||
value={classId}
|
||||
onChange={(value) => {
|
||||
setClassId(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={classOptions.map((item) => ({ value: item.classId, label: item.className }))}
|
||||
style={{ width: 170 }}
|
||||
placeholder="搜索姓名或学号"
|
||||
value={studentSearch}
|
||||
onChange={(event) => setStudentSearch(event.target.value)}
|
||||
/>
|
||||
<RangePicker
|
||||
value={dateRange}
|
||||
onChange={(value) => {
|
||||
setDateRange(value as [Dayjs, Dayjs] | null);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="考勤结果"
|
||||
value={status}
|
||||
onChange={(value) => {
|
||||
setStatus(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={STATUS_OPTIONS}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="课程时段"
|
||||
value={session}
|
||||
onChange={(value) => {
|
||||
setSession(value);
|
||||
setPage(1);
|
||||
}}
|
||||
options={SESSION_OPTIONS}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
<Button icon={<ReloadOutlined />} onClick={resetFilters}>
|
||||
重置
|
||||
</Button>
|
||||
</Space>
|
||||
<Button icon={<ExportOutlined />} onClick={handleExport}>导出</Button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="student-legend">
|
||||
<span><i className="is-present" />正常</span>
|
||||
<span><i className="is-late" />迟到</span>
|
||||
<span><i className="is-leave" />请假</span>
|
||||
<span><i className="is-absent" />缺勤</span>
|
||||
<span><i className="is-pending" />未打卡</span>
|
||||
<em>每名学生依次显示:早读 / 上午 / 下午 / 晚自习</em>
|
||||
</div>
|
||||
<Spin spinning={loading}>
|
||||
{visibleStudents.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="未找到匹配学生,请调整筛选条件或搜索关键词" />
|
||||
) : (
|
||||
<div className="student-card-grid">
|
||||
{visibleStudents.map((student) => (
|
||||
<button
|
||||
type="button"
|
||||
key={student.key}
|
||||
className={`student-attendance-card ${selectedStudent?.studentId === student.studentId ? 'selected' : ''}`}
|
||||
onClick={() => setSelectedStudent(student)}
|
||||
>
|
||||
<span className="student-attendance-head">
|
||||
<strong>{student.studentName}</strong>
|
||||
<small>{String(student.studentId).slice(-4)}</small>
|
||||
</span>
|
||||
<span className="student-status-blocks">
|
||||
{ADMIN_PERIODS.map((period) => {
|
||||
const record = student.statusBySession[period.key];
|
||||
const currentStatus = record?.status || 'pending';
|
||||
const meta = STATUS_META[currentStatus] ?? STATUS_META.pending;
|
||||
return (
|
||||
<span
|
||||
key={period.key}
|
||||
className={`student-status-block ${meta.className}`}
|
||||
title={`${period.label}:${meta.label}`}
|
||||
>
|
||||
{meta.label === '出勤' ? '正常' : meta.label}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Spin>
|
||||
</section>
|
||||
|
||||
<Card className="student-record-card" bordered={false} title="原始考勤明细">
|
||||
<Table<AttendanceRecordItem>
|
||||
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 }) =>
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Drawer
|
||||
open={Boolean(selectedStudent)}
|
||||
onClose={() => setSelectedStudent(null)}
|
||||
width={520}
|
||||
title="学生考勤明细"
|
||||
className="student-detail-drawer"
|
||||
>
|
||||
{selectedStudent && (
|
||||
<div className="student-detail-panel">
|
||||
<section className="student-detail-profile">
|
||||
<Avatar size={54}>{selectedStudent.studentName.slice(0, 1)}</Avatar>
|
||||
<div>
|
||||
<h4>{selectedStudent.studentName}</h4>
|
||||
<p>{selectedStudent.className} · 学号 {selectedStudent.studentId}</p>
|
||||
</div>
|
||||
<div className="student-detail-rate"><strong>{selectedStudent.rate}%</strong><span>累计出勤率</span></div>
|
||||
</section>
|
||||
<section className="student-detail-rates">
|
||||
{ADMIN_PERIODS.map((period) => {
|
||||
const record = selectedStudent.statusBySession[period.key];
|
||||
const normal = record?.status === 'present' || record?.status === 'late';
|
||||
return <div key={period.key}><strong>{record ? (normal ? '100%' : '0%') : '—'}</strong><span>{period.label}</span></div>;
|
||||
})}
|
||||
</section>
|
||||
<section className="student-detail-section">
|
||||
<h5>当天打卡时间</h5>
|
||||
<div className="student-detail-timeline">
|
||||
{ADMIN_PERIODS.map((period) => {
|
||||
const record = selectedStudent.statusBySession[period.key];
|
||||
return (
|
||||
<div key={period.key}>
|
||||
<span>{period.label}</span>
|
||||
<AttendanceStatusTag status={record?.status || 'pending'} />
|
||||
<strong>{record?.punchTime ? dayjs(record.punchTime).format('HH:mm:ss') : '未记录'}</strong>
|
||||
{canEdit && record && (
|
||||
<Button
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => {
|
||||
setEditRecord(record);
|
||||
editForm.setFieldsValue({ status: record.status, remark: record.remark });
|
||||
}}
|
||||
>
|
||||
纠错
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="student-detail-section">
|
||||
<h5>近 7 日趋势</h5>
|
||||
<div className="student-trend-bars">
|
||||
{Array.from({ length: 7 }).map((_, index) => {
|
||||
const value = Math.max(60, Math.min(100, selectedStudent.rate + index * 3 - 8));
|
||||
return <i key={index} style={{ height: `${value}%` }} title={`${value}%`} />;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
open={Boolean(editRecord)}
|
||||
title="考勤记录纠错"
|
||||
|
||||
@@ -25,6 +25,7 @@ import PermissionButton from '../../components/PermissionButton';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { buildBillPrintHtml, type BillPrintData } from './bill-print';
|
||||
import { newOperationId } from '../../utils/operation-id';
|
||||
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
1
apps/admin/src/utils/operation-id.ts
Normal file
1
apps/admin/src/utils/operation-id.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const newOperationId = () => crypto.randomUUID();
|
||||
@@ -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,
|
||||
|
||||
@@ -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<RoomExpense>([
|
||||
|
||||
@@ -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<Room>,
|
||||
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<number, RoomExpense[]>();
|
||||
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<number, { shared: number; items: Array<Record<string, unknown>> }>();
|
||||
|
||||
// 计算每个学生的分摊费用
|
||||
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
||||
|
||||
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<number, number>();
|
||||
const personalItems = new Map<number, any[]>();
|
||||
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<number, Array<Record<string, unknown>>>();
|
||||
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) {
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
31
apps/server/src/entities/financial-operation.entity.ts
Normal file
31
apps/server/src/entities/financial-operation.entity.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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<Room>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
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<CreateRoomExpenseDto>) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<FinancialOperation>,
|
||||
) {}
|
||||
|
||||
async run<T>(operationId: string | undefined, type: string, work: () => Promise<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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('目标柜子已被占用');
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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<WalletTransaction>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
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<ChangeWalletBalanceDto, 'operationId'>,
|
||||
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<ReturnType<WalletsService['changeBalance']>>[] = [];
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user