feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View File

@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, DataSource, EntityManager } from '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';
@@ -11,6 +11,7 @@ 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;
@@ -23,7 +24,6 @@ interface AgentBillRow {
status: string;
}
@Injectable()
export class BillsService {
constructor(
@@ -35,6 +35,7 @@ export class BillsService {
@InjectRepository(Room) private roomRepo: Repository<Room>,
private dataSource: DataSource,
private walletsService: WalletsService,
private generation: BillsGenerationService,
@Optional()
private financialOperations?: FinancialOperationsService,
) {}
@@ -44,220 +45,12 @@ export class BillsService {
*/
async generateBills(dto: GenerateBillsDto) {
const { operationId, ...request } = dto;
const work = () => this.generateBillsOnce(request as GenerateBillsDto);
const work = () => this.generation.generateBillsOnce(request as GenerateBillsDto);
return this.financialOperations
? this.financialOperations.run(operationId, 'bill.generate', work)
: work();
}
private 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())}` };
}
async createImmediatePersonalBill(
expense: PersonalExpense,
periodStart: string,
@@ -286,7 +79,8 @@ export class BillsService {
personalExpenseId: expense.id,
roomId: expense.roomId,
expenseType: expense.expenseType,
description: expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
description:
expense.description || (expense.expenseType === 'water' ? '学生水费' : '学生电费'),
days: 0,
totalRoomDays: 0,
roomTotalAmount: expense.amount,
@@ -323,19 +117,28 @@ export class BillsService {
}
async agentSearchBills(query: {
keyword?: string; periodStart?: string; periodEnd?: string; status?: string; limit?: number;
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')
.addSelect('student.name', 'studentName')
.addSelect('bill.periodStart', 'periodStart')
.addSelect('bill.periodEnd', 'periodEnd')
.addSelect('bill.totalAmount', 'totalAmount')
.addSelect('bill.paidAmount', 'paidAmount')
.addSelect('bill.outstandingAmount', 'outstandingAmount')
.addSelect('bill.status', 'status');
.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) {
@@ -347,14 +150,21 @@ export class BillsService {
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.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>();
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),
billId: Number(row.billId),
totalAmount: Number(row.totalAmount || 0),
paidAmount: Number(row.paidAmount || 0),
outstandingAmount: Number(row.outstandingAmount || 0),
}));
}
@@ -374,7 +184,9 @@ export class BillsService {
.createQueryBuilder('wallet')
.where('wallet.studentId IN (:...ids)', { ids: studentIds })
.getMany();
const balanceMap = new Map(wallets.map((wallet: any) => [wallet.studentId, Number(wallet.balance || 0)]));
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)),
@@ -394,7 +206,8 @@ export class BillsService {
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('账单状态无效');
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);
@@ -410,16 +223,18 @@ export class BillsService {
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);
});
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();
@@ -441,6 +256,65 @@ export class BillsService {
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('请选择要归档的账单');
@@ -469,11 +343,12 @@ export class BillsService {
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;
const matches =
status === 'paid'
? outstanding <= 0
: status === 'partially_paid'
? paid > 0 && outstanding > 0
: status === 'unpaid' && paid <= 0 && outstanding > 0;
if (!matches) throw new BadRequestException('账单状态必须与实付及未付金额一致');
}
}