Files
gongxue-base/apps/server/src/bills/bills-export.service.ts

252 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 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<string, string> = {
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<string, string> = {
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();
}
}