207 lines
7.2 KiB
TypeScript
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}`;
|
|
}
|