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 ''; } /** 导出给运营看的时间:按上海时区,避免 toISOString() 落成 UTC(少 8 小时) */ function formatShanghaiDateTime(value: Date): string { return value.toLocaleString('sv-SE', { timeZone: 'Asia/Shanghai' }); } export function formatExportDateTime(value?: Date | null): string { if (!value) return ''; return formatShanghaiDateTime(value); } 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 { 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 { 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 = formatShanghaiDateTime(new Date()).slice(0, 10); return `订单导出_${stamp}_${count}条.${format}`; }