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, @InjectRepository(BillItem) private itemRepo: Repository, @InjectRepository(Deposit) private depositRepo: Repository, ) {} /** * 导出账单列表为 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 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: 'paidAmount', width: 12 }, { header: '待补缴', key: 'outstandingAmount', width: 12 }, { 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 = { unpaid: '待支付', partially_paid: '部分支付', paid: '已结清', cancelled: '已取消', }; for (const bill of bills) { const total = Number(bill.totalAmount || 0); 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, 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') : '', }); } // 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 totalAmount = Number(bill.totalAmount || 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'); 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 = { unpaid: '待支付', partially_paid: '部分支付', paid: '已结清', cancelled: '已取消', }; // 标题 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); doc.fontSize(11).fillColor('#389E0D').text(`已扣余额: ¥${paidAmount.toFixed(2)}`); doc.fontSize(14).fillColor(outstandingAmount > 0 ? '#FF3B30' : '#389E0D').text(`待补缴: ¥${outstandingAmount.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(); } }