286 lines
11 KiB
TypeScript
286 lines
11 KiB
TypeScript
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<Bill>,
|
|
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
|
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
|
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
|
|
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
|
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
|
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<number, RoomExpense[]>();
|
|
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<Record<string, unknown>> }
|
|
>();
|
|
|
|
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<number, number>();
|
|
const personalItems = new Map<number, Array<Record<string, unknown>>>();
|
|
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())}`,
|
|
};
|
|
}
|
|
|
|
}
|