import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource, EntityManager } 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'; 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, @Optional() private financialOperations?: FinancialOperationsService, ) {} /** * 核心计费引擎:按"人天数"加权分摊 */ 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 ? this.resolveBillingPeriod(dto.billingMonth) : { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! }; if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) { throw new BadRequestException('账单周期无效,结束日期不能早于开始日期'); } const pStart = new Date(`${periodStart}T00:00:00Z`); const pEnd = new Date(`${periodEnd}T00:00:00Z`); const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } }); if (existingBills.length > 0) { throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`); } 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[] = []; const roomExpMap = new Map(); 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> }>(); for (const roomId of roomIds) { const expenses = roomExpMap.get(roomId) || []; const occupancies = await this.occRepo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .where('o.roomId = :roomId', { roomId }) .andWhere('o.billingStartDate <= :periodEnd', { periodEnd }) .andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart }) .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( 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); } const studentDays = shortTermOccs.map((occupancy) => { 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())) : pEnd; const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / 86_400_000) + 1); return { studentId: occupancy.studentId, days }; }); const totalDays = studentDays.reduce((sum, entry) => sum + entry.days, 0); if (totalDays === 0) continue; for (const expense of expenses) { const eligibleDays = studentDays.filter((entry) => entry.days > 0); 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(); const personalItems = new Map>>(); 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) => { 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: personal, totalAmount: total, source: 'batch', paidAmount: 0, outstandingAmount: total, status: 'unpaid', })); const items = [...(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); 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; }); 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) { if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false; const date = new Date(`${value}T00:00:00Z`); return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === 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())}` }; } 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 qb = this.billRepo .createQueryBuilder('bill') .leftJoin('bill.student', 'student') .select('bill.id', 'billId') .addSelect('student.name', 'studentName') .addSelect('bill.periodStart', 'periodStart') .addSelect('bill.periodEnd', 'periodEnd') .addSelect('bill.totalAmount', 'totalAmount') .addSelect('bill.paidAmount', 'paidAmount') .addSelect('bill.outstandingAmount', 'outstandingAmount') .addSelect('bill.status', 'status'); 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 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('账单状态必须与实付及未付金额一致'); } }