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

@@ -1,6 +1,6 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } from 'typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Room } from '../entities/room.entity';
@@ -23,6 +23,7 @@ export class ExpensesService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
private billsService: BillsService,
private dataSource: DataSource,
) {}
async getFormLookups() {
@@ -87,6 +88,8 @@ export class ExpensesService {
async deleteRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.roomExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
@@ -96,6 +99,8 @@ export class ExpensesService {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: In(uniqueIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用');
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const result = await this.roomExpRepo
.createQueryBuilder()
@@ -108,6 +113,8 @@ export class ExpensesService {
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
const e = await this.roomExpRepo.findOne({ where: { id } });
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能修改,请先取消账单');
if (!e) throw new NotFoundException('费用记录不存在');
const periodStart = dto.periodStart ?? e.periodStart;
const periodEnd = dto.periodEnd ?? e.periodEnd;
@@ -145,24 +152,16 @@ export class ExpensesService {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const expense = await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: dto.studentId,
expenseType: dto.expenseType,
amount: dto.amount,
expenseDate: dto.periodEnd,
description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
recordedBy: userId,
billId: null,
}),
);
try {
const bill = await this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
return { expense, bill };
} catch (error) {
await this.personalExpRepo.delete(expense.id);
throw error;
}
const expense = {
studentId: dto.studentId,
expenseType: dto.expenseType,
amount: dto.amount,
expenseDate: dto.periodEnd,
description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
recordedBy: userId,
billId: null,
} as PersonalExpense;
return this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
}
// 个人附加费
@@ -303,45 +302,50 @@ export class ExpensesService {
continue;
}
// 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
await this.roomExpRepo
.createQueryBuilder()
.delete()
.where('roomId = :roomId', { roomId: room.id })
.andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
.andWhere('expenseType IN (:...types)', { types: ['water', 'electricity'] })
.execute();
const existing = await this.roomExpRepo.find({
where: [
{ importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` },
{ importKey: `${room.id}:${periodStart}:${periodEnd}:water` },
],
});
const byType = new Map(existing.map((expense) => [expense.expenseType, expense]));
let savedAny = false;
// 导入电费
if (row.electricityFee > 0) {
await this.roomExpRepo.save(
this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
amount: row.electricityFee,
periodStart,
periodEnd,
description: `电量${row.electricityAmount}kWh`,
recordedBy: userId,
}),
);
const expense = byType.get('electricity') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期电费已计入账单,不能覆盖');
}
expense.amount = row.electricityFee;
expense.description = `电量${row.electricityAmount}kWh`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
savedAny = true;
}
// 导入水费
if (row.waterFee > 0) {
await this.roomExpRepo.save(
this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
amount: row.waterFee,
periodStart,
periodEnd,
description: `用水${row.waterAmount}`,
recordedBy: userId,
}),
);
const expense = byType.get('water') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:water`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期水费已计入账单,不能覆盖');
}
expense.amount = row.waterFee;
expense.description = `用水${row.waterAmount}`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
savedAny = true;
}