import { BadRequestException, Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room } from '../entities'; import { WalletsService } from '../wallets/wallets.service'; import type { GenerateBillsDto } from './dto/bill.dto'; @Injectable() export class BillsGenerationService { 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 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< number, { shared: number; items: Array> } >(); 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())}`, }; } }