@@ -0,0 +1,12 @@
|
||||
# PDF 导出字体
|
||||
|
||||
订单 PDF 导出需要支持中文的 TTF/OTF/TTC 字体。按优先级尝试:
|
||||
|
||||
1. 环境变量 `EXPORT_PDF_FONT_PATH`
|
||||
2. `assets/fonts/NotoSansSC-Regular.otf`(推荐,SIL OFL)
|
||||
3. `assets/fonts/simhei.ttf`
|
||||
4. `assets/fonts/msyh.ttc`
|
||||
5. Linux 系统字体(如 `fonts-noto-cjk`)
|
||||
6. Windows 系统字体(开发机)
|
||||
|
||||
本地开发可将 `C:\Windows\Fonts\simhei.ttf` 复制到本目录。
|
||||
@@ -3,6 +3,7 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
"deleteOutDir": true,
|
||||
"assets": [{ "include": "../assets/**/*", "outDir": "dist", "watchAssets": true }]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"prisma:seed-wecom-push": "ts-node --transpile-only prisma/seed-wecom-message-push.ts",
|
||||
"prisma:migrate-wecom-push": "ts-node --transpile-only prisma/migrate-wecom-message-push.ts",
|
||||
"prisma:upsert-super-admin": "ts-node --transpile-only scripts/upsert-super-admin.ts",
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts"
|
||||
"prisma:sync-benefit": "ts-node --transpile-only prisma/sync-benefit-to-price.ts",
|
||||
"prisma:merge-spu-dk000007-008": "ts-node --transpile-only prisma/merge-spu-dk000007-008.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/dysmsapi20170525": "^4.6.0",
|
||||
@@ -43,9 +44,11 @@
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"express": "^4.21.0",
|
||||
"ioredis": "^5.4.1",
|
||||
"ip2region": "^2.3.0",
|
||||
"pdfkit": "^0.19.1",
|
||||
"qrcode": "^1.5.4",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
@@ -57,6 +60,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"prisma": "^5.18.0",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* 方案 A:把 DK000007 / DK000008 合并为同一 SPU,保留原 SKU id 与 DK 码,订单改绑到对应 SKU。
|
||||
*
|
||||
* 用法:cd server/dukang-api && npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts
|
||||
* 预览:npx ts-node --transpile-only prisma/merge-spu-dk000007-008.ts --dry-run
|
||||
*
|
||||
* 幸存 SPU = DK000007 所在商品;默认规格 = 国标特级20。
|
||||
*/
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const CODES = ['DK000007', 'DK000008'] as const;
|
||||
const SURVIVOR_CODE = 'DK000007';
|
||||
const SPEC_ATTR_NAME = '规格';
|
||||
const SPU_NAME = '酒祖杜康(国标特级)';
|
||||
const SPEC_LABEL: Record<(typeof CODES)[number], string> = {
|
||||
DK000007: '国标特级20',
|
||||
DK000008: '国标特级30',
|
||||
};
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
function buildSpecKey(valueIds: bigint[]): string {
|
||||
if (!valueIds.length) return '';
|
||||
return [...valueIds]
|
||||
.map((id) => id.toString())
|
||||
.sort((a, b) => (BigInt(a) < BigInt(b) ? -1 : BigInt(a) > BigInt(b) ? 1 : 0))
|
||||
.join('_');
|
||||
}
|
||||
|
||||
async function ensureSkuImageColumn() {
|
||||
const rows = await prisma.$queryRaw<Array<{ cnt: bigint | number }>>`
|
||||
SELECT COUNT(*) AS cnt
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = 'common_product_sku'
|
||||
AND COLUMN_NAME = 'image_url'
|
||||
`;
|
||||
const cnt = Number(rows[0]?.cnt ?? 0);
|
||||
if (cnt > 0) return;
|
||||
await prisma.$executeRawUnsafe(
|
||||
'ALTER TABLE `common_product_sku` ADD COLUMN `image_url` VARCHAR(512) NULL AFTER `sort_order`',
|
||||
);
|
||||
console.log('added column common_product_sku.image_url');
|
||||
}
|
||||
|
||||
async function resolveProductByCode(skuCode: string) {
|
||||
const sku = await prisma.commonProductSku.findUnique({
|
||||
where: { skuCode },
|
||||
include: { product: true },
|
||||
});
|
||||
if (sku) return { product: sku.product, sku };
|
||||
const product = await prisma.commonProductItem.findFirst({
|
||||
where: { skuCode },
|
||||
});
|
||||
if (!product) {
|
||||
throw new Error(`找不到商品/SKU:${skuCode}`);
|
||||
}
|
||||
return { product, sku: null as null };
|
||||
}
|
||||
|
||||
async function coverUrlOf(productId: bigint, coverResourceId: bigint | null) {
|
||||
if (coverResourceId) {
|
||||
const res = await prisma.commonResource.findUnique({
|
||||
where: { id: coverResourceId },
|
||||
select: { url: true },
|
||||
});
|
||||
if (res?.url) return res.url;
|
||||
}
|
||||
const fallback = await prisma.commonResource.findFirst({
|
||||
where: { ownerType: 'PRODUCT', ownerId: productId, bizType: 'COVER', status: 'ACTIVE' },
|
||||
orderBy: { id: 'asc' },
|
||||
select: { url: true },
|
||||
});
|
||||
return fallback?.url ?? null;
|
||||
}
|
||||
|
||||
async function ensureSkuFromProduct(
|
||||
tx: PrismaClient,
|
||||
product: {
|
||||
id: bigint;
|
||||
skuCode: string;
|
||||
barcode69: string;
|
||||
spec: string;
|
||||
price: unknown;
|
||||
benefitAmount: unknown;
|
||||
status: 'DRAFT' | 'ON_SALE' | 'OFF_SALE';
|
||||
allowOnSitePickup: boolean;
|
||||
allowOnlinePurchase: boolean;
|
||||
allowCrossCityDelivery: boolean;
|
||||
},
|
||||
) {
|
||||
const existing =
|
||||
(await tx.commonProductSku.findUnique({ where: { skuCode: product.skuCode } })) ??
|
||||
(await tx.commonProductSku.findFirst({
|
||||
where: { productId: product.id, isDefault: true },
|
||||
}));
|
||||
if (existing) return existing;
|
||||
return tx.commonProductSku.create({
|
||||
data: {
|
||||
productId: product.id,
|
||||
skuCode: product.skuCode,
|
||||
barcode69: product.barcode69,
|
||||
specKey: '',
|
||||
specText: product.spec,
|
||||
price: product.price as never,
|
||||
benefitAmount: product.benefitAmount as never,
|
||||
status: product.status,
|
||||
allowOnSitePickup: product.allowOnSitePickup,
|
||||
allowOnlinePurchase: product.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: product.allowCrossCityDelivery,
|
||||
saleUnit: 'BOTTLE',
|
||||
bottlesPerUnit: 1,
|
||||
isDefault: true,
|
||||
sortOrder: 0,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await ensureSkuImageColumn();
|
||||
|
||||
const rows = [];
|
||||
for (const code of CODES) {
|
||||
const resolved = await resolveProductByCode(code);
|
||||
rows.push({ code, ...resolved });
|
||||
}
|
||||
|
||||
const survivorRow = rows.find((r) => r.code === SURVIVOR_CODE);
|
||||
if (!survivorRow) throw new Error(`缺少幸存码 ${SURVIVOR_CODE}`);
|
||||
const discardRows = rows.filter((r) => r.product.id !== survivorRow.product.id);
|
||||
const survivorId = survivorRow.product.id;
|
||||
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
dryRun,
|
||||
survivor: {
|
||||
productId: survivorId.toString(),
|
||||
name: survivorRow.product.name,
|
||||
skuCode: survivorRow.product.skuCode,
|
||||
},
|
||||
items: await Promise.all(
|
||||
rows.map(async (r) => {
|
||||
const orderCount = await prisma.order.count({ where: { productId: r.product.id } });
|
||||
const nullSku = await prisma.order.count({
|
||||
where: { productId: r.product.id, skuId: null },
|
||||
});
|
||||
return {
|
||||
code: r.code,
|
||||
productId: r.product.id.toString(),
|
||||
name: r.product.name,
|
||||
skuId: r.sku?.id.toString() ?? null,
|
||||
orders: orderCount,
|
||||
nullSkuOrders: nullSku,
|
||||
};
|
||||
}),
|
||||
),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
|
||||
if (discardRows.length === 0 && survivorRow.sku) {
|
||||
const sibling = await prisma.commonProductSku.findMany({
|
||||
where: { productId: survivorId, skuCode: { in: [...CODES] } },
|
||||
});
|
||||
if (sibling.length === CODES.length) {
|
||||
console.log('SKU 已在同一 SPU 上,继续核对订单 product_id / sku_id');
|
||||
}
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('[dry-run] skip writes');
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
const db = tx as unknown as PrismaClient;
|
||||
const skuByCode = new Map<string, { id: bigint }>();
|
||||
|
||||
for (const row of rows) {
|
||||
const sku = await ensureSkuFromProduct(db, row.product);
|
||||
skuByCode.set(row.code, sku);
|
||||
console.log(`ensured sku ${row.code} id=${sku.id} fromProduct=${row.product.id}`);
|
||||
}
|
||||
|
||||
let attr = await db.commonProductSpecAttr.findFirst({
|
||||
where: { productId: survivorId, name: SPEC_ATTR_NAME },
|
||||
});
|
||||
if (!attr) {
|
||||
attr = await db.commonProductSpecAttr.create({
|
||||
data: { productId: survivorId, name: SPEC_ATTR_NAME, sortOrder: 0 },
|
||||
});
|
||||
}
|
||||
|
||||
const valueIdByCode = new Map<string, bigint>();
|
||||
for (let i = 0; i < CODES.length; i++) {
|
||||
const code = CODES[i];
|
||||
const label = SPEC_LABEL[code];
|
||||
let value = await db.commonProductSpecValue.findFirst({
|
||||
where: { attrId: attr.id, name: label },
|
||||
});
|
||||
if (!value) {
|
||||
value = await db.commonProductSpecValue.create({
|
||||
data: { attrId: attr.id, name: label, sortOrder: i },
|
||||
});
|
||||
}
|
||||
valueIdByCode.set(code, value.id);
|
||||
}
|
||||
|
||||
for (let i = 0; i < CODES.length; i++) {
|
||||
const code = CODES[i];
|
||||
const sku = skuByCode.get(code)!;
|
||||
const valueId = valueIdByCode.get(code)!;
|
||||
const specKey = buildSpecKey([valueId]);
|
||||
const specText = SPEC_LABEL[code];
|
||||
const sourceProduct = rows.find((r) => r.code === code)!.product;
|
||||
const imageUrl = await coverUrlOf(sourceProduct.id, sourceProduct.coverResourceId);
|
||||
const isDefault = code === SURVIVOR_CODE;
|
||||
|
||||
await db.commonProductSkuSpec.deleteMany({ where: { skuId: sku.id } });
|
||||
await db.commonProductSku.update({
|
||||
where: { id: sku.id },
|
||||
data: {
|
||||
product: { connect: { id: survivorId } },
|
||||
specKey,
|
||||
specText,
|
||||
isDefault,
|
||||
sortOrder: i,
|
||||
imageUrl,
|
||||
status: sourceProduct.status,
|
||||
},
|
||||
});
|
||||
await db.commonProductSkuSpec.create({
|
||||
data: { skuId: sku.id, valueId },
|
||||
});
|
||||
|
||||
const orderRes = await db.order.updateMany({
|
||||
where: {
|
||||
OR: [{ skuId: sku.id }, { productId: sourceProduct.id, skuId: null }],
|
||||
},
|
||||
data: { productId: survivorId, skuId: sku.id },
|
||||
});
|
||||
console.log(
|
||||
`bound ${code} → product ${survivorId} sku ${sku.id} spec=${specText} orders=${orderRes.count}`,
|
||||
);
|
||||
}
|
||||
|
||||
const leftover = await db.order.findMany({
|
||||
where: { productId: { in: discardRows.map((r) => r.product.id) } },
|
||||
select: { id: true, orderNo: true, productId: true, skuId: true },
|
||||
});
|
||||
if (leftover.length) {
|
||||
throw new Error(
|
||||
`仍有订单绑在被合并 SPU 上:${leftover.map((o) => o.orderNo).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const discard of discardRows) {
|
||||
const phones = await db.commonProductVisibilityPhone.findMany({
|
||||
where: { productId: discard.product.id },
|
||||
});
|
||||
for (const p of phones) {
|
||||
await db.commonProductVisibilityPhone.upsert({
|
||||
where: { productId_phone: { productId: survivorId, phone: p.phone } },
|
||||
create: { productId: survivorId, phone: p.phone },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
await db.commonProductVisibilityPhone.deleteMany({
|
||||
where: { productId: discard.product.id },
|
||||
});
|
||||
await db.commonProductSkuSpec.deleteMany({
|
||||
where: { sku: { productId: discard.product.id } },
|
||||
});
|
||||
await db.commonProductSku.deleteMany({ where: { productId: discard.product.id } });
|
||||
await db.commonProductSpecAttr.deleteMany({ where: { productId: discard.product.id } });
|
||||
await db.commonResource.deleteMany({
|
||||
where: { ownerType: 'PRODUCT', ownerId: discard.product.id },
|
||||
});
|
||||
await db.commonProductItem.update({
|
||||
where: { id: discard.product.id },
|
||||
data: { status: 'OFF_SALE', coverResourceId: null },
|
||||
});
|
||||
await db.commonProductItem.delete({ where: { id: discard.product.id } });
|
||||
console.log(`deleted discard SPU ${discard.product.id} ${discard.code}`);
|
||||
}
|
||||
|
||||
const defaultSku = await db.commonProductSku.findUniqueOrThrow({
|
||||
where: { skuCode: SURVIVOR_CODE },
|
||||
});
|
||||
await db.commonProductItem.update({
|
||||
where: { id: survivorId },
|
||||
data: {
|
||||
name: SPU_NAME,
|
||||
skuCode: defaultSku.skuCode,
|
||||
barcode69: defaultSku.barcode69,
|
||||
spec: defaultSku.specText,
|
||||
price: defaultSku.price,
|
||||
benefitAmount: defaultSku.benefitAmount,
|
||||
allowOnSitePickup: defaultSku.allowOnSitePickup,
|
||||
allowOnlinePurchase: defaultSku.allowOnlinePurchase,
|
||||
allowCrossCityDelivery: defaultSku.allowCrossCityDelivery,
|
||||
status: 'ON_SALE',
|
||||
},
|
||||
});
|
||||
},
|
||||
{ timeout: 30000 },
|
||||
);
|
||||
|
||||
const skus = await prisma.commonProductSku.findMany({
|
||||
where: { skuCode: { in: [...CODES] } },
|
||||
orderBy: { sortOrder: 'asc' },
|
||||
});
|
||||
const orders = await prisma.order.groupBy({
|
||||
by: ['productId', 'skuId'],
|
||||
where: { sku: { skuCode: { in: [...CODES] } } },
|
||||
_count: true,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
done: true,
|
||||
skus: skus.map((s) => ({
|
||||
id: s.id.toString(),
|
||||
productId: s.productId.toString(),
|
||||
skuCode: s.skuCode,
|
||||
specText: s.specText,
|
||||
isDefault: s.isDefault,
|
||||
})),
|
||||
orders: orders.map((o) => ({
|
||||
productId: o.productId.toString(),
|
||||
skuId: o.skuId?.toString() ?? null,
|
||||
count: o._count,
|
||||
})),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
@@ -24,6 +24,7 @@ export const HqOperationAction = {
|
||||
ORDER_BATCH_DELETE: 'ORDER_BATCH_DELETE',
|
||||
ORDER_DELETE: 'ORDER_DELETE',
|
||||
ORDER_PROXY_CREATE: 'ORDER_PROXY_CREATE',
|
||||
ORDER_EXPORT: 'ORDER_EXPORT',
|
||||
STORE_CREATE: 'STORE_CREATE',
|
||||
STORE_UPDATE: 'STORE_UPDATE',
|
||||
STORE_STATUS: 'STORE_STATUS',
|
||||
@@ -147,6 +148,7 @@ export const HQ_OPERATION_ACTION_LABELS: Record<string, string> = {
|
||||
[HqOperationAction.ORDER_BATCH_DELETE]: '批量删除订单',
|
||||
[HqOperationAction.ORDER_DELETE]: '删除订单',
|
||||
[HqOperationAction.ORDER_PROXY_CREATE]: '总部代下单',
|
||||
[HqOperationAction.ORDER_EXPORT]: '导出订单',
|
||||
[HqOperationAction.STORE_CREATE]: '新增门店',
|
||||
[HqOperationAction.STORE_UPDATE]: '编辑门店',
|
||||
[HqOperationAction.STORE_STATUS]: '变更门店状态',
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { HqOperation } from '../../common/hq-operation/hq-operation.decorator';
|
||||
import { HqOperationAction } from '../../common/hq-operation/hq-operation.constants';
|
||||
import { AdminOrdersService } from './admin-orders.service';
|
||||
import { AdminShipOrderDto, BatchDeleteOrdersDto, HqLogisticsShipDto, UpdateOrderStatusDto } from './dto/admin-mutate.dto';
|
||||
import { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
|
||||
@Controller('admin/orders')
|
||||
@UseGuards(HqAuthGuard)
|
||||
@@ -26,6 +26,25 @@ export class AdminOrdersController {
|
||||
return this.ordersService.listBigScreen(limit);
|
||||
}
|
||||
|
||||
@Post('export/preview')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders')
|
||||
previewExport(@Body() dto: AdminOrdersExportDto) {
|
||||
return this.ordersService.previewExport(dto);
|
||||
}
|
||||
|
||||
@Post('export')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders')
|
||||
@HqOperation({
|
||||
action: HqOperationAction.ORDER_EXPORT,
|
||||
refType: 'ORDER',
|
||||
includeBody: true,
|
||||
})
|
||||
exportOrders(@Body() dto: AdminOrdersExportDto) {
|
||||
return this.ordersService.exportOrders(dto);
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@UseGuards(HqPermissionGuard)
|
||||
@RequireHqPermissions('orders_delete')
|
||||
|
||||
@@ -7,12 +7,35 @@ import { orderStatusLogWhere } from '../../common/event/event.helpers';
|
||||
import { mapOrderCompat, mapStatusLogCompat } from '../../common/compat/v31-compat';
|
||||
import { TradeService } from '../trade/trade.service';
|
||||
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
|
||||
import type { AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { AdminOrdersExportDto, AdminOrdersQueryDto } from './dto/admin-query.dto';
|
||||
import type { AdminShipOrderDto, HqLogisticsShipDto } from './dto/admin-mutate.dto';
|
||||
import type { XiaofeixiaCreateShipmentDto } from './dto/admin-courier.dto';
|
||||
import { FulfillmentService } from '../fulfillment/fulfillment.service';
|
||||
import { FulfillmentProviderService } from '../fulfillment/fulfillment-provider.service';
|
||||
import { AdminRedeemService } from './admin-redeem.service';
|
||||
import {
|
||||
buildExportFilename,
|
||||
buildOrdersPdf,
|
||||
buildOrdersXlsx,
|
||||
mapOrderToExportRow,
|
||||
} from './admin-order-export.util';
|
||||
|
||||
const ORDER_EXPORT_MAX = 5000;
|
||||
|
||||
type OrderFilterInput = Pick<
|
||||
AdminOrdersQueryDto,
|
||||
| 'orderNo'
|
||||
| 'status'
|
||||
| 'orderType'
|
||||
| 'userId'
|
||||
| 'cityId'
|
||||
| 'receiverPhone'
|
||||
| 'fulfillmentHold'
|
||||
| 'createdFrom'
|
||||
| 'createdTo'
|
||||
| 'excludeTest'
|
||||
| 'deliveryType'
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
export class AdminOrdersService {
|
||||
@@ -60,23 +83,7 @@ export class AdminOrdersService {
|
||||
async list(query: AdminOrdersQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const pageSize = query.pageSize ?? 20;
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
|
||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
|
||||
where.fulfillmentHold = true;
|
||||
}
|
||||
if (query.createdFrom || query.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
||||
if (query.createdTo) where.createdAt.lte = new Date(query.createdTo);
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
const where = this.buildOrderWhere(query);
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.order.findMany({
|
||||
@@ -89,6 +96,9 @@ export class AdminOrdersService {
|
||||
delivery: { select: { provider: true, trackingNo: true, providerOrderNo: true } },
|
||||
city: { select: { id: true, name: true, code: true } },
|
||||
fulfillmentWarehouse: { select: { id: true, name: true } },
|
||||
benefitCoupon: {
|
||||
select: { couponNo: true, totalAmount: true, usedAmount: true, balance: true, status: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
@@ -97,6 +107,114 @@ export class AdminOrdersService {
|
||||
return serializeBigInt({ items, total, page, pageSize });
|
||||
}
|
||||
|
||||
async previewExport(dto: AdminOrdersExportDto) {
|
||||
const count = await this.countExportOrders(dto);
|
||||
return { count, max: ORDER_EXPORT_MAX, exceeds: count > ORDER_EXPORT_MAX };
|
||||
}
|
||||
|
||||
async exportOrders(dto: AdminOrdersExportDto) {
|
||||
const count = await this.countExportOrders(dto);
|
||||
if (count > ORDER_EXPORT_MAX) {
|
||||
throw new BadRequestException(`超过 ${ORDER_EXPORT_MAX} 条,请缩小日期或筛选条件`);
|
||||
}
|
||||
if (count === 0) {
|
||||
throw new BadRequestException('没有可导出的订单');
|
||||
}
|
||||
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: this.buildExportWhere(dto),
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: ORDER_EXPORT_MAX,
|
||||
include: {
|
||||
city: { select: { name: true } },
|
||||
delivery: { select: { trackingNo: true } },
|
||||
benefitCoupon: {
|
||||
select: { totalAmount: true, usedAmount: true, balance: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const rows = orders.map((order) => mapOrderToExportRow(order));
|
||||
const buffer =
|
||||
dto.format === 'pdf' ? await buildOrdersPdf(rows) : await buildOrdersXlsx(rows);
|
||||
const filename = buildExportFilename(dto.format, rows.length);
|
||||
const mimeType =
|
||||
dto.format === 'pdf'
|
||||
? 'application/pdf'
|
||||
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
|
||||
return {
|
||||
filename,
|
||||
mimeType,
|
||||
contentBase64: buffer.toString('base64'),
|
||||
count: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
private buildOrderWhere(query: OrderFilterInput): Prisma.OrderWhereInput {
|
||||
const where: Prisma.OrderWhereInput = {};
|
||||
|
||||
if (query.orderNo) where.orderNo = { contains: query.orderNo };
|
||||
if (query.status) where.status = query.status as Prisma.EnumOrderStatusFilter['equals'];
|
||||
if (query.orderType) where.orderType = query.orderType as Prisma.EnumOrderTypeFilter['equals'];
|
||||
if (query.userId) where.userId = BigInt(query.userId);
|
||||
if (query.cityId) where.cityId = BigInt(query.cityId);
|
||||
if (query.receiverPhone) where.receiverPhone = { contains: query.receiverPhone };
|
||||
if (query.deliveryType) {
|
||||
where.deliveryType = query.deliveryType as Prisma.EnumDeliveryTypeFilter['equals'];
|
||||
}
|
||||
if (query.fulfillmentHold === true || query.fulfillmentHold === 'true') {
|
||||
where.fulfillmentHold = true;
|
||||
}
|
||||
if (query.createdFrom || query.createdTo) {
|
||||
where.createdAt = {};
|
||||
if (query.createdFrom) where.createdAt.gte = new Date(query.createdFrom);
|
||||
if (query.createdTo) where.createdAt.lte = this.endOfDay(query.createdTo);
|
||||
}
|
||||
if (query.excludeTest) where.isTest = false;
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
private buildExportWhere(dto: AdminOrdersExportDto): Prisma.OrderWhereInput {
|
||||
if (dto.scope === 'selected') {
|
||||
const ids = (dto.ids ?? []).map((id) => BigInt(id));
|
||||
if (!ids.length) {
|
||||
throw new BadRequestException('请先勾选要导出的订单');
|
||||
}
|
||||
return { id: { in: ids } };
|
||||
}
|
||||
|
||||
const normalized = this.normalizeExportFilter(dto);
|
||||
return this.buildOrderWhere(normalized);
|
||||
}
|
||||
|
||||
private async countExportOrders(dto: AdminOrdersExportDto): Promise<number> {
|
||||
return this.prisma.order.count({ where: this.buildExportWhere(dto) });
|
||||
}
|
||||
|
||||
private normalizeExportFilter(dto: AdminOrdersExportDto): OrderFilterInput {
|
||||
const createdFrom = dto.createdFrom;
|
||||
const createdTo = dto.createdTo;
|
||||
if (!createdFrom && !createdTo) {
|
||||
const from = new Date();
|
||||
from.setDate(from.getDate() - 30);
|
||||
from.setHours(0, 0, 0, 0);
|
||||
return {
|
||||
...dto,
|
||||
createdFrom: from.toISOString().slice(0, 10),
|
||||
createdTo: new Date().toISOString().slice(0, 10),
|
||||
};
|
||||
}
|
||||
return { ...dto, createdFrom, createdTo };
|
||||
}
|
||||
|
||||
private endOfDay(dateStr: string): Date {
|
||||
const end = new Date(dateStr);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
return end;
|
||||
}
|
||||
|
||||
async getOrderTrack(id: bigint) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { id },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||
|
||||
function toOptionalBoolean(value: unknown): boolean | undefined {
|
||||
if (value === undefined || value === null || value === '') return undefined;
|
||||
@@ -93,6 +93,66 @@ export class AdminOrdersQueryDto extends PaginationQueryDto {
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
/** HQ 订单导出(筛选 + 勾选范围) */
|
||||
export class AdminOrdersExportDto {
|
||||
@IsIn(['filter', 'selected'])
|
||||
scope: 'filter' | 'selected';
|
||||
|
||||
@IsIn(['xlsx', 'pdf'])
|
||||
format: 'xlsx' | 'pdf';
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
ids?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
orderNo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['NORMAL', 'PROXY', 'RESHIPMENT'])
|
||||
orderType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
receiverPhone?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
fulfillmentHold?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
createdTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => toOptionalBoolean(value))
|
||||
@IsBoolean()
|
||||
excludeTest?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['LOCAL', 'CROSS_CITY', 'ON_SITE_PICKUP'])
|
||||
deliveryType?: string;
|
||||
}
|
||||
|
||||
/** 概览页用户/订单 ECharts 聚合筛选 */
|
||||
|
||||
Reference in New Issue
Block a user