test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -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('账单状态必须与实付及未付金额一致');
}
}