Files
gongxue-base/backend/src/bills/bills.service.ts
陈浩 78676a124a feat: 恭学教育基地管理系统初始提交
- 后端: NestJS 11 + TypeORM + JWT认证 + SQLite/MySQL
- 前端: React 19 + Ant Design 6 + Vite 8 + ECharts
- 功能模块: 数据面板、学生管理、宿舍管理、入住管理、费用录入、账单管理、教室管理、押金管理、操作日志、账号管理
- 支持Docker一键部署
2026-06-06 17:26:10 +08:00

241 lines
9.8 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 { 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';
@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,
) {}
/**
* 核心计费引擎:按"人天数"加权分摊
*/
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')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
if (occupancies.length === 0) continue;
// 计算每个学生的计费天数
const studentDays: { studentId: number; days: number }[] = [];
let totalDays = 0;
for (const occ of occupancies) {
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 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 });
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} 条账单` };
}
}