355 lines
14 KiB
TypeScript
355 lines
14 KiB
TypeScript
import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||
import { InjectRepository } from '@nestjs/typeorm';
|
||
import { Repository, In, 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 { StudentWallet } from '../entities/student-wallet.entity';
|
||
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||
import { WalletsService } from '../wallets/wallets.service';
|
||
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
|
||
import { BillsGenerationService } from './bills-generation.service';
|
||
|
||
interface AgentBillRow {
|
||
billId: string | number;
|
||
studentName: string;
|
||
periodStart: string;
|
||
periodEnd: string;
|
||
totalAmount: string | number;
|
||
paidAmount: string | number;
|
||
outstandingAmount: string | number;
|
||
status: string;
|
||
}
|
||
|
||
@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,
|
||
private generation: BillsGenerationService,
|
||
@Optional()
|
||
private financialOperations?: FinancialOperationsService,
|
||
) {}
|
||
|
||
/**
|
||
* 核心计费引擎:按"人天数"加权分摊
|
||
*/
|
||
async generateBills(dto: GenerateBillsDto) {
|
||
const { operationId, ...request } = dto;
|
||
const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto);
|
||
return this.financialOperations
|
||
? this.financialOperations.run(operationId, 'bill.generate', work)
|
||
: work();
|
||
}
|
||
|
||
async createImmediatePersonalBill(
|
||
expense: PersonalExpense,
|
||
periodStart: string,
|
||
periodEnd: string,
|
||
recordedBy?: number,
|
||
) {
|
||
return this.dataSource.transaction(async (manager) => {
|
||
expense = await manager.save(manager.create(PersonalExpense, expense));
|
||
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,
|
||
personalExpenseId: expense.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 { expense, 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 agentSearchBills(query: {
|
||
keyword?: string;
|
||
periodStart?: string;
|
||
periodEnd?: string;
|
||
status?: string;
|
||
limit?: number;
|
||
}) {
|
||
const billSelects = [
|
||
['student.name', 'studentName'],
|
||
['bill.periodStart', 'periodStart'],
|
||
['bill.periodEnd', 'periodEnd'],
|
||
['bill.totalAmount', 'totalAmount'],
|
||
['bill.paidAmount', 'paidAmount'],
|
||
['bill.outstandingAmount', 'outstandingAmount'],
|
||
['bill.status', 'status'],
|
||
] as const;
|
||
const qb = this.billRepo
|
||
.createQueryBuilder('bill')
|
||
.leftJoin('bill.student', 'student')
|
||
.select('bill.id', 'billId');
|
||
for (const [column, alias] of billSelects) {
|
||
qb.addSelect(column, alias);
|
||
}
|
||
if (query.keyword) {
|
||
const billId = Number(query.keyword);
|
||
if (Number.isInteger(billId) && billId > 0) {
|
||
qb.andWhere('(student.name LIKE :keyword OR bill.id = :billId)', {
|
||
keyword: `%${query.keyword}%`,
|
||
billId,
|
||
});
|
||
} else {
|
||
qb.andWhere('student.name LIKE :keyword', { keyword: `%${query.keyword}%` });
|
||
}
|
||
}
|
||
if (query.periodStart)
|
||
qb.andWhere('bill.periodStart >= :periodStart', { periodStart: query.periodStart });
|
||
if (query.periodEnd)
|
||
qb.andWhere('bill.periodEnd <= :periodEnd', { periodEnd: query.periodEnd });
|
||
if (query.status) qb.andWhere('bill.status = :status', { status: query.status });
|
||
const rows = await qb
|
||
.orderBy('bill.generatedAt', 'DESC')
|
||
.limit(query.limit ?? 20)
|
||
.getRawMany<AgentBillRow>();
|
||
return rows.map((row) => ({
|
||
...row,
|
||
billId: Number(row.billId),
|
||
totalAmount: Number(row.totalAmount || 0),
|
||
paidAmount: Number(row.paidAmount || 0),
|
||
outstandingAmount: Number(row.outstandingAmount || 0),
|
||
}));
|
||
}
|
||
|
||
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('取消原因不能为空');
|
||
const work = () =>
|
||
this.dataSource.transaction(async (manager) => {
|
||
const bill = await manager
|
||
.createQueryBuilder(Bill, 'bill')
|
||
.where('bill.id = :id', { id })
|
||
.setLock('pessimistic_write')
|
||
.getOne();
|
||
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);
|
||
});
|
||
return this.financialOperations
|
||
? this.financialOperations.run(dto.operationId, `bill.cancel:${id}`, work)
|
||
: work();
|
||
}
|
||
|
||
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 purge(id: number) {
|
||
const bill = await this.billRepo.findOne({ where: { id } });
|
||
if (!bill) throw new NotFoundException('账单不存在');
|
||
if (bill.status !== 'cancelled') {
|
||
throw new BadRequestException('仅已取消账单可以永久删除,请先取消账单');
|
||
}
|
||
if (Number(bill.paidAmount) > 0) {
|
||
throw new BadRequestException('已发生资金流水的账单不能永久删除');
|
||
}
|
||
const personalExpenseCount = await this.personalExpRepo.count({ where: { billId: id } });
|
||
if (personalExpenseCount > 0) {
|
||
throw new BadRequestException('该账单仍关联个人费用,无法永久删除');
|
||
}
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.delete(BillItem, { billId: id });
|
||
await manager.delete(Bill, id);
|
||
});
|
||
return { message: '已永久删除账单(不可恢复)' };
|
||
}
|
||
|
||
async batchPurge(ids: number[]) {
|
||
const uniqueIds = [...new Set(ids || [])];
|
||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的账单');
|
||
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
||
throw new BadRequestException('账单 ID 无效');
|
||
}
|
||
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
|
||
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
|
||
const personalExpenseCount = await this.personalExpRepo.count({
|
||
where: { billId: In(uniqueIds) },
|
||
});
|
||
if (personalExpenseCount > 0) {
|
||
throw new BadRequestException('选中账单仍关联个人费用,无法永久删除');
|
||
}
|
||
|
||
const deleted: number[] = [];
|
||
const skipped: string[] = [];
|
||
for (const bill of bills) {
|
||
if (bill.status !== 'cancelled') {
|
||
skipped.push(`账单${bill.id}(未取消)`);
|
||
continue;
|
||
}
|
||
if (Number(bill.paidAmount) > 0) {
|
||
skipped.push(`账单${bill.id}(已支付)`);
|
||
continue;
|
||
}
|
||
await this.dataSource.transaction(async (manager) => {
|
||
await manager.delete(BillItem, { billId: bill.id });
|
||
await manager.delete(Bill, bill.id);
|
||
});
|
||
deleted.push(bill.id);
|
||
}
|
||
const message =
|
||
skipped.length > 0
|
||
? `已永久删除 ${deleted.length} 条账单;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})`
|
||
: `已永久删除 ${deleted.length} 条账单(不可恢复)`;
|
||
return { message, deleted: deleted.length, skipped: skipped.length };
|
||
}
|
||
|
||
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('账单状态必须与实付及未付金额一致');
|
||
}
|
||
}
|