Files
gongxue-base/apps/server/src/bills/bills.service.ts

355 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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('账单状态必须与实付及未付金额一致');
}
}