feat: add student wallet utility billing
This commit is contained in:
@@ -5,7 +5,7 @@ import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
import PDFDocument from 'pdfkit';
|
||||
import * as PDFDocument from 'pdfkit';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
@@ -34,20 +34,6 @@ export class BillsExportService {
|
||||
if (query.status) qb.andWhere('b.status = :status', { status: query.status });
|
||||
const bills = await qb.getMany();
|
||||
|
||||
// 查询涉及学生当前押金余额。账单生成时不冻结押金,导出只展示实时余额和已实际扣款。
|
||||
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
|
||||
const depMap = new Map<number, number>();
|
||||
if (studentIds.length > 0) {
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId IN (:...ids)', { ids: studentIds })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
for (const d of deposits) {
|
||||
depMap.set(d.studentId, (depMap.get(d.studentId) || 0) + Number(d.amount || 0));
|
||||
}
|
||||
}
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
workbook.creator = '恭学教育基地管理系统';
|
||||
|
||||
@@ -60,8 +46,8 @@ export class BillsExportService {
|
||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||
{ header: '个人费用', key: 'personal', width: 12 },
|
||||
{ header: '总金额', key: 'total', width: 12 },
|
||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
||||
{ header: '已扣押金', key: 'depositDeducted', width: 12 },
|
||||
{ header: '已扣余额', key: 'paidAmount', width: 12 },
|
||||
{ header: '待补缴', key: 'outstandingAmount', width: 12 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||
];
|
||||
@@ -70,12 +56,13 @@ export class BillsExportService {
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
unpaid: '待支付',
|
||||
partially_paid: '部分支付',
|
||||
paid: '已结清',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
for (const bill of bills) {
|
||||
const total = Number(bill.totalAmount || 0);
|
||||
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
|
||||
ws.addRow({
|
||||
id: bill.id,
|
||||
studentName: (bill as any).student?.name || '-',
|
||||
@@ -83,8 +70,8 @@ export class BillsExportService {
|
||||
shared: Number(bill.sharedAmount),
|
||||
personal: Number(bill.personalAmount),
|
||||
total,
|
||||
deposit: dep,
|
||||
depositDeducted: Number(bill.depositDeductedAmount || 0),
|
||||
paidAmount: Number(bill.paidAmount || 0),
|
||||
outstandingAmount: Number(bill.outstandingAmount || 0),
|
||||
status: statusMap[bill.status] || bill.status,
|
||||
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
||||
});
|
||||
@@ -142,15 +129,9 @@ export class BillsExportService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询该学生的当前可用押金。草稿账单只展示余额,不预生成抵扣金额。
|
||||
const deposits = await this.depositRepo
|
||||
.createQueryBuilder('d')
|
||||
.where('d.studentId = :sid', { sid: bill.studentId })
|
||||
.andWhere('d.status = :status', { status: 'paid' })
|
||||
.getMany();
|
||||
const availableDeposit = deposits.reduce((s, d) => s + Number(d.amount || 0), 0);
|
||||
const totalAmount = Number(bill.totalAmount || 0);
|
||||
const depositDeducted = Number(bill.depositDeductedAmount || 0);
|
||||
const paidAmount = Number(bill.paidAmount || 0);
|
||||
const outstandingAmount = Number(bill.outstandingAmount || 0);
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
@@ -185,8 +166,10 @@ export class BillsExportService {
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
unpaid: '待支付',
|
||||
partially_paid: '部分支付',
|
||||
paid: '已结清',
|
||||
cancelled: '已取消',
|
||||
};
|
||||
|
||||
// 标题
|
||||
@@ -216,18 +199,8 @@ export class BillsExportService {
|
||||
.fillColor('#007AFF')
|
||||
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||
doc.moveDown(0.3);
|
||||
if (availableDeposit > 0 || depositDeducted > 0) {
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor('#52C41A')
|
||||
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
||||
if (depositDeducted > 0) {
|
||||
doc
|
||||
.fontSize(11)
|
||||
.fillColor('#FA8C16')
|
||||
.text(`已扣押金: -¥${depositDeducted.toFixed(2)}`);
|
||||
}
|
||||
}
|
||||
doc.fontSize(11).fillColor('#389E0D').text(`已扣余额: ¥${paidAmount.toFixed(2)}`);
|
||||
doc.fontSize(14).fillColor(outstandingAmount > 0 ? '#FF3B30' : '#389E0D').text(`待补缴: ¥${outstandingAmount.toFixed(2)}`);
|
||||
doc.moveDown(1);
|
||||
|
||||
// 明细表格
|
||||
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Param,
|
||||
Body,
|
||||
Query,
|
||||
ParseIntPipe,
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
@@ -21,11 +20,7 @@ import { NotificationType } from '../entities/notification.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import {
|
||||
BatchUpdateBillStatusDto,
|
||||
GenerateBillsDto,
|
||||
UpdateBillStatusDto,
|
||||
} from './dto/bill.dto';
|
||||
import { CancelBillDto, GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
@@ -93,22 +88,54 @@ export class BillsController {
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('bill:view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.service.findOne(id);
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async updateStatus(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateBillStatusDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updateStatus(+id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '确认账单',
|
||||
targetId: +id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
// Send bill_paid notification
|
||||
try {
|
||||
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: NotificationType.BILL_PAID,
|
||||
title: '账单已确认',
|
||||
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
// Static routes must be declared before /:id/status, otherwise "batch" is
|
||||
// treated as an id and converted to NaN by the parameterized route.
|
||||
@Put('batch/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async batchUpdateStatus(@Body() body: BatchUpdateBillStatusDto, @Request() req: any) {
|
||||
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchUpdateStatus(body.ids, body.status);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '确认账单并扣押金',
|
||||
action: '确认账单',
|
||||
detail: `IDs: ${body.ids.join(',')}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -131,51 +158,36 @@ export class BillsController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
async updateStatus(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() dto: UpdateBillStatusDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
@Post(':id/cancel')
|
||||
@RequirePermission('bill:delete')
|
||||
async cancel(@Param('id') id: string, @Body() dto: CancelBillDto, @Request() req: any) {
|
||||
const result = await this.service.cancel(+id, dto, req.user?.id);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.updateStatus(id, dto);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '确认账单并扣押金',
|
||||
targetId: id,
|
||||
action: '取消账单并冲正',
|
||||
targetId: +id,
|
||||
targetType: 'bill',
|
||||
detail: dto.reason,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
// Send bill_paid notification
|
||||
try {
|
||||
const student = await this.studentRepo.findOne({ where: { id: result.studentId } });
|
||||
if (student?.userId) {
|
||||
void this.notificationsService.create({
|
||||
recipientIds: [student.userId],
|
||||
type: NotificationType.BILL_PAID,
|
||||
title: '账单已确认',
|
||||
content: `账单 #${result.id} 已确认收款,金额: ¥${result.totalAmount}`,
|
||||
});
|
||||
}
|
||||
} catch (_) { /* don't block response */ }
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('bill:delete')
|
||||
async remove(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(id);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '账单管理',
|
||||
action: '删除账单',
|
||||
targetId: id,
|
||||
targetId: +id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
@@ -233,18 +245,18 @@ export class BillsController {
|
||||
|
||||
@Get('export/pdf/:id')
|
||||
@RequirePermission('bill:export-pdf')
|
||||
async exportPdf(@Param('id', ParseIntPipe) id: number, @Res() res: Response, @Req() req: any) {
|
||||
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req?.user?.id,
|
||||
username: req?.user?.username,
|
||||
module: '账单管理',
|
||||
action: '导出账单',
|
||||
targetId: id,
|
||||
targetId: +id,
|
||||
targetType: 'bill',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return this.exportService.exportStudentPdf(id, res);
|
||||
return this.exportService.exportStudentPdf(+id, res);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { WalletsModule } from '../wallets/wallets.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
@@ -7,7 +8,6 @@ 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 { Student } from '../entities/student.entity';
|
||||
import { BillsService } from './bills.service';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
@@ -22,10 +22,10 @@ import { BillsController } from './bills.controller';
|
||||
PersonalExpense,
|
||||
Occupancy,
|
||||
Room,
|
||||
Deposit,
|
||||
Student,
|
||||
]),
|
||||
NotificationsModule,
|
||||
WalletsModule,
|
||||
],
|
||||
controllers: [BillsController],
|
||||
providers: [BillsService, BillsExportService],
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { WalletsService } from '../wallets/wallets.service';
|
||||
|
||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||
|
||||
@@ -56,7 +57,23 @@ describe('BillsService — generateBills', () => {
|
||||
occRepo = mockRepo<Occupancy>();
|
||||
roomRepo = mockRepo<Room>();
|
||||
depositRepo = mockRepo<Deposit>();
|
||||
dataSource = { transaction: jest.fn(), query: jest.fn().mockResolvedValue([]) };
|
||||
let nextBillId = 0;
|
||||
dataSource = {
|
||||
transaction: jest.fn(async (callback) => callback({
|
||||
create: (_entity: unknown, value: unknown) => value,
|
||||
save: jest.fn(async (value: any) => {
|
||||
if ('totalAmount' in value && 'studentId' in value) {
|
||||
const saved = { id: ++nextBillId, ...value };
|
||||
await (billRepo.save as jest.Mock)(saved);
|
||||
return saved;
|
||||
}
|
||||
await (itemRepo.save as jest.Mock)(value);
|
||||
return { id: value.id || 1, ...value };
|
||||
}),
|
||||
createQueryBuilder: jest.fn(() => ({ update: jest.fn().mockReturnThis(), set: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), execute: jest.fn().mockResolvedValue({ affected: 1 }) })),
|
||||
})),
|
||||
query: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -69,6 +86,7 @@ describe('BillsService — generateBills', () => {
|
||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: WalletsService, useValue: { debitBill: jest.fn(async (_manager, bill) => bill), refundBill: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches } from 'class-validator';
|
||||
import { ArrayNotEmpty, IsArray, IsIn, IsInt, IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
||||
|
||||
export class GenerateBillsDto {
|
||||
@IsString()
|
||||
@Matches(/^\d{4}-\d{2}$/)
|
||||
billingMonth: string; // YYYY-MM
|
||||
billingMonth: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
periodStart?: string; // deprecated, derived from billingMonth
|
||||
periodStart?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
periodEnd?: string; // deprecated, derived from billingMonth
|
||||
periodEnd?: string;
|
||||
}
|
||||
|
||||
export class UpdateBillStatusDto {
|
||||
@IsIn(['paid'])
|
||||
status: 'paid';
|
||||
@IsIn(['unpaid', 'partially_paid', 'paid'])
|
||||
status: 'unpaid' | 'partially_paid' | 'paid';
|
||||
}
|
||||
|
||||
export class CancelBillDto {
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export class BatchUpdateBillStatusDto extends UpdateBillStatusDto {
|
||||
|
||||
Reference in New Issue
Block a user