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 RoomsPage = lazy(() => import('./pages/Rooms'));
|
||||||
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
||||||
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
||||||
|
const UtilityBalancesPage = lazy(() => import('./pages/UtilityBalances'));
|
||||||
const BillsPage = lazy(() => import('./pages/Bills'));
|
const BillsPage = lazy(() => import('./pages/Bills'));
|
||||||
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
||||||
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
||||||
@@ -126,6 +127,14 @@ const App: React.FC = () => {
|
|||||||
</PermissionRoute>
|
</PermissionRoute>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<Route
|
||||||
|
path="utility-balances"
|
||||||
|
element={
|
||||||
|
<PermissionRoute permission="expense:view">
|
||||||
|
<UtilityBalancesPage />
|
||||||
|
</PermissionRoute>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route
|
<Route
|
||||||
path="deposits"
|
path="deposits"
|
||||||
element={
|
element={
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ const SECTIONS: MenuSection[] = [
|
|||||||
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
{ key: '/rooms', label: '房间管理', icon: 'home', permission: 'room:view' },
|
||||||
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
{ key: '/occupancies', label: '入住管理', icon: 'occupancy', permission: 'occupancy:view' },
|
||||||
{ key: '/expenses', label: '费用管理', icon: 'expense', permission: 'expense: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: '/bills', label: '账单管理', icon: 'bill', permission: 'bill:view' },
|
||||||
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
{ key: '/deposits', label: '押金管理', icon: 'deposit', permission: 'deposit:view' },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const iconMap: Record<string, React.ReactNode> = {
|
|||||||
overview: <AppstoreOutlined />,
|
overview: <AppstoreOutlined />,
|
||||||
occupancy: <SwapOutlined />,
|
occupancy: <SwapOutlined />,
|
||||||
expense: <DollarOutlined />,
|
expense: <DollarOutlined />,
|
||||||
|
utility: <WalletOutlined />,
|
||||||
bill: <FileTextOutlined />,
|
bill: <FileTextOutlined />,
|
||||||
deposit: <WalletOutlined />,
|
deposit: <WalletOutlined />,
|
||||||
classroom: <ReadOutlined />,
|
classroom: <ReadOutlined />,
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Input,
|
Input,
|
||||||
Select,
|
Select,
|
||||||
Tooltip,
|
|
||||||
Spin,
|
Spin,
|
||||||
Empty,
|
Empty,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
@@ -35,8 +34,7 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const typeMap: Record<string, string> = {
|
const typeMap: Record<string, string> = {
|
||||||
water: '水费',
|
utility: '水电费',
|
||||||
electricity: '电费',
|
|
||||||
cleaning: '保洁费',
|
cleaning: '保洁费',
|
||||||
rent: '租金',
|
rent: '租金',
|
||||||
damage: '损坏赔偿',
|
damage: '损坏赔偿',
|
||||||
@@ -60,7 +58,6 @@ const buildBillPrintHtml = (bill: any) => {
|
|||||||
const generatedAt = bill.generatedAt
|
const generatedAt = bill.generatedAt
|
||||||
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
|
? dayjs(bill.generatedAt).format('YYYY-MM-DD HH:mm')
|
||||||
: dayjs().format('YYYY-MM-DD HH:mm');
|
: dayjs().format('YYYY-MM-DD HH:mm');
|
||||||
const hasDeposit = Number(bill.availableDeposit || 0) > 0;
|
|
||||||
const items = bill.items || [];
|
const items = bill.items || [];
|
||||||
|
|
||||||
return `<!doctype html>
|
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; }
|
.section-title { margin: 16px 0 6px; font-size: 14px; font-weight: 700; text-decoration: underline; }
|
||||||
.amount-summary { font-size: 12px; line-height: 1.75; }
|
.amount-summary { font-size: 12px; line-height: 1.75; }
|
||||||
.total { color: #007aff; font-size: 14px; font-weight: 700; }
|
.total { color: #007aff; font-size: 14px; font-weight: 700; }
|
||||||
.deposit { color: #52c41a; font-size: 11px; }
|
.balance { color: #52c41a; font-size: 11px; }
|
||||||
.deposit-applied { color: #fa8c16; font-size: 11px; }
|
.balance-after { color: #fa541c; font-size: 12px; font-weight: 700; }
|
||||||
.after-deposit { color: #ff3b30; font-size: 14px; font-weight: 700; }
|
.shortage { color: #ff4d4f; font-size: 12px; font-weight: 700; }
|
||||||
table { width: 100%; border-collapse: collapse; table-layout: fixed; margin-top: 8px; }
|
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, 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; }
|
th { color: #333; font-weight: 700; }
|
||||||
@@ -141,13 +138,12 @@ const buildBillPrintHtml = (bill: any) => {
|
|||||||
<div class="section-title">费用汇总</div>
|
<div class="section-title">费用汇总</div>
|
||||||
<div class="amount-summary">
|
<div class="amount-summary">
|
||||||
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
|
<div>分摊费用: ${escapeHtml(money(bill.sharedAmount))}</div>
|
||||||
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
|
|
||||||
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</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
|
Number(bill.utilityShortageAmount || 0) > 0
|
||||||
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
|
? `<div class="shortage">需补缴: ${escapeHtml(money(bill.utilityShortageAmount))}</div>`
|
||||||
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div>
|
|
||||||
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</div>`
|
|
||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -370,13 +366,6 @@ const BillsPage: React.FC = () => {
|
|||||||
align: 'right' as const,
|
align: 'right' as const,
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '个人费用',
|
|
||||||
dataIndex: 'personalAmount',
|
|
||||||
width: 120,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (v: number) => `¥${Number(v).toFixed(2)}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '总计',
|
title: '总计',
|
||||||
dataIndex: 'totalAmount',
|
dataIndex: 'totalAmount',
|
||||||
@@ -385,32 +374,39 @@ const BillsPage: React.FC = () => {
|
|||||||
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '可用押金',
|
title: '当前水电余额',
|
||||||
dataIndex: 'availableDeposit',
|
dataIndex: 'utilityBalance',
|
||||||
width: 120,
|
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) =>
|
render: (v: number) =>
|
||||||
v > 0 ? (
|
Number(v || 0) > 0 ? (
|
||||||
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
|
<strong style={{ color: '#ff4d4f' }}>¥{Number(v).toFixed(2)}</strong>
|
||||||
) : (
|
) : (
|
||||||
<span style={{ color: '#999' }}>-</span>
|
<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: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -512,7 +508,7 @@ const BillsPage: React.FC = () => {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
<Select placeholder="费用类型" allowClear style={{ width: 120 }} value={filterExpenseType} onChange={setFilterExpenseType}
|
<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
|
<PermissionButton
|
||||||
permission="bill:confirm"
|
permission="bill:confirm"
|
||||||
onClick={() => batchUpdateStatus('confirmed')}
|
onClick={() => batchUpdateStatus('confirmed')}
|
||||||
@@ -634,51 +630,45 @@ const BillsPage: React.FC = () => {
|
|||||||
<Descriptions.Item label="分摊费用">
|
<Descriptions.Item label="分摊费用">
|
||||||
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
¥{Number(detailModal.sharedAmount).toFixed(2)}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
<Descriptions.Item label="个人费用">
|
|
||||||
¥{Number(detailModal.personalAmount).toFixed(2)}
|
|
||||||
</Descriptions.Item>
|
|
||||||
<Descriptions.Item label="合计" span={2}>
|
<Descriptions.Item label="合计" span={2}>
|
||||||
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
<strong style={{ fontSize: 18, color: '#007AFF' }}>
|
||||||
¥{Number(detailModal.totalAmount).toFixed(2)}
|
¥{Number(detailModal.totalAmount).toFixed(2)}
|
||||||
</strong>
|
</strong>
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
{Number(detailModal.availableDeposit || 0) > 0 && (
|
<div
|
||||||
<div
|
style={{
|
||||||
style={{
|
marginBottom: 16,
|
||||||
marginBottom: 16,
|
padding: 12,
|
||||||
padding: 12,
|
background: '#f6ffed',
|
||||||
background: '#f6ffed',
|
border: '1px solid #b7eb8f',
|
||||||
border: '1px solid #b7eb8f',
|
borderRadius: 8,
|
||||||
borderRadius: 8,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>水电余额</div>
|
||||||
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
|
<Space size={24} wrap>
|
||||||
押金联动(不影响实际押金状态,仅作收款参考)
|
<span>
|
||||||
</div>
|
当前余额:
|
||||||
<Space size={24} wrap>
|
<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>
|
<span>
|
||||||
当前可用押金:
|
需补缴:
|
||||||
<strong style={{ color: '#52c41a' }}>
|
<strong style={{ color: '#ff4d4f', fontSize: 16 }}>
|
||||||
¥{Number(detailModal.availableDeposit).toFixed(2)}
|
¥{Number(detailModal.utilityShortageAmount || 0).toFixed(2)}
|
||||||
</strong>
|
</strong>
|
||||||
</span>
|
</span>
|
||||||
<span>
|
)}
|
||||||
本账单可抵扣:
|
</Space>
|
||||||
<strong style={{ color: '#fa8c16' }}>
|
</div>
|
||||||
-¥{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>
|
|
||||||
)}
|
|
||||||
<h4>费用明细</h4>
|
<h4>费用明细</h4>
|
||||||
<Table
|
<Table
|
||||||
scroll={{ x: 700 }}
|
scroll={{ x: 700 }}
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ const isFormValidationError = (error: unknown) =>
|
|||||||
&& error !== null
|
&& error !== null
|
||||||
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
&& Array.isArray((error as { errorFields?: unknown }).errorFields);
|
||||||
|
|
||||||
|
const money = (value: unknown) => Number(Number(value || 0).toFixed(2));
|
||||||
|
|
||||||
const DepositsPage: React.FC = () => {
|
const DepositsPage: React.FC = () => {
|
||||||
const [data, setData] = useState<any[]>([]);
|
const [data, setData] = useState<any[]>([]);
|
||||||
const [students, setStudents] = useState<any[]>([]);
|
const [students, setStudents] = useState<any[]>([]);
|
||||||
@@ -221,8 +223,13 @@ const DepositsPage: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
const personalExpenseAmount = money(record.personalExpenseAmount);
|
||||||
|
const depositAmount = money(record.amount);
|
||||||
setRefundModal(record);
|
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">
|
<Form form={refundForm} layout="vertical">
|
||||||
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
|
<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>
|
</div>
|
||||||
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
|
<Form.Item
|
||||||
|
name="deductionAmount"
|
||||||
|
label="扣除金额(元)"
|
||||||
|
extra="自动填入该学生个人附加费用总和;超过押金金额时按押金金额封顶"
|
||||||
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
max={Number(refundModal?.amount || 500)}
|
max={money(refundModal?.amount || 500)}
|
||||||
precision={2}
|
precision={2}
|
||||||
style={{ width: '100%' }}
|
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,
|
OperationLog,
|
||||||
Deposit,
|
Deposit,
|
||||||
DepositInstallment,
|
DepositInstallment,
|
||||||
|
UtilityRecharge,
|
||||||
Classroom,
|
Classroom,
|
||||||
Organization,
|
Organization,
|
||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
@@ -71,6 +72,7 @@ import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
|||||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||||
import { AgentToolsModule } from './agent-tools';
|
import { AgentToolsModule } from './agent-tools';
|
||||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||||
|
import { UtilityBalancesModule } from './utility-balances/utility-balances.module';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
IntegrationConfig,
|
IntegrationConfig,
|
||||||
@@ -109,6 +111,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
OperationLog,
|
OperationLog,
|
||||||
Deposit,
|
Deposit,
|
||||||
DepositInstallment,
|
DepositInstallment,
|
||||||
|
UtilityRecharge,
|
||||||
Classroom,
|
Classroom,
|
||||||
Organization,
|
Organization,
|
||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
@@ -181,6 +184,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
AgentToolsModule,
|
AgentToolsModule,
|
||||||
ExpenseTypesModule,
|
ExpenseTypesModule,
|
||||||
AiConfigModule,
|
AiConfigModule,
|
||||||
|
UtilityBalancesModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
{ provide: APP_GUARD, useClass: ThrottlerGuard },
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
|||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Bill } from '../entities/bill.entity';
|
import { Bill } from '../entities/bill.entity';
|
||||||
import { BillItem } from '../entities/bill-item.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 * as ExcelJS from 'exceljs';
|
||||||
import PDFDocument from 'pdfkit';
|
import PDFDocument from 'pdfkit';
|
||||||
import { Response } from 'express';
|
import { Response } from 'express';
|
||||||
@@ -13,7 +13,8 @@ export class BillsExportService {
|
|||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
@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 });
|
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
|
||||||
const bills = await qb.getMany();
|
const bills = await qb.getMany();
|
||||||
|
|
||||||
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
|
|
||||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||||
const depMap = new Map<number, number>();
|
const balanceMap = await this.getUtilityBalanceMap(studentIds);
|
||||||
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 workbook = new ExcelJS.Workbook();
|
const workbook = new ExcelJS.Workbook();
|
||||||
workbook.creator = '恭学教育基地管理系统';
|
workbook.creator = '恭学教育基地管理系统';
|
||||||
@@ -58,11 +48,10 @@ export class BillsExportService {
|
|||||||
{ header: '学生姓名', key: 'studentName', width: 14 },
|
{ header: '学生姓名', key: 'studentName', width: 14 },
|
||||||
{ header: '计费周期', key: 'period', width: 24 },
|
{ header: '计费周期', key: 'period', width: 24 },
|
||||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||||
{ header: '个人费用', key: 'personal', width: 12 },
|
|
||||||
{ header: '总金额', key: 'total', width: 12 },
|
{ header: '总金额', key: 'total', width: 12 },
|
||||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
{ header: '当前水电余额', key: 'utilityBalance', width: 14 },
|
||||||
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
|
{ header: '扣本账单后余额', key: 'utilityBalanceAfterBill', width: 16 },
|
||||||
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
|
{ header: '需补缴', key: 'utilityShortageAmount', width: 12 },
|
||||||
{ header: '状态', key: 'status', width: 10 },
|
{ header: '状态', key: 'status', width: 10 },
|
||||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||||
];
|
];
|
||||||
@@ -77,19 +66,21 @@ export class BillsExportService {
|
|||||||
};
|
};
|
||||||
for (const bill of bills) {
|
for (const bill of bills) {
|
||||||
const total = Number(bill.totalAmount || 0);
|
const total = Number(bill.totalAmount || 0);
|
||||||
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
|
const balance = Number((balanceMap.get(bill.studentId) || 0).toFixed(2));
|
||||||
const applied = Number(Math.min(dep, total).toFixed(2));
|
const balanceAfterBill =
|
||||||
const after = Number(Math.max(0, total - applied).toFixed(2));
|
bill.status === 'confirmed' || bill.status === 'paid'
|
||||||
|
? balance
|
||||||
|
: Number((balance - total).toFixed(2));
|
||||||
|
const shortage = Math.max(0, -balanceAfterBill);
|
||||||
ws.addRow({
|
ws.addRow({
|
||||||
id: bill.id,
|
id: bill.id,
|
||||||
studentName: (bill as any).student?.name || '-',
|
studentName: (bill as any).student?.name || '-',
|
||||||
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
|
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
|
||||||
shared: Number(bill.sharedAmount),
|
shared: Number(bill.sharedAmount),
|
||||||
personal: Number(bill.personalAmount),
|
|
||||||
total,
|
total,
|
||||||
deposit: dep,
|
utilityBalance: balance,
|
||||||
depositApplied: applied,
|
utilityBalanceAfterBill: balanceAfterBill,
|
||||||
afterDeposit: after,
|
utilityShortageAmount: shortage,
|
||||||
status: statusMap[bill.status] || bill.status,
|
status: statusMap[bill.status] || bill.status,
|
||||||
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
||||||
});
|
});
|
||||||
@@ -147,16 +138,13 @@ export class BillsExportService {
|
|||||||
return;
|
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 totalAmount = Number(bill.totalAmount || 0);
|
||||||
const depositApplied = Math.min(availableDeposit, totalAmount);
|
const balanceMap = await this.getUtilityBalanceMap([bill.studentId]);
|
||||||
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
|
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 });
|
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||||
res.setHeader('Content-Type', 'application/pdf');
|
res.setHeader('Content-Type', 'application/pdf');
|
||||||
@@ -217,26 +205,16 @@ export class BillsExportService {
|
|||||||
doc.moveDown(0.3);
|
doc.moveDown(0.3);
|
||||||
doc.fontSize(12);
|
doc.fontSize(12);
|
||||||
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
|
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
|
||||||
doc.text(`个人费用: ¥${Number(bill.personalAmount).toFixed(2)}`);
|
|
||||||
doc
|
doc
|
||||||
.fontSize(14)
|
.fontSize(14)
|
||||||
.fillColor('#007AFF')
|
.fillColor('#007AFF')
|
||||||
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||||
doc.moveDown(0.3);
|
doc.moveDown(0.3);
|
||||||
if (availableDeposit > 0) {
|
doc.fontSize(11).fillColor('#52C41A').text(`当前水电余额: ¥${utilityBalance.toFixed(2)}`);
|
||||||
doc
|
doc
|
||||||
.fontSize(11)
|
.fontSize(11)
|
||||||
.fillColor('#52C41A')
|
.fillColor(utilityBalanceAfterBill < 0 ? '#FF3B30' : '#52C41A')
|
||||||
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
.text(`扣本账单后余额: ¥${utilityBalanceAfterBill.toFixed(2)}`);
|
||||||
doc
|
|
||||||
.fontSize(11)
|
|
||||||
.fillColor('#FA8C16')
|
|
||||||
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
|
|
||||||
doc
|
|
||||||
.fontSize(14)
|
|
||||||
.fillColor('#FF3B30')
|
|
||||||
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
|
|
||||||
}
|
|
||||||
doc.moveDown(1);
|
doc.moveDown(1);
|
||||||
|
|
||||||
// 明细表格
|
// 明细表格
|
||||||
@@ -284,4 +262,44 @@ export class BillsExportService {
|
|||||||
|
|
||||||
doc.end();
|
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 { Bill } from '../entities/bill.entity';
|
||||||
import { BillItem } from '../entities/bill-item.entity';
|
import { BillItem } from '../entities/bill-item.entity';
|
||||||
import { RoomExpense } from '../entities/room-expense.entity';
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.entity';
|
import { Room } from '../entities/room.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
|
import { UtilityRecharge } from '../entities/utility-recharge.entity';
|
||||||
import { BillsService } from './bills.service';
|
import { BillsService } from './bills.service';
|
||||||
import { BillsExportService } from './bills-export.service';
|
import { BillsExportService } from './bills-export.service';
|
||||||
import { BillsController } from './bills.controller';
|
import { BillsController } from './bills.controller';
|
||||||
@@ -19,11 +18,10 @@ import { BillsController } from './bills.controller';
|
|||||||
Bill,
|
Bill,
|
||||||
BillItem,
|
BillItem,
|
||||||
RoomExpense,
|
RoomExpense,
|
||||||
PersonalExpense,
|
|
||||||
Occupancy,
|
Occupancy,
|
||||||
Room,
|
Room,
|
||||||
Deposit,
|
|
||||||
Student,
|
Student,
|
||||||
|
UtilityRecharge,
|
||||||
]),
|
]),
|
||||||
NotificationsModule,
|
NotificationsModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import { BillsService } from './bills.service';
|
|||||||
import { Bill } from '../entities/bill.entity';
|
import { Bill } from '../entities/bill.entity';
|
||||||
import { BillItem } from '../entities/bill-item.entity';
|
import { BillItem } from '../entities/bill-item.entity';
|
||||||
import { RoomExpense } from '../entities/room-expense.entity';
|
import { RoomExpense } from '../entities/room-expense.entity';
|
||||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
|
||||||
import { Occupancy } from '../entities/occupancy.entity';
|
import { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.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>>;
|
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||||
|
|
||||||
@@ -42,20 +41,18 @@ describe('BillsService — generateBills', () => {
|
|||||||
let billRepo: MockRepository<Bill>;
|
let billRepo: MockRepository<Bill>;
|
||||||
let itemRepo: MockRepository<BillItem>;
|
let itemRepo: MockRepository<BillItem>;
|
||||||
let roomExpRepo: MockRepository<RoomExpense>;
|
let roomExpRepo: MockRepository<RoomExpense>;
|
||||||
let personalExpRepo: MockRepository<PersonalExpense>;
|
|
||||||
let occRepo: MockRepository<Occupancy>;
|
let occRepo: MockRepository<Occupancy>;
|
||||||
let roomRepo: MockRepository<Room>;
|
let roomRepo: MockRepository<Room>;
|
||||||
let depositRepo: MockRepository<Deposit>;
|
let utilityRechargeRepo: MockRepository<UtilityRecharge>;
|
||||||
let dataSource: { transaction: jest.Mock };
|
let dataSource: { transaction: jest.Mock };
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
billRepo = mockRepo<Bill>();
|
billRepo = mockRepo<Bill>();
|
||||||
itemRepo = mockRepo<BillItem>();
|
itemRepo = mockRepo<BillItem>();
|
||||||
roomExpRepo = mockRepo<RoomExpense>();
|
roomExpRepo = mockRepo<RoomExpense>();
|
||||||
personalExpRepo = mockRepo<PersonalExpense>();
|
|
||||||
occRepo = mockRepo<Occupancy>();
|
occRepo = mockRepo<Occupancy>();
|
||||||
roomRepo = mockRepo<Room>();
|
roomRepo = mockRepo<Room>();
|
||||||
depositRepo = mockRepo<Deposit>();
|
utilityRechargeRepo = mockRepo<UtilityRecharge>();
|
||||||
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
||||||
|
|
||||||
const module: TestingModule = await Test.createTestingModule({
|
const module: TestingModule = await Test.createTestingModule({
|
||||||
@@ -64,10 +61,9 @@ describe('BillsService — generateBills', () => {
|
|||||||
{ provide: getRepositoryToken(Bill), useValue: billRepo },
|
{ provide: getRepositoryToken(Bill), useValue: billRepo },
|
||||||
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
|
{ provide: getRepositoryToken(BillItem), useValue: itemRepo },
|
||||||
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
|
{ provide: getRepositoryToken(RoomExpense), useValue: roomExpRepo },
|
||||||
{ provide: getRepositoryToken(PersonalExpense), useValue: personalExpRepo },
|
|
||||||
{ provide: getRepositoryToken(Occupancy), useValue: occRepo },
|
{ provide: getRepositoryToken(Occupancy), useValue: occRepo },
|
||||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
{ provide: getRepositoryToken(UtilityRecharge), useValue: utilityRechargeRepo },
|
||||||
{ provide: DataSource, useValue: dataSource },
|
{ provide: DataSource, useValue: dataSource },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).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);
|
const result = await service.generateBills(PERIOD);
|
||||||
|
|
||||||
expect(result.count).toBe(1);
|
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);
|
const result = await service.generateBills(PERIOD);
|
||||||
|
|
||||||
expect(result.count).toBe(2);
|
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 result = await service.generateBills(PERIOD);
|
||||||
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
|
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);
|
const result = await service.generateBills(THREE_MONTHS);
|
||||||
expect(result.count).toBe(1);
|
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);
|
const result = await service.generateBills(PERIOD);
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
|
|
||||||
@@ -361,10 +336,6 @@ describe('BillsService — generateBills', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
|
||||||
mockQueryBuilder<PersonalExpense>([]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Mock student department query
|
// Mock student department query
|
||||||
(dataSource.query as jest.Mock).mockResolvedValue([
|
(dataSource.query as jest.Mock).mockResolvedValue([
|
||||||
{ id: 10, department_id: null },
|
{ id: 10, department_id: null },
|
||||||
@@ -394,7 +365,7 @@ describe('BillsService — generateBills', () => {
|
|||||||
expect(totalAll).toBeCloseTo(700, 0);
|
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(
|
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
|
||||||
mockQueryBuilder<RoomExpense>([
|
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);
|
const result = await service.generateBills(PERIOD);
|
||||||
expect(result.count).toBe(1);
|
expect(result.count).toBe(1);
|
||||||
|
|
||||||
@@ -432,8 +392,8 @@ describe('BillsService — generateBills', () => {
|
|||||||
const billData = savedCalls[0][0];
|
const billData = savedCalls[0][0];
|
||||||
|
|
||||||
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
|
expect(Number(billData.sharedAmount)).toBeCloseTo(300, 0);
|
||||||
expect(Number(billData.personalAmount)).toBe(50);
|
expect(Number(billData.personalAmount)).toBe(0);
|
||||||
expect(Number(billData.totalAmount)).toBeCloseTo(350, 0);
|
expect(Number(billData.totalAmount)).toBeCloseTo(300, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('zero overlapping days → no bill generated', async () => {
|
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);
|
const result = await service.generateBills(PERIOD);
|
||||||
// Occupancy outside period → no matching student days → no bill
|
// Occupancy outside period → no matching student days → no bill
|
||||||
expect(result.count).toBe(0);
|
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);
|
const result = await service.generateBills(PERIOD);
|
||||||
expect(result.count).toBe(0);
|
expect(result.count).toBe(0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,38 +1,32 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository, In, DataSource } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
import { Bill } from '../entities/bill.entity';
|
import { Bill } from '../entities/bill.entity';
|
||||||
import { BillItem } from '../entities/bill-item.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 { Occupancy } from '../entities/occupancy.entity';
|
||||||
import { Room } from '../entities/room.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';
|
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||||
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BillsService {
|
export class BillsService {
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||||
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
|
||||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
@InjectRepository(UtilityRecharge) private utilityRechargeRepo: Repository<UtilityRecharge>,
|
||||||
private dataSource: DataSource,
|
private dataSource: DataSource,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/** 核心计费引擎:按"人天数"加权分摊 */
|
||||||
* 核心计费引擎:按"人天数"加权分摊
|
|
||||||
*/
|
|
||||||
async generateBills(dto: GenerateBillsDto) {
|
async generateBills(dto: GenerateBillsDto) {
|
||||||
const { periodStart, periodEnd } = dto;
|
const { periodStart, periodEnd } = dto;
|
||||||
const pStart = new Date(periodStart);
|
const pStart = new Date(periodStart);
|
||||||
const pEnd = new Date(periodEnd);
|
const pEnd = new Date(periodEnd);
|
||||||
|
|
||||||
// 删除该周期已有的草稿账单
|
|
||||||
const existingDrafts = await this.billRepo.find({
|
const existingDrafts = await this.billRepo.find({
|
||||||
where: { periodStart, periodEnd, status: 'draft' },
|
where: { periodStart, periodEnd, status: 'draft' },
|
||||||
});
|
});
|
||||||
@@ -50,7 +44,6 @@ export class BillsService {
|
|||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取所有有费用的宿舍
|
|
||||||
const roomExpenses = await this.roomExpRepo
|
const roomExpenses = await this.roomExpRepo
|
||||||
.createQueryBuilder('e')
|
.createQueryBuilder('e')
|
||||||
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', {
|
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', {
|
||||||
@@ -59,18 +52,15 @@ export class BillsService {
|
|||||||
})
|
})
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
// 按宿舍分组费用
|
|
||||||
const roomExpMap = new Map<number, RoomExpense[]>();
|
const roomExpMap = new Map<number, RoomExpense[]>();
|
||||||
for (const exp of roomExpenses) {
|
for (const exp of roomExpenses) {
|
||||||
if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
|
if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
|
||||||
roomExpMap.get(exp.roomId)!.push(exp);
|
roomExpMap.get(exp.roomId)!.push(exp);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算每个学生的分摊费用
|
|
||||||
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
||||||
|
|
||||||
for (const [roomId, expenses] of roomExpMap) {
|
for (const [roomId, expenses] of roomExpMap) {
|
||||||
// 获取该宿舍在此周期内的所有入住记录
|
|
||||||
const occupancies = await this.occRepo
|
const occupancies = await this.occRepo
|
||||||
.createQueryBuilder('o')
|
.createQueryBuilder('o')
|
||||||
.leftJoinAndSelect('o.student', 'student')
|
.leftJoinAndSelect('o.student', 'student')
|
||||||
@@ -80,12 +70,9 @@ export class BillsService {
|
|||||||
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
|
||||||
.getMany();
|
.getMany();
|
||||||
|
|
||||||
|
|
||||||
// 分离长租与短租入住记录
|
|
||||||
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
|
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
|
||||||
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
|
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
|
||||||
|
|
||||||
// 长租:按月租费独立计费,不参与人天数分摊
|
|
||||||
for (const occ of longTermOccs) {
|
for (const occ of longTermOccs) {
|
||||||
const monthlyRate = Number(occ.room?.monthlyRate || 0);
|
const monthlyRate = Number(occ.room?.monthlyRate || 0);
|
||||||
if (!studentBillData.has(occ.studentId)) {
|
if (!studentBillData.has(occ.studentId)) {
|
||||||
@@ -104,10 +91,8 @@ export class BillsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 短租:原人天数加权分摊逻辑
|
|
||||||
if (shortTermOccs.length === 0) continue;
|
if (shortTermOccs.length === 0) continue;
|
||||||
|
|
||||||
// 计算每个学生的计费天数
|
|
||||||
const studentDays: { studentId: number; days: number }[] = [];
|
const studentDays: { studentId: number; days: number }[] = [];
|
||||||
let totalDays = 0;
|
let totalDays = 0;
|
||||||
|
|
||||||
@@ -128,7 +113,6 @@ export class BillsService {
|
|||||||
|
|
||||||
if (totalDays === 0) continue;
|
if (totalDays === 0) continue;
|
||||||
|
|
||||||
// 对每项费用进行分摊
|
|
||||||
for (const expense of expenses) {
|
for (const expense of expenses) {
|
||||||
for (const sd of studentDays) {
|
for (const sd of studentDays) {
|
||||||
if (sd.days === 0) continue;
|
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[] = [];
|
const bills: Bill[] = [];
|
||||||
for (const studentId of allStudentIds) {
|
for (const studentId of studentBillData.keys()) {
|
||||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||||
const personal = personalMap.get(studentId) || 0;
|
const total = Number(shared.toFixed(2));
|
||||||
const total = Number((shared + personal).toFixed(2));
|
|
||||||
|
|
||||||
const bill = this.billRepo.create({
|
const bill = this.billRepo.create({
|
||||||
studentId,
|
studentId,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
sharedAmount: Number(shared.toFixed(2)),
|
sharedAmount: Number(shared.toFixed(2)),
|
||||||
personalAmount: personal,
|
personalAmount: 0,
|
||||||
totalAmount: total,
|
totalAmount: total,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
});
|
});
|
||||||
const savedBill = await this.billRepo.save(bill);
|
const savedBill = await this.billRepo.save(bill);
|
||||||
|
|
||||||
// 保存明细
|
const items = studentBillData.get(studentId)?.items || [];
|
||||||
const items = [
|
|
||||||
...(studentBillData.get(studentId)?.items || []),
|
|
||||||
...(personalItems.get(studentId) || []),
|
|
||||||
];
|
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id }));
|
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 });
|
qb.innerJoin('b.items', 'bi', 'bi.expenseType = :et', { et: query.expenseType });
|
||||||
}
|
}
|
||||||
const bills = await qb.getMany();
|
const bills = await qb.getMany();
|
||||||
return this.attachDepositInfo(bills);
|
return this.attachUtilityBalanceInfo(bills);
|
||||||
}
|
}
|
||||||
|
|
||||||
async findOne(id: number) {
|
async findOne(id: number) {
|
||||||
const bill = await this.billRepo.findOne({ where: { id }, relations: ['student', 'items'] });
|
const bill = await this.billRepo.findOne({ where: { id }, relations: ['student', 'items'] });
|
||||||
if (!bill) throw new NotFoundException('账单不存在');
|
if (!bill) throw new NotFoundException('账单不存在');
|
||||||
const [withDeposit] = await this.attachDepositInfo([bill]);
|
const [withUtilityBalance] = await this.attachUtilityBalanceInfo([bill]);
|
||||||
return withDeposit;
|
return withUtilityBalance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private async attachUtilityBalanceInfo(bills: Bill[]): Promise<any[]> {
|
||||||
* 给账单挂上"押金联动"信息:
|
|
||||||
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额
|
|
||||||
* - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额))
|
|
||||||
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额
|
|
||||||
*/
|
|
||||||
private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
|
|
||||||
if (!bills || bills.length === 0) return bills;
|
if (!bills || bills.length === 0) return bills;
|
||||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||||
if (studentIds.length === 0) return bills;
|
if (studentIds.length === 0) return bills;
|
||||||
const deposits = await this.depositRepo
|
|
||||||
.createQueryBuilder('d')
|
const [rechargeRows, billRows] = await Promise.all([
|
||||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
this.utilityRechargeRepo
|
||||||
.andWhere('d.status = :status', { status: 'paid' })
|
.createQueryBuilder('r')
|
||||||
.getMany();
|
.select('r.studentId', 'studentId')
|
||||||
const depMap = new Map<number, number>();
|
.addSelect('SUM(r.amount)', 'amount')
|
||||||
for (const d of deposits) {
|
.where('r.studentId IN (:...ids)', { ids: studentIds })
|
||||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
.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) => {
|
return bills.map((b) => {
|
||||||
const total = Number(b.totalAmount || 0);
|
const total = Number(b.totalAmount || 0);
|
||||||
const available = Number((depMap.get(b.studentId) || 0).toFixed(2));
|
const currentBalance = Number(
|
||||||
const applied = Number(Math.min(available, total).toFixed(2));
|
((rechargeMap.get(b.studentId) || 0) - (usedMap.get(b.studentId) || 0)).toFixed(2),
|
||||||
const afterDeposit = Number(Math.max(0, total - applied).toFixed(2));
|
);
|
||||||
|
const balanceAfterBill =
|
||||||
|
b.status === 'confirmed' || b.status === 'paid'
|
||||||
|
? currentBalance
|
||||||
|
: Number((currentBalance - total).toFixed(2));
|
||||||
return Object.assign({}, b, {
|
return Object.assign({}, b, {
|
||||||
availableDeposit: available,
|
utilityBalance: currentBalance,
|
||||||
depositApplied: applied,
|
utilityBalanceAfterBill: balanceAfterBill,
|
||||||
amountAfterDeposit: afterDeposit,
|
utilityShortageAmount: Math.max(0, -balanceAfterBill),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ describe('DepositsService permission-scoped lookups', () => {
|
|||||||
const studentRepo = {
|
const studentRepo = {
|
||||||
find: jest.fn().mockResolvedValue([{ id: 2, name: '张三', studentNo: 'S2' }]),
|
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([
|
await expect(service.getStudentLookups()).resolves.toEqual([
|
||||||
{ id: 2, name: '张三', studentNo: 'S2' },
|
{ id: 2, name: '张三', studentNo: 'S2' },
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
|||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
import { DepositsService } from './deposits.service';
|
import { DepositsService } from './deposits.service';
|
||||||
import { DepositsController } from './deposits.controller';
|
import { DepositsController } from './deposits.controller';
|
||||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Deposit, DepositInstallment, Student]), OperationLogsModule, NotificationsModule],
|
imports: [
|
||||||
|
TypeOrmModule.forFeature([Deposit, DepositInstallment, Student, PersonalExpense]),
|
||||||
|
OperationLogsModule,
|
||||||
|
NotificationsModule,
|
||||||
|
],
|
||||||
controllers: [DepositsController],
|
controllers: [DepositsController],
|
||||||
providers: [DepositsService],
|
providers: [DepositsService],
|
||||||
exports: [DepositsService],
|
exports: [DepositsService],
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ describe('DepositsService — direct refund', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue(deposit),
|
findOne: jest.fn().mockResolvedValue(deposit),
|
||||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
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(
|
const result = await service.refund(
|
||||||
1,
|
1,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Repository } from 'typeorm';
|
|||||||
import { Deposit } from '../entities/deposit.entity';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
import { Student } from '../entities/student.entity';
|
import { Student } from '../entities/student.entity';
|
||||||
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
import { DepositInstallment } from '../entities/deposit-installment.entity';
|
||||||
|
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||||
|
|
||||||
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
|
||||||
|
|
||||||
@@ -16,6 +17,8 @@ export class DepositsService {
|
|||||||
private installmentRepo: Repository<DepositInstallment>,
|
private installmentRepo: Repository<DepositInstallment>,
|
||||||
@InjectRepository(Student)
|
@InjectRepository(Student)
|
||||||
private studentRepo: Repository<Student>,
|
private studentRepo: Repository<Student>,
|
||||||
|
@InjectRepository(PersonalExpense)
|
||||||
|
private personalExpenseRepo: Repository<PersonalExpense>,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getStudentLookups() {
|
async getStudentLookups() {
|
||||||
@@ -34,13 +37,15 @@ export class DepositsService {
|
|||||||
.orderBy('d.createdAt', 'DESC');
|
.orderBy('d.createdAt', 'DESC');
|
||||||
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
|
||||||
if (query?.status) qb.andWhere('d.status = :status', { status: query.status });
|
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) {
|
async findOne(id: number) {
|
||||||
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] });
|
||||||
if (!deposit) throw new NotFoundException('押金记录不存在');
|
if (!deposit) throw new NotFoundException('押金记录不存在');
|
||||||
return deposit;
|
const [withPersonalExpense] = await this.attachPersonalExpenseAmount([deposit]);
|
||||||
|
return withPersonalExpense;
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateDepositDto, userId?: number) {
|
async create(dto: CreateDepositDto, userId?: number) {
|
||||||
@@ -125,4 +130,29 @@ export class DepositsService {
|
|||||||
qb.groupBy('d.status');
|
qb.groupBy('d.status');
|
||||||
return qb.getRawMany();
|
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 { OperationLog } from './operation-log.entity';
|
||||||
export { Deposit } from './deposit.entity';
|
export { Deposit } from './deposit.entity';
|
||||||
export { DepositInstallment } from './deposit-installment.entity';
|
export { DepositInstallment } from './deposit-installment.entity';
|
||||||
|
export { UtilityRecharge } from './utility-recharge.entity';
|
||||||
export { Classroom, ClassroomStatus } from './classroom.entity';
|
export { Classroom, ClassroomStatus } from './classroom.entity';
|
||||||
export { Organization } from './organization.entity';
|
export { Organization } from './organization.entity';
|
||||||
export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.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';
|
import { CreateExpenseTypeDto, UpdateExpenseTypeDto } from './dto/expense-type.dto';
|
||||||
|
|
||||||
const DEFAULT_TYPES = [
|
const DEFAULT_TYPES = [
|
||||||
{ code: 'water', name: '水费', category: 'room', sortOrder: 1 },
|
{ code: 'utility', name: '水电费', category: 'room', sortOrder: 1 },
|
||||||
{ code: 'electricity', name: '电费', category: 'room', sortOrder: 2 },
|
|
||||||
{ code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 },
|
{ code: 'cleaning', name: '保洁费', category: 'room', sortOrder: 3 },
|
||||||
{ code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 },
|
{ code: 'damage', name: '损坏赔偿', category: 'both', sortOrder: 4 },
|
||||||
{ code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 },
|
{ code: 'penalty', name: '罚款', category: 'personal', sortOrder: 5 },
|
||||||
|
|||||||
@@ -208,9 +208,11 @@ export class ExpensesService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const utilityFee = Number(((row.electricityFee || 0) + (row.waterFee || 0)).toFixed(2));
|
||||||
|
|
||||||
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
||||||
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
||||||
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
|
if (utilityFee <= 0) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
|
`第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
|
||||||
);
|
);
|
||||||
@@ -218,53 +220,28 @@ export class ExpensesService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
|
// 幂等:先删除该房间在同一周期已有的水电费用记录,避免重复导入产生脏数据。
|
||||||
|
// 同时兼容清理旧版本拆开的 water/electricity 记录。
|
||||||
await this.roomExpRepo
|
await this.roomExpRepo
|
||||||
.createQueryBuilder()
|
.createQueryBuilder()
|
||||||
.delete()
|
.delete()
|
||||||
.where('roomId = :roomId', { roomId: room.id })
|
.where('roomId = :roomId', { roomId: room.id })
|
||||||
.andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
|
.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();
|
.execute();
|
||||||
|
|
||||||
let savedAny = false;
|
await this.roomExpRepo.save(
|
||||||
// 导入电费
|
this.roomExpRepo.create({
|
||||||
if (row.electricityFee > 0) {
|
roomId: room.id,
|
||||||
await this.roomExpRepo.save(
|
expenseType: 'utility',
|
||||||
this.roomExpRepo.create({
|
amount: utilityFee,
|
||||||
roomId: room.id,
|
periodStart,
|
||||||
expenseType: 'electricity',
|
periodEnd,
|
||||||
amount: row.electricityFee,
|
description: `电量${row.electricityAmount || 0}kWh,电费¥${Number(row.electricityFee || 0).toFixed(2)};用水${row.waterAmount || 0}吨,水费¥${Number(row.waterFee || 0).toFixed(2)}`,
|
||||||
periodStart,
|
recordedBy: userId,
|
||||||
periodEnd,
|
}),
|
||||||
description: `电量${row.electricityAmount}kWh`,
|
);
|
||||||
recordedBy: userId,
|
imported++;
|
||||||
}),
|
|
||||||
);
|
|
||||||
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} 无有效金额`);
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
|
errors.push(`第${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
|
||||||
skipped++;
|
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