import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, EntityManager, Repository } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { Student } from '../entities/student.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { WalletTransaction } from '../entities/wallet-transaction.entity'; import { In } from 'typeorm'; 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)); @Injectable() export class WalletsService { constructor( @InjectRepository(StudentWallet) private walletRepo: Repository, @InjectRepository(WalletTransaction) private transactionRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, private dataSource: DataSource, private financialOperations?: FinancialOperationsService, ) {} async findAll(query?: { keyword?: string; debtOnly?: boolean }) { const students = await this.studentRepo .createQueryBuilder('student') .where('student.status = :status', { status: 'active' }) .andWhere( query?.keyword ? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)' : '1 = 1', query?.keyword ? { keyword: `%${query.keyword}%` } : {}, ) .orderBy('student.name', 'ASC') .getMany(); if (!students.length) return []; const ids = students.map((student) => student.id); const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } }); const bills = await this.dataSource.getRepository(Bill) .createQueryBuilder('bill') .select('bill.studentId', 'studentId') .addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount') .where('bill.studentId IN (:...ids)', { ids }) .andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] }) .groupBy('bill.studentId') .getRawMany<{ studentId: number; outstandingAmount: string }>(); const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet])); const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)])); return students .map((student) => ({ studentId: student.id, studentName: student.name, studentNo: student.studentNo, balance: money(walletMap.get(student.id)?.balance), outstandingAmount: debtMap.get(student.id) || 0, })) .filter((row) => !query?.debtOnly || row.outstandingAmount > 0); } async findTransactions(studentId: number) { return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }); } 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, recordedBy?: number, operationId?: string, transactionManager?: EntityManager, ) { const amount = money(dto.amount); if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { throw new BadRequestException('调账金额最多保留两位小数'); } if (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 } }); if (!student) throw new NotFoundException('学生不存在'); const work = async (manager: EntityManager) => { const wallet = await this.getOrCreateWallet(manager, dto.studentId, true); const nextBalance = money(Number(wallet.balance) + amount); if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0'); wallet.balance = nextBalance; await manager.save(wallet); await manager.save( manager.create(WalletTransaction, { studentId: dto.studentId, billId: null, operationId: operationId ?? null, type: dto.type, amount, balanceAfter: nextBalance, description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'), recordedBy: recordedBy || null, }), ); const payments = amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : []; const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId }); return { wallet: finalWallet, payments }; }; return transactionManager ? work(transactionManager) : this.dataSource.transaction(work); } async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { const { operationId, ...batch } = dto; 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) { results.push(await this.changeBalanceOnce({ studentId, amount: batch.amount, type: batch.type, description: batch.description, }, recordedBy, operationId ? `${operationId}:${studentId}` : undefined, manager)); } 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) { if (bill.status === 'cancelled') return bill; const total = money(bill.totalAmount); const paid = Math.max(0, Math.min(money(bill.paidAmount), total)); const remaining = money(Math.max(0, total - paid)); if (remaining <= 0) { bill.paidAmount = total; bill.outstandingAmount = 0; bill.status = 'paid'; return manager.save(bill); } const wallet = await this.getOrCreateWallet(manager, bill.studentId); const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining)); if (amount <= 0) { bill.paidAmount = paid; bill.outstandingAmount = remaining; bill.status = paid > 0 ? 'partially_paid' : 'unpaid'; return manager.save(bill); } wallet.balance = money(Number(wallet.balance) - amount); bill.paidAmount = money(paid + amount); bill.outstandingAmount = money(Math.max(0, total - Number(bill.paidAmount))); bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid'; await manager.save(wallet); await manager.save(bill); await manager.save( manager.create(WalletTransaction, { studentId: bill.studentId, billId: bill.id, type: 'bill_payment', amount: -amount, balanceAfter: wallet.balance, description: `账单 #${bill.id} 自动扣款`, recordedBy: recordedBy ?? null, }), ); return bill; } async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) { if (bill.status === 'cancelled') return bill; const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount))); if (paid > 0) { const wallet = await this.getOrCreateWallet(manager, bill.studentId); wallet.balance = money(Number(wallet.balance) + paid); await manager.save(wallet); await manager.save( manager.create(WalletTransaction, { studentId: bill.studentId, billId: bill.id, type: 'bill_refund', amount: paid, balanceAfter: wallet.balance, description: `取消账单 #${bill.id} 冲正:${reason}`, recordedBy: recordedBy ?? null, }), ); } bill.status = 'cancelled'; bill.paidAmount = 0; bill.outstandingAmount = 0; bill.cancelledAt = new Date(); bill.cancelReason = reason; return manager.save(bill); } private async settleOutstandingBills(manager: EntityManager, studentId: number, recordedBy?: number) { const bills = await manager .createQueryBuilder(Bill, 'bill') .where('bill.studentId = :studentId', { studentId }) .andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] }) .andWhere('bill.outstandingAmount > 0') .orderBy('bill.periodStart', 'ASC') .addOrderBy('bill.id', 'ASC') .getMany(); const settled: Bill[] = []; for (const bill of bills) { const wallet = await manager.findOne(StudentWallet, { where: { studentId } }); if (!wallet || money(wallet.balance) <= 0) break; settled.push(await this.debitBill(manager, bill, recordedBy)); } return settled; } private async getOrCreateWallet(manager: EntityManager, studentId: number, lock = false) { const find = async () => { 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; } }