feat: allow repeated bill generation and editable deposit deductions
This commit is contained in:
@@ -356,14 +356,13 @@ const BillsPage: React.FC = () => {
|
||||
name="billingMonth"
|
||||
label="账单月份"
|
||||
rules={[{ required: true, message: '请选择账单月份' }]}
|
||||
extra="只能选择已结束月份,每个月只能生成一次账单"
|
||||
extra="选择任意账单月份;重复生成时无变化不生成,有变化则生成差额账单"
|
||||
>
|
||||
<DatePicker
|
||||
style={{ width: '100%' }}
|
||||
picker="month"
|
||||
placeholder="选择月份"
|
||||
format="YYYY-MM"
|
||||
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
@@ -120,6 +120,7 @@ const DepositsPage: React.FC = () => {
|
||||
const values = await refundForm.validateFields();
|
||||
await api.put(`/deposits/${refundModal.id}/refund`, {
|
||||
refundDate: values.refundDate.format('YYYY-MM-DD'),
|
||||
deductionAmount: values.deductionAmount || 0,
|
||||
notes: values.notes,
|
||||
});
|
||||
message.success('退还操作完成');
|
||||
@@ -210,7 +211,13 @@ const DepositsPage: React.FC = () => {
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setRefundModal(record);
|
||||
refundForm.setFieldsValue({ refundDate: dayjs() });
|
||||
refundForm.setFieldsValue({
|
||||
refundDate: dayjs(),
|
||||
deductionAmount: Math.min(
|
||||
moneyNumber(record.amount),
|
||||
moneyNumber(record.personalExpenseAmount),
|
||||
),
|
||||
});
|
||||
}}
|
||||
>
|
||||
退还
|
||||
@@ -361,33 +368,24 @@ const DepositsPage: React.FC = () => {
|
||||
<strong>¥{moneyNumber(refundModal?.personalExpenseAmount).toFixed(2)}</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
自动扣除:{' '}
|
||||
<strong style={{ color: '#fa8c16' }}>
|
||||
¥
|
||||
{Math.min(
|
||||
moneyNumber(refundModal?.amount),
|
||||
moneyNumber(refundModal?.personalExpenseAmount),
|
||||
).toFixed(2)}
|
||||
</strong>
|
||||
</div>
|
||||
<div style={{ marginTop: 4 }}>
|
||||
实际退还:{' '}
|
||||
<strong style={{ color: '#52c41a' }}>
|
||||
¥
|
||||
{Math.max(
|
||||
0,
|
||||
moneyNumber(refundModal?.amount) -
|
||||
Math.min(
|
||||
moneyNumber(refundModal?.amount),
|
||||
moneyNumber(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="默认填入未出账个人附加费,不能超过当前可用押金"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={moneyNumber(refundModal?.amount)}
|
||||
precision={2}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -36,30 +36,8 @@ export class BillsService {
|
||||
const pEnd = new Date(periodEnd);
|
||||
|
||||
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||
if (existingBills.length > 0) {
|
||||
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
|
||||
}
|
||||
|
||||
const existingDrafts: Bill[] = [];
|
||||
if (existingDrafts.length > 0) {
|
||||
const draftIds = existingDrafts.map((b) => b.id);
|
||||
await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ billId: null })
|
||||
.where('billId IN (:...ids)', { ids: draftIds })
|
||||
.execute();
|
||||
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 existingBatchBills = existingBills.filter((bill) => bill.source === 'batch');
|
||||
const existingBatchBillIds = existingBatchBills.map((bill) => bill.id);
|
||||
|
||||
// 获取所有有费用的宿舍
|
||||
const roomExpenses = await this.roomExpRepo
|
||||
@@ -169,7 +147,12 @@ export class BillsService {
|
||||
periodStart,
|
||||
periodEnd,
|
||||
})
|
||||
.andWhere('pe.billId IS NULL')
|
||||
.andWhere(
|
||||
existingBatchBillIds.length > 0
|
||||
? '(pe.billId IS NULL OR pe.billId IN (:...existingBatchBillIds))'
|
||||
: 'pe.billId IS NULL',
|
||||
existingBatchBillIds.length > 0 ? { existingBatchBillIds } : {},
|
||||
)
|
||||
.getMany();
|
||||
|
||||
const personalMap = new Map<number, number>();
|
||||
@@ -198,18 +181,58 @@ export class BillsService {
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
|
||||
const existingStudentBills = existingBatchBills.filter(
|
||||
(bill) => bill.studentId === studentId && bill.status !== 'cancelled',
|
||||
);
|
||||
const existingTotal = this.roundMoney(
|
||||
existingStudentBills.reduce((sum, bill) => sum + Number(bill.totalAmount || 0), 0),
|
||||
);
|
||||
const paidAmount = this.roundMoney(
|
||||
existingStudentBills.reduce((sum, bill) => sum + Number(bill.paidAmount || 0), 0),
|
||||
);
|
||||
|
||||
if (existingStudentBills.length > 0 && this.moneyEquals(existingTotal, total)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const remainingTotal = this.roundMoney(Math.max(0, total - paidAmount));
|
||||
const ratio = total > 0 ? remainingTotal / total : 0;
|
||||
const remainingShared = this.roundMoney(shared * ratio);
|
||||
const remainingPersonal = this.roundMoney(remainingTotal - remainingShared);
|
||||
|
||||
const savedBill = await this.dataSource.transaction(async (manager) => {
|
||||
const deletableBills = existingStudentBills.filter((bill) => Number(bill.paidAmount || 0) <= 0);
|
||||
const fundedBills = existingStudentBills.filter((bill) => Number(bill.paidAmount || 0) > 0);
|
||||
|
||||
if (deletableBills.length) {
|
||||
const ids = deletableBills.map((bill) => bill.id);
|
||||
await manager.update(PersonalExpense, { billId: In(ids) }, { billId: null });
|
||||
await manager.delete(BillItem, { billId: In(ids) });
|
||||
await manager.delete(Bill, { id: In(ids) });
|
||||
}
|
||||
|
||||
for (const bill of fundedBills) {
|
||||
const paid = this.roundMoney(Number(bill.paidAmount || 0));
|
||||
await manager.update(Bill, bill.id, {
|
||||
totalAmount: paid,
|
||||
outstandingAmount: 0,
|
||||
status: 'paid',
|
||||
});
|
||||
}
|
||||
|
||||
if (remainingTotal <= 0) return null;
|
||||
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
sharedAmount: Number(shared.toFixed(2)),
|
||||
personalAmount: personal,
|
||||
totalAmount: total,
|
||||
sharedAmount: remainingShared,
|
||||
personalAmount: remainingPersonal,
|
||||
totalAmount: remainingTotal,
|
||||
source: 'batch',
|
||||
paidAmount: 0,
|
||||
outstandingAmount: total,
|
||||
outstandingAmount: remainingTotal,
|
||||
status: 'unpaid',
|
||||
}),
|
||||
);
|
||||
@@ -218,7 +241,13 @@ export class BillsService {
|
||||
...(personalItems.get(studentId) || []),
|
||||
];
|
||||
for (const item of items) {
|
||||
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
|
||||
await manager.save(
|
||||
manager.create(BillItem, {
|
||||
...item,
|
||||
studentAmount: this.roundMoney(Number(item.studentAmount || 0) * ratio),
|
||||
billId: bill.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
|
||||
if (includedPersonal.length) {
|
||||
@@ -232,10 +261,24 @@ export class BillsService {
|
||||
bill = await this.walletsService.debitBill(manager, bill);
|
||||
return bill;
|
||||
});
|
||||
bills.push(savedBill);
|
||||
if (savedBill) bills.push(savedBill);
|
||||
}
|
||||
|
||||
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
|
||||
return {
|
||||
message: bills.length > 0 ? `成功生成 ${bills.length} 条差额账单` : '账单无变化,未生成新账单',
|
||||
count: bills.length,
|
||||
bills,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
};
|
||||
}
|
||||
|
||||
private roundMoney(value: number) {
|
||||
return Number(Number(value || 0).toFixed(2));
|
||||
}
|
||||
|
||||
private moneyEquals(left: number, right: number) {
|
||||
return this.roundMoney(left) === this.roundMoney(right);
|
||||
}
|
||||
|
||||
private resolveBillingPeriod(billingMonth: string) {
|
||||
@@ -244,11 +287,6 @@ export class BillsService {
|
||||
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())}` };
|
||||
|
||||
@@ -2,7 +2,7 @@ import { DepositsService } from './deposits.service';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
|
||||
describe('DepositsService — direct refund', () => {
|
||||
it('deducts unbilled personal expenses before refunding the remaining balance', async () => {
|
||||
it('deducts the submitted amount before refunding the remaining balance', async () => {
|
||||
const deposit = {
|
||||
id: 1,
|
||||
studentId: 10,
|
||||
@@ -13,21 +13,11 @@ describe('DepositsService — direct refund', () => {
|
||||
findOne: jest.fn().mockResolvedValue(deposit),
|
||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||
};
|
||||
const personalExpenseRepo = {
|
||||
createQueryBuilder: jest.fn().mockReturnValue({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([{ studentId: 10, amount: '120' }]),
|
||||
}),
|
||||
};
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never, personalExpenseRepo as never);
|
||||
const service = new DepositsService(repo as never, {} as never, {} as never, {} as never);
|
||||
|
||||
const result = await service.refund(
|
||||
1,
|
||||
{ refundDate: '2026-07-13', notes: '退还剩余押金' },
|
||||
{ refundDate: '2026-07-13', deductionAmount: 120, notes: '退还剩余押金' },
|
||||
42,
|
||||
);
|
||||
|
||||
@@ -36,7 +26,7 @@ describe('DepositsService — direct refund', () => {
|
||||
amount: 0,
|
||||
refundAmount: 380,
|
||||
deductionAmount: 120,
|
||||
deductionReason: '自动扣除个人附加费用 ¥120.00',
|
||||
deductionReason: '扣除个人附加费用 ¥120.00',
|
||||
notes: '退还剩余押金',
|
||||
status: 'refunded',
|
||||
refundedBy: 42,
|
||||
|
||||
@@ -112,15 +112,16 @@ export class DepositsService {
|
||||
}
|
||||
|
||||
const depositAmount = Number(deposit.amount);
|
||||
const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId);
|
||||
const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2));
|
||||
const deductionAmount = Number(Number(dto.deductionAmount || 0).toFixed(2));
|
||||
if (deductionAmount < 0) throw new BadRequestException('扣除金额不能小于0');
|
||||
if (deductionAmount > depositAmount) throw new BadRequestException('扣除金额不能大于当前可用押金');
|
||||
const refundAmount = Number((depositAmount - deductionAmount).toFixed(2));
|
||||
|
||||
deposit.refundDate = dto.refundDate;
|
||||
deposit.refundAmount = refundAmount;
|
||||
deposit.deductionAmount = deductionAmount;
|
||||
deposit.deductionReason =
|
||||
deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
|
||||
deductionAmount > 0 ? `扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
|
||||
deposit.amount = 0;
|
||||
deposit.status = refundAmount > 0 ? 'refunded' : 'depleted';
|
||||
if (dto.notes) deposit.notes = dto.notes;
|
||||
@@ -158,11 +159,6 @@ export class DepositsService {
|
||||
);
|
||||
}
|
||||
|
||||
private async getPersonalExpenseAmount(studentId: number) {
|
||||
const amountMap = await this.getPersonalExpenseAmountMap([studentId]);
|
||||
return amountMap.get(studentId) || 0;
|
||||
}
|
||||
|
||||
private async getPersonalExpenseAmountMap(studentIds: number[]) {
|
||||
const amountMap = new Map<number, number>();
|
||||
if (!studentIds.length) return amountMap;
|
||||
|
||||
@@ -20,6 +20,10 @@ export class RefundDepositDto {
|
||||
@IsDateString()
|
||||
refundDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
deductionAmount?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
Reference in New Issue
Block a user