forked from wangziqi/gongxue-base
446 lines
18 KiB
TypeScript
446 lines
18 KiB
TypeScript
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<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 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<number, RoomExpense[]>();
|
|
for (const exp of roomExpenses) {
|
|
if (!roomExpMap.has(exp.roomId)) roomExpMap.set(exp.roomId, []);
|
|
roomExpMap.get(exp.roomId)!.push(exp);
|
|
}
|
|
|
|
// 计算每个学生的分摊费用
|
|
const studentBillData = new Map<number, { shared: number; items: any[] }>();
|
|
|
|
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<number, number>();
|
|
const personalItems = new Map<number, any[]>();
|
|
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<any[]> {
|
|
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('账单状态必须与实付及未付金额一致');
|
|
}
|
|
}
|