fix: harden financial transaction boundaries

This commit is contained in:
2026-07-18 15:33:41 +08:00
parent 4af7acbeaa
commit 5836b421d8
19 changed files with 514 additions and 314 deletions

View File

@@ -284,7 +284,7 @@ describe('BillsService — generateBills', () => {
// Bug-exposing tests
// ============================================================
it.skip('BUG: long-term multi-month period → monthlyRate not multiplied by months', async () => {
it('long-term multi-month period multiplies and prorates monthly rent', async () => {
// 3-month period: JanMar 2026
const THREE_MONTHS = { periodStart: '2026-01-01', periodEnd: '2026-03-31' };
@@ -328,7 +328,7 @@ describe('BillsService — generateBills', () => {
expect(actual).toBeCloseTo(expected, 0);
});
it.skip('BUG: long-term partial month → full monthlyRate charged instead of prorated', async () => {
it('long-term partial month prorates by calendar days', async () => {
// Student occupies only Jun 1530 (16 days out of 30), monthlyRate 600
(roomExpRepo.createQueryBuilder as jest.Mock).mockReturnValue(
mockQueryBuilder<RoomExpense>([

View File

@@ -1,4 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource, EntityManager } from 'typeorm';
import { Bill } from '../entities/bill.entity';
@@ -10,6 +10,7 @@ import { Room } from '../entities/room.entity';
import { StudentWallet } from '../entities/student-wallet.entity';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
@Injectable()
@@ -23,12 +24,22 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
private dataSource: DataSource,
private walletsService: WalletsService,
@Optional()
private financialOperations?: FinancialOperationsService,
) {}
/**
* 核心计费引擎:按"人天数"加权分摊
*/
async generateBills(dto: GenerateBillsDto) {
const { operationId, ...request } = dto;
const work = () => this.generateBillsOnce(request as GenerateBillsDto);
return this.financialOperations
? this.financialOperations.run(operationId, 'bill.generate', work)
: work();
}
private async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
@@ -37,55 +48,30 @@ export class BillsService {
}
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) {
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
}
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
.createQueryBuilder('e')
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
periodStart,
periodEnd,
})
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd })
.andWhere('e.status = :status', { status: 'active' })
.getMany();
// 按宿舍分组费用
const longTermOccupancies: Occupancy[] = [];
const roomExpMap = new Map<number, RoomExpense[]>();
for (const exp of roomExpenses) {
if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
roomExpMap.get(exp.roomId)!.push(exp);
for (const expense of roomExpenses) {
const expenses = roomExpMap.get(expense.roomId) || [];
expenses.push(expense);
roomExpMap.set(expense.roomId, expenses);
}
const roomIds = new Set([
...roomExpMap.keys(),
...longTermOccupancies.filter((occupancy) => occupancy.stayType === 'long').map((occupancy) => occupancy.roomId),
]);
const studentBillData = new Map<number, { shared: number; items: Array<Record<string, unknown>> }>();
// 计算每个学生的分摊费用
const studentBillData = new Map<number, { shared: number; items: any[] }>();
for (const [roomId, expenses] of roomExpMap) {
// 获取该宿舍在此周期内的所有入住记录
for (const roomId of roomIds) {
const expenses = roomExpMap.get(roomId) || [];
const occupancies = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
@@ -94,112 +80,92 @@ export class BillsService {
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
// 分离长租与短租入住记录
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
// 长租:按月租费独立计费,不参与人天数分摊
for (const occ of longTermOccs) {
const monthlyRate = Number(occ.room?.monthlyRate || 0);
if (!studentBillData.has(occ.studentId)) {
studentBillData.set(occ.studentId, { shared: 0, items: [] });
}
const data = studentBillData.get(occ.studentId)!;
data.shared += monthlyRate;
for (const occupancy of longTermOccs) {
const rent = this.calculateLongTermRent(
occupancy,
periodStart,
periodEnd,
Number(occupancy.room?.monthlyRate || 0),
);
if (rent <= 0) continue;
const data = studentBillData.get(occupancy.studentId) || { shared: 0, items: [] };
data.shared += rent;
data.items.push({
roomId,
expenseType: 'rent',
description: `长租月租费 (${occ.room?.roomNumber || '未知房间'})`,
description: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: monthlyRate,
studentAmount: monthlyRate,
roomTotalAmount: rent,
studentAmount: rent,
});
studentBillData.set(occupancy.studentId, data);
}
// 短租:原人天数加权分摊逻辑
if (shortTermOccs.length === 0) continue;
// 计算每个学生的计费天数
const studentDays: { studentId: number; days: number }[] = [];
let totalDays = 0;
for (const occ of shortTermOccs) {
const start = new Date(
Math.max(new Date(occ.billingStartDate).getTime(), pStart.getTime()),
);
const end = occ.billingEndDate
? new Date(Math.min(new Date(occ.billingEndDate).getTime(), pEnd.getTime()))
const studentDays = shortTermOccs.map((occupancy) => {
const start = new Date(Math.max(new Date(occupancy.billingStartDate).getTime(), pStart.getTime()));
const end = occupancy.billingEndDate
? new Date(Math.min(new Date(occupancy.billingEndDate).getTime(), pEnd.getTime()))
: pEnd;
const days = Math.max(
0,
Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1,
);
studentDays.push({ studentId: occ.studentId, days });
totalDays += days;
}
const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1);
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
// 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。
for (const expense of expenses) {
const eligibleDays = studentDays.filter((sd) => sd.days > 0);
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
const expenseTotal = Number(Number(expense.amount).toFixed(2));
let allocated = 0;
for (const [index, sd] of eligibleDays.entries()) {
for (const [index, entry] of eligibleDays.entries()) {
const amount = index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((sd.days / totalDays) * expenseTotal).toFixed(2));
: Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
if (!studentBillData.has(sd.studentId)) {
studentBillData.set(sd.studentId, { shared: 0, items: [] });
}
const data = studentBillData.get(sd.studentId)!;
const data = studentBillData.get(entry.studentId) || { shared: 0, items: [] };
data.shared += amount;
data.items.push({
roomExpenseId: expense.id,
roomId,
expenseType: expense.expenseType,
description: `${expense.expenseType} 分摊`,
days: sd.days,
days: entry.days,
totalRoomDays: totalDays,
roomTotalAmount: expense.amount,
studentAmount: amount,
});
studentBillData.set(entry.studentId, data);
}
}
}
// 获取个人附加费
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
periodStart,
periodEnd,
})
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd })
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
const personalItems = new Map<number, any[]>();
for (const pe of personalExps) {
personalMap.set(pe.studentId, (personalMap.get(pe.studentId) || 0) + Number(pe.amount));
if (!personalItems.has(pe.studentId)) personalItems.set(pe.studentId, []);
personalItems.get(pe.studentId)!.push({
roomId: pe.roomId,
expenseType: pe.expenseType,
description: `个人费用: ${pe.description || pe.expenseType}`,
const personalItems = new Map<number, Array<Record<string, unknown>>>();
for (const expense of personalExps) {
personalMap.set(expense.studentId, (personalMap.get(expense.studentId) || 0) + Number(expense.amount));
const items = personalItems.get(expense.studentId) || [];
items.push({
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: `个人费用: ${expense.description || expense.expenseType}`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: pe.amount,
studentAmount: pe.amount,
roomTotalAmount: expense.amount,
studentAmount: expense.amount,
});
personalItems.set(expense.studentId, items);
}
// 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
const bills = await this.dataSource.transaction(async (manager) => {
const generated: Bill[] = [];
@@ -207,31 +173,23 @@ export class BillsService {
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,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal,
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}),
);
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
for (const item of items) {
await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
}
let bill = await manager.save(manager.create(Bill, {
studentId,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: personal,
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}));
const items = [...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || [])];
for (const item of items) await manager.save(manager.create(BillItem, { ...item, billId: bill.id }));
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager
.createQueryBuilder()
await manager.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
@@ -242,10 +200,31 @@ export class BillsService {
}
return generated;
});
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
}
private calculateLongTermRent(occupancy: Occupancy, periodStart: string, periodEnd: string, monthlyRate: number) {
const activeStart = occupancy.billingStartDate > periodStart ? occupancy.billingStartDate : periodStart;
const activeEnd = occupancy.billingEndDate && occupancy.billingEndDate < periodEnd
? occupancy.billingEndDate
: periodEnd;
if (activeEnd < activeStart || monthlyRate <= 0) return 0;
const [startYear, startMonth] = activeStart.split('-').map(Number);
const [endYear, endMonth] = activeEnd.split('-').map(Number);
let total = 0;
for (let year = startYear, month = startMonth; year < endYear || (year === endYear && month <= endMonth);) {
const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate();
const prefix = `${year}-${String(month).padStart(2, '0')}-`;
const overlapStart = activeStart > `${prefix}01` ? activeStart : `${prefix}01`;
const monthEnd = `${prefix}${String(daysInMonth).padStart(2, '0')}`;
const overlapEnd = activeEnd < monthEnd ? activeEnd : monthEnd;
const days = Math.floor((Date.parse(`${overlapEnd}T00:00:00Z`) - Date.parse(`${overlapStart}T00:00:00Z`)) / 86_400_000) + 1;
total += monthlyRate * days / daysInMonth;
if (++month > 12) { month = 1; year++; }
}
return Number(total.toFixed(2));
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
@@ -275,6 +254,7 @@ export class BillsService {
recordedBy?: number,
) {
return this.dataSource.transaction(async (manager) => {
expense = await manager.save(manager.create(PersonalExpense, expense));
let bill = await manager.save(
manager.create(Bill, {
studentId: expense.studentId,
@@ -292,6 +272,7 @@ export class BillsService {
await manager.save(
manager.create(BillItem, {
billId: bill.id,
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
@@ -304,7 +285,7 @@ export class BillsService {
expense.billId = bill.id;
await manager.save(expense);
bill = await this.walletsService.debitBill(manager, bill, recordedBy);
return bill;
return { expense, bill };
});
}
@@ -382,13 +363,19 @@ export class BillsService {
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 } });
const work = () => this.dataSource.transaction(async (manager) => {
const bill = await manager.createQueryBuilder(Bill, 'bill')
.where('bill.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
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, reason, recordedBy);
});
return this.financialOperations
? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work)
: work();
}
async remove(id: number) {

View File

@@ -1,6 +1,11 @@
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNotEmpty, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
export class GenerateBillsDto {
@IsOptional()
@IsString()
@Matches(/^[\w-]{8,64}$/)
operationId?: string;
@IsString()
@Matches(/^\d{4}-\d{2}$/)
billingMonth: string;
@@ -20,6 +25,11 @@ export class UpdateBillStatusDto {
}
export class CancelBillDto {
@IsOptional()
@IsString()
@Matches(/^[\w-]{8,64}$/)
operationId?: string;
@IsString()
@IsNotEmpty()
@Matches(/\S/)