Files
gongxue-base/apps/server/src/bills/bills-generation.service.ts
wangziqi f50301148d fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

367 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { BadRequestException, ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, Repository } from 'typeorm';
import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy } from '../entities';
import { WalletsService } from '../wallets/wallets.service';
import type { GenerateBillsDto } from './dto/bill.dto';
import dayjs from '../common/dayjs';
/** 把 TypeORM 可能 hydrate 成 Date 的 date-only 字段归一化为 YYYY-MM-DD 字符串。 */
function toDateOnly(value: Date | string | null | undefined): string {
if (value == null) return '';
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return String(value);
}
/** YYYY-MM-DD 字符串(字典序即时间序)取较大/较小者。 */
function maxDateOnly(a: string, b: string): string {
return a > b ? a : b;
}
function minDateOnly(a: string, b: string): string {
return a < b ? a : b;
}
/** YYYY-MM-DD 字符串相差的天数a 晚于 b 返回负值)。 */
function daysBetweenDateOnly(a: string, b: string): number {
const [ay, am, ad] = a.split('-').map(Number);
const [by, bm, bd] = b.split('-').map(Number);
return Math.round((Date.UTC(by, bm - 1, bd) - Date.UTC(ay, am - 1, ad)) / 86_400_000);
}
@Injectable()
export class BillsGenerationService {
constructor(
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
private dataSource: DataSource,
private walletsService: WalletsService,
) {}
async generateBillsOnce(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账单周期无效,结束日期不能早于开始日期');
}
const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e')
.where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', {
periodStart,
periodEnd,
})
.andWhere('e.status = :status', { status: 'active' })
.getMany();
// 长租入住:即使本周期没有费用记录,也按长租计费纳入开票范围
const longTermOccupancies: Occupancy[] = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.stayType = :stayType', { stayType: 'long' })
.andWhere('o.status = :status', { status: 'active' })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
const roomExpMap = new Map<number, RoomExpense[]>();
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>> }
>();
// 一次性按 roomIds In(...) 查询本周期内全部在住/曾住的入住记录,内存按 roomId 分组,
// 避免对每个房间循环 createQueryBuilder(...).getMany()N+1
// 有意不按 o.status 过滤:这里要查这些房间在本周期内全部在住/曾住的入住记录来分摊费用,
// 退宿后归档的入住记录只要 billingStartDate/billingEndDate 覆盖本周期仍应参与分摊
// (与 occupancy-operations.getRoomOccupanciesInPeriod 的计费语义一致)。
// status='active' 过滤只用于上面 roomIds 聚合(决定哪些房间进入开票范围),不用于分摊查询。
const roomIdList = [...roomIds];
const allOccupancies: Occupancy[] =
roomIdList.length > 0
? await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('o.roomId IN (:...roomIds)', { roomIds: roomIdList })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', {
periodStart,
})
.getMany()
: [];
const occupanciesByRoom = new Map<number, Occupancy[]>();
for (const occupancy of allOccupancies) {
const list = occupanciesByRoom.get(occupancy.roomId) || [];
list.push(occupancy);
occupanciesByRoom.set(occupancy.roomId, list);
}
for (const roomId of roomIds) {
const expenses = roomExpMap.get(roomId) || [];
const occupancies = occupanciesByRoom.get(roomId) || [];
const shortTermOccs = occupancies.filter((occupancy) => occupancy.stayType !== 'long');
const longTermOccs = occupancies.filter((occupancy) => occupancy.stayType === 'long');
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: `长租月租费 (${occupancy.room?.roomNumber || '未知房间'})`,
days: 0,
totalRoomDays: 0,
roomTotalAmount: rent,
studentAmount: rent,
});
studentBillData.set(occupancy.studentId, data);
}
// 日期统一用 YYYY-MM-DD 字符串比较/运算,避免 TypeORM 把 date 列 hydrate 成 Date 后比较不可靠
const studentDays = shortTermOccs.map((occupancy) => {
const startStr = toDateOnly(occupancy.billingStartDate);
const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd;
if (!startStr) return { studentId: occupancy.studentId, days: 0 };
const start = maxDateOnly(startStr, periodStart);
const end = minDateOnly(endStr, periodEnd);
const days = end < start ? 0 : daysBetweenDateOnly(start, end) + 1;
return { studentId: occupancy.studentId, days };
});
const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0);
if (totalDays === 0) continue;
const eligibleDays = studentDays.filter((entry) => entry.days > 0);
for (const expense of expenses) {
const expenseTotal = Number(Number(expense.amount).toFixed(2));
let allocated = 0;
for (const [index, entry] of eligibleDays.entries()) {
const amount =
index === eligibleDays.length - 1
? Number((expenseTotal - allocated).toFixed(2))
: Number(((entry.days / totalDays) * expenseTotal).toFixed(2));
allocated = Number((allocated + amount).toFixed(2));
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: 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,
})
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
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: 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) => {
// 首次生成的并发防护FOR UPDATE 对不存在的行不加锁,两个并发请求都能通过检查;
// 这里在事务内先拿按周期 key 的 MySQL advisory lockGET_LOCK 对不存在的 key 同样生效),
// 失败/超时直接抛 ConflictException重复检查放进锁内。financialOperations.run 只覆盖
// 带 operationId 的幂等场景,无 operationId 的并发首次生成靠这把锁兜底。
const lockName = `gongxue:bills-gen:${periodStart}-${periodEnd}`;
const lockRows = (await manager.query(
`SELECT GET_LOCK('${lockName}', 5) AS acquired`,
)) as unknown as Array<{ acquired?: unknown }> | undefined;
if (Number(lockRows?.[0]?.acquired ?? 0) !== 1) {
throw new ConflictException(
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单正在生成中,请勿重复提交`,
);
}
try {
// 周期内重复生成检查移进事务内,并对同一 (periodStart, periodEnd) 加锁复查:
// 并发请求会在锁释放后看到已提交的账单并抛「账单已生成」,保证只有一个请求成功。
const existingBills = await manager
.createQueryBuilder(Bill, 'b')
.setLock('pessimistic_write')
.where('b.periodStart = :periodStart', { periodStart })
.andWhere('b.periodEnd = :periodEnd', { periodEnd })
.getMany();
if (existingBills.length > 0) {
throw new BadRequestException(
`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`,
);
}
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,
periodStart,
periodEnd,
sharedAmount: Number(shared.toFixed(2)),
personalAmount: Number(personal.toFixed(2)),
totalAmount: total,
source: 'batch',
paidAmount: 0,
outstandingAmount: total,
status: 'unpaid',
}),
);
const items = [
...(studentBillData.get(studentId)?.items || []),
...(personalItems.get(studentId) || []),
];
// 逐条 save 改为批量一次保存,减少往返
if (items.length > 0) {
await manager.save(
manager.create(
BillItem,
items.map((item) => ({ ...item, billId: bill.id })),
),
);
}
const includedPersonal = personalExps.filter((expense) => expense.studentId === studentId);
if (includedPersonal.length) {
await manager
.createQueryBuilder()
.update(PersonalExpense)
.set({ billId: bill.id })
.where('id IN (:...ids)', { ids: includedPersonal.map((expense) => expense.id) })
.execute();
}
bill = await this.walletsService.debitBill(manager, bill);
generated.push(bill);
}
return generated;
} finally {
await manager.query(`SELECT RELEASE_LOCK('${lockName}')`);
}
});
return {
message: `成功生成 ${bills.length} 条账单`,
count: bills.length,
bills,
periodStart,
periodEnd,
};
}
private calculateLongTermRent(
occupancy: Occupancy,
periodStart: string,
periodEnd: string,
monthlyRate: number,
) {
const startStr = toDateOnly(occupancy.billingStartDate);
const endStr = toDateOnly(occupancy.billingEndDate) || periodEnd;
const activeStart = startStr > periodStart ? startStr : periodStart;
const activeEnd = endStr && endStr < periodEnd ? endStr : 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 = dayjs.utc(`${year}-${month}`).daysInMonth();
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`);
return !Number.isNaN(date.getTime()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === value;
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const year = Number(matched[1]);
const month = Number(matched[2]);
if (month < 1 || month > 12) throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
const targetMonthStart = new Date(year, month - 1, 1);
const currentMonthStart = new Date();
currentMonthStart.setDate(1);
currentMonthStart.setHours(0, 0, 0, 0);
if (targetMonthStart >= currentMonthStart)
throw new BadRequestException('只能生成已结束月份的账单');
const targetMonthEnd = new Date(year, month, 0);
const pad = (value: number) => String(value).padStart(2, '0');
return {
periodStart: `${year}-${pad(month)}-01`,
periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}`,
};
}
}