Files
dukang/server/dukang-api/src/modules/ops/admin-order-export.util.ts
T
jacy 6880b5daf4
CI / verify (pull_request) Has been cancelled
v4.0.11HQ后端优化(快链、账单日等)
2026-09-02 15:45:12 +08:00

207 lines
7.2 KiB
TypeScript

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<string, string> = {
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<OrderExportRow>[] {
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<string, string> = {
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<Buffer> {
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<Buffer> {
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}`;
}