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

304 lines
12 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, EntityManager, Repository, In } 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 { BatchChangeWalletBalanceDto, ChangeWalletBalanceDto } from './dto/wallet.dto';
import { FinancialOperationsService } from '../financial-operations/financial-operations.service';
import { Room } from '../entities/room.entity';
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,
private financialOperations?: FinancialOperationsService,
) {}
async findAll(query?: { keyword?: string; debtOnly?: boolean; roomType?: string }) {
const qb = this.studentRepo
.createQueryBuilder('student')
.leftJoin('student.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
.leftJoin('occupancy.room', 'room')
.where('student.status = :status', { status: 'active' });
if (query?.keyword) {
qb.andWhere('(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', {
keyword: `%${query.keyword}%`,
});
}
if (query?.roomType) {
qb.andWhere('room.roomType = :roomType', { roomType: query.roomType });
}
const rows = await qb
.select([
'student.id AS studentId',
'student.name AS studentName',
'student.studentNo AS studentNo',
'room.roomType AS roomType',
'room.roomNumber AS roomNumber',
])
.orderBy('student.name', 'ASC')
.getRawMany<{
studentId: number;
studentName: string;
studentNo: string | null;
roomType: string | null;
roomNumber: string | null;
}>();
if (!rows.length) return [];
const ids = rows.map((row) => Number(row.studentId));
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 rows
.map((row) => ({
studentId: Number(row.studentId),
studentName: row.studentName,
studentNo: row.studentNo || undefined,
roomType: row.roomType || undefined,
roomNumber: row.roomNumber || undefined,
balance: money(walletMap.get(Number(row.studentId))?.balance),
outstandingAmount: debtMap.get(Number(row.studentId)) || 0,
}))
.filter((row) => !query?.debtOnly || row.outstandingAmount > 0);
}
async findRoomTypes() {
const rows = await this.dataSource
.getRepository(Room)
.createQueryBuilder('room')
.innerJoin('room.occupancies', 'occupancy', 'occupancy.checkOutDate IS NULL')
.innerJoin('occupancy.student', 'student', 'student.status = :status', { status: 'active' })
.select('room.roomType', 'roomType')
.where('room.roomType IS NOT NULL')
.andWhere("room.roomType <> ''")
.distinct(true)
.orderBy('room.roomType', 'ASC')
.getRawMany<{ roomType: string }>();
return rows.map((row) => row.roomType);
}
async findTransactions(studentId: number) {
return this.transactionRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } });
}
async changeBalance(dto: ChangeWalletBalanceDto, recordedBy?: number) {
const { operationId, ...change } = dto;
return this.financialOperations
? this.financialOperations.run(operationId, 'wallet.change_balance', () =>
this.changeBalanceOnce(change, recordedBy, operationId),
)
: this.changeBalanceOnce(change, recordedBy, operationId);
}
private async changeBalanceOnce(
dto: Omit<ChangeWalletBalanceDto, 'operationId'>,
recordedBy?: number,
operationId?: string,
transactionManager?: EntityManager,
) {
const amount = money(dto.amount);
if (
!Number.isFinite(dto.amount) ||
Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8
) {
throw new BadRequestException('调账金额最多保留两位小数');
}
if (amount === 0) throw new BadRequestException('调账金额不能为 0');
if (dto.type === 'recharge' && amount <= 0) throw new BadRequestException('充值金额必须大于 0');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const work = async (manager: EntityManager) => {
const wallet = await this.getOrCreateWallet(manager, dto.studentId, true);
const nextBalance = money(Number(wallet.balance) + 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,
operationId: operationId ?? null,
type: dto.type,
amount,
balanceAfter: nextBalance,
description: dto.description || (dto.type === 'recharge' ? '财务充值' : '余额调账'),
recordedBy: recordedBy || null,
}),
);
const payments =
amount > 0 ? await this.settleOutstandingBills(manager, dto.studentId, recordedBy) : [];
const finalWallet = await manager.findOneByOrFail(StudentWallet, {
studentId: dto.studentId,
});
return { wallet: finalWallet, payments };
};
return transactionManager ? work(transactionManager) : this.dataSource.transaction(work);
}
async batchChangeBalance(dto: BatchChangeWalletBalanceDto, recordedBy?: number) {
const { operationId, ...batch } = dto;
const work = () =>
this.dataSource.transaction(async (manager) => {
const uniqueStudentIds = Array.from(new Set(batch.studentIds));
const results: Array<{ wallet: StudentWallet; payments: Bill[] }> = [];
for (const studentId of uniqueStudentIds) {
results.push(
await this.changeBalanceOnce(
{
studentId,
amount: batch.amount,
type: batch.type,
description: batch.description,
},
recordedBy,
operationId ? `${operationId}:${studentId}` : undefined,
manager,
),
);
}
return { count: uniqueStudentIds.length, results };
});
return this.financialOperations
? this.financialOperations.run(operationId, 'wallet.batch_change_balance', work)
: work();
}
async debitBill(manager: EntityManager, bill: Bill, recordedBy?: number) {
if (bill.status === 'cancelled') return bill;
const total = money(bill.totalAmount);
const paid = Math.max(0, Math.min(money(bill.paidAmount), total));
const remaining = money(Math.max(0, total - paid));
if (remaining <= 0) {
bill.paidAmount = total;
bill.outstandingAmount = 0;
bill.status = 'paid';
return manager.save(bill);
}
const wallet = await this.getOrCreateWallet(manager, bill.studentId);
const amount = money(Math.min(Math.max(0, money(wallet.balance)), remaining));
if (amount <= 0) {
bill.paidAmount = paid;
bill.outstandingAmount = remaining;
bill.status = paid > 0 ? 'partially_paid' : 'unpaid';
return manager.save(bill);
}
wallet.balance = money(Number(wallet.balance) - amount);
bill.paidAmount = money(paid + amount);
bill.outstandingAmount = money(Math.max(0, total - 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) {
if (bill.status === 'cancelled') return bill;
const paid = Math.max(0, Math.min(money(bill.paidAmount), money(bill.totalAmount)));
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, lock = false) {
const find = async () => {
if (!lock || !manager.createQueryBuilder) {
return manager.findOne(StudentWallet, { where: { studentId } });
}
let query = manager
.createQueryBuilder(StudentWallet, 'wallet')
.where('wallet.studentId = :studentId', { studentId });
query = query.setLock('pessimistic_write');
return query.getOne();
};
let wallet = await find();
if (!wallet) {
try {
if (manager.insert) await manager.insert(StudentWallet, { studentId, balance: 0 });
else wallet = await manager.save(manager.create(StudentWallet, { studentId, balance: 0 }));
} catch {
// A concurrent request may have inserted the one wallet row.
}
wallet ||= await find();
}
if (!wallet) throw new NotFoundException('学生钱包创建失败');
return wallet;
}
}