import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; import { Bill } from '../entities/bill.entity'; import { BillItem } from '../entities/bill-item.entity'; import { RoomExpense } from '../entities/room-expense.entity'; import { PersonalExpense } from '../entities/personal-expense.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; import { StudentWallet } from '../entities/student-wallet.entity'; import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { WalletsService } from '../wallets/wallets.service'; import { FinancialOperationsService } from '../financial-operations/financial-operations.service'; import { BillsGenerationService } from './bills-generation.service'; interface AgentBillRow { billId: string | number; studentName: string; periodStart: string; periodEnd: string; totalAmount: string | number; paidAmount: string | number; outstandingAmount: string | number; status: string; } @Injectable() export class BillsService { constructor( @InjectRepository(Bill) private billRepo: Repository, @InjectRepository(BillItem) private itemRepo: Repository, @InjectRepository(RoomExpense) private roomExpRepo: Repository, @InjectRepository(PersonalExpense) private personalExpRepo: Repository, @InjectRepository(Occupancy) private occRepo: Repository, @InjectRepository(Room) private roomRepo: Repository, private dataSource: DataSource, private walletsService: WalletsService, private generation: BillsGenerationService, @Optional() private financialOperations?: FinancialOperationsService, ) {} /** * 核心计费引擎:按"人天数"加权分摊 */ async generateBills(dto: GenerateBillsDto) { const { operationId, ...request } = dto; const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto); return this.financialOperations ? this.financialOperations.run(operationId, 'bill.generate', work) : work(); } async createImmediatePersonalBill( expense: PersonalExpense, periodStart: string, periodEnd: string, recordedBy?: number, ) { return this.dataSource.transaction(async (manager) => { expense = await manager.save(manager.create(PersonalExpense, expense)); let bill = await manager.save( manager.create(Bill, { studentId: expense.studentId, periodStart, periodEnd, sharedAmount: 0, personalAmount: Number(expense.amount), totalAmount: Number(expense.amount), source: 'student_utility', paidAmount: 0, outstandingAmount: Number(expense.amount), status: 'unpaid', }), ); await manager.save( manager.create(BillItem, { billId: bill.id, personalExpenseId: expense.id, roomId: expense.roomId, expenseType: expense.expenseType, description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'), days: 0, totalRoomDays: 0, roomTotalAmount: expense.amount, studentAmount: expense.amount, }), ); expense.billId = bill.id; await manager.save(expense); bill = await this.walletsService.debitBill(manager, bill, recordedBy); return { expense, bill }; }); } async findAll(query?: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string; expenseType?: string; }) { const qb = this.billRepo .createQueryBuilder('b') .leftJoinAndSelect('b.student', 'student') .orderBy('b.generatedAt', 'DESC'); if (query?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart }); if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd }); if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId }); if (query?.status) qb.andWhere('b.status = :status', { status: query.status }); if (query?.expenseType) { qb.innerJoin('b.items', 'bi', 'bi.expenseType = :et', { et: query.expenseType }); } const bills = await qb.getMany(); return this.attachDepositInfo(bills); } async agentSearchBills(query: { keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number; }) { const billSelects = [ ['student.name', 'studentName'], ['bill.periodStart', 'periodStart'], ['bill.periodEnd', 'periodEnd'], ['bill.totalAmount', 'totalAmount'], ['bill.paidAmount', 'paidAmount'], ['bill.outstandingAmount', 'outstandingAmount'], ['bill.status', 'status'], ] as const; const qb = this.billRepo .createQueryBuilder('bill') .leftJoin('bill.student', 'student') .select('bill.id', 'billId'); for (const [column, alias] of billSelects) { qb.addSelect(column, alias); } if (query.keyword) { const billId = Number(query.keyword); if (Number.isInteger(billId) && billId > 0) { qb.andWhere('(student.name LIKE :keyword OR bill.id = :billId)', { keyword: `%${query.keyword}%`, billId, }); } else { qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` }); } } if (query.periodStart) qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart }); if (query.periodEnd) qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd }); if (query.status) qb.andWhere('bill.status = :status', { status: query.status }); const rows = await qb .orderBy('bill.generatedAt', 'DESC') .limit(query.limit ?? 20) .getRawMany(); return rows.map((row) => ({ ...row, billId: Number(row.billId), totalAmount: Number(row.totalAmount || 0), paidAmount: Number(row.paidAmount || 0), outstandingAmount: Number(row.outstandingAmount || 0), })); } async findOne(id: number) { const bill = await this.billRepo.findOne({ where: { id }, relations: ['student', 'items'] }); if (!bill) throw new NotFoundException('账单不存在'); const [withDeposit] = await this.attachDepositInfo([bill]); return withDeposit; } /** 查询时附加钱包余额和实际支付数据。 */ private async attachDepositInfo(bills: Bill[]): Promise { if (!bills?.length) return bills; const studentIds = Array.from(new Set(bills.map((bill) => bill.studentId))); const wallets = await this.dataSource .getRepository(StudentWallet) .createQueryBuilder('wallet') .where('wallet.studentId IN (:...ids)', { ids: studentIds }) .getMany(); const balanceMap = new Map( wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]), ); return bills.map((bill) => ({ ...bill, walletBalance: Number((balanceMap.get(bill.studentId) || 0).toFixed(2)), paidAmount: Number(bill.paidAmount || 0), outstandingAmount: Number(bill.outstandingAmount || 0), })); } async updateStatus(id: number, dto: UpdateBillStatusDto) { const bill = await this.billRepo.findOne({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); this.assertStatusMatchesAmounts(bill, dto.status); bill.status = dto.status; return this.billRepo.save(bill); } async batchUpdateStatus(ids: number[], status: string) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要更新的账单'); if (!['unpaid', 'partially_paid', 'paid'].includes(status)) throw new BadRequestException('账单状态无效'); const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); for (const bill of bills) this.assertStatusMatchesAmounts(bill, status); await this.billRepo .createQueryBuilder() .update() .set({ status }) .where('id IN (:...ids)', { ids: uniqueIds }) .execute(); return { message: `成功更新 ${uniqueIds.length} 条账单状态` }; } async cancel(id: number, dto: CancelBillDto, recordedBy?: number) { const reason = dto.reason?.trim(); if (!reason) throw new BadRequestException('取消原因不能为空'); const work = () => this.dataSource.transaction(async (manager) => { const bill = await manager .createQueryBuilder(Bill, 'bill') .where('bill.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status === 'cancelled') throw new BadRequestException('账单已经取消'); await manager.update(PersonalExpense, { billId: id }, { billId: null }); 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) { const exists = await this.billRepo.findOne({ where: { id } }); if (!exists) throw new NotFoundException('账单不存在'); if (exists.status === 'cancelled') throw new BadRequestException('账单已归档'); if (Number(exists.paidAmount) > 0) { throw new BadRequestException('已发生资金流水的账单请使用取消账单并冲正'); } await this.billRepo.update(id, { status: 'cancelled', outstandingAmount: 0, cancelReason: '归档未支付账单', cancelledAt: new Date(), }); return { message: '账单已归档' }; } async purge(id: number) { const bill = await this.billRepo.findOne({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); if (bill.status !== 'cancelled') { throw new BadRequestException('仅已取消账单可以永久删除,请先取消账单'); } if (Number(bill.paidAmount) > 0) { throw new BadRequestException('已发生资金流水的账单不能永久删除'); } const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: id } }); if (personalExpenseCount > 0) { throw new BadRequestException('该账单仍关联个人费用,无法永久删除'); } await this.dataSource.transaction(async (manager) => { await manager.delete(BillItem, { billId: id }); await manager.delete(Bill, id); }); return { message: '已永久删除账单(不可恢复)' }; } async batchPurge(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的账单'); if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) { throw new BadRequestException('账单 ID 无效'); } const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: In(uniqueIds) }, }); if (personalExpenseCount > 0) { throw new BadRequestException('选中账单仍关联个人费用,无法永久删除'); } const deleted: number[] = []; const skipped: string[] = []; for (const bill of bills) { if (bill.status !== 'cancelled') { skipped.push(`账单${bill.id}(未取消)`); continue; } if (Number(bill.paidAmount) > 0) { skipped.push(`账单${bill.id}(已支付)`); continue; } await this.dataSource.transaction(async (manager) => { await manager.delete(BillItem, { billId: bill.id }); await manager.delete(Bill, bill.id); }); deleted.push(bill.id); } const message = skipped.length > 0 ? `已永久删除 ${deleted.length} 条账单;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` : `已永久删除 ${deleted.length} 条账单(不可恢复)`; return { message, deleted: deleted.length, skipped: skipped.length }; } async batchRemove(ids: number[]) { const uniqueIds = [...new Set(ids || [])]; if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单'); const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } }); if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在'); if (bills.some((bill) => Number(bill.paidAmount) > 0)) { throw new BadRequestException('选中账单包含资金流水,请逐条取消并冲正'); } const targetIds = bills.filter((bill) => bill.status !== 'cancelled').map((bill) => bill.id); if (targetIds.length > 0) { await this.billRepo .createQueryBuilder() .update() .set({ status: 'cancelled', outstandingAmount: 0, cancelReason: '批量归档未支付账单', cancelledAt: new Date(), }) .where('id IN (:...ids)', { ids: targetIds }) .execute(); } return { message: `成功归档 ${targetIds.length} 条账单`, archived: targetIds.length }; } private assertStatusMatchesAmounts(bill: Bill, status: string) { const paid = Number(bill.paidAmount || 0); const outstanding = Number(bill.outstandingAmount || 0); const matches = status === 'paid' ? outstanding <= 0 : status === 'partially_paid' ? paid > 0 && outstanding > 0 : status === 'unpaid' && paid <= 0 && outstanding > 0; if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致'); } }