import { BadRequestException, Injectable, NotFoundException } 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'; @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, ) {} /** * 核心计费引擎:按"人天数"加权分摊 */ async generateBills(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 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 .createQueryBuilder('e') .where('e.periodStart >= :periodStart AND e.periodEnd <= :periodEnd', { periodStart, periodEnd, }) .andWhere('e.status = :status', { status: 'active' }) .getMany(); // 按宿舍分组费用 const roomExpMap = new Map(); for (const exp of roomExpenses) { if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []); roomExpMap.get(exp.roomId)!.push(exp); } // 计算每个学生的分摊费用 const studentBillData = new Map(); for (const [roomId, expenses] of roomExpMap) { // 获取该宿舍在此周期内的所有入住记录 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((o) => o.stayType !== 'long'); const longTermOccs = occupancies.filter((o) => o.stayType === 'long'); // 长租:按月租费独立计费,不参与人天数分摊 for (const occ of longTermOccs) { const monthlyRate = Number(occ.room?.monthlyRate || 0); if (!studentBillData.has(occ.studentId)) { studentBillData.set(occ.studentId, { shared: 0, items: [] }); } const data = studentBillData.get(occ.studentId)!; data.shared += monthlyRate; data.items.push({ roomId, expenseType: 'rent', description: `长租月租费 (${occ.room?.roomNumber || '未知房间'})`, days: 0, totalRoomDays: 0, roomTotalAmount: monthlyRate, studentAmount: monthlyRate, }); } // 短租:原人天数加权分摊逻辑 if (shortTermOccs.length === 0) continue; // 计算每个学生的计费天数 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; const days = Math.max( 0, Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1, ); studentDays.push({ studentId: occ.studentId, days }); totalDays += days; } if (totalDays === 0) continue; // 对每项费用进行分摊;最后一人承接舍入尾差,保证分摊合计与原费用一致。 for (const expense of expenses) { const eligibleDays = studentDays.filter((sd) => sd.days > 0); const expenseTotal = Number(Number(expense.amount).toFixed(2)); let allocated = 0; for (const [index, sd] of eligibleDays.entries()) { const amount = index === eligibleDays.length - 1 ? Number((expenseTotal - allocated).toFixed(2)) : Number(((sd.days / totalDays) * expenseTotal).toFixed(2)); allocated = Number((allocated + amount).toFixed(2)); if (!studentBillData.has(sd.studentId)) { studentBillData.set(sd.studentId, { shared: 0, items: [] }); } const data = studentBillData.get(sd.studentId)!; data.shared += amount; data.items.push({ roomId, expenseType: expense.expenseType, description: `${expense.expenseType} 分摊`, days: sd.days, totalRoomDays: totalDays, roomTotalAmount: expense.amount, studentAmount: amount, }); } } } // 获取个人附加费 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 pe of personalExps) { personalMap.set(pe.studentId, (personalMap.get(pe.studentId) || 0) + Number(pe.amount)); if (!personalItems.has(pe.studentId)) personalItems.set(pe.studentId, []); personalItems.get(pe.studentId)!.push({ roomId: pe.roomId, expenseType: pe.expenseType, description: `个人费用: ${pe.description || pe.expenseType}`, days: 0, totalRoomDays: 0, roomTotalAmount: pe.amount, studentAmount: pe.amount, }); } // 合并所有涉及的学生,并在同一个事务中生成整批账单,避免中途失败留下半批数据。 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 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) => { 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, 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 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 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('取消原因不能为空'); return this.dataSource.transaction(async (manager) => { const bill = await manager.findOne(Bill, { where: { id } }); 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); }); } 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('账单状态必须与实付及未付金额一致'); } }