1 Commits

Author SHA1 Message Date
3c4e3bf162 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.
2026-07-14 18:05:04 +08:00
23 changed files with 719 additions and 321 deletions

View File

@@ -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={

View File

@@ -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' },
],

View File

@@ -49,6 +49,7 @@ const iconMap: Record<string, React.ReactNode> = {
overview: <AppstoreOutlined />,
occupancy: <SwapOutlined />,
expense: <DollarOutlined />,
utility: <WalletOutlined />,
bill: <FileTextOutlined />,
deposit: <WalletOutlined />,
classroom: <ReadOutlined />,

View File

@@ -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 }}

View File

@@ -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%' }}
/>

View 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;

View File

@@ -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 },

View File

@@ -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;
}
}

View File

@@ -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,
],

View File

@@ -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);
});

View File

@@ -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),
});
});
}

View File

@@ -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' },

View File

@@ -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],

View File

@@ -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,

View File

@@ -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,
}),
);
}
}

View File

@@ -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';

View 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;
}

View File

@@ -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 },

View File

@@ -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++;

View 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;
}

View File

@@ -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;
}
}

View 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 {}

View 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)),
};
});
}
}