forked from wangziqi/gongxue-base
feat: allow repeated bill generation and editable deposit deductions
This commit is contained in:
@@ -356,14 +356,13 @@ const BillsPage: React.FC = () => {
|
|||||||
name="billingMonth"
|
name="billingMonth"
|
||||||
label="账单月份"
|
label="账单月份"
|
||||||
rules={[{ required: true, message: '请选择账单月份' }]}
|
rules={[{ required: true, message: '请选择账单月份' }]}
|
||||||
extra="只能选择已结束月份,每个月只能生成一次账单"
|
extra="选择任意账单月份;重复生成时无变化不生成,有变化则生成差额账单"
|
||||||
>
|
>
|
||||||
<DatePicker
|
<DatePicker
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
picker="month"
|
picker="month"
|
||||||
placeholder="选择月份"
|
placeholder="选择月份"
|
||||||
format="YYYY-MM"
|
format="YYYY-MM"
|
||||||
disabledDate={(current) => !!current && !current.endOf('month').isBefore(dayjs(), 'day')}
|
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ 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,
|
||||||
notes: values.notes,
|
notes: values.notes,
|
||||||
});
|
});
|
||||||
message.success('退还操作完成');
|
message.success('退还操作完成');
|
||||||
@@ -210,7 +211,13 @@ const DepositsPage: React.FC = () => {
|
|||||||
type="primary"
|
type="primary"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setRefundModal(record);
|
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>
|
<strong>¥{moneyNumber(refundModal?.personalExpenseAmount).toFixed(2)}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginTop: 4 }}>
|
<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>
|
||||||
</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="默认填入未出账个人附加费,不能超过当前可用押金"
|
||||||
|
>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={moneyNumber(refundModal?.amount)}
|
||||||
|
precision={2}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
<Form.Item name="notes" label="备注">
|
<Form.Item name="notes" label="备注">
|
||||||
<Input.TextArea rows={2} />
|
<Input.TextArea rows={2} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -36,30 +36,8 @@ export class BillsService {
|
|||||||
const pEnd = new Date(periodEnd);
|
const pEnd = new Date(periodEnd);
|
||||||
|
|
||||||
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||||
if (existingBills.length > 0) {
|
const existingBatchBills = existingBills.filter((bill) => bill.source === 'batch');
|
||||||
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
|
const existingBatchBillIds = existingBatchBills.map((bill) => bill.id);
|
||||||
}
|
|
||||||
|
|
||||||
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 roomExpenses = await this.roomExpRepo
|
const roomExpenses = await this.roomExpRepo
|
||||||
@@ -169,7 +147,12 @@ export class BillsService {
|
|||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
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();
|
.getMany();
|
||||||
|
|
||||||
const personalMap = new Map<number, number>();
|
const personalMap = new Map<number, number>();
|
||||||
@@ -198,18 +181,58 @@ export class BillsService {
|
|||||||
const personal = personalMap.get(studentId) || 0;
|
const personal = personalMap.get(studentId) || 0;
|
||||||
const total = Number((shared + personal).toFixed(2));
|
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 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(
|
let bill = await manager.save(
|
||||||
manager.create(Bill, {
|
manager.create(Bill, {
|
||||||
studentId,
|
studentId,
|
||||||
periodStart,
|
periodStart,
|
||||||
periodEnd,
|
periodEnd,
|
||||||
sharedAmount: Number(shared.toFixed(2)),
|
sharedAmount: remainingShared,
|
||||||
personalAmount: personal,
|
personalAmount: remainingPersonal,
|
||||||
totalAmount: total,
|
totalAmount: remainingTotal,
|
||||||
source: 'batch',
|
source: 'batch',
|
||||||
paidAmount: 0,
|
paidAmount: 0,
|
||||||
outstandingAmount: total,
|
outstandingAmount: remainingTotal,
|
||||||
status: 'unpaid',
|
status: 'unpaid',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@@ -218,7 +241,13 @@ export class BillsService {
|
|||||||
...(personalItems.get(studentId) || []),
|
...(personalItems.get(studentId) || []),
|
||||||
];
|
];
|
||||||
for (const item of items) {
|
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);
|
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
|
||||||
if (includedPersonal.length) {
|
if (includedPersonal.length) {
|
||||||
@@ -232,10 +261,24 @@ export class BillsService {
|
|||||||
bill = await this.walletsService.debitBill(manager, bill);
|
bill = await this.walletsService.debitBill(manager, bill);
|
||||||
return 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) {
|
private resolveBillingPeriod(billingMonth: string) {
|
||||||
@@ -244,11 +287,6 @@ export class BillsService {
|
|||||||
const year = Number(matched[1]);
|
const year = Number(matched[1]);
|
||||||
const month = Number(matched[2]);
|
const month = Number(matched[2]);
|
||||||
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
|
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 targetMonthEnd = new Date(year, month, 0);
|
||||||
const pad = (value: number) => String(value).padStart(2, '0');
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };
|
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';
|
import { Deposit } from '../entities/deposit.entity';
|
||||||
|
|
||||||
describe('DepositsService — direct refund', () => {
|
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 = {
|
const deposit = {
|
||||||
id: 1,
|
id: 1,
|
||||||
studentId: 10,
|
studentId: 10,
|
||||||
@@ -13,21 +13,11 @@ describe('DepositsService — direct refund', () => {
|
|||||||
findOne: jest.fn().mockResolvedValue(deposit),
|
findOne: jest.fn().mockResolvedValue(deposit),
|
||||||
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
save: jest.fn().mockImplementation(async (value: Deposit) => value),
|
||||||
};
|
};
|
||||||
const personalExpenseRepo = {
|
const service = new DepositsService(repo as never, {} as never, {} as never, {} as never);
|
||||||
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 result = await service.refund(
|
const result = await service.refund(
|
||||||
1,
|
1,
|
||||||
{ refundDate: '2026-07-13', notes: '退还剩余押金' },
|
{ refundDate: '2026-07-13', deductionAmount: 120, notes: '退还剩余押金' },
|
||||||
42,
|
42,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -36,7 +26,7 @@ describe('DepositsService — direct refund', () => {
|
|||||||
amount: 0,
|
amount: 0,
|
||||||
refundAmount: 380,
|
refundAmount: 380,
|
||||||
deductionAmount: 120,
|
deductionAmount: 120,
|
||||||
deductionReason: '自动扣除个人附加费用 ¥120.00',
|
deductionReason: '扣除个人附加费用 ¥120.00',
|
||||||
notes: '退还剩余押金',
|
notes: '退还剩余押金',
|
||||||
status: 'refunded',
|
status: 'refunded',
|
||||||
refundedBy: 42,
|
refundedBy: 42,
|
||||||
|
|||||||
@@ -112,15 +112,16 @@ export class DepositsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const depositAmount = Number(deposit.amount);
|
const depositAmount = Number(deposit.amount);
|
||||||
const personalExpenseAmount = await this.getPersonalExpenseAmount(deposit.studentId);
|
const deductionAmount = Number(Number(dto.deductionAmount || 0).toFixed(2));
|
||||||
const deductionAmount = Number(Math.min(depositAmount, personalExpenseAmount).toFixed(2));
|
if (deductionAmount < 0) throw new BadRequestException('扣除金额不能小于0');
|
||||||
|
if (deductionAmount > depositAmount) throw new BadRequestException('扣除金额不能大于当前可用押金');
|
||||||
const refundAmount = Number((depositAmount - deductionAmount).toFixed(2));
|
const refundAmount = Number((depositAmount - deductionAmount).toFixed(2));
|
||||||
|
|
||||||
deposit.refundDate = dto.refundDate;
|
deposit.refundDate = dto.refundDate;
|
||||||
deposit.refundAmount = refundAmount;
|
deposit.refundAmount = refundAmount;
|
||||||
deposit.deductionAmount = deductionAmount;
|
deposit.deductionAmount = deductionAmount;
|
||||||
deposit.deductionReason =
|
deposit.deductionReason =
|
||||||
deductionAmount > 0 ? `自动扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
|
deductionAmount > 0 ? `扣除个人附加费用 ¥${deductionAmount.toFixed(2)}` : '';
|
||||||
deposit.amount = 0;
|
deposit.amount = 0;
|
||||||
deposit.status = refundAmount > 0 ? 'refunded' : 'depleted';
|
deposit.status = refundAmount > 0 ? 'refunded' : 'depleted';
|
||||||
if (dto.notes) deposit.notes = dto.notes;
|
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[]) {
|
private async getPersonalExpenseAmountMap(studentIds: number[]) {
|
||||||
const amountMap = new Map<number, number>();
|
const amountMap = new Map<number, number>();
|
||||||
if (!studentIds.length) return amountMap;
|
if (!studentIds.length) return amountMap;
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ export class RefundDepositDto {
|
|||||||
@IsDateString()
|
@IsDateString()
|
||||||
refundDate: string;
|
refundDate: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber({ maxDecimalPlaces: 2 })
|
||||||
|
deductionAmount?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user