forked from wangziqi/gongxue-base
309 lines
11 KiB
TypeScript
309 lines
11 KiB
TypeScript
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<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>,
|
||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||
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<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.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<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: 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<any[]> {
|
||
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<number, number>();
|
||
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} 条账单` };
|
||
}
|
||
}
|