修复账单生成时未计算分摊费用 #10

Merged
xiongyuxing merged 3 commits from xiongyuxing/gongxue-base:main into main 2026-07-14 08:41:14 +00:00
13 changed files with 368 additions and 263 deletions

View File

@@ -26,11 +26,8 @@ import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' }, draft: { text: '草稿', color: 'default' },
confirmed: { text: '已确认', color: 'blue' },
paid: { text: '已支付', color: 'green' }, paid: { text: '已支付', color: 'green' },
}; };
@@ -61,6 +58,7 @@ const buildBillPrintHtml = (bill: any) => {
? 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 hasDeposit = Number(bill.availableDeposit || 0) > 0;
const depositDeducted = Number(bill.depositDeductedAmount || 0);
const items = bill.items || []; const items = bill.items || [];
return `<!doctype html> return `<!doctype html>
@@ -93,8 +91,7 @@ const buildBillPrintHtml = (bill: any) => {
.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; } .deposit { color: #52c41a; font-size: 11px; }
.deposit-applied { color: #fa8c16; font-size: 11px; } .deposit-deducted { color: #fa8c16; font-size: 11px; }
.after-deposit { color: #ff3b30; font-size: 14px; 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; }
@@ -144,10 +141,9 @@ const buildBillPrintHtml = (bill: any) => {
<div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div> <div>个人费用: ${escapeHtml(money(bill.personalAmount))}</div>
<div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div> <div class="total">应付总额: ${escapeHtml(money(bill.totalAmount))}</div>
${ ${
hasDeposit hasDeposit || depositDeducted > 0
? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div> ? `<div class="deposit">可用押金: ${escapeHtml(money(bill.availableDeposit))}</div>
<div class="deposit-applied">押金抵扣: -${escapeHtml(money(bill.depositApplied))}</div> ${depositDeducted > 0 ? `<div class="deposit-deducted">已扣押金: -${escapeHtml(money(depositDeducted))}</div>` : ''}`
<div class="after-deposit">抵扣后应付: ${escapeHtml(money(bill.amountAfterDeposit ?? bill.totalAmount))}</div>`
: '' : ''
} }
</div> </div>
@@ -243,6 +239,16 @@ const BillsPage: React.FC = () => {
}); });
}, [bills, searchText, filterStatus]); }, [bills, searchText, filterStatus]);
const selectedBillRows = useMemo(
() => bills.filter((bill: any) => selectedRows.includes(bill.id)),
[bills, selectedRows],
);
const canBatchConfirm =
selectedBillRows.length > 0
&& selectedBillRows.every((bill: any) => bill.status === 'draft' && bill.depositSufficient);
const canBatchDelete =
selectedBillRows.length > 0 && selectedBillRows.every((bill: any) => bill.status !== 'paid');
const handleGenerate = async () => { const handleGenerate = async () => {
if (saving) return; if (saving) return;
@@ -250,8 +256,7 @@ const BillsPage: React.FC = () => {
const values = await generateForm.validateFields(); const values = await generateForm.validateFields();
setSaving(true); setSaving(true);
const res: any = await api.post('/bills/generate', { const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'), billingMonth: values.billingMonth.format('YYYY-MM'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
}); });
message.success(res.message || '生成成功'); message.success(res.message || '生成成功');
setGenerateModal(false); setGenerateModal(false);
@@ -282,10 +287,10 @@ const BillsPage: React.FC = () => {
const updateStatus = async (id: number, status: string) => { const updateStatus = async (id: number, status: string) => {
try { try {
await api.put(`/bills/${id}/status`, { status }); await api.put(`/bills/${id}/status`, { status });
message.success('状态更新成功'); message.success('账单已确认支付,押金已自动扣除');
fetchData(); fetchData();
if (detailModal?.id === id) { if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status }); void showDetail(id);
} }
} catch (e: any) { } catch (e: any) {
message.error(e?.message || '操作失败'); message.error(e?.message || '操作失败');
@@ -298,7 +303,7 @@ const BillsPage: React.FC = () => {
setBatchLoading(true); setBatchLoading(true);
try { try {
await api.put('/bills/batch/status', { ids: selectedRows, status }); await api.put('/bills/batch/status', { ids: selectedRows, status });
message.success(`批量更新 ${selectedRows.length} 条账单`); message.success(`确认支付 ${selectedRows.length} 条账单,并自动扣除押金`);
setSelectedRows([]); setSelectedRows([]);
fetchData(); fetchData();
} catch (e: any) { } catch (e: any) {
@@ -396,20 +401,15 @@ const BillsPage: React.FC = () => {
), ),
}, },
{ {
title: '抵扣后应付', title: '已扣押金',
dataIndex: 'amountAfterDeposit', dataIndex: 'depositDeductedAmount',
width: 130, width: 120,
render: (v: number, r: any) => { render: (v: number) =>
const has = Number(r.availableDeposit || 0) > 0; Number(v || 0) > 0 ? (
if (!has) return <span style={{ color: '#999' }}>-</span>; <span style={{ color: '#fa8c16' }}>¥{Number(v).toFixed(2)}</span>
const after = Number(v ?? r.totalAmount).toFixed(2); ) : (
const applied = Number(r.depositApplied || 0).toFixed(2); <span style={{ color: '#999' }}>-</span>
return ( ),
<Tooltip title={`已抵扣押金 ¥${applied}`}>
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
</Tooltip>
);
},
}, },
{ {
title: '状态', title: '状态',
@@ -437,23 +437,23 @@ const BillsPage: React.FC = () => {
</PermissionButton> </PermissionButton>
{record.status === 'draft' && ( {record.status === 'draft' && (
<PermissionButton <Tooltip
permission="bill:confirm" title={
size="small" record.depositSufficient
onClick={() => updateStatus(record.id, 'confirmed')} ? '确认后将自动从该学生押金余额中扣除账单金额'
: '押金不足,请先到押金管理收取押金'
}
> >
<PermissionButton
</PermissionButton> permission="bill:confirm"
)} size="small"
{record.status === 'confirmed' && ( type="primary"
<PermissionButton disabled={!record.depositSufficient}
permission="bill:confirm" onClick={() => updateStatus(record.id, 'paid')}
size="small" >
type="primary"
onClick={() => updateStatus(record.id, 'paid')} </PermissionButton>
> </Tooltip>
</PermissionButton>
)} )}
<PermissionButton <PermissionButton
permission="bill:export-pdf" permission="bill:export-pdf"
@@ -469,7 +469,13 @@ const BillsPage: React.FC = () => {
okText="删除" okText="删除"
cancelText="取消" cancelText="取消"
> >
<PermissionButton permission="bill:delete" size="small" danger icon={<DeleteOutlined />}> <PermissionButton
permission="bill:delete"
size="small"
danger
disabled={record.status === 'paid'}
icon={<DeleteOutlined />}
>
</PermissionButton> </PermissionButton>
</Popconfirm> </Popconfirm>
@@ -507,38 +513,30 @@ const BillsPage: React.FC = () => {
onChange={(v) => setFilterStatus(v)} onChange={(v) => setFilterStatus(v)}
options={[ options={[
{ value: 'draft', label: '草稿' }, { value: 'draft', label: '草稿' },
{ value: 'confirmed', label: '已确认' },
{ value: 'paid', label: '已支付' }, { value: 'paid', label: '已支付' },
]} ]}
/> />
<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:'water',label:'水费'},{value:'electricity',label:'电费'},{value:'cleaning',label:'保洁费'},{value:'rent',label:'租金'},{value:'other',label:'其他'}]} />
<PermissionButton
permission="bill:confirm"
onClick={() => batchUpdateStatus('confirmed')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton <PermissionButton
permission="bill:confirm" permission="bill:confirm"
type="primary" type="primary"
onClick={() => batchUpdateStatus('paid')} onClick={() => batchUpdateStatus('paid')}
disabled={selectedRows.length === 0} disabled={!canBatchConfirm}
> >
</PermissionButton> </PermissionButton>
<Popconfirm <Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`} title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete} onConfirm={batchDelete}
okText="删除" okText="删除"
cancelText="取消" cancelText="取消"
disabled={selectedRows.length === 0} disabled={!canBatchDelete}
> >
<PermissionButton <PermissionButton
permission="bill:delete" permission="bill:delete"
danger danger
disabled={selectedRows.length === 0} disabled={!canBatchDelete}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
> >
@@ -595,15 +593,19 @@ const BillsPage: React.FC = () => {
> >
<Form form={generateForm} layout="vertical"> <Form form={generateForm} layout="vertical">
<Form.Item <Form.Item
name="period" name="billingMonth"
label="账单周期" label="账单月份"
rules={[{ required: true, message: '请选择账单周期' }]} rules={[{ required: true, message: '请选择账单月份' }]}
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用" extra="只能选择已结束月份,每个月只能生成一次账单"
> >
<RangePicker <DatePicker
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']} picker="month"
format="YYYY-MM-DD" placeholder="选择月份"
format="YYYY-MM"
disabledDate={(current) =>
!!current && !current.endOf('month').isBefore(dayjs(), 'day')
}
/> />
</Form.Item> </Form.Item>
</Form> </Form>
@@ -643,18 +645,20 @@ const BillsPage: React.FC = () => {
</strong> </strong>
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </Descriptions>
{Number(detailModal.availableDeposit || 0) > 0 && ( {(Number(detailModal.availableDeposit || 0) > 0
|| Number(detailModal.depositDeductedAmount || 0) > 0
|| detailModal.status === 'draft') && (
<div <div
style={{ style={{
marginBottom: 16, marginBottom: 16,
padding: 12, padding: 12,
background: '#f6ffed', background: detailModal.depositSufficient || detailModal.status === 'paid' ? '#f6ffed' : '#fff2f0',
border: '1px solid #b7eb8f', border: `1px solid ${detailModal.depositSufficient || detailModal.status === 'paid' ? '#b7eb8f' : '#ffccc7'}`,
borderRadius: 8, borderRadius: 8,
}} }}
> >
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}> <div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
</div> </div>
<Space size={24} wrap> <Space size={24} wrap>
<span> <span>
@@ -664,16 +668,9 @@ const BillsPage: React.FC = () => {
</strong> </strong>
</span> </span>
<span> <span>
<strong style={{ color: '#fa8c16' }}> <strong style={{ color: '#fa8c16' }}>
-¥{Number(detailModal.depositApplied || 0).toFixed(2)} -¥{Number(detailModal.depositDeductedAmount || 0).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa541c', fontSize: 16 }}>
¥
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
</strong> </strong>
</span> </span>
</Space> </Space>

View File

@@ -22,10 +22,9 @@ import { message } from '../../ui/app-message';
import { buildDepositStudentOptions } from './deposit-student-option'; import { buildDepositStudentOptions } from './deposit-student-option';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' }, paid: { text: '有余额', color: 'green' },
refunded: { text: '已全退', color: 'blue' }, refunded: { text: '已全退', color: 'blue' },
partial_refund: { text: '部分退还', color: 'orange' }, depleted: { text: '已扣完', color: 'red' },
deducted: { text: '已全扣', color: 'red' },
}; };
const installmentStatusMap: Record<string, { text: string; color: string }> = { const installmentStatusMap: Record<string, { text: string; color: string }> = {
@@ -100,7 +99,7 @@ const DepositsPage: React.FC = () => {
paidDate: values.paidDate.format('YYYY-MM-DD'), paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes, notes: values.notes,
}); });
message.success('押金记录已创建'); message.success('押金金额已增加');
setCreateModal(false); setCreateModal(false);
createForm.resetFields(); createForm.resetFields();
fetchData(); fetchData();
@@ -119,8 +118,6 @@ const DepositsPage: React.FC = () => {
const values = await refundForm.validateFields(); const values = await refundForm.validateFields();
await api.put(`/deposits/${refundModal.id}/refund`, { await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'), refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
deductionReason: values.deductionReason,
notes: values.notes, notes: values.notes,
}); });
message.success('退还操作完成'); message.success('退还操作完成');
@@ -180,24 +177,13 @@ const DepositsPage: React.FC = () => {
const columns = useMemo(() => [ const columns = useMemo(() => [
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' }, { title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', width: 110, render: (v: number) => `¥${Number(v).toFixed(2)}` }, { title: '当前可用押金', dataIndex: 'amount', width: 130, render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate', width: 110 }, { title: '最近收取日期', dataIndex: 'paidDate', width: 120 },
{ {
title: '状态', title: '状态',
dataIndex: 'status', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>, render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
}, },
{
title: '退还金额',
dataIndex: 'refundAmount',
render: (v: any) => (v != null ? `¥${Number(v).toFixed(2)}` : '-'),
},
{
title: '扣除金额',
dataIndex: 'deductionAmount',
render: (v: any) => (v > 0 ? `¥${Number(v).toFixed(2)}` : '-'),
},
{ title: '扣除原因', dataIndex: 'deductionReason', width: 120, render: (v: any) => v || '-' },
{ title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' }, { title: '退还日期', dataIndex: 'refundDate', width: 110, render: (v: any) => v || '-' },
{ title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' }, { title: '备注', dataIndex: 'notes', width: 120, render: (v: any) => v || '-' },
{ {
@@ -222,7 +208,7 @@ const DepositsPage: React.FC = () => {
type="primary" type="primary"
onClick={() => { onClick={() => {
setRefundModal(record); setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 }); refundForm.setFieldsValue({ refundDate: dayjs() });
}} }}
> >
退 退
@@ -285,10 +271,9 @@ const DepositsPage: React.FC = () => {
value={filterStatus} value={filterStatus}
onChange={(v) => setFilterStatus(v)} onChange={(v) => setFilterStatus(v)}
options={[ options={[
{ value: 'paid', label: '已缴' }, { value: 'paid', label: '有余额' },
{ value: 'refunded', label: '已全退' }, { value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' }, { value: 'depleted', label: '已扣完' },
{ value: 'deducted', label: '已全扣' },
]} ]}
/> />
</Space> </Space>
@@ -342,11 +327,11 @@ const DepositsPage: React.FC = () => {
options={studentOptions} options={studentOptions}
/> />
</Form.Item> </Form.Item>
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}> <Form.Item name="amount" label="本次收取金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} /> <InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="paidDate" label="缴纳日期" rules={[{ required: true }]}> <Form.Item name="paidDate" 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="notes" label="备注"> <Form.Item name="notes" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
@@ -365,22 +350,11 @@ 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> : <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
</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">
<InputNumber
min={0}
max={Number(refundModal?.amount || 500)}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="deductionReason" label="扣除原因">
<Input placeholder="如:房间损坏赔偿" />
</Form.Item>
<Form.Item name="notes" label="备注"> <Form.Item name="notes" label="备注">
<Input.TextArea rows={2} /> <Input.TextArea rows={2} />
</Form.Item> </Form.Item>
@@ -398,8 +372,8 @@ const DepositsPage: React.FC = () => {
{detailModal && ( {detailModal && (
<div> <div>
<Card size="small" style={{ marginBottom: 16 }}> <Card size="small" style={{ marginBottom: 16 }}>
<p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p> <p><strong>:</strong> ¥{Number(detailModal.amount).toFixed(2)}</p>
<p><strong>:</strong> {detailModal.paidDate}</p> <p><strong>:</strong> {detailModal.paidDate}</p>
<p> <p>
<strong>:</strong>{' '} <strong>:</strong>{' '}
<Tag color={statusMap[detailModal.status]?.color}> <Tag color={statusMap[detailModal.status]?.color}>

View File

@@ -34,7 +34,7 @@ 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 depMap = new Map<number, number>();
if (studentIds.length > 0) { if (studentIds.length > 0) {
@@ -61,8 +61,7 @@ export class BillsExportService {
{ header: '个人费用', key: 'personal', width: 12 }, { header: '个人费用', key: 'personal', width: 12 },
{ header: '总金额', key: 'total', width: 12 }, { header: '总金额', key: 'total', width: 12 },
{ header: '可用押金', key: 'deposit', width: 12 }, { header: '可用押金', key: 'deposit', width: 12 },
{ header: '押金抵扣', key: 'depositApplied', width: 12 }, { header: '已扣押金', key: 'depositDeducted', width: 12 },
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
{ header: '状态', key: 'status', width: 10 }, { header: '状态', key: 'status', width: 10 },
{ header: '生成时间', key: 'generatedAt', width: 20 }, { header: '生成时间', key: 'generatedAt', width: 20 },
]; ];
@@ -72,14 +71,11 @@ export class BillsExportService {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
draft: '草稿', draft: '草稿',
confirmed: '已确认',
paid: '已结清', paid: '已结清',
}; };
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 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));
ws.addRow({ ws.addRow({
id: bill.id, id: bill.id,
studentName: (bill as any).student?.name || '-', studentName: (bill as any).student?.name || '-',
@@ -88,8 +84,7 @@ export class BillsExportService {
personal: Number(bill.personalAmount), personal: Number(bill.personalAmount),
total, total,
deposit: dep, deposit: dep,
depositApplied: applied, depositDeducted: Number(bill.depositDeductedAmount || 0),
afterDeposit: after,
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,7 +142,7 @@ export class BillsExportService {
return; return;
} }
// 查询该学生的可用押金(已缴未退) // 查询该学生的当前可用押金。草稿账单只展示余额,不预生成抵扣金额。
const deposits = await this.depositRepo const deposits = await this.depositRepo
.createQueryBuilder('d') .createQueryBuilder('d')
.where('d.studentId = :sid', { sid: bill.studentId }) .where('d.studentId = :sid', { sid: bill.studentId })
@@ -155,8 +150,7 @@ export class BillsExportService {
.getMany(); .getMany();
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0); 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 depositDeducted = Number(bill.depositDeductedAmount || 0);
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
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');
@@ -192,7 +186,6 @@ export class BillsExportService {
const statusMap: Record<string, string> = { const statusMap: Record<string, string> = {
draft: '草稿', draft: '草稿',
confirmed: '已确认',
paid: '已结清', paid: '已结清',
}; };
@@ -223,19 +216,17 @@ export class BillsExportService {
.fillColor('#007AFF') .fillColor('#007AFF')
.text(`应付总额: ¥${totalAmount.toFixed(2)}`); .text(`应付总额: ¥${totalAmount.toFixed(2)}`);
doc.moveDown(0.3); doc.moveDown(0.3);
if (availableDeposit > 0) { if (availableDeposit > 0 || depositDeducted > 0) {
doc doc
.fontSize(11) .fontSize(11)
.fillColor('#52C41A') .fillColor('#52C41A')
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`); .text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
doc if (depositDeducted > 0) {
.fontSize(11) doc
.fillColor('#FA8C16') .fontSize(11)
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`); .fillColor('#FA8C16')
doc .text(`已扣押金: -¥${depositDeducted.toFixed(2)}`);
.fontSize(14) }
.fillColor('#FF3B30')
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
} }
doc.moveDown(1); doc.moveDown(1);

View File

@@ -7,6 +7,7 @@ import {
Param, Param,
Body, Body,
Query, Query,
ParseIntPipe,
UseGuards, UseGuards,
Request, Request,
Res, Res,
@@ -20,7 +21,11 @@ import { NotificationType } from '../entities/notification.entity';
import { Student } from '../entities/student.entity'; import { Student } from '../entities/student.entity';
import { Bill } from '../entities/bill.entity'; import { Bill } from '../entities/bill.entity';
import { BillsExportService } from './bills-export.service'; import { BillsExportService } from './bills-export.service';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import {
BatchUpdateBillStatusDto,
GenerateBillsDto,
UpdateBillStatusDto,
} from './dto/bill.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils'; import { extractRequestInfo } from '../common/request-utils';
@@ -49,7 +54,7 @@ export class BillsController {
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '生成账单', action: '生成账单',
detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count}`, detail: `周期 ${result.periodStart}~${result.periodEnd}, 生成 ${result.count}`,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
@@ -62,7 +67,7 @@ export class BillsController {
recipientIds: [student.userId], recipientIds: [student.userId],
type: NotificationType.BILL_GENERATED, type: NotificationType.BILL_GENERATED,
title: '新账单', title: '新账单',
content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${dto.periodStart}~${dto.periodEnd}`, content: `您有一笔新账单,金额: ¥${bill.totalAmount}, 周期: ${result.periodStart}~${result.periodEnd}`,
}); });
} }
} }
@@ -88,54 +93,22 @@ export class BillsController {
@Get(':id') @Get(':id')
@RequirePermission('bill:view') @RequirePermission('bill:view')
findOne(@Param('id') id: string) { findOne(@Param('id', ParseIntPipe) id: number) {
return this.service.findOne(+id); return this.service.findOne(id);
}
@Put(':id/status')
@RequirePermission('bill:confirm')
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(+id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单',
targetId: +id,
targetType: 'bill',
ipAddress,
userAgent,
});
// Send bill_paid notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
});
}
} catch (_) { /* don't block response */ }
return result;
} }
// Static routes must be declared before /:id/status, otherwise "batch" is
// treated as an id and converted to NaN by the parameterized route.
@Put('batch/status') @Put('batch/status')
@RequirePermission('bill:confirm') @RequirePermission('bill:confirm')
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) { async batchUpdateStatus(@Body() body: BatchUpdateBillStatusDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchUpdateStatus(body.ids, body.status); const result = await this.service.batchUpdateStatus(body.ids, body.status);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '确认账单', action: '确认账单并扣押金',
detail: `IDs: ${body.ids.join(',')}`, detail: `IDs: ${body.ids.join(',')}`,
ipAddress, ipAddress,
userAgent, userAgent,
@@ -158,17 +131,51 @@ export class BillsController {
return result; return result;
} }
@Put(':id/status')
@RequirePermission('bill:confirm')
async updateStatus(
@Param('id', ParseIntPipe) id: number,
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '确认账单并扣押金',
targetId: id,
targetType: 'bill',
ipAddress,
userAgent,
});
// Send bill_paid notification
try {
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
if (student?.userId) {
void this.notificationsService.create({
recipientIds: [student.userId],
type: NotificationType.BILL_PAID,
title: '账单已确认',
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
});
}
} catch (_) { /* don't block response */ }
return result;
}
@Delete(':id') @Delete(':id')
@RequirePermission('bill:delete') @RequirePermission('bill:delete')
async remove(@Param('id') id: string, @Request() req: any) { async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id); const result = await this.service.remove(id);
await this.logService.log({ await this.logService.log({
userId: req.user?.id, userId: req.user?.id,
username: req.user?.username, username: req.user?.username,
module: '账单管理', module: '账单管理',
action: '删除账单', action: '删除账单',
targetId: +id, targetId: id,
targetType: 'bill', targetType: 'bill',
ipAddress, ipAddress,
userAgent, userAgent,
@@ -226,18 +233,18 @@ export class BillsController {
@Get('export/pdf/:id') @Get('export/pdf/:id')
@RequirePermission('bill:export-pdf') @RequirePermission('bill:export-pdf')
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) { async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req); const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({ await this.logService.log({
userId: req?.user?.id, userId: req?.user?.id,
username: req?.user?.username, username: req?.user?.username,
module: '账单管理', module: '账单管理',
action: '导出账单', action: '导出账单',
targetId: +id, targetId: id,
targetType: 'bill', targetType: 'bill',
ipAddress, ipAddress,
userAgent, userAgent,
}); });
return this.exportService.exportStudentPdf(+id, res); return this.exportService.exportStudentPdf(id, res);
} }
} }

View File

@@ -166,6 +166,43 @@ describe('BillsService — generateBills', () => {
).toBeCloseTo(300, 0); ).toBeCloseTo(300, 0);
}); });
it('includes room expenses whose periods are inside the generated bill period', async () => {
const qb = mockQueryBuilder<RoomExpense>([
{
id: 1, roomId: 1, expenseType: 'water',
amount: '300' as unknown as number, periodStart: '2026-07-01', periodEnd: '2026-07-31',
} as RoomExpense,
]);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<Occupancy>([
{
id: 1, studentId: 10, roomId: 1,
billingStartDate: '2026-07-01', billingEndDate: null as unknown as string,
stayType: 'short', room: undefined,
} as Occupancy,
]),
);
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<PersonalExpense>([]),
);
const result = await service.generateBills({
periodStart: '2026-06-29',
periodEnd: '2026-07-31',
});
expect(result.count).toBe(1);
expect(qb.where).toHaveBeenCalledWith(
'e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd',
{ periodStart: '2026-06-29', periodEnd: '2026-07-31' },
);
const savedCalls = (billRepo.save as jest.Mock).mock.calls as Array<[Record<string, unknown>]>;
expect(Number(savedCalls[0][0].sharedAmount)).toBeCloseTo(300, 0);
});
it('mixed → long-term get individual bills, short-term share expenses', async () => { it('mixed → long-term get individual bills, short-term share expenses', async () => {
// Room 1: two expenses // Room 1: two expenses
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue( (roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(

View File

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource } from 'typeorm'; import { Repository, In, DataSource, EntityManager } 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 { RoomExpense } from '../entities/room-expense.entity';
@@ -28,32 +28,21 @@ export class BillsService {
* 核心计费引擎:按"人天数"加权分摊 * 核心计费引擎:按"人天数"加权分摊
*/ */
async generateBills(dto: GenerateBillsDto) { async generateBills(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto; const { periodStart, periodEnd } = this.resolveBillingPeriod(dto.billingMonth);
const pStart = new Date(periodStart); const pStart = new Date(periodStart);
const pEnd = new Date(periodEnd); const pEnd = new Date(periodEnd);
// 删除该周期已有的草稿账单 const existingBills = await this.billRepo.find({
const existingDrafts = await this.billRepo.find({ where: { periodStart, periodEnd },
where: { periodStart, periodEnd, status: 'draft' },
}); });
if (existingDrafts.length > 0) { if (existingBills.length > 0) {
const draftIds = existingDrafts.map((b) => b.id); throw new BadRequestException(`${dto.billingMonth} 月账单已生成,不能重复生成`);
await this.itemRepo
.createQueryBuilder()
.delete()
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.billRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: draftIds })
.execute();
} }
// 获取所有有费用的宿舍 // 获取账单周期内所有有费用的宿舍
const roomExpenses = await this.roomExpRepo 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', {
periodStart, periodStart,
periodEnd, periodEnd,
}) })
@@ -208,7 +197,41 @@ export class BillsService {
bills.push(savedBill); bills.push(savedBill);
} }
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills }; return {
message: `成功生成 ${dto.billingMonth}${bills.length} 条账单`,
count: bills.length,
periodStart,
periodEnd,
bills,
};
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) {
throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
}
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) {
throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
}
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart) {
throw new BadRequestException('只能生成已结束月份的账单');
}
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return {
periodStart: `${year}-${pad(month)}-01`,
periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`,
};
} }
async findAll(query?: { async findAll(query?: {
@@ -242,9 +265,9 @@ export class BillsService {
/** /**
* 给账单挂上"押金联动"信息: * 给账单挂上"押金联动"信息:
* - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额 * - availableDeposit: 学生当前实时可用押金余额,生成账单时不会冻结
* - depositApplied: 本张账单可从押金抵扣的金额min(押金, 应付总额) * - depositSufficient: 草稿账单是否已有足够余额可确认
* - amountAfterDeposit: 抵扣押金后学生需另外支付的金额 * - depositDeductedAmount: 已确认账单实际扣除的押金金额
*/ */
private async attachDepositInfo(bills: Bill[]): Promise<any[]> { private async attachDepositInfo(bills: Bill[]): Promise<any[]> {
if (!bills || bills.length === 0) return bills; if (!bills || bills.length === 0) return bills;
@@ -253,7 +276,6 @@ export class BillsService {
const deposits = await this.depositRepo const deposits = await this.depositRepo
.createQueryBuilder('d') .createQueryBuilder('d')
.where('d.studentId IN (:...ids)', { ids: studentIds }) .where('d.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany(); .getMany();
const depMap = new Map<number, number>(); const depMap = new Map<number, number>();
for (const d of deposits) { for (const d of deposits) {
@@ -262,42 +284,83 @@ export class BillsService {
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 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));
return Object.assign({}, b, { return Object.assign({}, b, {
availableDeposit: available, availableDeposit: available,
depositApplied: applied, depositSufficient: available >= total,
amountAfterDeposit: afterDeposit, depositDeductedAmount: Number(b.depositDeductedAmount || 0),
}); });
}); });
} }
async updateStatus(id: number, dto: UpdateBillStatusDto) { async updateStatus(id: number, dto: UpdateBillStatusDto) {
const bill = await this.billRepo.findOne({ where: { id } }); if (dto.status !== 'paid') {
if (!bill) throw new NotFoundException('账单不存在'); throw new BadRequestException('账单只能通过确认支付完成扣款');
bill.status = dto.status; }
return this.billRepo.save(bill); return this.dataSource.transaction((manager) => this.payBill(manager, id));
} }
async batchUpdateStatus(ids: number[], status: string) { async batchUpdateStatus(ids: number[], status: string) {
await this.billRepo if (status !== 'paid') {
.createQueryBuilder() throw new BadRequestException('账单只能通过确认支付完成扣款');
.update() }
.set({ status }) const uniqueIds = Array.from(new Set(ids));
.where('id IN (:...ids)', { ids }) await this.dataSource.transaction(async (manager) => {
.execute(); for (const id of uniqueIds) await this.payBill(manager, id);
return { message: `成功更新 ${ids.length} 条账单状态` }; });
return { message: `成功确认 ${uniqueIds.length} 条账单并扣除押金` };
}
private async payBill(manager: EntityManager, id: number) {
const billRepo = manager.getRepository(Bill);
const depositRepo = manager.getRepository(Deposit);
const lock = this.supportsPessimisticLocks()
? ({ mode: 'pessimistic_write' } as const)
: undefined;
const bill = await billRepo.findOne({ where: { id }, ...(lock ? { lock } : {}) });
if (!bill) throw new NotFoundException(`账单 ${id} 不存在`);
if (bill.status === 'paid') return bill;
if (bill.status !== 'draft') throw new BadRequestException(`账单 ${id} 当前状态无法确认支付`);
const deposit = await depositRepo.findOne({
where: { studentId: bill.studentId },
...(lock ? { lock } : {}),
});
const available = Number(deposit?.amount || 0);
const required = Number(bill.totalAmount || 0);
if (!deposit || available < required) {
throw new BadRequestException(
`账单 ${id} 押金不足:需 ¥${required.toFixed(2)},当前可用 ¥${available.toFixed(2)},请先到押金管理收取押金`,
);
}
deposit.amount = Number((available - required).toFixed(2));
deposit.status = deposit.amount > 0 ? 'paid' : 'depleted';
bill.depositDeductedAmount = required;
bill.status = 'paid';
await depositRepo.save(deposit);
return billRepo.save(bill);
}
private supportsPessimisticLocks() {
return ['mysql', 'mariadb', 'postgres', 'cockroachdb', 'mssql', 'oracle'].includes(
String(this.dataSource.options.type),
);
} }
async remove(id: number) { async remove(id: number) {
const exists = await this.billRepo.findOne({ where: { id } }); const exists = await this.billRepo.findOne({ where: { id } });
if (!exists) throw new NotFoundException('账单不存在'); if (!exists) throw new NotFoundException('账单不存在');
if (exists.status === 'paid') throw new BadRequestException('已支付账单不能删除');
await this.itemRepo.delete({ billId: id }); await this.itemRepo.delete({ billId: id });
await this.billRepo.delete(id); await this.billRepo.delete(id);
return { message: '账单已删除' }; return { message: '账单已删除' };
} }
async batchRemove(ids: number[]) { async batchRemove(ids: number[]) {
const bills = await this.billRepo.find({ where: { id: In(ids) } });
if (bills.some((bill) => bill.status === 'paid')) {
throw new BadRequestException('已支付账单不能删除');
}
await this.itemRepo await this.itemRepo
.createQueryBuilder() .createQueryBuilder()
.delete() .delete()

View File

@@ -1,14 +1,27 @@
import { IsString, IsOptional } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches } from 'class-validator';
export class GenerateBillsDto { export class GenerateBillsDto {
@IsString() @IsString()
periodStart: string; // YYYY-MM-DD @Matches(/^\d{4}-\d{2}$/)
billingMonth: string; // YYYY-MM
@IsOptional()
@IsString() @IsString()
periodEnd: string; // YYYY-MM-DD periodStart?: string; // deprecated, derived from billingMonth
@IsOptional()
@IsString()
periodEnd?: string; // deprecated, derived from billingMonth
} }
export class UpdateBillStatusDto { export class UpdateBillStatusDto {
@IsString() @IsIn(['paid'])
status: 'draft' | 'confirmed' | 'paid'; status: 'paid';
}
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {
@IsArray()
@ArrayNotEmpty()
@IsInt({ each: true })
ids: number[];
} }

View File

@@ -170,7 +170,7 @@ export class DepositsController {
action: '退还押金', action: '退还押金',
targetId: +id, targetId: +id,
targetType: 'deposit', targetType: 'deposit',
detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`, detail: `退还全部可用押金 ¥${result.refundAmount}`,
ipAddress, ipAddress,
userAgent, userAgent,
}); });
@@ -182,7 +182,7 @@ export class DepositsController {
recipientIds: [student.userId], recipientIds: [student.userId],
type: 'deposit_refunded', type: 'deposit_refunded',
title: '押金已退还', title: '押金已退还',
content: `您的押金已退还,退还¥${result.refundAmount},扣除¥${result.deductionAmount}`, content: `您的剩余押金已全部退还,金额: ¥${result.refundAmount}`,
}); });
} }
} catch (_) { /* don't block response */ } } catch (_) { /* don't block response */ }

View File

@@ -46,16 +46,28 @@ export class DepositsService {
async create(dto: CreateDepositDto, userId?: number) { async create(dto: CreateDepositDto, userId?: number) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
const deposit = this.repo.create({ if (Number(dto.amount) <= 0) throw new BadRequestException('收取金额必须大于0');
studentId: dto.studentId,
amount: dto.amount,
paidDate: dto.paidDate,
notes: dto.notes,
status: 'paid',
recordedBy: userId,
});
return this.repo.save(deposit); const existing = await this.repo.findOne({ where: { studentId: dto.studentId } });
if (existing) {
existing.amount = Number((Number(existing.amount || 0) + Number(dto.amount)).toFixed(2));
existing.paidDate = dto.paidDate;
existing.status = 'paid';
existing.recordedBy = userId ?? null;
if (dto.notes) existing.notes = dto.notes;
return this.repo.save(existing);
}
return this.repo.save(
this.repo.create({
studentId: dto.studentId,
amount: dto.amount,
paidDate: dto.paidDate,
notes: dto.notes,
status: 'paid',
recordedBy: userId,
}),
);
} }
async addInstallment(depositId: number, amount: number, dueDate: string) { async addInstallment(depositId: number, amount: number, dueDate: string) {
@@ -90,18 +102,16 @@ export class DepositsService {
async refund(id: number, dto: RefundDepositDto, userId?: number) { async refund(id: number, dto: RefundDepositDto, userId?: number) {
const deposit = await this.repo.findOne({ where: { id } }); const deposit = await this.repo.findOne({ where: { id } });
if (!deposit) throw new NotFoundException('押金记录不存在'); if (!deposit) throw new NotFoundException('押金记录不存在');
if (deposit.status !== 'paid') throw new BadRequestException('该押金已处理'); if (deposit.status !== 'paid' || Number(deposit.amount) <= 0) {
throw new BadRequestException('该学生当前没有可退押金');
}
const deduction = dto.deductionAmount || 0; const refundAmount = Number(deposit.amount);
const refundAmount = Number(deposit.amount) - deduction;
if (refundAmount < 0) throw new BadRequestException('扣除金额不能大于押金金额');
deposit.refundDate = dto.refundDate; deposit.refundDate = dto.refundDate;
deposit.deductionAmount = deduction;
deposit.deductionReason = dto.deductionReason || '';
deposit.refundAmount = refundAmount; deposit.refundAmount = refundAmount;
deposit.status = deposit.amount = 0;
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded'; deposit.status = 'refunded';
if (dto.notes) deposit.notes = dto.notes; if (dto.notes) deposit.notes = dto.notes;
deposit.refundedBy = userId ?? null; deposit.refundedBy = userId ?? null;
deposit.refundedAt = new Date(); deposit.refundedAt = new Date();

View File

@@ -19,14 +19,6 @@ export class RefundDepositDto {
@IsString() @IsString()
refundDate: string; refundDate: string;
@IsOptional()
@IsNumber()
deductionAmount?: number;
@IsOptional()
@IsString()
deductionReason?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
notes?: string; notes?: string;

View File

@@ -33,6 +33,9 @@ export class Bill {
@Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 }) @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
totalAmount: number; totalAmount: number;
@Column({ name: 'deposit_deducted_amount', type: 'decimal', precision: 10, scale: 2, default: 0 })
depositDeductedAmount: number;
@Column({ type: 'varchar', length: 20, default: 'draft' }) @Column({ type: 'varchar', length: 20, default: 'draft' })
status: string; status: string;

View File

@@ -21,7 +21,7 @@ export class Deposit {
@Column({ type: 'decimal', precision: 10, scale: 2, default: 500 }) @Column({ type: 'decimal', precision: 10, scale: 2, default: 500 })
amount: number; amount: number;
// paid: 已缴 | refunded: 已退 | deducted: 已扣除(部分或全部) // paid: 有可用余额 | refunded: 余额已全部退还 | depleted: 余额已被账单扣完
@Column({ type: 'varchar', length: 20, default: 'paid' }) @Column({ type: 'varchar', length: 20, default: 'paid' })
status: string; status: string;
@@ -43,8 +43,8 @@ export class Deposit {
@Column({ type: 'text', nullable: true }) @Column({ type: 'text', nullable: true })
notes: string; notes: string;
@Column({ name: 'recorded_by', nullable: true }) @Column({ name: 'recorded_by', type: 'integer', nullable: true })
recordedBy: number; recordedBy: number | null;
@Column({ name: 'refunded_by', type: 'integer', nullable: true }) @Column({ name: 'refunded_by', type: 'integer', nullable: true })
refundedBy: number | null; refundedBy: number | null;

View File

@@ -106,9 +106,18 @@ export class OccupanciesService {
if (dto.collectDeposit) { if (dto.collectDeposit) {
const existingDeposit = await this.depositRepo.findOne({ const existingDeposit = await this.depositRepo.findOne({
where: { studentId: dto.studentId, status: 'paid' }, where: { studentId: dto.studentId },
}); });
if (!existingDeposit) { if (existingDeposit) {
existingDeposit.amount = Number(
(Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2),
);
existingDeposit.status = 'paid';
existingDeposit.paidDate = dto.checkInDate;
existingDeposit.recordedBy = userId ?? null;
existingDeposit.notes = '入住登记自动收取';
await this.depositRepo.save(existingDeposit);
} else {
await this.depositRepo.save( await this.depositRepo.save(
this.depositRepo.create({ this.depositRepo.create({
studentId: dto.studentId, studentId: dto.studentId,
@@ -529,9 +538,18 @@ export class OccupanciesService {
// 9. 自动收取押金(仅对新入住且非历史记录的学生) // 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !row.checkOutDate?.trim()) { if (options?.autoDeposit && !row.checkOutDate?.trim()) {
const existingDeposit = await this.depositRepo.findOne({ const existingDeposit = await this.depositRepo.findOne({
where: { studentId: student.id, status: 'paid' }, where: { studentId: student.id },
}); });
if (!existingDeposit) { if (existingDeposit) {
existingDeposit.amount = Number(
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(2),
);
existingDeposit.status = 'paid';
existingDeposit.paidDate = checkInDate;
existingDeposit.notes = '入住导入自动收取';
await this.depositRepo.save(existingDeposit);
depositsCreated++;
} else {
await this.depositRepo.save( await this.depositRepo.save(
this.depositRepo.create({ this.depositRepo.create({
studentId: student.id, studentId: student.id,