feat: add student wallet utility billing

This commit is contained in:
2026-07-14 20:39:32 +08:00
parent b480070e69
commit c75a08affe
29 changed files with 986 additions and 548 deletions

View File

@@ -7,8 +7,9 @@ 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 { StudentWallet } from '../entities/student-wallet.entity';
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
import { WalletsService } from '../wallets/wallets.service';
@Injectable()
@@ -20,26 +21,47 @@ export class BillsService {
@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 walletsService: WalletsService,
) {}
/**
* 核心计费引擎:按"人天数"加权分摊
*/
async generateBills(dto: GenerateBillsDto) {
const { periodStart, periodEnd } = this.resolveBillingPeriod(dto.billingMonth);
const { periodStart, periodEnd } = dto.billingMonth
? this.resolveBillingPeriod(dto.billingMonth)
: { periodStart: dto.periodStart!, periodEnd: dto.periodEnd! };
const pStart = new Date(periodStart);
const pEnd = new Date(periodEnd);
const existingBills = await this.billRepo.find({
where: { periodStart, periodEnd },
});
const existingBills = await this.billRepo.find({ where: { periodStart, periodEnd } });
if (existingBills.length > 0) {
throw new BadRequestException(`${dto.billingMonth} 账单已生成,不能重复生成`);
throw new BadRequestException(`${dto.billingMonth || `${periodStart}~${periodEnd}`} 账单已生成,不能重复生成`);
}
// 获取账单周期内所有有费用的宿舍
const existingDrafts: Bill[] = [];
if (existingDrafts.length > 0) {
const draftIds = existingDrafts.map((b) => b.id);
await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ billId: null })
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
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', {
@@ -147,6 +169,7 @@ export class BillsService {
periodStart,
periodEnd,
})
.andWhere('pe.billId IS NULL')
.getMany();
const personalMap = new Map<number, number>();
@@ -175,63 +198,100 @@ export class BillsService {
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.dataSource.transaction(async (manager) => {
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);
return bill;
});
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: `成功生成 ${dto.billingMonth}${bills.length} 条账单`,
count: bills.length,
periodStart,
periodEnd,
bills,
};
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills, periodStart, periodEnd };
}
private resolveBillingPeriod(billingMonth: string) {
const matched = /^(\d{4})-(\d{2})$/.exec(billingMonth || '');
if (!matched) {
throw new BadRequestException('账单月份格式错误,请使用 YYYY-MM');
}
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');
}
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('只能生成已结束月份的账单');
}
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())}`,
};
return { periodStart: `${year}-${pad(month)}-01`, periodEnd: `${year}-${pad(month)}-${pad(targetMonthEnd.getDate())}` };
}
async createImmediatePersonalBill(
expense: PersonalExpense,
periodStart: string,
periodEnd: string,
recordedBy?: number,
) {
return this.dataSource.transaction(async (manager) => {
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,
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 bill;
});
}
async findAll(query?: {
@@ -263,107 +323,80 @@ export class BillsService {
return withDeposit;
}
/**
* 给账单挂上"押金联动"信息:
* - availableDeposit: 学生当前实时可用押金余额,生成账单时不会冻结
* - depositSufficient: 草稿账单是否已有足够余额可确认
* - depositDeductedAmount: 已确认账单实际扣除的押金金额
*/
/** 查询时附加钱包余额和实际支付数据。 */
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 })
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 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));
return Object.assign({}, b, {
availableDeposit: available,
depositSufficient: available >= total,
depositDeductedAmount: Number(b.depositDeductedAmount || 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)),
paidAmount: Number(bill.paidAmount || 0),
outstandingAmount: Number(bill.outstandingAmount || 0),
}));
}
async updateStatus(id: number, dto: UpdateBillStatusDto) {
if (dto.status !== 'paid') {
throw new BadRequestException('账单只能通过确认支付完成扣款');
const bill = await this.billRepo.findOne({ where: { id } });
if (!bill) throw new NotFoundException('账单不存在');
if (dto.status === 'paid' && Number(bill.outstandingAmount) > 0) {
throw new BadRequestException('存在未付金额,不能直接标记为已支付');
}
return this.dataSource.transaction((manager) => this.payBill(manager, id));
bill.status = dto.status;
return this.billRepo.save(bill);
}
async batchUpdateStatus(ids: number[], status: string) {
if (status !== 'paid') {
throw new BadRequestException('账单只能通过确认支付完成扣款');
const bills = await this.billRepo.find({ where: { id: In(ids) } });
if (status === 'paid' && bills.some((bill) => Number(bill.outstandingAmount) > 0)) {
throw new BadRequestException('选中账单存在未付金额,不能直接标记为已支付');
}
const uniqueIds = Array.from(new Set(ids));
await this.dataSource.transaction(async (manager) => {
for (const id of uniqueIds) await this.payBill(manager, id);
});
return { message: `成功确认 ${uniqueIds.length} 条账单并扣除押金` };
await this.billRepo
.createQueryBuilder()
.update()
.set({ status })
.where('id IN (:...ids)', { ids })
.execute();
return { message: `成功更新 ${ids.length} 条账单状态` };
}
private async payBill(manager: EntityManager, id: number) {
const billRepo = manager.getRepository(Bill);
const depositRepo = manager.getRepository(Deposit);
const lock = this.supportsPessimisticLocks()
? ({ mode: 'pessimistic_write' } as const)
: undefined;
const bill = await billRepo.findOne({ where: { id }, ...(lock ? { lock } : {}) });
if (!bill) throw new NotFoundException(`账单 ${id} 不存在`);
if (bill.status === 'paid') return bill;
if (bill.status !== 'draft') throw new BadRequestException(`账单 ${id} 当前状态无法确认支付`);
const deposit = await depositRepo.findOne({
where: { studentId: bill.studentId },
...(lock ? { lock } : {}),
async cancel(id: number, dto: CancelBillDto, recordedBy?: number) {
return this.dataSource.transaction(async (manager) => {
const bill = await manager.findOne(Bill, { where: { id } });
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, dto.reason, recordedBy);
});
const available = Number(deposit?.amount || 0);
const required = Number(bill.totalAmount || 0);
if (!deposit || available < required) {
throw new BadRequestException(
`账单 ${id} 押金不足:需 ¥${required.toFixed(2)},当前可用 ¥${available.toFixed(2)},请先到押金管理收取押金`,
);
}
deposit.amount = Number((available - required).toFixed(2));
deposit.status = deposit.amount > 0 ? 'paid' : 'depleted';
bill.depositDeductedAmount = required;
bill.status = 'paid';
await depositRepo.save(deposit);
return billRepo.save(bill);
}
private supportsPessimisticLocks() {
return ['mysql', 'mariadb', 'postgres', 'cockroachdb', 'mssql', 'oracle'].includes(
String(this.dataSource.options.type),
);
}
async remove(id: number) {
const exists = await this.billRepo.findOne({ where: { id } });
if (!exists) throw new NotFoundException('账单不存在');
if (exists.status === 'paid') throw new BadRequestException('已支付账单不能删除');
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
}
await this.itemRepo.delete({ billId: id });
await this.personalExpRepo.update({ billId: id }, { billId: null });
await this.billRepo.delete(id);
return { message: '账单已删除' };
}
async batchRemove(ids: number[]) {
const bills = await this.billRepo.find({ where: { id: In(ids) } });
if (bills.some((bill) => bill.status === 'paid')) {
throw new BadRequestException('已支付账单不能删除');
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
}
await this.itemRepo
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute();
await this.personalExpRepo
.createQueryBuilder()
.delete()
.update()
.set({ billId: null })
.where('billId IN (:...ids)', { ids })
.execute();
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();