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

288 lines
10 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 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();
}
}