forked from wangziqi/gongxue-base
由 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)
367 lines
15 KiB
TypeScript
367 lines
15 KiB
TypeScript
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 lock(GET_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())}`,
|
||
};
|
||
}
|
||
|
||
}
|