forked from wangziqi/gongxue-base
feat: add student wallet utility billing
This commit is contained in:
167
apps/server/src/wallets/wallets.service.ts
Normal file
167
apps/server/src/wallets/wallets.service.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentWallet } from '../entities/student-wallet.entity';
|
||||
import { WalletTransaction } from '../entities/wallet-transaction.entity';
|
||||
import { In } from 'typeorm';
|
||||
import { ChangeWalletBalanceDto } from './dto/wallet.dto';
|
||||
|
||||
const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2));
|
||||
|
||||
@Injectable()
|
||||
export class WalletsService {
|
||||
constructor(
|
||||
@InjectRepository(StudentWallet) private walletRepo: Repository<StudentWallet>,
|
||||
@InjectRepository(WalletTransaction) private transactionRepo: Repository<WalletTransaction>,
|
||||
@InjectRepository(Student) private studentRepo: Repository<Student>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { keyword?: string; debtOnly?: boolean }) {
|
||||
const students = await this.studentRepo
|
||||
.createQueryBuilder('student')
|
||||
.where('student.status = :status', { status: 'active' })
|
||||
.andWhere(
|
||||
query?.keyword
|
||||
? '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)'
|
||||
: '1 = 1',
|
||||
query?.keyword ? { keyword: `%${query.keyword}%` } : {},
|
||||
)
|
||||
.orderBy('student.name', 'ASC')
|
||||
.getMany();
|
||||
if (!students.length) return [];
|
||||
|
||||
const ids = students.map((student) => student.id);
|
||||
const wallets = await this.walletRepo.find({ where: { studentId: In(ids) } });
|
||||
const bills = await this.dataSource.getRepository(Bill)
|
||||
.createQueryBuilder('bill')
|
||||
.select('bill.studentId', 'studentId')
|
||||
.addSelect('SUM(bill.outstandingAmount)', 'outstandingAmount')
|
||||
.where('bill.studentId IN (:...ids)', { ids })
|
||||
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
|
||||
.groupBy('bill.studentId')
|
||||
.getRawMany<{ studentId: number; outstandingAmount: string }>();
|
||||
const walletMap = new Map(wallets.map((wallet) => [wallet.studentId, wallet]));
|
||||
const debtMap = new Map(bills.map((bill) => [Number(bill.studentId), money(bill.outstandingAmount)]));
|
||||
return students
|
||||
.map((student) => ({
|
||||
studentId: student.id,
|
||||
studentName: student.name,
|
||||
studentNo: student.studentNo,
|
||||
balance: money(walletMap.get(student.id)?.balance),
|
||||
outstandingAmount: debtMap.get(student.id) || 0,
|
||||
}))
|
||||
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
|
||||
}
|
||||
|
||||
async findTransactions(studentId: number) {
|
||||
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
|
||||
}
|
||||
|
||||
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
|
||||
if (dto.type === 'recharge' && dto.amount <= 0) throw new BadRequestException('充值金额必须大于 0');
|
||||
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const wallet = await this.getOrCreateWallet(manager, dto.studentId);
|
||||
const nextBalance = money(Number(wallet.balance) + dto.amount);
|
||||
if (nextBalance < 0) throw new BadRequestException('调账后余额不能小于 0');
|
||||
wallet.balance = nextBalance;
|
||||
await manager.save(wallet);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: dto.studentId,
|
||||
billId: null,
|
||||
type: dto.type,
|
||||
amount: money(dto.amount),
|
||||
balanceAfter: nextBalance,
|
||||
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
const payments = dto.amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
|
||||
const finalWallet = await manager.findOneByOrFail(StudentWallet, { studentId: dto.studentId });
|
||||
return { wallet: finalWallet, payments };
|
||||
});
|
||||
}
|
||||
|
||||
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
|
||||
if (bill.status === 'cancelled' || money(bill.outstandingAmount) <= 0) return bill;
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
const amount = money(Math.min(Number(wallet.balance), Number(bill.outstandingAmount)));
|
||||
if (amount <= 0) {
|
||||
bill.status = money(bill.paidAmount) > 0 ? 'partially_paid' : 'unpaid';
|
||||
return manager.save(bill);
|
||||
}
|
||||
wallet.balance = money(Number(wallet.balance) - amount);
|
||||
bill.paidAmount = money(Number(bill.paidAmount) + amount);
|
||||
bill.outstandingAmount = money(Number(bill.totalAmount) - Number(bill.paidAmount));
|
||||
bill.status = bill.outstandingAmount <= 0 ? 'paid' : 'partially_paid';
|
||||
await manager.save(wallet);
|
||||
await manager.save(bill);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: bill.studentId,
|
||||
billId: bill.id,
|
||||
type: 'bill_payment',
|
||||
amount: -amount,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `账单 #${bill.id} 自动扣款`,
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
return bill;
|
||||
}
|
||||
|
||||
async refundBill(manager: EntityManager, bill: Bill, reason: string, recordedBy?: number) {
|
||||
const paid = money(bill.paidAmount);
|
||||
if (paid > 0) {
|
||||
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
|
||||
wallet.balance = money(Number(wallet.balance) + paid);
|
||||
await manager.save(wallet);
|
||||
await manager.save(
|
||||
manager.create(WalletTransaction, {
|
||||
studentId: bill.studentId,
|
||||
billId: bill.id,
|
||||
type: 'bill_refund',
|
||||
amount: paid,
|
||||
balanceAfter: wallet.balance,
|
||||
description: `取消账单 #${bill.id} 冲正:${reason}`,
|
||||
recordedBy: recordedBy || null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
bill.status = 'cancelled';
|
||||
bill.paidAmount = 0;
|
||||
bill.outstandingAmount = 0;
|
||||
bill.cancelledAt = new Date();
|
||||
bill.cancelReason = reason;
|
||||
return manager.save(bill);
|
||||
}
|
||||
|
||||
private async settleOutstandingBills(manager: EntityManager, studentId: number, recordedBy?: number) {
|
||||
const bills = await manager
|
||||
.createQueryBuilder(Bill, 'bill')
|
||||
.where('bill.studentId = :studentId', { studentId })
|
||||
.andWhere('bill.status IN (:...statuses)', { statuses: ['unpaid', 'partially_paid'] })
|
||||
.andWhere('bill.outstandingAmount > 0')
|
||||
.orderBy('bill.periodStart', 'ASC')
|
||||
.addOrderBy('bill.id', 'ASC')
|
||||
.getMany();
|
||||
const settled: Bill[] = [];
|
||||
for (const bill of bills) {
|
||||
const wallet = await manager.findOne(StudentWallet, { where: { studentId } });
|
||||
if (!wallet || money(wallet.balance) <= 0) break;
|
||||
settled.push(await this.debitBill(manager, bill, recordedBy));
|
||||
}
|
||||
return settled;
|
||||
}
|
||||
|
||||
private async getOrCreateWallet(manager: EntityManager, studentId: number) {
|
||||
let wallet = await manager.findOne(StudentWallet, { where: { studentId } });
|
||||
if (!wallet) wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
|
||||
return wallet;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user