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'; import { pickExportColumns, type ExportColumnDef, } from '../../common/export/column-export.util'; const DELIVERY_TYPE_LABELS: Record = { LOCAL: '同城', CROSS_CITY: '跨城', ON_SITE_PICKUP: '现场提货', }; export type OrderExportRow = { orderNo: string; createdAt: string; status: string; productName: string; productSpec: string; quantity: number; deliveryType: string; payAmount: number; logisticsFee: string; 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, deliveryType: DELIVERY_TYPE_LABELS[order.deliveryType ?? ''] || order.deliveryType || '', payAmount: Number(order.payAmount), logisticsFee: '', benefitBrief: formatBenefitBrief(order), receiverName: order.receiverName, receiverPhone: order.receiverPhone, address, }; } function orderExportColumnDefs(): ExportColumnDef[] { return [ { key: '订单号', header: '订单号', value: (r) => r.orderNo }, { key: '下单时间', header: '下单时间', value: (r) => r.createdAt }, { key: '状态', header: '状态', value: (r) => r.status }, { key: '商品', header: '商品', value: (r) => r.productName }, { key: '规格', header: '规格', value: (r) => r.productSpec }, { key: '数量', header: '数量', value: (r) => r.quantity }, { key: '配送方式', header: '配送方式', value: (r) => r.deliveryType }, { key: '实付', header: '实付', value: (r) => r.payAmount.toFixed(2) }, { key: '运费', header: '运费', value: (r) => r.logisticsFee }, { key: '好客权益', header: '好客权益', value: (r) => r.benefitBrief }, { key: '收货人', header: '收货人', value: (r) => r.receiverName }, { key: '电话', header: '电话', value: (r) => r.receiverPhone }, { key: '地址', header: '地址', value: (r) => r.address }, ]; } const ORDER_COLUMN_ALIASES: Record = { orderNo: '订单号', createdAt: '下单时间', status: '状态', productName: '商品', productSpec: '规格', quantity: '数量', deliveryType: '配送方式', payAmount: '实付', logisticsFee: '运费', benefitBrief: '好客权益', receiverName: '收货人', receiverPhone: '电话', phone: '电话', receiverAddress: '地址', address: '地址', 手机: '电话', 收货地址: '地址', }; function normalizeOrderColumns(columnKeys?: string[]): string[] | undefined { if (!columnKeys?.length) return undefined; return columnKeys.map((k) => ORDER_COLUMN_ALIASES[k] ?? k); } 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[], columnKeys?: string[]): Promise { const cols = pickExportColumns(orderExportColumnDefs(), normalizeOrderColumns(columnKeys)); const workbook = new ExcelJS.Workbook(); const sheet = workbook.addWorksheet('订单'); sheet.addRow(cols.map((c) => c.header)); for (const row of rows) { sheet.addRow(cols.map((c) => c.value(row))); } sheet.columns.forEach((col) => { col.width = 16; }); const buffer = await workbook.xlsx.writeBuffer(); return Buffer.from(buffer); } export async function buildOrdersPdf(rows: OrderExportRow[], columnKeys?: string[]): Promise { const cols = pickExportColumns(orderExportColumnDefs(), normalizeOrderColumns(columnKeys)); 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', (c) => chunks.push(c as Buffer)); doc.on('end', () => resolve(Buffer.concat(chunks))); doc.on('error', reject); doc.font(fontPath); doc.fontSize(10).text(cols.map((c) => c.header).join(' | ')); doc.moveDown(0.5); for (const row of rows) { doc.text(cols.map((c) => String(c.value(row))).join(' | ')); } doc.end(); }); } export function buildExportFilename(format: 'xlsx' | 'pdf', count: number): string { const stamp = new Date().toISOString().slice(0, 10); return `订单导出_${stamp}_${count}.${format}`; }