v3.5.5版本上传
CI / verify (pull_request) Waiting to run

This commit is contained in:
2026-08-23 12:26:57 +08:00
parent f9161368e1
commit 22cd00da42
18 changed files with 2036 additions and 294 deletions
@@ -0,0 +1,211 @@
import { ORDER_STATUS_LABELS } from '@dukang/shared-types';
import ExcelJS from 'exceljs';
import * as fs from 'fs';
import * as path from 'path';
import PDFDocument from 'pdfkit';
const EXPORT_HEADERS = [
'订单号',
'下单时间',
'状态',
'商品',
'规格',
'数量',
'实付',
'好客权益',
'收货人',
'手机',
'收货地址',
] as const;
export type OrderExportRow = {
orderNo: string;
createdAt: string;
status: string;
productName: string;
productSpec: string;
quantity: number;
payAmount: number;
benefitBrief: string;
receiverName: string;
receiverPhone: string;
address: string;
};
function formatBenefitBrief(order: {
benefitAmount?: { toString(): string } | number | null;
benefitCoupon?: {
totalAmount?: { toString(): string } | number;
usedAmount?: { toString(): string } | number;
balance?: { toString(): string } | number;
} | null;
}): string {
const coupon = order.benefitCoupon;
if (coupon) {
return `总额¥${Number(coupon.totalAmount).toFixed(2)} / 已用¥${Number(coupon.usedAmount).toFixed(2)} / 余¥${Number(coupon.balance).toFixed(2)}`;
}
if (order.benefitAmount != null) return `¥${Number(order.benefitAmount).toFixed(2)}`;
return '';
}
export function formatExportDateTime(value?: Date | null): string {
if (!value) return '';
return value.toISOString().slice(0, 19).replace('T', ' ');
}
export function mapOrderToExportRow(order: {
orderNo: string;
createdAt: Date;
status: string;
productName: string;
productSpec: string;
quantity: number;
payAmount: { toString(): string } | number;
benefitAmount?: { toString(): string } | number | null;
receiverName: string;
receiverPhone: string;
receiverProvince: string;
receiverCity: string;
receiverDistrict: string;
receiverAddress: string;
benefitCoupon?: {
totalAmount?: { toString(): string } | number;
usedAmount?: { toString(): string } | number;
balance?: { toString(): string } | number;
} | null;
deliveryType?: string;
}): OrderExportRow {
const isOnSite =
order.deliveryType === 'ON_SITE_PICKUP' ||
order.receiverAddress === '现场取货' ||
order.receiverAddress === '现场提货' ||
(order.receiverProvince === '现场' && order.receiverCity === '现场');
const address = isOnSite
? '现场取货'
: `${order.receiverProvince}${order.receiverCity}${order.receiverDistrict}${order.receiverAddress}`;
return {
orderNo: order.orderNo,
createdAt: formatExportDateTime(order.createdAt),
status: ORDER_STATUS_LABELS[order.status] || order.status,
productName: order.productName,
productSpec: order.productSpec,
quantity: order.quantity,
payAmount: Number(order.payAmount),
benefitBrief: formatBenefitBrief(order),
receiverName: order.receiverName,
receiverPhone: order.receiverPhone,
address,
};
}
export function rowToCells(row: OrderExportRow): string[] {
return [
row.orderNo,
row.createdAt,
row.status,
row.productName,
row.productSpec,
String(row.quantity),
row.payAmount.toFixed(2),
row.benefitBrief,
row.receiverName,
row.receiverPhone,
row.address,
];
}
function resolvePdfFontPath(): string {
const candidates = [
process.env.EXPORT_PDF_FONT_PATH,
path.join(process.cwd(), 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
path.join(process.cwd(), 'assets', 'fonts', 'simhei.ttf'),
path.join(process.cwd(), 'assets', 'fonts', 'msyh.ttc'),
path.join(process.cwd(), 'dist', 'assets', 'fonts', 'simhei.ttf'),
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc',
'C:\\Windows\\Fonts\\simhei.ttf',
'C:\\Windows\\Fonts\\msyh.ttc',
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
throw new Error('未找到可用于 PDF 的中文字体,请将字体文件放到 server/dukang-api/assets/fonts/');
}
export async function buildOrdersXlsx(rows: OrderExportRow[]): Promise<Buffer> {
const workbook = new ExcelJS.Workbook();
const sheet = workbook.addWorksheet('订单');
sheet.addRow([...EXPORT_HEADERS]);
for (const row of rows) {
sheet.addRow(rowToCells(row));
}
sheet.columns.forEach((col) => {
col.width = 16;
});
sheet.getColumn(4).width = 22;
sheet.getColumn(8).width = 36;
sheet.getColumn(11).width = 36;
const buffer = await workbook.xlsx.writeBuffer();
return Buffer.from(buffer);
}
export async function buildOrdersPdf(rows: OrderExportRow[]): Promise<Buffer> {
const fontPath = resolvePdfFontPath();
const chunks: Buffer[] = [];
return new Promise((resolve, reject) => {
const doc = new PDFDocument({
size: 'A4',
layout: 'landscape',
margin: 24,
bufferPages: true,
});
doc.on('data', (chunk) => chunks.push(chunk as Buffer));
doc.on('end', () => resolve(Buffer.concat(chunks)));
doc.on('error', reject);
doc.registerFont('zh', fontPath);
doc.font('zh');
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
const colWidths = [72, 78, 48, 88, 64, 32, 48, 110, 48, 72, 120];
const scale = pageWidth / colWidths.reduce((sum, w) => sum + w, 0);
const widths = colWidths.map((w) => w * scale);
const rowHeight = 28;
const fontSize = 7;
let y = doc.page.margins.top;
const drawRow = (cells: string[], isHeader = false) => {
let x = doc.page.margins.left;
const height = isHeader ? 24 : rowHeight;
if (y + height > doc.page.height - doc.page.margins.bottom) {
doc.addPage({ size: 'A4', layout: 'landscape', margin: 24 });
y = doc.page.margins.top;
}
doc.fontSize(isHeader ? 8 : fontSize);
cells.forEach((cell, index) => {
doc.rect(x, y, widths[index], height).stroke('#dddddd');
doc.text(cell || '', x + 2, y + 4, {
width: widths[index] - 4,
height: height - 6,
lineBreak: true,
});
x += widths[index];
});
y += height;
};
drawRow([...EXPORT_HEADERS], true);
for (const row of rows) {
drawRow(rowToCells(row));
}
doc.end();
});
}
export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string {
const stamp = new Date().toISOString().slice(0, 10);
return `订单导出_${stamp}_${count}条.${format}`;
}