Merge financial safety fixes

This commit is contained in:
2026-07-18 15:34:36 +08:00
19 changed files with 514 additions and 314 deletions

View File

@@ -25,6 +25,7 @@ import PermissionButton from '../../components/PermissionButton';
import { downloadBlob } from '../../utils/download'; import { downloadBlob } from '../../utils/download';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { buildBillPrintHtml, type BillPrintData } from './bill-print'; import { buildBillPrintHtml, type BillPrintData } from './bill-print';
import { newOperationId } from '../../utils/operation-id';
const statusMap: Record<string, { text: string; color: string }> = { const statusMap: Record<string, { text: string; color: string }> = {
@@ -93,6 +94,7 @@ const BillsPage: React.FC = () => {
const values = await generateForm.validateFields(); const values = await generateForm.validateFields();
try { try {
const res: any = await api.post('/bills/generate', { const res: any = await api.post('/bills/generate', {
operationId: newOperationId(),
billingMonth: values.billingMonth.format('YYYY-MM'), billingMonth: values.billingMonth.format('YYYY-MM'),
}); });
message.success(res.message || '生成成功'); message.success(res.message || '生成成功');
@@ -128,7 +130,7 @@ const BillsPage: React.FC = () => {
okText: '确认取消', cancelText: '返回', okText: '确认取消', cancelText: '返回',
onOk: async () => { onOk: async () => {
if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); } if (!reason.trim()) { message.error('请输入取消原因'); throw new Error('reason required'); }
await api.post(`/bills/${id}/cancel`, { reason: reason.trim() }); await api.post(`/bills/${id}/cancel`, { operationId: newOperationId(), reason: reason.trim() });
message.success('账单已取消,已扣余额已冲正退回'); message.success('账单已取消,已扣余额已冲正退回');
fetchData(); fetchData();
}, },

View File

@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
import api from '../../api'; import api from '../../api';
import PermissionButton from '../../components/PermissionButton'; import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message'; import { message } from '../../ui/app-message';
import { newOperationId } from '../../utils/operation-id';
interface WalletRow { interface WalletRow {
studentId: number; studentId: number;
@@ -59,7 +60,7 @@ const WalletsPage: React.FC = () => {
const values = await form.validateFields(); const values = await form.validateFields();
setSaving(true); setSaving(true);
try { try {
const result: any = await api.post('/wallets/change-balance', { studentId: selected.studentId, ...values }); const result: any = await api.post('/wallets/change-balance', { operationId: newOperationId(), studentId: selected.studentId, ...values });
const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0); const paid = (result.payments || []).reduce((sum: number, bill: any) => sum + Number(bill.paidAmount || 0), 0);
message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新'); message.success(paid > 0 ? `余额已更新,并自动补扣历史账单` : '余额已更新');
setSelected(null); setSelected(null);
@@ -73,6 +74,7 @@ const WalletsPage: React.FC = () => {
setSaving(true); setSaving(true);
try { try {
const result: any = await api.post('/wallets/batch-change-balance', { const result: any = await api.post('/wallets/batch-change-balance', {
operationId: newOperationId(),
studentIds: selectedRowKeys, studentIds: selectedRowKeys,
...values, ...values,
}); });

View File

@@ -0,0 +1 @@
export const newOperationId = () => crypto.randomUUID();

View File

@@ -46,6 +46,7 @@ import {
AiConfig, AiConfig,
StudentWallet, StudentWallet,
WalletTransaction, WalletTransaction,
FinancialOperation,
} from './entities'; } from './entities';
import { AuthModule } from './auth/auth.module'; import { AuthModule } from './auth/auth.module';
import { AuthorizationModule } from './authorization'; import { AuthorizationModule } from './authorization';
@@ -76,6 +77,7 @@ import { DatabaseMigrationsModule } from './database/database-migrations.module'
import { AgentToolsModule } from './agent-tools'; import { AgentToolsModule } from './agent-tools';
import { AiConfigModule } from './ai-config/ai-config.module'; import { AiConfigModule } from './ai-config/ai-config.module';
import { WalletsModule } from './wallets/wallets.module'; import { WalletsModule } from './wallets/wallets.module';
import { FinancialOperationsModule } from './financial-operations/financial-operations.module';
import { import {
IntegrationConfig, IntegrationConfig,
@@ -143,6 +145,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
AiConfig, AiConfig,
StudentWallet, StudentWallet,
WalletTransaction, WalletTransaction,
FinancialOperation,
]; ];
if (dbType === 'mysql') { if (dbType === 'mysql') {
return { return {
@@ -177,6 +180,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
OperationLogsModule, OperationLogsModule,
DepositsModule, DepositsModule,
WalletsModule, WalletsModule,
FinancialOperationsModule,
ClassroomsModule, ClassroomsModule,
AttendanceModule, AttendanceModule,
AttendanceDevicesModule, AttendanceDevicesModule,

View File

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

View File

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

View File

@@ -117,6 +117,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
description VARCHAR(300), recorded_by INTEGER, description VARCHAR(300), recorded_by INTEGER,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`); )`);
await runner.query(`CREATE TABLE IF NOT EXISTS financial_operations (
id ${pk}, operation_id VARCHAR(64) NOT NULL UNIQUE, type VARCHAR(64) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'running', result_json TEXT, error_message VARCHAR(500),
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
const walletTransactions = await runner.getTable('wallet_transactions');
if (walletTransactions) {
const columns = new Set(walletTransactions.columns.map((column) => column.name));
if (!columns.has('operation_id')) {
await runner.query('ALTER TABLE wallet_transactions ADD COLUMN operation_id VARCHAR(64)');
}
}
const billItems = await runner.getTable('bill_items');
if (billItems) {
const columns = new Set(billItems.columns.map((column) => column.name));
for (const [name, definition] of [
['room_expense_id', 'INTEGER'],
['personal_expense_id', 'INTEGER'],
]) {
if (!columns.has(name)) await runner.query(`ALTER TABLE bill_items ADD COLUMN ${name} ${definition}`);
}
}
const roomExpenses = await runner.getTable('room_expenses');
if (roomExpenses) {
const columns = new Set(roomExpenses.columns.map((column) => column.name));
if (!columns.has('import_key')) {
await runner.query('ALTER TABLE room_expenses ADD COLUMN import_key VARCHAR(120)');
}
const refreshedRoomExpenses = await runner.getTable('room_expenses');
const hasImportKey = refreshedRoomExpenses?.indices.some((index) =>
index.isUnique && index.columnNames.length === 1 && index.columnNames[0] === 'import_key');
if (!hasImportKey) {
await runner.query(isMySQL
? 'CREATE UNIQUE INDEX idx_room_expenses_import_key ON room_expenses (import_key)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_room_expenses_import_key ON room_expenses (import_key)');
}
}
const bills = await runner.getTable('bills'); const bills = await runner.getTable('bills');
if (bills) { if (bills) {
const columns = new Set(bills.columns.map((column) => column.name)); const columns = new Set(bills.columns.map((column) => column.name));

View File

@@ -9,6 +9,12 @@ export class BillItem {
@Column({ name: 'bill_id' }) @Column({ name: 'bill_id' })
billId: number; billId: number;
@Column({ name: 'room_expense_id', type: 'integer', nullable: true })
roomExpenseId: number | null;
@Column({ name: 'personal_expense_id', type: 'integer', nullable: true })
personalExpenseId: number | null;
@Column({ name: 'room_id', nullable: true }) @Column({ name: 'room_id', nullable: true })
roomId: number; roomId: number;

View File

@@ -0,0 +1,31 @@
import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm';
export type FinancialOperationStatus = 'running' | 'completed' | 'failed';
@Entity('financial_operations')
@Index(['operationId'], { unique: true })
export class FinancialOperation {
@PrimaryGeneratedColumn()
id: number;
@Column({ name: 'operation_id', type: 'varchar', length: 64, unique: true })
operationId: string;
@Column({ type: 'varchar', length: 64 })
type: string;
@Column({ type: 'varchar', length: 20, default: 'running' })
status: FinancialOperationStatus;
@Column({ name: 'result_json', type: 'text', nullable: true })
resultJson: string | null;
@Column({ name: 'error_message', type: 'varchar', length: 500, nullable: true })
errorMessage: string | null;
@CreateDateColumn({ name: 'created_at' })
createdAt: Date;
@UpdateDateColumn({ name: 'updated_at' })
updatedAt: Date;
}

View File

@@ -39,3 +39,4 @@ export { AiConfig } from '../ai-config/ai-config.entity';
export * from './student-wallet.entity'; export * from './student-wallet.entity';
export * from './wallet-transaction.entity'; export * from './wallet-transaction.entity';
export * from './financial-operation.entity';

View File

@@ -5,10 +5,12 @@ import {
CreateDateColumn, CreateDateColumn,
ManyToOne, ManyToOne,
JoinColumn, JoinColumn,
Index,
} from 'typeorm'; } from 'typeorm';
import { Room } from './room.entity'; import { Room } from './room.entity';
@Entity('room_expenses') @Entity('room_expenses')
@Index(['importKey'], { unique: true })
export class RoomExpense { export class RoomExpense {
@PrimaryGeneratedColumn() @PrimaryGeneratedColumn()
id: number; id: number;
@@ -34,6 +36,9 @@ export class RoomExpense {
@Column({ name: 'recorded_by', nullable: true }) @Column({ name: 'recorded_by', nullable: true })
recordedBy: number; recordedBy: number;
@Column({ name: 'import_key', type: 'varchar', length: 120, nullable: true, unique: true })
importKey: string | null;
@Column({ type: 'varchar', length: 20, default: 'active' }) @Column({ type: 'varchar', length: 20, default: 'active' })
status: 'active' | 'archived'; status: 'active' | 'archived';

View File

@@ -12,6 +12,9 @@ export class WalletTransaction {
@Column({ name: 'bill_id', type: 'integer', nullable: true }) @Column({ name: 'bill_id', type: 'integer', nullable: true })
billId: number | null; billId: number | null;
@Column({ name: 'operation_id', type: 'varchar', length: 64, nullable: true })
operationId: string | null;
@Column({ type: 'varchar', length: 30 }) @Column({ type: 'varchar', length: 30 })
type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund'; type: 'recharge' | 'adjustment' | 'bill_payment' | 'bill_refund';

View File

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

View File

@@ -0,0 +1,12 @@
import { Global, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { FinancialOperation } from '../entities/financial-operation.entity';
import { FinancialOperationsService } from './financial-operations.service';
@Global()
@Module({
imports: [TypeOrmModule.forFeature([FinancialOperation])],
providers: [FinancialOperationsService],
exports: [FinancialOperationsService],
})
export class FinancialOperationsModule {}

View File

@@ -0,0 +1,55 @@
import { ConflictException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FinancialOperation } from '../entities/financial-operation.entity';
@Injectable()
export class FinancialOperationsService {
constructor(
@InjectRepository(FinancialOperation)
private readonly repo: Repository<FinancialOperation>,
) {}
async run<T>(operationId: string | undefined, type: string, work: () => Promise<T>): Promise<T> {
if (!operationId) return work();
if (!/^[\w-]{8,64}$/.test(operationId)) throw new ConflictException('operationId 格式无效');
const existing = await this.repo.findOne({ where: { operationId } });
if (existing) {
if (existing.type !== type) throw new ConflictException('operationId 已用于其他操作');
if (existing.status === 'completed' && existing.resultJson) return JSON.parse(existing.resultJson) as T;
if (existing.status === 'running') throw new ConflictException('该操作正在处理中,请勿重复提交');
}
let operation = existing;
if (!operation) {
try {
operation = await this.repo.save(this.repo.create({ operationId, type, status: 'running' }));
} catch (error) {
const concurrent = await this.repo.findOne({ where: { operationId } });
if (concurrent?.status === 'completed' && concurrent.resultJson) {
return JSON.parse(concurrent.resultJson) as T;
}
throw new ConflictException('该操作正在处理中,请勿重复提交', { cause: error });
}
} else {
operation.status = 'running';
operation.errorMessage = null;
operation.resultJson = null;
await this.repo.save(operation);
}
try {
const result = await work();
operation.status = 'completed';
operation.resultJson = JSON.stringify(result);
await this.repo.save(operation);
return result;
} catch (error) {
operation.status = 'failed';
operation.errorMessage = error instanceof Error ? error.message.slice(0, 500) : '未知错误';
await this.repo.save(operation);
throw error;
}
}
}

View File

@@ -49,42 +49,45 @@ export class OccupanciesService {
async checkIn(dto: CheckInDto, userId?: number) { async checkIn(dto: CheckInDto, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
return this.dataSource.transaction(async (manager) => {
// 检查学生是否已有活跃入住 const existing = await manager.createQueryBuilder(Occupancy, 'occupancy')
const existing = await this.repo.findOne({ .where('occupancy.studentId = :studentId', { studentId: dto.studentId })
where: { studentId: dto.studentId, checkOutDate: IsNull() }, .andWhere('occupancy.checkOutDate IS NULL')
}); .setLock('pessimistic_write')
.getOne();
if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿'); if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿');
// 检查宿舍容量 const room = await manager.createQueryBuilder(Room, 'room')
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } }); .where('room.id = :roomId', { roomId: dto.roomId })
.setLock('pessimistic_write')
.getOne();
if (!room) throw new NotFoundException('宿舍不存在'); if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived' || room.status === 'maintenance') { if (room.status === 'archived' || room.status === 'maintenance') {
throw new BadRequestException('该宿舍当前不可入住'); throw new BadRequestException('该宿舍当前不可入住');
} }
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } }); const count = await manager.count(Occupancy, { where: { roomId: dto.roomId, checkOutDate: IsNull() } });
if (count >= room.capacity) throw new BadRequestException('宿舍已满'); if (count >= room.capacity) throw new BadRequestException('宿舍已满');
const student = await manager.findOne(Student, { where: { id: dto.studentId } });
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
// 床位校验
if (dto.bedId) { if (dto.bedId) {
const bed = await this.bedRepo.findOne({ where: { id: dto.bedId, roomId: dto.roomId } }); const bed = await manager.createQueryBuilder(Bed, 'bed')
.where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.bedId, roomId: dto.roomId })
.setLock('pessimistic_write')
.getOne();
if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍'); if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍');
if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中'); if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中');
} }
// 柜子校验
if (dto.lockerId) { if (dto.lockerId) {
const locker = await this.lockerRepo.findOne({ const locker = await manager.createQueryBuilder(Locker, 'locker')
where: { id: dto.lockerId, roomId: dto.roomId }, .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.lockerId, roomId: dto.roomId })
}); .setLock('pessimistic_write')
.getOne();
if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍'); if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
if (locker.status !== 'available') throw new BadRequestException('柜子已被占用或维修中'); if (locker.status !== 'available') throw new BadRequestException('柜子已被占用或维修中');
} }
const occ = this.repo.create({ const saved = await manager.save(manager.create(Occupancy, {
studentId: dto.studentId, studentId: dto.studentId,
roomId: dto.roomId, roomId: dto.roomId,
checkInDate: dto.checkInDate, checkInDate: dto.checkInDate,
@@ -94,54 +97,41 @@ export class OccupanciesService {
notes: dto.notes, notes: dto.notes,
bedId: dto.bedId, bedId: dto.bedId,
lockerId: dto.lockerId, lockerId: dto.lockerId,
}); }));
const saved = await this.repo.save(occ); if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' });
if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' });
// 更新床位/柜子状态 if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' });
if (dto.bedId) {
await this.bedRepo.update(dto.bedId, { status: 'occupied' });
}
if (dto.lockerId) {
await this.lockerRepo.update(dto.lockerId, { status: 'occupied' });
}
// 更新宿舍状态
if (count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
}
if (dto.collectDeposit) { if (dto.collectDeposit) {
const existingDeposit = await this.depositRepo.findOne({ let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } });
where: { studentId: dto.studentId }, if (deposit) {
}); deposit.amount = Number((Number(deposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2));
if (existingDeposit) { deposit.status = 'paid';
existingDeposit.amount = Number( deposit.paidDate = dto.checkInDate;
(Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2), deposit.recordedBy = userId ?? null;
); deposit.notes = '入住登记自动收取';
existingDeposit.status = 'paid';
existingDeposit.paidDate = dto.checkInDate;
existingDeposit.recordedBy = userId ?? null;
existingDeposit.notes = '入住登记自动收取';
await this.depositRepo.save(existingDeposit);
} else { } else {
await this.depositRepo.save( deposit = manager.create(Deposit, {
this.depositRepo.create({
studentId: dto.studentId, studentId: dto.studentId,
amount: dto.depositAmount ?? 500, amount: dto.depositAmount ?? 500,
paidDate: dto.checkInDate, paidDate: dto.checkInDate,
status: 'paid', status: 'paid',
recordedBy: userId, recordedBy: userId,
notes: '入住登记自动收取', notes: '入住登记自动收取',
}), });
);
} }
await manager.save(deposit);
} }
return saved; return saved;
});
} }
async checkOut(occupancyId: number, dto: CheckOutDto) { async checkOut(occupancyId: number, dto: CheckOutDto) {
const occ = await this.repo.findOne({ where: { id: occupancyId } }); return this.dataSource.transaction(async (manager) => {
const occ = await manager.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId })
.setLock('pessimistic_write')
.getOne();
if (!occ) throw new NotFoundException('入住记录不存在'); if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
@@ -150,24 +140,15 @@ export class OccupanciesService {
dto.billingEndDate || dto.checkOutDate, dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日', '计费截止日不能早于计费起始日',
); );
occ.checkOutDate = dto.checkOutDate; occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || ''; occ.checkOutReason = dto.checkOutReason || '';
await this.repo.save(occ); await manager.save(occ);
if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });
// 释放床位/柜子 if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });
if (occ.bedId) { await manager.update(Room, occ.roomId, { status: 'available' });
await this.bedRepo.update(occ.bedId, { status: 'available' });
}
if (occ.lockerId) {
await this.lockerRepo.update(occ.lockerId, { status: 'available' });
}
// 更新宿舍状态
await this.roomRepo.update(occ.roomId, { status: 'available' });
return occ; return occ;
});
} }
async transferRoom(occupancyId: number, dto: TransferRoomDto) { async transferRoom(occupancyId: number, dto: TransferRoomDto) {
@@ -175,7 +156,10 @@ export class OccupanciesService {
await runner.connect(); await runner.connect();
await runner.startTransaction(); await runner.startTransaction();
try { try {
const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } }); const oldOcc = await runner.manager.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId })
.setLock('pessimistic_write')
.getOne();
if (!oldOcc) throw new NotFoundException('入住记录不存在'); if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId) if (oldOcc.roomId === dto.newRoomId)
@@ -201,7 +185,10 @@ export class OccupanciesService {
} }
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 检查新房容量 // 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } }); const newRoom = await runner.manager.createQueryBuilder(Room, 'room')
.where('room.id = :roomId', { roomId: dto.newRoomId })
.setLock('pessimistic_write')
.getOne();
if (!newRoom) throw new NotFoundException('目标宿舍不存在'); if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住'); throw new BadRequestException('目标宿舍当前不可入住');
@@ -213,16 +200,18 @@ export class OccupanciesService {
// 新床位校验 // 新床位校验
if (dto.newBedId) { if (dto.newBedId) {
const newBed = await runner.manager.findOne(Bed, { const newBed = await runner.manager.createQueryBuilder(Bed, 'bed')
where: { id: dto.newBedId, roomId: dto.newRoomId }, .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.newBedId, roomId: dto.newRoomId })
}); .setLock('pessimistic_write')
.getOne();
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
} }
if (dto.newLockerId) { if (dto.newLockerId) {
const newLocker = await runner.manager.findOne(Locker, { const newLocker = await runner.manager.createQueryBuilder(Locker, 'locker')
where: { id: dto.newLockerId, roomId: dto.newRoomId }, .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.newLockerId, roomId: dto.newRoomId })
}); .setLock('pessimistic_write')
.getOne();
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
} }

View File

@@ -1,7 +1,12 @@
import { Type } from 'class-transformer'; import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, MaxLength, NotEquals } from 'class-validator'; import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsNumber, IsOptional, IsString, Matches, MaxLength, NotEquals } from 'class-validator';
export class ChangeWalletBalanceDto { export class ChangeWalletBalanceDto {
@IsOptional()
@IsString()
@Matches(/^[\w-]{8,64}$/)
operationId?: string;
@IsInt() @IsInt()
studentId: number; studentId: number;
@@ -20,6 +25,11 @@ export class ChangeWalletBalanceDto {
export class BatchChangeWalletBalanceDto { export class BatchChangeWalletBalanceDto {
@IsOptional()
@IsString()
@Matches(/^[\w-]{8,64}$/)
operationId?: string;
@IsArray() @IsArray()
@ArrayNotEmpty() @ArrayNotEmpty()
@IsInt({ each: true }) @IsInt({ each: true })

View File

@@ -7,6 +7,7 @@ import { StudentWallet } from '../entities/student-wallet.entity';
import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto'; import { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
@@ -17,6 +18,7 @@ export class WalletsService {
@InjectRepository(WalletTransaction) private transactionRepo: Repository<WalletTransaction>, @InjectRepository(WalletTransaction) private transactionRepo: Repository<WalletTransaction>,
@InjectRepository(Student) private studentRepo: Repository<Student>, @InjectRepository(Student) private studentRepo: Repository<Student>,
private dataSource: DataSource, private dataSource: DataSource,
private financialOperations?: FinancialOperationsService,
) {} ) {}
async findAll(query?: { keyword?: string; debtOnly?: boolean }) { async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
@@ -61,6 +63,18 @@ export class WalletsService {
} }
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) { async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
const { operationId, ...change } = dto;
return this.financialOperations
? this.financialOperations.run(operationId, 'wallet.change_balance', () => this.changeBalanceOnce(change, recordedBy, operationId))
: this.changeBalanceOnce(change, recordedBy, operationId);
}
private async changeBalanceOnce(
dto: Omit<ChangeWalletBalanceDto, 'operationId'>,
recordedBy?: number,
operationId?: string,
transactionManager?: EntityManager,
) {
const amount = money(dto.amount); const amount = money(dto.amount);
if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) {
throw new BadRequestException('调账金额最多保留两位小数'); throw new BadRequestException('调账金额最多保留两位小数');
@@ -69,8 +83,8 @@ export class WalletsService {
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0'); if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在'); if (!student) throw new NotFoundException('学生不存在');
return this.dataSource.transaction(async (manager) => { const work = async (manager: EntityManager) => {
const wallet = await this.getOrCreateWallet(manager, dto.studentId); const wallet = await this.getOrCreateWallet(manager, dto.studentId, true);
const nextBalance = money(Number(wallet.balance) + amount); const nextBalance = money(Number(wallet.balance) + amount);
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0'); if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
wallet.balance = nextBalance; wallet.balance = nextBalance;
@@ -79,6 +93,7 @@ export class WalletsService {
manager.create(WalletTransaction, { manager.create(WalletTransaction, {
studentId: dto.studentId, studentId: dto.studentId,
billId: null, billId: null,
operationId: operationId ?? null,
type: dto.type, type: dto.type,
amount, amount,
balanceAfter: nextBalance, balanceAfter: nextBalance,
@@ -89,21 +104,28 @@ export class WalletsService {
const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
return { wallet: finalWallet, payments }; return { wallet: finalWallet, payments };
}); };
return transactionManager ? work(transactionManager) : this.dataSource.transaction(work);
} }
async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) {
const uniqueStudentIds = Array.from(new Set(dto.studentIds)); const { operationId, ...batch } = dto;
const results: Awaited<ReturnType<WalletsService['changeBalance']>>[] = []; const work = () => this.dataSource.transaction(async (manager) => {
const uniqueStudentIds = Array.from(new Set(batch.studentIds));
const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = [];
for (const studentId of uniqueStudentIds) { for (const studentId of uniqueStudentIds) {
results.push(await this.changeBalance({ results.push(await this.changeBalanceOnce({
studentId, studentId,
amount: dto.amount, amount: batch.amount,
type: dto.type, type: batch.type,
description: dto.description, description: batch.description,
}, recordedBy)); }, recordedBy, operationId ? `${operationId}:${studentId}` : undefined, manager));
} }
return { count: uniqueStudentIds.length, results }; return { count: uniqueStudentIds.length, results };
});
return this.financialOperations
? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work)
: work();
} }
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) { async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
@@ -194,9 +216,27 @@ export class WalletsService {
return settled; return settled;
} }
private async getOrCreateWallet(manager: EntityManager, studentId: number) { private async getOrCreateWallet(manager: EntityManager, studentId: number, lock = false) {
let wallet = await manager.findOne(StudentWallet, { where: { studentId } }); const find = async () => {
if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 })); if (!lock || !manager.createQueryBuilder) {
return manager.findOne(StudentWallet, { where: { studentId } });
}
return manager.createQueryBuilder(StudentWallet, 'wallet')
.where('wallet.studentId = :studentId', { studentId })
.setLock('pessimistic_write')
.getOne();
};
let wallet = await find();
if (!wallet) {
try {
if (manager.insert) await manager.insert(StudentWallet, { studentId, balance: 0 });
else wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
} catch {
// A concurrent request may have inserted the one wallet row.
}
wallet ||= await find();
}
if (!wallet) throw new NotFoundException('学生钱包创建失败');
return wallet; return wallet;
} }
} }