From c75a08affedb09865c7682b96a148271275ccaf2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Tue, 14 Jul 2026 20:39:32 +0800 Subject: [PATCH] feat: add student wallet utility billing --- apps/admin/src/App.tsx | 9 + apps/admin/src/auth/menu-policy.ts | 6 +- apps/admin/src/layouts/MainLayout.tsx | 1 + apps/admin/src/pages/Bills/index.tsx | 386 +++--------------- apps/admin/src/pages/Expenses/index.tsx | 43 ++ apps/admin/src/pages/Wallets/index.tsx | 102 +++++ apps/server/src/app.module.ts | 6 + apps/server/src/bills/bills-export.service.ts | 57 +-- apps/server/src/bills/bills.controller.ts | 92 +++-- apps/server/src/bills/bills.module.ts | 4 +- apps/server/src/bills/bills.service.spec.ts | 20 +- apps/server/src/bills/bills.service.ts | 281 +++++++------ apps/server/src/bills/dto/bill.dto.ts | 18 +- .../database/database-migrations.service.ts | 44 ++ apps/server/src/entities/bill.entity.ts | 18 +- apps/server/src/entities/index.ts | 3 + .../src/entities/personal-expense.entity.ts | 3 + .../src/entities/student-wallet.entity.ts | 32 ++ .../src/entities/wallet-transaction.entity.ts | 32 ++ apps/server/src/expenses/dto/expense.dto.ts | 27 +- .../src/expenses/expenses.controller.ts | 20 + apps/server/src/expenses/expenses.module.ts | 2 + apps/server/src/expenses/expenses.service.ts | 27 ++ apps/server/src/rbac/rbac.service.ts | 3 + apps/server/src/wallets/dto/wallet.dto.ts | 18 + apps/server/src/wallets/wallets.controller.ts | 44 ++ apps/server/src/wallets/wallets.module.ts | 17 + .../src/wallets/wallets.service.spec.ts | 52 +++ apps/server/src/wallets/wallets.service.ts | 167 ++++++++ 29 files changed, 986 insertions(+), 548 deletions(-) create mode 100644 apps/admin/src/pages/Wallets/index.tsx create mode 100644 apps/server/src/entities/student-wallet.entity.ts create mode 100644 apps/server/src/entities/wallet-transaction.entity.ts create mode 100644 apps/server/src/wallets/dto/wallet.dto.ts create mode 100644 apps/server/src/wallets/wallets.controller.ts create mode 100644 apps/server/src/wallets/wallets.module.ts create mode 100644 apps/server/src/wallets/wallets.service.spec.ts create mode 100644 apps/server/src/wallets/wallets.service.ts diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 70a01af..59c48e5 100644 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -14,6 +14,7 @@ const RoomsPage = lazy(() => import('./pages/Rooms')); const OccupanciesPage = lazy(() => import('./pages/Occupancies')); const ExpensesPage = lazy(() => import('./pages/Expenses')); const BillsPage = lazy(() => import('./pages/Bills')); +const WalletsPage = lazy(() => import('./pages/Wallets')); const RoomVisualPage = lazy(() => import('./pages/RoomVisual')); const OperationLogsPage = lazy(() => import('./pages/OperationLogs')); const UsersPage = lazy(() => import('./pages/Users')); @@ -134,6 +135,14 @@ const App: React.FC = () => { } /> + + + + } + /> = { expense: , bill: , deposit: , + wallet: , classroom: , rental: , organization: , diff --git a/apps/admin/src/pages/Bills/index.tsx b/apps/admin/src/pages/Bills/index.tsx index 0a45510..a8ab646 100644 --- a/apps/admin/src/pages/Bills/index.tsx +++ b/apps/admin/src/pages/Bills/index.tsx @@ -10,7 +10,6 @@ import { Popconfirm, Input, Select, - Tooltip, Spin, Empty, } from 'antd'; @@ -26,174 +25,23 @@ import PermissionButton from '../../components/PermissionButton'; import { downloadBlob } from '../../utils/download'; import { message } from '../../ui/app-message'; + const statusMap: Record = { - draft: { text: '草稿', color: 'default' }, + unpaid: { text: '待支付', color: 'orange' }, + partially_paid: { text: '部分支付', color: 'gold' }, paid: { text: '已支付', color: 'green' }, + cancelled: { text: '已取消', color: 'default' }, }; const typeMap: Record = { water: '水费', electricity: '电费', cleaning: '保洁费', - rent: '租金', damage: '损坏赔偿', penalty: '罚款', other: '其他', }; -const escapeHtml = (value: unknown) => - String(value ?? '') - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') - .replaceAll('"', '"') - .replaceAll("'", '''); - -const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`; - -const buildBillPrintHtml = (bill: any) => { - const studentName = bill.student?.name || '-'; - const status = statusMap[bill.status]?.text || bill.status || '-'; - const generatedAt = bill.generatedAt - ? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm') - : dayjs().format('YYYY-MM-DD HH:mm'); - const hasDeposit = Number(bill.availableDeposit || 0) > 0; - const depositDeducted = Number(bill.depositDeductedAmount || 0); - const items = bill.items || []; - - return ` - - - - 账单_${escapeHtml(studentName)}_${escapeHtml(bill.id)} - - - - -
-

恭学教育基地水电费账单

-
生成时间: ${escapeHtml(generatedAt)}
- -
-
学生姓名: ${escapeHtml(studentName)}
-
计费周期: ${escapeHtml(bill.periodStart)} ~ ${escapeHtml(bill.periodEnd)}
-
账单状态: ${escapeHtml(status)}
-
- -
-
费用汇总
-
-
分摊费用: ${escapeHtml(money(bill.sharedAmount))}
-
个人费用: ${escapeHtml(money(bill.personalAmount))}
-
应付总额: ${escapeHtml(money(bill.totalAmount))}
- ${ - hasDeposit || depositDeducted > 0 - ? `
可用押金: ${escapeHtml(money(bill.availableDeposit))}
- ${depositDeducted > 0 ? `
已扣押金: -${escapeHtml(money(depositDeducted))}
` : ''}` - : '' - } -
-
- -
-
费用明细
- - - - - - - - - - - - ${ - items.length - ? items - .map( - (item: any) => ` - - - - - - `, - ) - .join('') - : '' - } - -
费用类型说明天数总人天金额(元)
${escapeHtml(typeMap[item.expenseType] || item.expenseType || '-')}${escapeHtml(item.description || '-')}${escapeHtml(item.days || 0)}${escapeHtml(item.totalRoomDays || 0)}${escapeHtml(Number(item.studentAmount || 0).toFixed(2))}
暂无费用明细
-
- - -
- - -`; -}; - const BillsPage: React.FC = () => { const [bills, setBills] = useState([]); const [loading, setLoading] = useState(false); @@ -239,34 +87,19 @@ const BillsPage: React.FC = () => { }); }, [bills, searchText, filterStatus]); - const selectedBillRows = useMemo( - () => bills.filter((bill: any) => selectedRows.includes(bill.id)), - [bills, selectedRows], - ); - const canBatchConfirm = - selectedBillRows.length > 0 - && selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient); - const canBatchDelete = - selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid'); - const handleGenerate = async () => { - if (saving) return; - + setSaving(true); + const values = await generateForm.validateFields(); try { - const values = await generateForm.validateFields(); - setSaving(true); const res: any = await api.post('/bills/generate', { billingMonth: values.billingMonth.format('YYYY-MM'), }); message.success(res.message || '生成成功'); setGenerateModal(false); generateForm.resetFields(); - void fetchData(); + fetchData(); } catch (e: any) { - // Ant Design 的表单校验失败会 reject;字段本身已展示错误,无需再弹“生成失败”。 - if (!e?.errorFields) { - message.error(e?.message || '生成失败'); - } + message.error(e?.message || '生成失败'); } finally { setSaving(false); } @@ -284,43 +117,29 @@ const BillsPage: React.FC = () => { } }; - const updateStatus = async (id: number, status: string) => { - try { - await api.put(`/bills/${id}/status`, { status }); - message.success('账单已确认支付,押金已自动扣除'); - fetchData(); - if (detailModal?.id === id) { - void showDetail(id); - } - } catch (e: any) { - message.error(e?.message || '操作失败'); - } - }; - const batchUpdateStatus = async (status: string) => { - if (selectedRows.length === 0) return message.warning('请先选择账单'); - if (batchLoading) return; - setBatchLoading(true); - try { - await api.put('/bills/batch/status', { ids: selectedRows, status }); - message.success(`已确认支付 ${selectedRows.length} 条账单,并自动扣除押金`); - setSelectedRows([]); - fetchData(); - } catch (e: any) { - message.error(e?.message || '操作失败'); - } finally { - setBatchLoading(false); - } + + const handleCancel = async (id: number) => { + let reason = ''; + Modal.confirm({ + title: '取消账单并退回已扣余额', + content: { reason = event.target.value; }} />, + okText: '确认取消', cancelText: '返回', + onOk: async () => { + if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); } + await api.post(`/bills/${id}/cancel`, { reason: reason.trim() }); + message.success('账单已取消,已扣余额已冲正退回'); + fetchData(); + }, + }); }; const handleDelete = async (id: number) => { try { await api.delete(`/bills/${id}`); - message.success('账单已删除'); + message.success('删除成功'); fetchData(); - } catch (e: any) { - message.error(e?.message || '删除失败'); - } + } catch (error: any) { message.error(error?.message || '删除失败'); } }; const batchDelete = async () => { @@ -346,24 +165,11 @@ const BillsPage: React.FC = () => { ); }; - const handleExportPdf = useCallback(async (billId: number) => { - const printWindow = window.open('', '_blank'); - if (!printWindow) { - message.error('无法打开打印窗口,请允许浏览器弹窗后重试'); - return; - } - - printWindow.document.write('账单加载中账单加载中...'); - try { - const bill = await api.get(`/bills/${billId}`); - printWindow.document.open(); - printWindow.document.write(buildBillPrintHtml(bill)); - printWindow.document.close(); - } catch (e: any) { - printWindow.close(); - message.error(e?.message || '账单数据加载失败'); - } - }, []); + const handleExportPdf = (billId: number) => { + downloadBlob(`/bills/export/pdf/${billId}`, `账单_${billId}.pdf`).catch(() => + message.error('导出失败'), + ); + }; const columns = useMemo(() => [ { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, @@ -390,26 +196,16 @@ const BillsPage: React.FC = () => { render: (v: number) => ¥{Number(v).toFixed(2)}, }, { - title: '可用押金', - dataIndex: 'availableDeposit', - width: 120, - render: (v: number) => - v > 0 ? ( - ¥{Number(v).toFixed(2)} - ) : ( - - - ), + title: '已扣余额', dataIndex: 'paidAmount', width: 110, + render: (value: number) => ¥{Number(value || 0).toFixed(2)}, }, { - title: '已扣押金', - dataIndex: 'depositDeductedAmount', - width: 120, - render: (v: number) => - Number(v || 0) > 0 ? ( - ¥{Number(v).toFixed(2)} - ) : ( - - - ), + title: '待补缴', dataIndex: 'outstandingAmount', width: 110, + render: (value: number) => 0 ? '#cf1322' : '#389e0d' }}>¥{Number(value || 0).toFixed(2)}, + }, + { + title: '钱包余额', dataIndex: 'walletBalance', width: 110, + render: (value: number) => `¥${Number(value || 0).toFixed(2)}`, }, { title: '状态', @@ -436,25 +232,6 @@ const BillsPage: React.FC = () => { > 详情 - {record.status === 'draft' && ( - - updateStatus(record.id, 'paid')} - > - 确认支付 - - - )} { > PDF - handleDelete(record.id)} - okText="删除" - cancelText="取消" - > - } - > - 删除 + {record.status !== 'cancelled' && ( + handleCancel(record.id)}> + 取消并冲正 - + )} + {Number(record.paidAmount || 0) === 0 && record.status !== 'cancelled' && ( + handleDelete(record.id)} okText="删除" cancelText="取消"> + }>删除 + + )} ), }, - ], [showDetail, updateStatus, handleDelete, handleExportPdf]); + ], [showDetail, handleDelete, handleCancel, handleExportPdf]); return (
@@ -512,31 +283,25 @@ const BillsPage: React.FC = () => { value={filterStatus} onChange={(v) => setFilterStatus(v)} options={[ - { value: 'draft', label: '草稿' }, + { value: 'unpaid', label: '待支付' }, + { value: 'partially_paid', label: '部分支付' }, { value: 'paid', label: '已支付' }, + { value: 'cancelled', label: '已取消' }, ]} /> ({ value: student.id, label: `${student.name} (${student.studentNo || `#${student.id}`})` }))} /> + +