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'; 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, ) {} 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 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('学生不存在'); return this.dataSource.transaction(async (manager) => { const wallet = await this.getOrCreateWallet(manager, dto.studentId); 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, 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 }; }); } async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) { const uniqueStudentIds = Array.from(new Set(dto.studentIds)); const results: Awaited>[] = []; for (const studentId of uniqueStudentIds) { results.push(await this.changeBalance({ studentId, amount: dto.amount, type: dto.type, description: dto.description, }, recordedBy)); } return { count: uniqueStudentIds.length, results }; } 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) { let wallet = await manager.findOne(StudentWallet, { where: { studentId } }); if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 })); return wallet; } }