test: harden business boundary conditions
This commit is contained in:
@@ -32,8 +32,11 @@ export class BillsService {
|
||||
const { periodStart, periodEnd } = dto.billingMonth
|
||||
? this.resolveBillingPeriod(dto.billingMonth)
|
||||
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
|
||||
const pStart = new Date(periodStart);
|
||||
const pEnd = new Date(periodEnd);
|
||||
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
|
||||
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
|
||||
}
|
||||
const pStart = new Date(`${periodStart}T00:00:00Z`);
|
||||
const pEnd = new Date(`${periodEnd}T00:00:00Z`);
|
||||
|
||||
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
|
||||
if (existingBills.length > 0) {
|
||||
@@ -139,11 +142,16 @@ export class BillsService {
|
||||
|
||||
if (totalDays === 0) continue;
|
||||
|
||||
// 对每项费用进行分摊
|
||||
// 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。
|
||||
for (const expense of expenses) {
|
||||
for (const sd of studentDays) {
|
||||
if (sd.days === 0) continue;
|
||||
const amount = Number(((sd.days / totalDays) * Number(expense.amount)).toFixed(2));
|
||||
const eligibleDays = studentDays.filter((sd) => sd.days > 0);
|
||||
const expenseTotal = Number(Number(expense.amount).toFixed(2));
|
||||
let allocated = 0;
|
||||
for (const [index, sd] of eligibleDays.entries()) {
|
||||
const amount = index === eligibleDays.length - 1
|
||||
? Number((expenseTotal - allocated).toFixed(2))
|
||||
: Number(((sd.days / totalDays) * expenseTotal).toFixed(2));
|
||||
allocated = Number((allocated + amount).toFixed(2));
|
||||
if (!studentBillData.has(sd.studentId)) {
|
||||
studentBillData.set(sd.studentId, { shared: 0, items: [] });
|
||||
}
|
||||
@@ -189,16 +197,14 @@ export class BillsService {
|
||||
}
|
||||
|
||||
|
||||
// 合并所有涉及的学生
|
||||
// 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。
|
||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
||||
// 生成账单
|
||||
const bills: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
|
||||
const savedBill = await this.dataSource.transaction(async (manager) => {
|
||||
const bills = await this.dataSource.transaction(async (manager) => {
|
||||
const generated: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
const personal = personalMap.get(studentId) || 0;
|
||||
const total = Number((shared + personal).toFixed(2));
|
||||
let bill = await manager.save(
|
||||
manager.create(Bill, {
|
||||
studentId,
|
||||
@@ -230,14 +236,20 @@ export class BillsService {
|
||||
.execute();
|
||||
}
|
||||
bill = await this.walletsService.debitBill(manager, bill);
|
||||
return bill;
|
||||
});
|
||||
bills.push(savedBill);
|
||||
}
|
||||
generated.push(bill);
|
||||
}
|
||||
return generated;
|
||||
});
|
||||
|
||||
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
|
||||
}
|
||||
|
||||
private isValidDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
|
||||
}
|
||||
|
||||
private resolveBillingPeriod(billingMonth: string) {
|
||||
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
|
||||
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
|
||||
@@ -344,34 +356,36 @@ export class BillsService {
|
||||
async updateStatus(id: number, dto: UpdateBillStatusDto) {
|
||||
const bill = await this.billRepo.findOne({ where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) {
|
||||
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
|
||||
}
|
||||
this.assertStatusMatchesAmounts(bill, dto.status);
|
||||
bill.status = dto.status;
|
||||
return this.billRepo.save(bill);
|
||||
}
|
||||
|
||||
async batchUpdateStatus(ids: number[], status: string) {
|
||||
const bills = await this.billRepo.find({ where: { id: In(ids) } });
|
||||
if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) {
|
||||
throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付');
|
||||
}
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单');
|
||||
if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效');
|
||||
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
|
||||
for (const bill of bills) this.assertStatusMatchesAmounts(bill, status);
|
||||
await this.billRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status })
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.where('id IN (:...ids)', { ids: uniqueIds })
|
||||
.execute();
|
||||
return { message: `成功更新 ${ids.length} 条账单状态` };
|
||||
return { message: `成功更新 ${uniqueIds.length} 条账单状态` };
|
||||
}
|
||||
|
||||
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
|
||||
const reason = dto.reason?.trim();
|
||||
if (!reason) throw new BadRequestException('取消原因不能为空');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const bill = await manager.findOne(Bill, { where: { id } });
|
||||
if (!bill) throw new NotFoundException('账单不存在');
|
||||
if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消');
|
||||
await manager.update(PersonalExpense, { billId: id }, { billId: null });
|
||||
return this.walletsService.refundBill(manager, bill, dto.reason, recordedBy);
|
||||
return this.walletsService.refundBill(manager, bill, reason, recordedBy);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,25 +395,38 @@ export class BillsService {
|
||||
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
|
||||
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
|
||||
}
|
||||
await this.itemRepo.delete({ billId: id });
|
||||
await this.personalExpRepo.update({ billId: id }, { billId: null });
|
||||
await this.billRepo.delete(id);
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(BillItem, { billId: id });
|
||||
await manager.update(PersonalExpense, { billId: id }, { billId: null });
|
||||
await manager.delete(Bill, id);
|
||||
});
|
||||
return { message: '账单已删除' };
|
||||
}
|
||||
|
||||
async batchRemove(ids: number[]) {
|
||||
const bills = await this.billRepo.find({ where: { id: In(ids) } });
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
|
||||
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
|
||||
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
|
||||
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
|
||||
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
|
||||
}
|
||||
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute();
|
||||
await this.personalExpRepo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ billId: null })
|
||||
.where('billId IN (:...ids)', { ids })
|
||||
.execute();
|
||||
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();
|
||||
return { message: `成功删除 ${ids.length} 条账单` };
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(BillItem, { billId: In(uniqueIds) });
|
||||
await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
|
||||
await manager.delete(Bill, uniqueIds);
|
||||
});
|
||||
return { message: `成功删除 ${uniqueIds.length} 条账单` };
|
||||
}
|
||||
|
||||
private assertStatusMatchesAmounts(bill: Bill, status: string) {
|
||||
const paid = Number(bill.paidAmount || 0);
|
||||
const outstanding = Number(bill.outstandingAmount || 0);
|
||||
const matches = status === 'paid'
|
||||
? outstanding <= 0
|
||||
: status === 'partially_paid'
|
||||
? paid > 0 && outstanding > 0
|
||||
: status === 'unpaid' && paid <= 0 && outstanding > 0;
|
||||
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user