From 8e996e01fb1c55dac4fc058291644d7dfd65b163 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 15 Sep 2026 12:05:02 +0800 Subject: [PATCH] =?UTF-8?q?fix(pdd):=20=E5=81=9C=E6=AD=A2=E6=97=A7?= =?UTF-8?q?=E5=8D=95=E5=88=B7=E5=8C=B9=E9=85=8D=E6=97=A5=E5=BF=97=EF=BC=8C?= =?UTF-8?q?=E5=B9=B6=E4=BF=AE=E5=A4=8D=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8?= =?UTF-8?q?=E5=95=86=E5=93=81=E6=98=BE=E7=A4=BA=E4=B8=BA=200?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同步时已有工单/匹配结果未变不再写日志;列表按 Number(order_id) 挂 items,并补齐 PDD goods_name 回退与嵌套商品解析。 Co-authored-by: Cursor --- .../worker-platform/product-match-repo.ts | 21 +++++ .../admin/admin-order-read-helpers.test.ts | 36 ++++++++ .../src/services/admin/admin-read-service.ts | 26 +++--- .../admin/admin-read-shared-helpers.ts | 30 ++++++- .../platforms/pdd/pdd-order-mapper.test.ts | 28 ++++++ .../platforms/pdd/pdd-order-mapper.ts | 85 +++++++++++++++---- .../sync-worker-orders-service.ts | 39 ++++++--- 7 files changed, 222 insertions(+), 43 deletions(-) diff --git a/apps/backend/src/repositories/worker-platform/product-match-repo.ts b/apps/backend/src/repositories/worker-platform/product-match-repo.ts index 97f76b9f..2f69eda3 100644 --- a/apps/backend/src/repositories/worker-platform/product-match-repo.ts +++ b/apps/backend/src/repositories/worker-platform/product-match-repo.ts @@ -231,6 +231,27 @@ export async function createWorkProductMatchLog(input: { return result.rows[0] || null } +/** 同一订单项最近一条匹配日志(用于同步去重,避免旧单反复写入)。 */ +export async function getLatestWorkProductMatchLogByOrderItemId( + orderItemId: number | string, +): Promise { + const id = Number(orderItemId) + if (!Number.isInteger(id) || id <= 0) { + return null + } + const result = await query( + ` + SELECT * + FROM work_product_match_logs + WHERE order_item_id = $1 + ORDER BY id DESC + LIMIT 1 + `, + [id], + ) + return result.rows[0] || null +} + /** 匹配日志分页查询:表本身全量保留,可按结果状态、关键词(商品/SKU/店铺)与日期范围筛选。 */ export async function listWorkProductMatchLogs({ page = 1, diff --git a/apps/backend/src/services/admin/admin-order-read-helpers.test.ts b/apps/backend/src/services/admin/admin-order-read-helpers.test.ts index a9c2c511..c4227628 100644 --- a/apps/backend/src/services/admin/admin-order-read-helpers.test.ts +++ b/apps/backend/src/services/admin/admin-order-read-helpers.test.ts @@ -101,6 +101,42 @@ test('mapAdminOrderListItem falls back to order raw_payload_json when orderItems assert.equal(mapped.itemSummary, '和平精英地铁逃生装备满钻低价苏尔南怒嚎暗潮手枪 (安卓血宴1钻)') }) +test('mapAdminOrderListItem falls back to PDD goods_name/out_sku_sn when orderItems empty', () => { + const order: OrderListRow = { + id: 3, + provider: 'pdd', + platform: 'pdd', + shop_id: 'xiangrikui', + shop_name: '向日葵', + platform_order_id: '260915-72034516082531', + order_status: 'paid', + pay_status: 'paid', + buyer_id: '', + buyer_name: '', + receiver_contact: '', + total_amount: 34100, + currency: 'CNY', + raw_payload_json: { + order_sn: '260915-72034516082531', + goods_name: '和平精英地铁逃生苏尔南暗潮怒嚎血宴', + spec: '安卓怒嚎3钻', + out_sku_sn: '安卓怒嚎3钻', + goods_number: 1, + order_amount: 34100, + }, + paid_at: '2026-09-15T02:48:00.000Z', + created_at: '2026-09-15T02:48:00.000Z', + updated_at: '2026-09-15T02:48:00.000Z', + } + + const mapped = mapAdminOrderListItem(order, { orderItems: [], tasks: [] }) + + assert.equal(mapped.itemTitle, '和平精英地铁逃生苏尔南暗潮怒嚎血宴') + assert.equal(mapped.skuNick, '安卓怒嚎3钻') + assert.equal(mapped.itemCount, 1) + assert.equal(mapped.totalQuantity, 1) +}) + test('summarizeOrderItems formats multiple items cleanly', () => { const items: OrderItemRow[] = [ { diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 55e4fd59..cd77579d 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -93,7 +93,7 @@ export async function getAdminOrders( dateTo: normalizeDateQuery(query.dateTo, true), }) - const orderIds = items.map((item) => item.id) + const orderIds = items.map((item) => Number(item.id)).filter((id) => id > 0) const oids = items.map((item) => item.platform_order_id).filter(Boolean) const [orderItems, tasks, vouchers] = await Promise.all([ listOrderItemsByOrderIds(orderIds), @@ -105,23 +105,29 @@ export async function getAdminOrders( const vouchersByOid = groupRowsByOid(vouchers) return { - items: items.map((item) => - mapAdminOrderListItem(item, { - orderItems: orderItemsByOrderId.get(item.id) || [], - tasks: tasksByOrderId.get(item.id) || [], + items: items.map((item) => { + const orderId = Number(item.id) + return mapAdminOrderListItem(item, { + orderItems: orderItemsByOrderId.get(orderId) || [], + tasks: tasksByOrderId.get(orderId) || [], vouchers: vouchersByOid.get(item.platform_order_id) || [], - }), - ), + }) + }), pagination: { page, pageSize, total }, } } -function groupRowsByOrderId(rows: T[]): Map { +function groupRowsByOrderId(rows: T[]): Map { const grouped = new Map() for (const row of rows) { - const group = grouped.get(row.order_id) || [] + // pg BIGINT 常以 string 返回;统一 Number 键,避免列表挂不上 order_items →「商品 0」。 + const orderId = Number(row.order_id) + if (!Number.isFinite(orderId) || orderId <= 0) { + continue + } + const group = grouped.get(orderId) || [] group.push(row) - grouped.set(row.order_id, group) + grouped.set(orderId, group) } return grouped } diff --git a/apps/backend/src/services/admin/admin-read-shared-helpers.ts b/apps/backend/src/services/admin/admin-read-shared-helpers.ts index 6e1c942b..4380466d 100644 --- a/apps/backend/src/services/admin/admin-read-shared-helpers.ts +++ b/apps/backend/src/services/admin/admin-read-shared-helpers.ts @@ -462,6 +462,8 @@ export function resolveOrderItemTitle( spec.goods_name, spec.goodsName, spec.productName, + snapshot.goods_name, + snapshot.goodsName, snapshot.productName, ]) @@ -482,6 +484,13 @@ export function resolveOrderItemTitle( orderPayload.productName, body.title, orderPayload.title, + // 拼多多 MMS 字段 + body.goods_name, + orderPayload.goods_name, + body.goodsName, + orderPayload.goodsName, + body.spec, + orderPayload.spec, ]) if (fromOrder) { return fromOrder @@ -533,6 +542,12 @@ export function resolveOrderItemSkuNick( snapshot.externalSkuName, snapshot.externalSkuNameNormalized, snapshot.skuNick, + spec.outSkuSn, + spec.out_sku_sn, + snapshot.outSkuSn, + snapshot.out_sku_sn, + spec.spec, + snapshot.spec, spec.skuName, ]) @@ -552,7 +567,17 @@ export function resolveOrderItemSkuNick( orderPayload.body && typeof orderPayload.body === 'object' ? orderPayload.body : orderPayload, ) const ext = parseExtRecord(body.ext || orderPayload.ext) - const fromOrder = pickFirstNonEmpty([ext.skuNick, body.skuNick, orderPayload.skuNick]) + const fromOrder = pickFirstNonEmpty([ + ext.skuNick, + body.skuNick, + orderPayload.skuNick, + body.out_sku_sn, + orderPayload.out_sku_sn, + body.outSkuSn, + orderPayload.outSkuSn, + body.spec, + orderPayload.spec, + ]) if (fromOrder) { return fromOrder } @@ -581,8 +606,9 @@ export function resolveOrderItemDeliveryMode( tasks: TaskLike[] | null | undefined, orderItemId: number | string, ): string { + const normalizedOrderItemId = Number(orderItemId) const task = (Array.isArray(tasks) ? tasks : []).find( - (item) => item.order_item_id === orderItemId, + (item) => Number(item.order_item_id) === normalizedOrderItemId, ) if (!task) { diff --git a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts index d8cf5216..be2a510a 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.test.ts @@ -99,3 +99,31 @@ test('mapPddOrderToSourceEvent prioritizes out_sku_sn as primary matching field' assert.equal((item.spec as Record).goodsName, '和平精英苏尔南手枪皮肤') assert.equal((item.spec as Record).spec, '暗潮1钻') }) + +test('mapPddOrderToSourceEvent reads nested order_goods when top-level goods fields missing', () => { + const nestedOnly: PddRawOrderItem = { + order_sn: '260915-111', + order_status: 2, + order_status_str: '已发货', + order_amount: 9900, + goods_id: '', + goods_name: '', + order_goods: [ + { + goods_id: 555, + goods_name: '套装-浪漫天命', + spec: '默认', + goods_number: 2, + out_sku_sn: 'suit_romantic', + }, + ], + } as PddRawOrderItem + + const event = mapPddOrderToSourceEvent(nestedOnly, { shopId: 's1', shopName: '店1' }) + const item = event.items[0]! + assert.equal(item.skuCode, 'suit_romantic') + assert.equal(item.skuName, 'suit_romantic') + assert.equal(item.quantity, 2) + assert.equal((item.spec as Record).goodsName, '套装-浪漫天命') + assert.equal((item.spec as Record).goodsId, '555') +}) diff --git a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts index aaedc8c9..45aaa6aa 100644 --- a/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts +++ b/apps/backend/src/services/platforms/pdd/pdd-order-mapper.ts @@ -38,6 +38,13 @@ export interface MapPddOrderOptions { shopName: string } +type PddGoodsLine = { + goodsId: string + goodsName: string + spec: string + quantity: number +} + /** * Extract external SKU code (out_sku_sn) from raw PDD order payload. * 从拼多多原始报文中提取外部商家编码 out_sku_sn(作为派发工单及商品匹配的关键字段)。 @@ -53,28 +60,71 @@ export function extractPddOutSkuSn(rawOrder: PddRawOrderItem): string { return direct.trim() } + for (const entry of listPddNestedGoodsEntries(rawOrder)) { + const nestedSn = + entry?.out_sku_sn || + entry?.outer_id || + entry?.out_goods_sn || + entry?.outSkuSn || + entry?.outerId + if (typeof nestedSn === 'string' && nestedSn.trim()) { + return nestedSn.trim() + } + } + return '' +} + +/** + * 从顶层或嵌套商品行提取 goods_id / goods_name / spec / quantity。 + * 部分 MMS 报文商品字段只在 order_goods / item_list 内。 + */ +export function extractPddGoodsLine(rawOrder: PddRawOrderItem): PddGoodsLine { + const top = { + goodsId: String(rawOrder.goods_id || '').trim(), + goodsName: String(rawOrder.goods_name || '').trim(), + spec: String(rawOrder.spec || '').trim(), + quantity: Math.max(1, Number(rawOrder.goods_number || 1) || 1), + } + if (top.goodsId || top.goodsName || top.spec) { + return top + } + + for (const entry of listPddNestedGoodsEntries(rawOrder)) { + const goodsId = String(entry?.goods_id || entry?.goodsId || entry?.sku_id || '').trim() + const goodsName = String(entry?.goods_name || entry?.goodsName || entry?.name || '').trim() + const spec = String(entry?.spec || entry?.sku_spec || entry?.skuSpec || '').trim() + const quantity = Math.max( + 1, + Number(entry?.goods_number || entry?.goods_count || entry?.quantity || 1) || 1, + ) + if (goodsId || goodsName || spec) { + return { goodsId, goodsName, spec, quantity } + } + } + + return top +} + +function listPddNestedGoodsEntries(rawOrder: PddRawOrderItem): Array> { const nestedCandidates = [ (rawOrder as any).order_items, (rawOrder as any).order_goods, (rawOrder as any).items, (rawOrder as any).order_item_list, + (rawOrder as any).item_list, ] + const entries: Array> = [] for (const list of nestedCandidates) { - if (Array.isArray(list)) { - for (const entry of list) { - const nestedSn = - entry?.out_sku_sn || - entry?.outer_id || - entry?.out_goods_sn || - entry?.outSkuSn || - entry?.outerId - if (typeof nestedSn === 'string' && nestedSn.trim()) { - return nestedSn.trim() - } + if (!Array.isArray(list)) { + continue + } + for (const entry of list) { + if (entry && typeof entry === 'object' && !Array.isArray(entry)) { + entries.push(entry as Record) } } } - return '' + return entries } /** @@ -114,11 +164,12 @@ export function mapPddOrderToSourceEvent( const timestampSec = rawOrder.confirm_time || rawOrder.order_time || Math.floor(Date.now() / 1000) const paidAt = new Date(timestampSec * 1000).toISOString() - // Build items array / 构建订单商品明细 - const goodsIdStr = String(rawOrder.goods_id || '') - const goodsName = String(rawOrder.goods_name || '').trim() - const spec = String(rawOrder.spec || '').trim() - const quantity = Math.max(1, Number(rawOrder.goods_number || 1)) + // Build items array / 构建订单商品明细(顶层缺失时回退嵌套商品行) + const goodsLine = extractPddGoodsLine(rawOrder) + const goodsIdStr = goodsLine.goodsId + const goodsName = goodsLine.goodsName + const spec = goodsLine.spec + const quantity = goodsLine.quantity // 提取商家外部编码 out_sku_sn(如 "安卓血宴3钻"),作为核心匹配字段 const outSkuSn = extractPddOutSkuSn(rawOrder) diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index fb3f47c6..91ad0985 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -9,6 +9,7 @@ import { createWorkProductMatchLog, createWorkOrder, createWorkOrderEvent, + getLatestWorkProductMatchLogByOrderItemId, getWorkOrderByOrderItemId, listWorkOrdersByPlatformOrderId, listWorkProductRuleMappings, @@ -68,8 +69,30 @@ export async function syncWorkerOrdersForSourceOrder( const skuNick = matchContext.skuNick || String(outSkuSn || snapshot.spec || item.sku_name || '').trim() - // Record product match diagnostics for all platform orders (including PDD) - if (sellerId || relItemId || itemTitle) { + // 已有工单:跳过匹配日志,避免 PDD 定时同步对旧单反复刷「命中」日志。 + const existingWorkOrder = await getWorkOrderByOrderItemId(item.id) + if (existingWorkOrder) { + skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' }) + decisions.push( + buildWorkerConsumerDecision(item, { + result: 'existing', + reason: 'already_exists', + ruleId: match.rule?.id, + ruleKey: match.rule?.rule_key, + }), + ) + continue + } + + const latestLog = await getLatestWorkProductMatchLogByOrderItemId(item.id) + const sameMatchOutcome = + latestLog && + String(latestLog.match_status || '') === match.reason && + Number(latestLog.rule_id || 0) === Number(match.rule?.id || 0) && + Number(latestLog.mapping_id || 0) === Number(match.mappingId || 0) + + // 匹配结果未变时不重复写诊断日志(旧单未建工单也会被定时同步反复扫到)。 + if (!sameMatchOutcome && (sellerId || relItemId || itemTitle)) { await createWorkProductMatchLog({ orderId: Number(order.id), orderItemId: Number(item.id), @@ -122,18 +145,6 @@ export async function syncWorkerOrdersForSourceOrder( ) continue } - if (await getWorkOrderByOrderItemId(item.id)) { - skipped.push({ orderItemId: Number(item.id), reason: 'already_exists' }) - decisions.push( - buildWorkerConsumerDecision(item, { - result: 'existing', - reason: 'already_exists', - ruleId: rule.id, - ruleKey: rule.rule_key, - }), - ) - continue - } // 订单号跨订单唯一:同一订单的多个 SKU 件允许共享订单号(按件建单是既有设计), // 但订单号已挂在其他订单下时跳过,避免自动同步产生撞号工单。 const samePlatformOrders = await listWorkOrdersByPlatformOrderId(order.platform_order_id)