forked from wangziqi/gongxue-base
feat: implement utility recharge feature with CRUD operations and balance management
- Added UtilityRecharge entity to manage utility recharge records. - Created DTO for utility recharge with validation. - Developed UtilityBalancesService to handle business logic for utility recharges and balances. - Implemented UtilityBalancesController for API endpoints related to utility recharges. - Introduced utility balance management in BillsService to reflect utility charges in bills. - Updated DepositsService to include personal expense amounts in deposit records. - Created frontend page for managing utility balances and recharges with Ant Design components. - Refactored existing bill generation logic to exclude personal expenses and integrate utility charges. - Updated tests to cover new utility recharge functionality and ensure existing features remain intact.
This commit is contained in:
@@ -13,6 +13,7 @@ const StudentsPage = lazy(() => import('./pages/Students'));
|
||||
const RoomsPage = lazy(() => import('./pages/Rooms'));
|
||||
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
||||
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
||||
const UtilityBalancesPage = lazy(() => import('./pages/UtilityBalances'));
|
||||
const BillsPage = lazy(() => import('./pages/Bills'));
|
||||
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
||||
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
||||
@@ -126,6 +127,14 @@ const App: React.FC = () => {
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="utility-balances"
|
||||
element={
|
||||
<PermissionRoute permission="expense:view">
|
||||
<UtilityBalancesPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="deposits"
|
||||
element={
|
||||
|
||||
@@ -72,6 +72,7 @@ const SECTIONS: MenuSection[] = [
|
||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense:view' },
|
||||
{ key: '/utility-balances', label: '水电余额', icon: 'utility', permission: 'expense:view' },
|
||||
{ key: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||
],
|
||||
|
||||
@@ -49,6 +49,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
||||
overview: <AppstoreOutlined />,
|
||||
occupancy: <SwapOutlined />,
|
||||
expense: <DollarOutlined />,
|
||||
utility: <WalletOutlined />,
|
||||
bill: <FileTextOutlined />,
|
||||
deposit: <WalletOutlined />,
|
||||
classroom: <ReadOutlined />,
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Popconfirm,
|
||||
Input,
|
||||
Select,
|
||||
Tooltip,
|
||||
Spin,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
@@ -35,8 +34,7 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
||||
};
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
water: '水费',
|
||||
electricity: '电费',
|
||||
utility: '水电费',
|
||||
cleaning: '保洁费',
|
||||
rent: '租金',
|
||||
damage: '损坏赔偿',
|
||||
@@ -60,7 +58,6 @@ const buildBillPrintHtml = (bill: any) => {
|
||||
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 items = bill.items || [];
|
||||
|
||||
return `<!doctype html>
|
||||
@@ -92,9 +89,9 @@ const buildBillPrintHtml = (bill: any) => {
|
||||
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
|
||||
.amount-summary { font-size: 12px; line-height: 1.75; }
|
||||
.total { color: #007aff; font-size: 14px; font-weight: 700; }
|
||||
.deposit { color: #52c41a; font-size: 11px; }
|
||||
.deposit-applied { color: #fa8c16; font-size: 11px; }
|
||||
.after-deposit { color: #ff3b30; font-size: 14px; font-weight: 700; }
|
||||
.balance { color: #52c41a; font-size: 11px; }
|
||||
.balance-after { color: #fa541c; font-size: 12px; font-weight: 700; }
|
||||
.shortage { color: #ff4d4f; font-size: 12px; font-weight: 700; }
|
||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
|
||||
th, td { padding: 5px 6px; border-bottom: 1px solid #ccc; font-size: 9px; line-height: 1.45; text-align: left; vertical-align: top; word-break: break-word; }
|
||||
th { color: #333; font-weight: 700; }
|
||||
@@ -141,13 +138,12 @@ const buildBillPrintHtml = (bill: any) => {
|
||||
<div class="section-title">费用汇总</div>
|
||||
<div class="amount-summary">
|
||||
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
|
||||
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
|
||||
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
|
||||
<div class="balance">当前水电余额: ${escapeHtml(money(bill.utilityBalance))}</div>
|
||||
<div class="balance-after">扣本账单后余额: ${escapeHtml(money(bill.utilityBalanceAfterBill))}</div>
|
||||
${
|
||||
hasDeposit
|
||||
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
|
||||
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div>
|
||||
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</div>`
|
||||
Number(bill.utilityShortageAmount || 0) > 0
|
||||
? `<div class="shortage">需补缴: ${escapeHtml(money(bill.utilityShortageAmount))}</div>`
|
||||
: ''
|
||||
}
|
||||
</div>
|
||||
@@ -370,13 +366,6 @@ const BillsPage: React.FC = () => {
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '个人费用',
|
||||
dataIndex: 'personalAmount',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||
},
|
||||
{
|
||||
title: '总计',
|
||||
dataIndex: 'totalAmount',
|
||||
@@ -385,32 +374,39 @@ const BillsPage: React.FC = () => {
|
||||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '可用押金',
|
||||
dataIndex: 'availableDeposit',
|
||||
width: 120,
|
||||
title: '当前水电余额',
|
||||
dataIndex: 'utilityBalance',
|
||||
width: 130,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<span style={{ color: Number(v || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
¥{Number(v || 0).toFixed(2)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '扣本账单后余额',
|
||||
dataIndex: 'utilityBalanceAfterBill',
|
||||
width: 150,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<strong style={{ color: Number(v || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
¥{Number(v || 0).toFixed(2)}
|
||||
</strong>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '需补缴',
|
||||
dataIndex: 'utilityShortageAmount',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: number) =>
|
||||
v > 0 ? (
|
||||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
||||
Number(v || 0) > 0 ? (
|
||||
<strong style={{ color: '#ff4d4f' }}>¥{Number(v).toFixed(2)}</strong>
|
||||
) : (
|
||||
<span style={{ color: '#999' }}>-</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '抵扣后应付',
|
||||
dataIndex: 'amountAfterDeposit',
|
||||
width: 130,
|
||||
render: (v: number, r: any) => {
|
||||
const has = Number(r.availableDeposit || 0) > 0;
|
||||
if (!has) return <span style={{ color: '#999' }}>-</span>;
|
||||
const after = Number(v ?? r.totalAmount).toFixed(2);
|
||||
const applied = Number(r.depositApplied || 0).toFixed(2);
|
||||
return (
|
||||
<Tooltip title={`已抵扣押金 ¥${applied}`}>
|
||||
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -512,7 +508,7 @@ const BillsPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
||||
options={[{value:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||||
options={[{value:'utility',label:'水电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
|
||||
<PermissionButton
|
||||
permission="bill:confirm"
|
||||
onClick={() => batchUpdateStatus('confirmed')}
|
||||
@@ -634,51 +630,45 @@ const BillsPage: React.FC = () => {
|
||||
<Descriptions.Item label="分摊费用">
|
||||
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="个人费用">
|
||||
¥{Number(detailModal.personalAmount).toFixed(2)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="合计" span={2}>
|
||||
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
||||
¥{Number(detailModal.totalAmount).toFixed(2)}
|
||||
</strong>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{Number(detailModal.availableDeposit || 0) > 0 && (
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
||||
押金联动(不影响实际押金状态,仅作收款参考)
|
||||
</div>
|
||||
<Space size={24} wrap>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
padding: 12,
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>水电余额</div>
|
||||
<Space size={24} wrap>
|
||||
<span>
|
||||
当前余额:
|
||||
<strong style={{ color: Number(detailModal.utilityBalance || 0) < 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
¥{Number(detailModal.utilityBalance || 0).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
扣本账单后余额:
|
||||
<strong style={{ color: Number(detailModal.utilityBalanceAfterBill || 0) < 0 ? '#ff4d4f' : '#52c41a', fontSize: 16 }}>
|
||||
¥{Number(detailModal.utilityBalanceAfterBill || 0).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
{Number(detailModal.utilityShortageAmount || 0) > 0 && (
|
||||
<span>
|
||||
当前可用押金:
|
||||
<strong style={{ color: '#52c41a' }}>
|
||||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
||||
需补缴:
|
||||
<strong style={{ color: '#ff4d4f', fontSize: 16 }}>
|
||||
¥{Number(detailModal.utilityShortageAmount || 0).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
本账单可抵扣:
|
||||
<strong style={{ color: '#fa8c16' }}>
|
||||
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
<span>
|
||||
抵扣后实付:
|
||||
<strong style={{ color: '#fa541c', fontSize: 16 }}>
|
||||
¥
|
||||
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
|
||||
</strong>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<h4>费用明细</h4>
|
||||
<Table
|
||||
scroll={{ x: 700 }}
|
||||
|
||||
@@ -38,6 +38,8 @@ const isFormValidationError = (error: unknown) =>
|
||||
&& error !== null
|
||||
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||
|
||||
const money = (value: unknown) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
const DepositsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
@@ -221,8 +223,13 @@ const DepositsPage: React.FC = () => {
|
||||
size="small"
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
const personalExpenseAmount = money(record.personalExpenseAmount);
|
||||
const depositAmount = money(record.amount);
|
||||
setRefundModal(record);
|
||||
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
|
||||
refundForm.setFieldsValue({
|
||||
refundDate: dayjs(),
|
||||
deductionAmount: Math.min(personalExpenseAmount, depositAmount),
|
||||
});
|
||||
}}
|
||||
>
|
||||
退还
|
||||
@@ -365,15 +372,25 @@ const DepositsPage: React.FC = () => {
|
||||
>
|
||||
<Form form={refundForm} layout="vertical">
|
||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
||||
押金金额: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
|
||||
<div>
|
||||
押金金额: <strong>¥{money(refundModal?.amount).toFixed(2)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
个人附加费合计:{' '}
|
||||
<strong>¥{money(refundModal?.personalExpenseAmount).toFixed(2)}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
|
||||
<Form.Item
|
||||
name="deductionAmount"
|
||||
label="扣除金额(元)"
|
||||
extra="自动填入该学生个人附加费用总和;超过押金金额时按押金金额封顶"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={Number(refundModal?.amount || 500)}
|
||||
max={money(refundModal?.amount || 500)}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
190
apps/admin/src/pages/UtilityBalances/index.tsx
Normal file
190
apps/admin/src/pages/UtilityBalances/index.tsx
Normal file
@@ -0,0 +1,190 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { DatePicker, Empty, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const money = (value: unknown) => `¥${Number(value || 0).toFixed(2)}`;
|
||||
|
||||
const UtilityBalancesPage: React.FC = () => {
|
||||
const [balances, setBalances] = useState<any[]>([]);
|
||||
const [recharges, setRecharges] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [studentFilter, setStudentFilter] = useState<number | undefined>();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [balanceRows, rechargeRows, studentRows]: any[] = await Promise.all([
|
||||
api.get('/utility-balances/balances'),
|
||||
api.get('/utility-balances/recharges', {
|
||||
params: studentFilter ? { studentId: studentFilter } : undefined,
|
||||
}),
|
||||
api.get('/utility-balances/student-lookups'),
|
||||
]);
|
||||
setBalances(balanceRows);
|
||||
setRecharges(rechargeRows);
|
||||
setStudents(studentRows);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [studentFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
await api.post('/utility-balances/recharges', {
|
||||
studentId: values.studentId,
|
||||
amount: values.amount,
|
||||
rechargeDate: values.rechargeDate.format('YYYY-MM-DD'),
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success('充值成功');
|
||||
setModalOpen(false);
|
||||
form.resetFields();
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
if (!e?.errorFields) message.error(e?.message || '充值失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const studentOptions = useMemo(
|
||||
() => students.map((s) => ({ value: s.id, label: `${s.name}${s.studentNo ? `(${s.studentNo})` : ''}` })),
|
||||
[students],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
style={{ width: 220 }}
|
||||
placeholder="按学生筛选充值记录"
|
||||
value={studentFilter}
|
||||
onChange={setStudentFilter}
|
||||
options={studentOptions}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="expense:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
form.setFieldsValue({ rechargeDate: dayjs() });
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
水电余额充值
|
||||
</PermissionButton>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
title={() => '水电余额汇总'}
|
||||
loading={loading}
|
||||
dataSource={balances}
|
||||
rowKey="studentId"
|
||||
pagination={{ defaultPageSize: 10, showSizeChanger: true }}
|
||||
locale={{ emptyText: <Empty description="暂无余额数据" /> }}
|
||||
columns={[
|
||||
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '学号', render: (_: any, r: any) => r.student?.studentNo || '-' },
|
||||
{ title: '累计充值', dataIndex: 'totalRecharged', align: 'right' as const, render: money },
|
||||
{ title: '账单扣款', dataIndex: 'usedAmount', align: 'right' as const, render: money },
|
||||
{
|
||||
title: '剩余水电余额',
|
||||
dataIndex: 'balance',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<strong style={{ color: Number(v) < 0 ? '#ff4d4f' : '#52c41a' }}>{money(v)}</strong>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Table
|
||||
style={{ marginTop: 24 }}
|
||||
title={() => '充值记录'}
|
||||
loading={loading}
|
||||
dataSource={recharges}
|
||||
rowKey="id"
|
||||
pagination={{ defaultPageSize: 15, showSizeChanger: true }}
|
||||
locale={{ emptyText: <Empty description="暂无充值记录" /> }}
|
||||
columns={[
|
||||
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '充值金额', dataIndex: 'amount', align: 'right' as const, render: money },
|
||||
{ title: '充值日期', dataIndex: 'rechargeDate' },
|
||||
{ title: '类型', render: () => <Tag color="green">充值</Tag> },
|
||||
{ title: '备注', dataIndex: 'notes' },
|
||||
{
|
||||
title: '录入时间',
|
||||
dataIndex: 'createdAt',
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '-'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: any, record: any) => (
|
||||
<Popconfirm
|
||||
title="确定删除这条充值记录?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/utility-balances/recharges/${record.id}`);
|
||||
message.success('删除成功');
|
||||
fetchData();
|
||||
}}
|
||||
>
|
||||
<PermissionButton permission="expense:delete" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title="水电余额充值"
|
||||
open={modalOpen}
|
||||
onOk={handleCreate}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
okText="确认充值"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="studentId" label="学生" rules={[{ required: true, message: '请选择学生' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={studentOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="充值金额" rules={[{ required: true, message: '请输入充值金额' }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="rechargeDate" label="充值日期" rules={[{ required: true, message: '请选择充值日期' }]}>
|
||||
<DatePicker style={{ width: '100%' }} format="YYYY-MM-DD" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UtilityBalancesPage;
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
OperationLog,
|
||||
Deposit,
|
||||
DepositInstallment,
|
||||
UtilityRecharge,
|
||||
Classroom,
|
||||
Organization,
|
||||
ClassroomRental,
|
||||
@@ -71,6 +72,7 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||
import { AgentToolsModule } from './agent-tools';
|
||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||
import { UtilityBalancesModule } from './utility-balances/utility-balances.module';
|
||||
|
||||
import {
|
||||
IntegrationConfig,
|
||||
@@ -109,6 +111,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
OperationLog,
|
||||
Deposit,
|
||||
DepositInstallment,
|
||||
UtilityRecharge,
|
||||
Classroom,
|
||||
Organization,
|
||||
ClassroomRental,
|
||||
@@ -181,6 +184,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
AgentToolsModule,
|
||||
ExpenseTypesModule,
|
||||
AiConfigModule,
|
||||
UtilityBalancesModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import { Response } from 'express';
|
||||
@@ -13,7 +13,8 @@ export class BillsExportService {
|
||||
constructor(
|
||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
@InjectRepository(UtilityRecharge)
|
||||
private utilityRechargeRepo: Repository<UtilityRecharge>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -34,19 +35,8 @@ export class BillsExportService {
|
||||
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
|
||||
const bills = await qb.getMany();
|
||||
|
||||
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
|
||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||
const depMap = new Map<number, number>();
|
||||
if (studentIds.length > 0) {
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
for (const d of deposits) {
|
||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
||||
}
|
||||
}
|
||||
const balanceMap = await this.getUtilityBalanceMap(studentIds);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = '恭学教育基地管理系统';
|
||||
@@ -58,11 +48,10 @@ export class BillsExportService {
|
||||
{ header: '学生姓名', key: 'studentName', width: 14 },
|
||||
{ header: '计费周期', key: 'period', width: 24 },
|
||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||
{ header: '个人费用', key: 'personal', width: 12 },
|
||||
{ header: '总金额', key: 'total', width: 12 },
|
||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
||||
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
|
||||
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
|
||||
{ header: '当前水电余额', key: 'utilityBalance', width: 14 },
|
||||
{ header: '扣本账单后余额', key: 'utilityBalanceAfterBill', width: 16 },
|
||||
{ header: '需补缴', key: 'utilityShortageAmount', width: 12 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||
];
|
||||
@@ -77,19 +66,21 @@ export class BillsExportService {
|
||||
};
|
||||
for (const bill of bills) {
|
||||
const total = Number(bill.totalAmount || 0);
|
||||
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
|
||||
const applied = Number(Math.min(dep, total).toFixed(2));
|
||||
const after = Number(Math.max(0, total - applied).toFixed(2));
|
||||
const balance = Number((balanceMap.get(bill.studentId) || 0).toFixed(2));
|
||||
const balanceAfterBill =
|
||||
bill.status === 'confirmed' || bill.status === 'paid'
|
||||
? balance
|
||||
: Number((balance - total).toFixed(2));
|
||||
const shortage = Math.max(0, -balanceAfterBill);
|
||||
ws.addRow({
|
||||
id: bill.id,
|
||||
studentName: (bill as any).student?.name || '-',
|
||||
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
|
||||
shared: Number(bill.sharedAmount),
|
||||
personal: Number(bill.personalAmount),
|
||||
total,
|
||||
deposit: dep,
|
||||
depositApplied: applied,
|
||||
afterDeposit: after,
|
||||
utilityBalance: balance,
|
||||
utilityBalanceAfterBill: balanceAfterBill,
|
||||
utilityShortageAmount: shortage,
|
||||
status: statusMap[bill.status] || bill.status,
|
||||
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
||||
});
|
||||
@@ -147,16 +138,13 @@ export class BillsExportService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询该学生的可用押金(已缴未退)
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId = :sid', { sid: bill.studentId })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
|
||||
const totalAmount = Number(bill.totalAmount || 0);
|
||||
const depositApplied = Math.min(availableDeposit, totalAmount);
|
||||
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
|
||||
const balanceMap = await this.getUtilityBalanceMap([bill.studentId]);
|
||||
const utilityBalance = Number((balanceMap.get(bill.studentId) || 0).toFixed(2));
|
||||
const utilityBalanceAfterBill =
|
||||
bill.status === 'confirmed' || bill.status === 'paid'
|
||||
? utilityBalance
|
||||
: Number((utilityBalance - totalAmount).toFixed(2));
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
@@ -217,26 +205,16 @@ export class BillsExportService {
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(12);
|
||||
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
|
||||
doc.text(`个人费用: ¥${Number(bill.personalAmount).toFixed(2)}`);
|
||||
doc
|
||||
.fontSize(14)
|
||||
.fillColor('#007AFF')
|
||||
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||
doc.moveDown(0.3);
|
||||
if (availableDeposit > 0) {
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor('#52C41A')
|
||||
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor('#FA8C16')
|
||||
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
|
||||
doc
|
||||
.fontSize(14)
|
||||
.fillColor('#FF3B30')
|
||||
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
|
||||
}
|
||||
doc.fontSize(11).fillColor('#52C41A').text(`当前水电余额: ¥${utilityBalance.toFixed(2)}`);
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor(utilityBalanceAfterBill < 0 ? '#FF3B30' : '#52C41A')
|
||||
.text(`扣本账单后余额: ¥${utilityBalanceAfterBill.toFixed(2)}`);
|
||||
doc.moveDown(1);
|
||||
|
||||
// 明细表格
|
||||
@@ -284,4 +262,44 @@ export class BillsExportService {
|
||||
|
||||
doc.end();
|
||||
}
|
||||
|
||||
private async getUtilityBalanceMap(studentIds: number[]) {
|
||||
const balanceMap = new Map<number, number>();
|
||||
if (studentIds.length === 0) return balanceMap;
|
||||
|
||||
const [rechargeRows, billRows] = await Promise.all([
|
||||
this.utilityRechargeRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('r.studentId', 'studentId')
|
||||
.addSelect('SUM(r.amount)', 'amount')
|
||||
.where('r.studentId IN (:...studentIds)', { studentIds })
|
||||
.groupBy('r.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||
this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
.select('b.studentId', 'studentId')
|
||||
.addSelect('SUM(b.totalAmount)', 'amount')
|
||||
.where('b.studentId IN (:...studentIds)', { studentIds })
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: ['confirmed', 'paid'] })
|
||||
.groupBy('b.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||
]);
|
||||
|
||||
const rechargeMap = new Map<number, number>();
|
||||
for (const row of rechargeRows) {
|
||||
rechargeMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||
}
|
||||
const paidMap = new Map<number, number>();
|
||||
for (const row of billRows) {
|
||||
paidMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||
}
|
||||
|
||||
for (const studentId of studentIds) {
|
||||
balanceMap.set(
|
||||
studentId,
|
||||
Number(((rechargeMap.get(studentId) || 0) - (paidMap.get(studentId) || 0)).toFixed(2)),
|
||||
);
|
||||
}
|
||||
return balanceMap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,11 +4,10 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||
import { BillsService } from './bills.service';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import { BillsController } from './bills.controller';
|
||||
@@ -19,11 +18,10 @@ import { BillsController } from './bills.controller';
|
||||
Bill,
|
||||
BillItem,
|
||||
RoomExpense,
|
||||
PersonalExpense,
|
||||
Occupancy,
|
||||
Room,
|
||||
Deposit,
|
||||
Student,
|
||||
UtilityRecharge,
|
||||
]),
|
||||
NotificationsModule,
|
||||
],
|
||||
|
||||
@@ -5,10 +5,9 @@ import { BillsService } from './bills.service';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||
|
||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||
|
||||
@@ -42,20 +41,18 @@ describe('BillsService — generateBills', () => {
|
||||
let billRepo: MockRepository<Bill>;
|
||||
let itemRepo: MockRepository<BillItem>;
|
||||
let roomExpRepo: MockRepository<RoomExpense>;
|
||||
let personalExpRepo: MockRepository<PersonalExpense>;
|
||||
let occRepo: MockRepository<Occupancy>;
|
||||
let roomRepo: MockRepository<Room>;
|
||||
let depositRepo: MockRepository<Deposit>;
|
||||
let utilityRechargeRepo: MockRepository<UtilityRecharge>;
|
||||
let dataSource: { transaction: jest.Mock };
|
||||
|
||||
beforeEach(async () => {
|
||||
billRepo = mockRepo<Bill>();
|
||||
itemRepo = mockRepo<BillItem>();
|
||||
roomExpRepo = mockRepo<RoomExpense>();
|
||||
personalExpRepo = mockRepo<PersonalExpense>();
|
||||
occRepo = mockRepo<Occupancy>();
|
||||
roomRepo = mockRepo<Room>();
|
||||
depositRepo = mockRepo<Deposit>();
|
||||
utilityRechargeRepo = mockRepo<UtilityRecharge>();
|
||||
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -64,10 +61,9 @@ describe('BillsService — generateBills', () => {
|
||||
{ provide: getRepositoryToken(Bill), useValue: billRepo },
|
||||
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
|
||||
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
|
||||
{ provide: getRepositoryToken(PersonalExpense), useValue: personalExpRepo },
|
||||
{ provide: getRepositoryToken(Occupancy), useValue: occRepo },
|
||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
||||
{ provide: getRepositoryToken(UtilityRecharge), useValue: utilityRechargeRepo },
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
],
|
||||
}).compile();
|
||||
@@ -100,11 +96,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
// No personal expenses
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
@@ -145,10 +136,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
|
||||
expect(result.count).toBe(2);
|
||||
@@ -203,10 +190,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
||||
|
||||
@@ -253,10 +236,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(THREE_MONTHS);
|
||||
expect(result.count).toBe(1);
|
||||
|
||||
@@ -295,10 +274,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
expect(result.count).toBe(1);
|
||||
|
||||
@@ -361,10 +336,6 @@ describe('BillsService — generateBills', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
// Mock student department query
|
||||
(dataSource.query as jest.Mock).mockResolvedValue([
|
||||
{ id: 10, department_id: null },
|
||||
@@ -394,7 +365,7 @@ describe('BillsService — generateBills', () => {
|
||||
expect(totalAll).toBeCloseTo(700, 0);
|
||||
});
|
||||
|
||||
it('personal expenses → added on top of shared allocation', async () => {
|
||||
it('personal expenses → excluded from generated bill totals', async () => {
|
||||
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<RoomExpense>([
|
||||
{
|
||||
@@ -414,17 +385,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
// Personal expense: damage fee of 50
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
expenseType: 'damage', amount: '50' as unknown as number,
|
||||
expenseDate: '2026-06-15', description: 'broken chair',
|
||||
} as PersonalExpense,
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
expect(result.count).toBe(1);
|
||||
|
||||
@@ -432,8 +392,8 @@ describe('BillsService — generateBills', () => {
|
||||
const billData = savedCalls[0][0];
|
||||
|
||||
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
|
||||
expect(Number(billData.personalAmount)).toBe(50);
|
||||
expect(Number(billData.totalAmount)).toBeCloseTo(350, 0);
|
||||
expect(Number(billData.personalAmount)).toBe(0);
|
||||
expect(Number(billData.totalAmount)).toBeCloseTo(300, 0);
|
||||
});
|
||||
|
||||
it('zero overlapping days → no bill generated', async () => {
|
||||
@@ -457,10 +417,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
// Occupancy outside period → no matching student days → no bill
|
||||
expect(result.count).toBe(0);
|
||||
@@ -481,10 +437,6 @@ describe('BillsService — generateBills', () => {
|
||||
]),
|
||||
);
|
||||
|
||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||
mockQueryBuilder<PersonalExpense>([]),
|
||||
);
|
||||
|
||||
const result = await service.generateBills(PERIOD);
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, DataSource } from 'typeorm';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class BillsService {
|
||||
constructor(
|
||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
@InjectRepository(UtilityRecharge) private utilityRechargeRepo: Repository<UtilityRecharge>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 核心计费引擎:按"人天数"加权分摊
|
||||
*/
|
||||
/** 核心计费引擎:按"人天数"加权分摊 */
|
||||
async generateBills(dto: GenerateBillsDto) {
|
||||
const { periodStart, periodEnd } = dto;
|
||||
const pStart = new Date(periodStart);
|
||||
const pEnd = new Date(periodEnd);
|
||||
|
||||
// 删除该周期已有的草稿账单
|
||||
const existingDrafts = await this.billRepo.find({
|
||||
where: { periodStart, periodEnd, status: 'draft' },
|
||||
});
|
||||
@@ -50,7 +44,6 @@ export class BillsService {
|
||||
.execute();
|
||||
}
|
||||
|
||||
// 获取所有有费用的宿舍
|
||||
const roomExpenses = await this.roomExpRepo
|
||||
.createQueryBuilder('e')
|
||||
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', {
|
||||
@@ -59,18 +52,15 @@ export class BillsService {
|
||||
})
|
||||
.getMany();
|
||||
|
||||
// 按宿舍分组费用
|
||||
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);
|
||||
}
|
||||
|
||||
// 计算每个学生的分摊费用
|
||||
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
||||
|
||||
for (const [roomId, expenses] of roomExpMap) {
|
||||
// 获取该宿舍在此周期内的所有入住记录
|
||||
const occupancies = await this.occRepo
|
||||
.createQueryBuilder('o')
|
||||
.leftJoinAndSelect('o.student', 'student')
|
||||
@@ -80,12 +70,9 @@ export class BillsService {
|
||||
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
||||
.getMany();
|
||||
|
||||
|
||||
// 分离长租与短租入住记录
|
||||
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)) {
|
||||
@@ -104,10 +91,8 @@ export class BillsService {
|
||||
});
|
||||
}
|
||||
|
||||
// 短租:原人天数加权分摊逻辑
|
||||
if (shortTermOccs.length === 0) continue;
|
||||
|
||||
// 计算每个学生的计费天数
|
||||
const studentDays: { studentId: number; days: number }[] = [];
|
||||
let totalDays = 0;
|
||||
|
||||
@@ -128,7 +113,6 @@ export class BillsService {
|
||||
|
||||
if (totalDays === 0) continue;
|
||||
|
||||
// 对每项费用进行分摊
|
||||
for (const expense of expenses) {
|
||||
for (const sd of studentDays) {
|
||||
if (sd.days === 0) continue;
|
||||
@@ -151,57 +135,23 @@ export class BillsService {
|
||||
}
|
||||
}
|
||||
|
||||
// 获取个人附加费
|
||||
const personalExps = await this.personalExpRepo
|
||||
.createQueryBuilder('pe')
|
||||
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
.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}`,
|
||||
days: 0,
|
||||
totalRoomDays: 0,
|
||||
roomTotalAmount: pe.amount,
|
||||
studentAmount: pe.amount,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 合并所有涉及的学生
|
||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
||||
// 生成账单
|
||||
const bills: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
for (const studentId of studentBillData.keys()) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
const total = Number(shared.toFixed(2));
|
||||
|
||||
const bill = this.billRepo.create({
|
||||
studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: Number(shared.toFixed(2)),
|
||||
personalAmount: personal,
|
||||
personalAmount: 0,
|
||||
totalAmount: total,
|
||||
status: 'draft',
|
||||
});
|
||||
const savedBill = await this.billRepo.save(bill);
|
||||
|
||||
// 保存明细
|
||||
const items = [
|
||||
...(studentBillData.get(studentId)?.items || []),
|
||||
...(personalItems.get(studentId) || []),
|
||||
];
|
||||
const items = studentBillData.get(studentId)?.items || [];
|
||||
for (const item of items) {
|
||||
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id }));
|
||||
}
|
||||
@@ -230,44 +180,57 @@ export class BillsService {
|
||||
qb.innerJoin('b.items', 'bi', 'bi.expenseType = :et', { et: query.expenseType });
|
||||
}
|
||||
const bills = await qb.getMany();
|
||||
return this.attachDepositInfo(bills);
|
||||
return this.attachUtilityBalanceInfo(bills);
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const bill = await this.billRepo.findOne({ where: { id }, relations: ['student', 'items'] });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
const [withDeposit] = await this.attachDepositInfo([bill]);
|
||||
return withDeposit;
|
||||
const [withUtilityBalance] = await this.attachUtilityBalanceInfo([bill]);
|
||||
return withUtilityBalance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 给账单挂上"押金联动"信息:
|
||||
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额
|
||||
* - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额))
|
||||
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额
|
||||
*/
|
||||
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
||||
private async attachUtilityBalanceInfo(bills: Bill[]): Promise<any[]> {
|
||||
if (!bills || bills.length === 0) return bills;
|
||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||
if (studentIds.length === 0) return bills;
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
const depMap = new Map<number, number>();
|
||||
for (const d of deposits) {
|
||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
||||
}
|
||||
|
||||
const [rechargeRows, billRows] = await Promise.all([
|
||||
this.utilityRechargeRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('r.studentId', 'studentId')
|
||||
.addSelect('SUM(r.amount)', 'amount')
|
||||
.where('r.studentId IN (:...ids)', { ids: studentIds })
|
||||
.groupBy('r.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||
this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
.select('b.studentId', 'studentId')
|
||||
.addSelect('SUM(b.totalAmount)', 'amount')
|
||||
.where('b.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('b.status IN (:...statuses)', { statuses: ['confirmed', 'paid'] })
|
||||
.groupBy('b.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||
]);
|
||||
|
||||
const rechargeMap = new Map<number, number>();
|
||||
for (const row of rechargeRows) rechargeMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||
const usedMap = new Map<number, number>();
|
||||
for (const row of billRows) usedMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||
|
||||
return bills.map((b) => {
|
||||
const total = Number(b.totalAmount || 0);
|
||||
const available = Number((depMap.get(b.studentId) || 0).toFixed(2));
|
||||
const applied = Number(Math.min(available, total).toFixed(2));
|
||||
const afterDeposit = Number(Math.max(0, total - applied).toFixed(2));
|
||||
const currentBalance = Number(
|
||||
((rechargeMap.get(b.studentId) || 0) - (usedMap.get(b.studentId) || 0)).toFixed(2),
|
||||
);
|
||||
const balanceAfterBill =
|
||||
b.status === 'confirmed' || b.status === 'paid'
|
||||
? currentBalance
|
||||
: Number((currentBalance - total).toFixed(2));
|
||||
return Object.assign({}, b, {
|
||||
availableDeposit: available,
|
||||
depositApplied: applied,
|
||||
amountAfterDeposit: afterDeposit,
|
||||
utilityBalance: currentBalance,
|
||||
utilityBalanceAfterBill: balanceAfterBill,
|
||||
utilityShortageAmount: Math.max(0, -balanceAfterBill),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => {
|
||||
const studentRepo = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
|
||||
};
|
||||
const service = new DepositsService({} as never, {} as never, studentRepo as never);
|
||||
const service = new DepositsService({} as never, {} as never, studentRepo as never, {} as never);
|
||||
|
||||
await expect(service.getStudentLookups()).resolves.toEqual([
|
||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||
|
||||
@@ -3,13 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { DepositsService } from './deposits.service';
|
||||
import { DepositsController } from './deposits.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, PersonalExpense]),
|
||||
OperationLogsModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [DepositsController],
|
||||
providers: [DepositsService],
|
||||
exports: [DepositsService],
|
||||
|
||||
@@ -12,7 +12,7 @@ describe('DepositsService — direct refund', () => {
|
||||
findOne: jest.fn().mockResolvedValue(deposit),
|
||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||
};
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never);
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never, {} as never);
|
||||
|
||||
const result = await service.refund(
|
||||
1,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
|
||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||
|
||||
@@ -16,6 +17,8 @@ export class DepositsService {
|
||||
private installmentRepo: Repository<DepositInstallment>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(PersonalExpense)
|
||||
private personalExpenseRepo: Repository<PersonalExpense>,
|
||||
) {}
|
||||
|
||||
async getStudentLookups() {
|
||||
@@ -34,13 +37,15 @@ export class DepositsService {
|
||||
.orderBy('d.createdAt', 'DESC');
|
||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
||||
return qb.getMany();
|
||||
const deposits = await qb.getMany();
|
||||
return this.attachPersonalExpenseAmount(deposits);
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||
return deposit;
|
||||
const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]);
|
||||
return withPersonalExpense;
|
||||
}
|
||||
|
||||
async create(dto: CreateDepositDto, userId?: number) {
|
||||
@@ -125,4 +130,29 @@ export class DepositsService {
|
||||
qb.groupBy('d.status');
|
||||
return qb.getRawMany();
|
||||
}
|
||||
|
||||
private async attachPersonalExpenseAmount(deposits: Deposit[]) {
|
||||
if (!deposits.length) return deposits;
|
||||
const studentIds = Array.from(new Set(deposits.map((d) => d.studentId).filter(Boolean)));
|
||||
if (!studentIds.length) return deposits;
|
||||
|
||||
const rows = await this.personalExpenseRepo
|
||||
.createQueryBuilder('pe')
|
||||
.select('pe.studentId', 'studentId')
|
||||
.addSelect('SUM(pe.amount)', 'amount')
|
||||
.where('pe.studentId IN (:...studentIds)', { studentIds })
|
||||
.groupBy('pe.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>();
|
||||
|
||||
const amountMap = new Map<number, number>();
|
||||
for (const row of rows) {
|
||||
amountMap.set(Number(row.studentId), Number(Number(row.amount || 0).toFixed(2)));
|
||||
}
|
||||
|
||||
return deposits.map((deposit) =>
|
||||
Object.assign({}, deposit, {
|
||||
personalExpenseAmount: amountMap.get(deposit.studentId) || 0,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ export { User } from './user.entity';
|
||||
export { OperationLog } from './operation-log.entity';
|
||||
export { Deposit } from './deposit.entity';
|
||||
export { DepositInstallment } from './deposit-installment.entity';
|
||||
export { UtilityRecharge } from './utility-recharge.entity';
|
||||
export { Classroom, ClassroomStatus } from './classroom.entity';
|
||||
export { Organization } from './organization.entity';
|
||||
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity';
|
||||
|
||||
37
apps/server/src/entities/utility-recharge.entity.ts
Normal file
37
apps/server/src/entities/utility-recharge.entity.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import {
|
||||
Entity,
|
||||
PrimaryGeneratedColumn,
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
ManyToOne,
|
||||
JoinColumn,
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
|
||||
@Entity('utility_recharges')
|
||||
export class UtilityRecharge {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'student_id' })
|
||||
studentId: number;
|
||||
|
||||
@Column({ type: 'decimal', precision: 10, scale: 2 })
|
||||
amount: number;
|
||||
|
||||
@Column({ name: 'recharge_date', type: 'date' })
|
||||
rechargeDate: string;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string;
|
||||
|
||||
@Column({ name: 'recorded_by', nullable: true })
|
||||
recordedBy: number;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@ManyToOne(() => Student, { eager: true })
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
}
|
||||
@@ -5,8 +5,7 @@ import { ExpenseType } from '../entities/expense-type.entity';
|
||||
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
|
||||
|
||||
const DEFAULT_TYPES = [
|
||||
{ code: 'water', name: '水费', category: 'room', sortOrder: 1 },
|
||||
{ code: 'electricity', name: '电费', category: 'room', sortOrder: 2 },
|
||||
{ code: 'utility', name: '水电费', category: 'room', sortOrder: 1 },
|
||||
{ code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 },
|
||||
{ code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 },
|
||||
{ code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 },
|
||||
|
||||
@@ -208,9 +208,11 @@ export class ExpensesService {
|
||||
continue;
|
||||
}
|
||||
|
||||
const utilityFee = Number(((row.electricityFee || 0) + (row.waterFee || 0)).toFixed(2));
|
||||
|
||||
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
||||
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
||||
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
|
||||
if (utilityFee <= 0) {
|
||||
errors.push(
|
||||
`第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
|
||||
);
|
||||
@@ -218,53 +220,28 @@ export class ExpensesService {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
|
||||
// 幂等:先删除该房间在同一周期已有的水电费用记录,避免重复导入产生脏数据。
|
||||
// 同时兼容清理旧版本拆开的 water/electricity 记录。
|
||||
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'] })
|
||||
.andWhere('expenseType IN (:...types)', { types: ['utility', 'water', 'electricity'] })
|
||||
.execute();
|
||||
|
||||
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,
|
||||
}),
|
||||
);
|
||||
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,
|
||||
}),
|
||||
);
|
||||
savedAny = true;
|
||||
}
|
||||
|
||||
if (savedAny) imported++;
|
||||
else {
|
||||
skipped++;
|
||||
errors.push(`第${rowNum}行: ${row.roomNumber} 无有效金额`);
|
||||
}
|
||||
await this.roomExpRepo.save(
|
||||
this.roomExpRepo.create({
|
||||
roomId: room.id,
|
||||
expenseType: 'utility',
|
||||
amount: utilityFee,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
description: `电量${row.electricityAmount || 0}kWh,电费¥${Number(row.electricityFee || 0).toFixed(2)};用水${row.waterAmount || 0}吨,水费¥${Number(row.waterFee || 0).toFixed(2)}`,
|
||||
recordedBy: userId,
|
||||
}),
|
||||
);
|
||||
imported++;
|
||||
} catch (e: any) {
|
||||
errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
|
||||
skipped++;
|
||||
|
||||
17
apps/server/src/utility-balances/dto/utility-recharge.dto.ts
Normal file
17
apps/server/src/utility-balances/dto/utility-recharge.dto.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { IsInt, IsNumber, IsOptional, IsString, Min } from 'class-validator';
|
||||
|
||||
export class CreateUtilityRechargeDto {
|
||||
@IsInt()
|
||||
studentId: number;
|
||||
|
||||
@IsNumber()
|
||||
@Min(0.01)
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
rechargeDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query, Request, UseGuards } from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { CreateUtilityRechargeDto } from './dto/utility-recharge.dto';
|
||||
import { UtilityBalancesService } from './utility-balances.service';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('utility-balances')
|
||||
export class UtilityBalancesController {
|
||||
constructor(
|
||||
private service: UtilityBalancesService,
|
||||
private logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
@Get('student-lookups')
|
||||
@RequirePermission('expense:view')
|
||||
getStudentLookups() {
|
||||
return this.service.getStudentLookups();
|
||||
}
|
||||
|
||||
@Get('balances')
|
||||
@RequirePermission('expense:view')
|
||||
getBalances() {
|
||||
return this.service.getBalances();
|
||||
}
|
||||
|
||||
@Get('recharges')
|
||||
@RequirePermission('expense:view')
|
||||
findAll(@Query('studentId') studentId?: string) {
|
||||
return this.service.findAll({ studentId: studentId ? +studentId : undefined });
|
||||
}
|
||||
|
||||
@Post('recharges')
|
||||
@RequirePermission('expense:create')
|
||||
async create(@Body() dto: CreateUtilityRechargeDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.create(dto, req.user?.id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '水电余额',
|
||||
action: '充值',
|
||||
targetId: result.id,
|
||||
targetType: 'utility-recharge',
|
||||
detail: `学生${dto.studentId} 充值¥${dto.amount}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete('recharges/:id')
|
||||
@RequirePermission('expense:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '水电余额',
|
||||
action: '删除充值',
|
||||
targetId: +id,
|
||||
targetType: 'utility-recharge',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
}
|
||||
16
apps/server/src/utility-balances/utility-balances.module.ts
Normal file
16
apps/server/src/utility-balances/utility-balances.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { UtilityBalancesController } from './utility-balances.controller';
|
||||
import { UtilityBalancesService } from './utility-balances.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([UtilityRecharge, Student, Bill]), OperationLogsModule],
|
||||
controllers: [UtilityBalancesController],
|
||||
providers: [UtilityBalancesService],
|
||||
exports: [UtilityBalancesService],
|
||||
})
|
||||
export class UtilityBalancesModule {}
|
||||
102
apps/server/src/utility-balances/utility-balances.service.ts
Normal file
102
apps/server/src/utility-balances/utility-balances.service.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||
import { CreateUtilityRechargeDto } from './dto/utility-recharge.dto';
|
||||
|
||||
@Injectable()
|
||||
export class UtilityBalancesService {
|
||||
constructor(
|
||||
@InjectRepository(UtilityRecharge)
|
||||
private rechargeRepo: Repository<UtilityRecharge>,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(Bill)
|
||||
private billRepo: Repository<Bill>,
|
||||
) {}
|
||||
|
||||
async getStudentLookups() {
|
||||
return this.studentRepo.find({
|
||||
select: ['id', 'name', 'studentNo'],
|
||||
where: { status: 'active' },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findAll(query?: { studentId?: number }) {
|
||||
const where: Record<string, unknown> = {};
|
||||
if (query?.studentId) where.studentId = query.studentId;
|
||||
return this.rechargeRepo.find({
|
||||
where,
|
||||
relations: ['student'],
|
||||
order: { rechargeDate: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async create(dto: CreateUtilityRechargeDto, userId?: number) {
|
||||
if (dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
return this.rechargeRepo.save(
|
||||
this.rechargeRepo.create({
|
||||
studentId: dto.studentId,
|
||||
amount: dto.amount,
|
||||
rechargeDate: dto.rechargeDate,
|
||||
notes: dto.notes,
|
||||
recordedBy: userId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const exists = await this.rechargeRepo.findOne({ where: { id } });
|
||||
if (!exists) throw new NotFoundException('充值记录不存在');
|
||||
await this.rechargeRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async getBalances() {
|
||||
const [students, rechargeRows, billRows] = await Promise.all([
|
||||
this.studentRepo.find({
|
||||
select: ['id', 'name', 'studentNo', 'status'],
|
||||
order: { name: 'ASC' },
|
||||
}),
|
||||
this.rechargeRepo
|
||||
.createQueryBuilder('r')
|
||||
.select('r.studentId', 'studentId')
|
||||
.addSelect('SUM(r.amount)', 'amount')
|
||||
.groupBy('r.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||
this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
.select('b.studentId', 'studentId')
|
||||
.addSelect('SUM(b.totalAmount)', 'amount')
|
||||
.where('b.status IN (:...statuses)', { statuses: ['confirmed', 'paid'] })
|
||||
.groupBy('b.studentId')
|
||||
.getRawMany<{ studentId: number | string; amount: string | number | null }>(),
|
||||
]);
|
||||
|
||||
const rechargeMap = new Map<number, number>();
|
||||
for (const row of rechargeRows) {
|
||||
rechargeMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||
}
|
||||
const usedMap = new Map<number, number>();
|
||||
for (const row of billRows) {
|
||||
usedMap.set(Number(row.studentId), Number(row.amount || 0));
|
||||
}
|
||||
|
||||
return students.map((student) => {
|
||||
const totalRecharged = Number((rechargeMap.get(student.id) || 0).toFixed(2));
|
||||
const usedAmount = Number((usedMap.get(student.id) || 0).toFixed(2));
|
||||
return {
|
||||
student,
|
||||
studentId: student.id,
|
||||
totalRecharged,
|
||||
usedAmount,
|
||||
balance: Number((totalRecharged - usedAmount).toFixed(2)),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user