test: harden business boundary conditions
This commit is contained in:
77
apps/server/src/bills/bills.boundaries.spec.ts
Normal file
77
apps/server/src/bills/bills.boundaries.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('账单状态必须与实付及未付金额一致');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user