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

@@ -0,0 +1,77 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { BillsService } from './bills.service';
import { Bill } from '../entities/bill.entity';
function queryBuilder() {
return {
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
};
}
function createService(bills: Partial<Bill>[] = []) {
const billRepo = {
find: jest.fn().mockResolvedValue(bills),
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
save: jest.fn(async (value) => value),
createQueryBuilder: jest.fn(() => queryBuilder()),
};
const manager = {
delete: jest.fn(),
update: jest.fn(),
};
const dataSource = { transaction: jest.fn(async (callback) => callback(manager)) };
const service = new BillsService(
billRepo as any,
{ delete: jest.fn() } as any,
{} as any,
{ update: jest.fn() } as any,
{} as any,
{} as any,
dataSource as any,
{} as any,
);
return { service, billRepo, dataSource, manager };
}
describe('BillsService state and batch boundaries', () => {
it('rejects an empty batch status update', async () => {
const { service, billRepo } = createService();
await expect(service.batchUpdateStatus([], 'paid')).rejects.toBeInstanceOf(BadRequestException);
expect(billRepo.find).not.toHaveBeenCalled();
});
it('rejects a batch status update when some ids do not exist', async () => {
const { service, billRepo } = createService([{ id: 1, paidAmount: 0, outstandingAmount: 10 }]);
await expect(service.batchUpdateStatus([1, 2], 'unpaid')).rejects.toBeInstanceOf(NotFoundException);
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
});
it('rejects marking a partially paid bill unpaid', async () => {
const { service, billRepo } = createService([{ id: 1, paidAmount: 10, outstandingAmount: 90, status: 'partially_paid' }]);
await expect(service.updateStatus(1, { status: 'unpaid' })).rejects.toBeInstanceOf(BadRequestException);
expect(billRepo.save).not.toHaveBeenCalled();
});
it('rejects an empty batch delete', async () => {
const { service, dataSource } = createService();
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a batch delete when some ids do not exist', async () => {
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes a bill and its links in one transaction', async () => {
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.delete).toHaveBeenCalledTimes(2);
expect(manager.update).toHaveBeenCalledTimes(1);
});
});

View File

@@ -544,3 +544,52 @@ describe('BillsService — generateBills', () => {
expect(result.count).toBe(0);
});
});
describe('BillsService — allocation rounding boundary', () => {
it('keeps allocated cents equal to the original expense total', async () => {
const billRepo = mockRepo<Bill>();
const itemRepo = mockRepo<BillItem>();
const roomExpRepo = mockRepo<RoomExpense>();
const personalExpRepo = mockRepo<PersonalExpense>();
const occRepo = mockRepo<Occupancy>();
const roomRepo = mockRepo<Room>();
let nextBillId = 0;
const dataSource = {
query: jest.fn().mockResolvedValue([]),
transaction: jest.fn(async (callback) => callback({
create: (_entity: unknown, value: any) => value,
save: jest.fn(async (value: any) => ({ id: value.id || ++nextBillId, ...value })),
createQueryBuilder: jest.fn(() => ({
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
execute: jest.fn().mockResolvedValue({ affected: 1 }),
})),
})),
};
const service = new BillsService(
billRepo as any,
itemRepo as any,
roomExpRepo as any,
personalExpRepo as any,
occRepo as any,
roomRepo as any,
dataSource as any,
{ debitBill: jest.fn(async (_manager, bill) => bill) } as any,
);
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<RoomExpense>([
{ id: 1, roomId: 1, expenseType: 'water', amount: 100, periodStart: '2026-06-01', periodEnd: '2026-06-30' } as RoomExpense,
]));
(occRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<Occupancy>([
{ id: 1, roomId: 1, studentId: 1, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 2, roomId: 1, studentId: 2, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
{ id: 3, roomId: 1, studentId: 3, stayType: 'short', billingStartDate: '2026-06-01', billingEndDate: '2026-06-01' } as Occupancy,
]));
(personalExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(mockQueryBuilder<PersonalExpense>([]));
const result = await service.generateBills({ periodStart: '2026-06-01', periodEnd: '2026-06-30' } as any);
expect(result.bills.map((bill) => Number(bill.totalAmount))).toEqual([33.33, 33.33, 33.34]);
expect(result.bills.reduce((sum, bill) => sum + Number(bill.totalAmount), 0)).toBe(100);
});
});

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

View File

@@ -1,4 +1,4 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class GenerateBillsDto {
@IsString()
@@ -21,6 +21,8 @@ export class UpdateBillStatusDto {
export class CancelBillDto {
@IsString()
@IsNotEmpty()
@Matches(/\S/)
@MaxLength(300)
reason: string;
}