feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
242
apps/server/src/bills/bills-export.service.ts
Normal file
242
apps/server/src/bills/bills-export.service.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
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 * as PDFDocument from 'pdfkit';
|
||||
import { Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class BillsExportService {
|
||||
constructor(
|
||||
@InjectRepository(Bill) private billRepo: Repository<Bill>,
|
||||
@InjectRepository(BillItem) private itemRepo: Repository<BillItem>,
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 导出账单列表为 Excel
|
||||
*/
|
||||
async exportExcel(query: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string }, res: Response) {
|
||||
const qb = this.billRepo.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.student', 'student')
|
||||
.leftJoinAndSelect('b.items', 'items')
|
||||
.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();
|
||||
|
||||
// 查询涉及学生的"已缴未退"押金,用于导出押金抵扣字段
|
||||
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 = '恭学教育基地管理系统';
|
||||
|
||||
// Sheet 1: 账单汇总
|
||||
const ws = workbook.addWorksheet('账单汇总');
|
||||
ws.columns = [
|
||||
{ header: '账单ID', key: 'id', width: 10 },
|
||||
{ header: '学生姓名', key: 'studentName', width: 14 },
|
||||
{ header: '计费周期', key: 'period', width: 24 },
|
||||
{ header: '分摊费用', key: 'shared', width: 12 },
|
||||
{ header: '个人费用', key: 'personal', width: 12 },
|
||||
{ header: '总金额', key: 'total', width: 12 },
|
||||
{ header: '可用押金', key: 'deposit', width: 12 },
|
||||
{ header: '押金抵扣', key: 'depositApplied', width: 12 },
|
||||
{ header: '抵扣后应付', key: 'afterDeposit', width: 14 },
|
||||
{ header: '状态', key: 'status', width: 10 },
|
||||
{ header: '生成时间', key: 'generatedAt', width: 20 },
|
||||
];
|
||||
// 表头样式
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
const statusMap: Record<string, string> = { draft: '草稿', confirmed: '已确认', paid: '已结清' };
|
||||
for (const bill of bills) {
|
||||
const total = Number(bill.totalAmount || 0);
|
||||
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
|
||||
const applied = Number(Math.min(dep, total).toFixed(2));
|
||||
const after = Number(Math.max(0, total - applied).toFixed(2));
|
||||
ws.addRow({
|
||||
id: bill.id,
|
||||
studentName: (bill as any).student?.name || '-',
|
||||
period: `${bill.periodStart} ~ ${bill.periodEnd}`,
|
||||
shared: Number(bill.sharedAmount),
|
||||
personal: Number(bill.personalAmount),
|
||||
total,
|
||||
deposit: dep,
|
||||
depositApplied: applied,
|
||||
afterDeposit: after,
|
||||
status: statusMap[bill.status] || bill.status,
|
||||
generatedAt: bill.generatedAt ? new Date(bill.generatedAt).toLocaleString('zh-CN') : '',
|
||||
});
|
||||
}
|
||||
|
||||
// Sheet 2: 费用明细
|
||||
const ws2 = workbook.addWorksheet('费用明细');
|
||||
ws2.columns = [
|
||||
{ header: '账单ID', key: 'billId', width: 10 },
|
||||
{ header: '学生姓名', key: 'studentName', width: 14 },
|
||||
{ header: '费用类型', key: 'expenseType', width: 12 },
|
||||
{ header: '说明', key: 'description', width: 24 },
|
||||
{ header: '计费天数', key: 'days', width: 10 },
|
||||
{ header: '宿舍总人天', key: 'totalRoomDays', width: 12 },
|
||||
{ header: '宿舍总费用', key: 'roomTotal', width: 12 },
|
||||
{ header: '学生应付', key: 'studentAmount', width: 12 },
|
||||
];
|
||||
ws2.getRow(1).font = { bold: true };
|
||||
ws2.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
|
||||
for (const bill of bills) {
|
||||
for (const item of bill.items || []) {
|
||||
ws2.addRow({
|
||||
billId: bill.id,
|
||||
studentName: (bill as any).student?.name || '-',
|
||||
expenseType: item.expenseType,
|
||||
description: item.description,
|
||||
days: item.days,
|
||||
totalRoomDays: item.totalRoomDays,
|
||||
roomTotal: Number(item.roomTotalAmount),
|
||||
studentAmount: Number(item.studentAmount),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`);
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出单个学生的 PDF 账单
|
||||
*/
|
||||
async exportStudentPdf(billId: number, res: Response) {
|
||||
const bill = await this.billRepo.findOne({ where: { id: billId }, relations: ['student', 'items'] });
|
||||
if (!bill) { res.status(404).json({ message: '账单不存在' }); 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 depositApplied = Math.min(availableDeposit, totalAmount);
|
||||
const amountAfterDeposit = Math.max(0, totalAmount - depositApplied);
|
||||
|
||||
const doc = new PDFDocument({ size: 'A4', margin: 50 });
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', `attachment; filename=bill_${billId}.pdf`);
|
||||
doc.pipe(res);
|
||||
|
||||
// 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux)
|
||||
const fontPaths = [
|
||||
'/System/Library/Fonts/PingFang.ttc', // macOS
|
||||
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', // Linux Noto
|
||||
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
|
||||
'/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc',
|
||||
'/usr/share/fonts/noto-cjk/NotoSansSC-Regular.otf',
|
||||
'/usr/share/fonts/wqy-microhei/wqy-microhei.ttc', // Linux WenQuanYi
|
||||
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
|
||||
];
|
||||
let fontRegistered = false;
|
||||
const fs = require('fs');
|
||||
for (const fp of fontPaths) {
|
||||
try {
|
||||
if (fs.existsSync(fp)) {
|
||||
doc.registerFont('Chinese', fp);
|
||||
doc.font('Chinese');
|
||||
fontRegistered = true;
|
||||
break;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
if (!fontRegistered) {
|
||||
// 如果没有中文字体,使用 Helvetica(中文可能乱码)
|
||||
doc.font('Helvetica');
|
||||
}
|
||||
|
||||
const statusMap: Record<string, string> = { draft: '草稿', confirmed: '已确认', paid: '已结清' };
|
||||
|
||||
// 标题
|
||||
doc.fontSize(20).text('恭学教育基地水电费账单', { align: 'center' });
|
||||
doc.moveDown(0.5);
|
||||
doc.fontSize(10).fillColor('#666').text(`生成时间: ${new Date().toLocaleString('zh-CN')}`, { align: 'center' });
|
||||
doc.moveDown(1);
|
||||
|
||||
// 基本信息
|
||||
doc.fontSize(12).fillColor('#000');
|
||||
doc.text(`学生姓名: ${(bill as any).student?.name || '-'}`);
|
||||
doc.text(`计费周期: ${bill.periodStart} ~ ${bill.periodEnd}`);
|
||||
doc.text(`账单状态: ${statusMap[bill.status] || bill.status}`);
|
||||
doc.moveDown(0.5);
|
||||
|
||||
// 金额汇总
|
||||
doc.fontSize(14).text('费用汇总', { underline: true });
|
||||
doc.moveDown(0.3);
|
||||
doc.fontSize(12);
|
||||
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
|
||||
doc.text(`个人费用: ¥${Number(bill.personalAmount).toFixed(2)}`);
|
||||
doc.fontSize(14).fillColor('#007AFF').text(`应付总额: ¥${totalAmount.toFixed(2)}`);
|
||||
doc.moveDown(0.3);
|
||||
if (availableDeposit > 0) {
|
||||
doc.fontSize(11).fillColor('#52C41A').text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
|
||||
doc.fontSize(11).fillColor('#FA8C16').text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
|
||||
doc.fontSize(14).fillColor('#FF3B30').text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
|
||||
}
|
||||
doc.moveDown(1);
|
||||
|
||||
// 明细表格
|
||||
doc.fontSize(14).fillColor('#000').text('费用明细', { underline: true });
|
||||
doc.moveDown(0.5);
|
||||
|
||||
const items = bill.items || [];
|
||||
const tableTop = doc.y;
|
||||
const colWidths = [120, 180, 60, 60, 70];
|
||||
const headers = ['费用类型', '说明', '天数', '总人天', '金额(元)'];
|
||||
|
||||
// 表头
|
||||
doc.fontSize(10).fillColor('#333');
|
||||
let x = 50;
|
||||
for (let i = 0; i < headers.length; i++) {
|
||||
doc.text(headers[i], x, tableTop, { width: colWidths[i], align: 'left' });
|
||||
x += colWidths[i];
|
||||
}
|
||||
doc.moveDown(0.3);
|
||||
doc.moveTo(50, doc.y).lineTo(540, doc.y).stroke('#ccc');
|
||||
doc.moveDown(0.2);
|
||||
|
||||
// 数据行
|
||||
for (const item of items) {
|
||||
const y = doc.y;
|
||||
x = 50;
|
||||
doc.fontSize(9).fillColor('#000');
|
||||
doc.text(item.expenseType || '', x, y, { width: colWidths[0] }); x += colWidths[0];
|
||||
doc.text(item.description || '', x, y, { width: colWidths[1] }); x += colWidths[1];
|
||||
doc.text(String(item.days || 0), x, y, { width: colWidths[2] }); x += colWidths[2];
|
||||
doc.text(String(item.totalRoomDays || 0), x, y, { width: colWidths[3] }); x += colWidths[3];
|
||||
doc.text(Number(item.studentAmount).toFixed(2), x, y, { width: colWidths[4] });
|
||||
doc.moveDown(0.8);
|
||||
}
|
||||
|
||||
doc.moveDown(2);
|
||||
doc.fontSize(8).fillColor('#999').text('本账单由恭学教育基地管理系统自动生成', { align: 'center' });
|
||||
|
||||
doc.end();
|
||||
}
|
||||
}
|
||||
108
apps/server/src/bills/bills.controller.ts
Normal file
108
apps/server/src/bills/bills.controller.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, Request, Res, Req } from '@nestjs/common';
|
||||
import { BillsService } from './bills.service';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import { 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';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import type { Response } from 'express';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('bills')
|
||||
export class BillsController {
|
||||
constructor(private service: BillsService, private exportService: BillsExportService, private logService: OperationLogsService) {}
|
||||
|
||||
@Post('generate')
|
||||
@RequirePermission('bill:generate')
|
||||
async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.generateBills(dto);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: '生成账单', detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count} 条`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('bill:view')
|
||||
findAll(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
@Query('studentId') studentId?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
periodStart, periodEnd,
|
||||
studentId: studentId ? +studentId : undefined,
|
||||
status,
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('bill:view')
|
||||
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: `状态变更为${dto.status}`, targetId: +id, targetType: 'bill', ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('batch/status')
|
||||
@RequirePermission('bill:confirm')
|
||||
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: `批量状态变更为${body.status}`, detail: `IDs: ${body.ids.join(',')}`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('bill:delete')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: '删除账单', targetId: +id, targetType: 'bill', ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('batch/delete')
|
||||
@RequirePermission('bill:delete')
|
||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchRemove(body.ids);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: '批量删除账单', detail: `IDs: ${body.ids.join(',')}`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('export/excel')
|
||||
@RequirePermission('bill:export-excel')
|
||||
async exportExcel(
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
@Query('studentId') studentId?: string,
|
||||
@Query('status') status?: 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: '导出Excel', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, ipAddress, userAgent });
|
||||
return this.exportService.exportExcel({
|
||||
periodStart, periodEnd,
|
||||
studentId: studentId ? +studentId : undefined,
|
||||
status,
|
||||
}, res!);
|
||||
}
|
||||
|
||||
@Get('export/pdf/:id')
|
||||
@RequirePermission('bill:export-pdf')
|
||||
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: '导出PDF', targetId: +id, targetType: 'bill', ipAddress, userAgent });
|
||||
return this.exportService.exportStudentPdf(+id, res);
|
||||
}
|
||||
}
|
||||
20
apps/server/src/bills/bills.module.ts
Normal file
20
apps/server/src/bills/bills.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/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 { BillsService } from './bills.service';
|
||||
import { BillsExportService } from './bills-export.service';
|
||||
import { BillsController } from './bills.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room, Deposit])],
|
||||
controllers: [BillsController],
|
||||
providers: [BillsService, BillsExportService],
|
||||
exports: [BillsService],
|
||||
})
|
||||
export class BillsModule {}
|
||||
240
apps/server/src/bills/bills.service.ts
Normal file
240
apps/server/src/bills/bills.service.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
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} 条账单` };
|
||||
}
|
||||
}
|
||||
14
apps/server/src/bills/dto/bill.dto.ts
Normal file
14
apps/server/src/bills/dto/bill.dto.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { IsString, IsOptional } from 'class-validator';
|
||||
|
||||
export class GenerateBillsDto {
|
||||
@IsString()
|
||||
periodStart: string; // YYYY-MM-DD
|
||||
|
||||
@IsString()
|
||||
periodEnd: string; // YYYY-MM-DD
|
||||
}
|
||||
|
||||
export class UpdateBillStatusDto {
|
||||
@IsString()
|
||||
status: 'draft' | 'confirmed' | 'paid';
|
||||
}
|
||||
Reference in New Issue
Block a user