import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, 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 { Deposit } from '../entities/deposit.entity'; import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto'; import { CampusScope } from '../common/campus-scope'; @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, @InjectRepository(Deposit) private depositRepo: Repository, private dataSource: DataSource, private readonly scope: CampusScope, ) {} /** * 核心计费引擎:按"人天数"加权分摊 */ async generateBills(dto: GenerateBillsDto) { const { periodStart, periodEnd } = dto; const pStart = new Date(periodStart); const pEnd = new Date(periodEnd); // 删除该周期已有的草稿账单 const existingDrafts = await this.billRepo.find({ where: { periodStart, periodEnd, status: 'draft' }, }); if (existingDrafts.length > 0) { const draftIds = existingDrafts.map((b) => b.id); 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, }) .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.rentalType !== 'long'); const longTermOccs = occupancies.filter((o) => o.rentalType === '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) { for (const sd of studentDays) { if (sd.days === 0) continue; const amount = Number(((sd.days / totalDays) * Number(expense.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, }) .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: 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)); const bill = this.billRepo.create({ studentId, periodStart, periodEnd, sharedAmount: Number(shared.toFixed(2)), personalAmount: personal, totalAmount: total, status: 'draft', }); const savedBill = await this.billRepo.save(bill); // 保存明细 const items = [ ...(studentBillData.get(studentId)?.items || []), ...(personalItems.get(studentId) || []), ]; for (const item of items) { await this.itemRepo.save(this.itemRepo.create({ ...item, billId: savedBill.id })); } bills.push(savedBill); } return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills }; } async findAll(query?: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string; }) { const scopeIds = await this.scope.getScopeDepartmentIds(); const qb = this.billRepo .createQueryBuilder('b') .leftJoinAndSelect('b.student', 'student') .orderBy('b.generatedAt', 'DESC'); if (scopeIds) qb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds }); 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 }); 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; } /** * 给账单挂上"押金联动"信息: * - availableDeposit: 当前学生处于已缴未退状态(paid)的押金总额 * - depositApplied: 本张账单可从押金抵扣的金额(min(押金, 应付总额)) * - amountAfterDeposit: 抵扣押金后学生需另外支付的金额 */ private async attachDepositInfo(bills: Bill[]): Promise { if (!bills || bills.length === 0) return bills; const studentIds = Array.from(new Set(bills.map((b) => b.studentId))); if (studentIds.length === 0) return bills; const deposits = await this.depositRepo .createQueryBuilder('d') .where('d.studentId IN (:...ids)', { ids: studentIds }) .andWhere('d.status = :status', { status: 'paid' }) .getMany(); const depMap = new Map(); for (const d of deposits) { depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0)); } return bills.map((b) => { const total = Number(b.totalAmount || 0); const available = Number((depMap.get(b.studentId) || 0).toFixed(2)); const applied = Number(Math.min(available, total).toFixed(2)); const afterDeposit = Number(Math.max(0, total - applied).toFixed(2)); return Object.assign({}, b, { availableDeposit: available, depositApplied: applied, amountAfterDeposit: afterDeposit, }); }); } async updateStatus(id: number, dto: UpdateBillStatusDto) { const bill = await this.billRepo.findOne({ where: { id } }); if (!bill) throw new NotFoundException('账单不存在'); bill.status = dto.status; return this.billRepo.save(bill); } async batchUpdateStatus(ids: number[], status: string) { await this.billRepo .createQueryBuilder() .update() .set({ status }) .where('id IN (:...ids)', { ids }) .execute(); return { message: `成功更新 ${ids.length} 条账单状态` }; } async remove(id: number) { const exists = await this.billRepo.findOne({ where: { id } }); if (!exists) throw new NotFoundException('账单不存在'); await this.itemRepo.delete({ billId: id }); await this.billRepo.delete(id); return { message: '账单已删除' }; } async batchRemove(ids: number[]) { await this.itemRepo .createQueryBuilder() .delete() .where('billId IN (:...ids)', { ids }) .execute(); await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute(); return { message: `成功删除 ${ids.length} 条账单` }; } }