From ac3bb1f686831a9254c4eb958e55e50001f1d03e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 12:38:40 +0800 Subject: [PATCH] =?UTF-8?q?fix(fulfillment):=20=E5=86=BB=E7=BB=93=E5=B7=B2?= =?UTF-8?q?=E5=88=86=E9=85=8D=E8=AE=A2=E5=8D=95=E7=9A=84=E5=86=85=E9=83=A8?= =?UTF-8?q?SKU=E4=BA=8B=E5=AE=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/repositories/order-item-repo.test.ts | 57 +++++++++++++++- .../src/repositories/order-item-repo.ts | 66 ++++++++++++------- .../fulfillment-routing.integration.test.ts | 10 +++ 3 files changed, 109 insertions(+), 24 deletions(-) diff --git a/apps/backend/src/repositories/order-item-repo.test.ts b/apps/backend/src/repositories/order-item-repo.test.ts index bb19fd31..34f31e42 100644 --- a/apps/backend/src/repositories/order-item-repo.test.ts +++ b/apps/backend/src/repositories/order-item-repo.test.ts @@ -1,7 +1,11 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { preserveAssignedOrderItemState, resolveOrderItemSyncPlan } from './order-item-repo.js' +import { + preserveAssignedOrderItemState, + replaceOrderItemsWithExecutor, + resolveOrderItemSyncPlan, +} from './order-item-repo.js' test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => { const existingItems = [ @@ -136,6 +140,8 @@ test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => { const stabilized = preserveAssignedOrderItemState(existing as never, incoming) + assert.equal(stabilized.skuCode, 'lucky_coin_x2') + assert.equal(stabilized.skuName, '幸运币2个') assert.deepEqual(stabilized.specJson, { skuNick: '幸运币2个', sourceSpec: 'new' }) assert.deepEqual(stabilized.itemSnapshotJson, { externalSkuName: 'new title', @@ -147,3 +153,52 @@ test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => { affiliateDash: { sku: 'lucky_coin_x2' }, }) }) + +test('已分配订单重推写库时不会用平台新 SKU 覆盖冻结值', async () => { + const existing = { + id: '41', + order_id: 7, + fulfillment_status: 'assigned', + fulfillment_channel: 'affiliate_dash', + sku_code: 'lucky_coin_x2', + sku_name: '幸运币2个', + quantity: 1, + spec_json: { skuNick: '幸运币2个' }, + item_snapshot_json: { + internalSku: '幸运币2个', + internalSkuChannel: 'affiliate_dash', + isConfigured: true, + fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' }, + affiliateDash: { sku: 'lucky_coin_x2' }, + }, + } + const incoming = { + skuCode: 'platform-new', + skuName: '平台新名称', + quantity: 1, + specJson: JSON.stringify({ skuNick: '平台新名称' }), + itemSnapshotJson: JSON.stringify({ externalSkuName: '平台新名称' }), + createdAt: '2026-11-03T00:00:00.000Z', + updatedAt: '2026-11-03T00:00:00.000Z', + } + const updateParams: unknown[][] = [] + let selectCount = 0 + + const executor = async (sql: string, params: unknown[] = []) => { + if (sql.includes('SELECT *') && sql.includes('FROM order_items')) { + selectCount += 1 + return { rows: selectCount === 1 ? [existing] : [] } + } + if (sql.includes('UPDATE order_items')) { + updateParams.push(params) + return { rows: [], rowCount: 1 } + } + throw new Error(`unexpected SQL: ${sql}`) + } + + await replaceOrderItemsWithExecutor(executor as never, 7, [incoming]) + + assert.equal(updateParams.length, 1) + assert.equal(updateParams[0]?.[0], 'lucky_coin_x2') + assert.equal(updateParams[0]?.[1], '幸运币2个') +}) diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index 3b4b6ddc..3d218507 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -91,7 +91,7 @@ export async function replaceOrderItemsWithExecutor( for (const update of plan.updates) { const effectiveItem = preserveAssignedOrderItemState( - existingById.get(update.orderItemId), + existingById.get(Number(update.orderItemId)), update.item, ) await executor( @@ -107,8 +107,8 @@ export async function replaceOrderItemsWithExecutor( WHERE id = $7 `, [ - update.item.skuCode, - update.item.skuName, + effectiveItem.skuCode, + effectiveItem.skuName, update.item.quantity, safeJsonStringify(effectiveItem.specJson || '{}'), safeJsonStringify(effectiveItem.itemSnapshotJson || effectiveItem.specJson || '{}'), @@ -197,6 +197,8 @@ export function preserveAssignedOrderItemState( return { ...incoming, + skuCode: existing.sku_code, + skuName: existing.sku_name, specJson: { ...incomingSpec, ...(frozenInternalSku ? { skuNick: frozenInternalSku } : {}), @@ -342,25 +344,43 @@ export async function updateOrderItemSourceSnapshot( orderItemId: number | string, input: { skuName: string; itemSnapshotJson: string; updatedAt: string; specJson?: string }, ): Promise { - const specClause = input.specJson ? ', spec_json = $5::jsonb' : '' - const params: unknown[] = [ - input.skuName, - input.itemSnapshotJson, - input.updatedAt, - Number(orderItemId), - ] - if (input.specJson) { - params.push(input.specJson) - } + return withTransaction(async (client) => { + const selected = await client.query( + `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + [Number(orderItemId)], + ) + const existing = selected.rows[0] + if (!existing) return null - const result = await query<{ id: number }>( - ` - UPDATE order_items - SET sku_name = $1, item_snapshot_json = $2::jsonb, updated_at = $3 ${specClause} - WHERE id = $4 - RETURNING id - `, - params, - ) - return result.rows[0] ? getOrderItemById(orderItemId) : null + const effectiveItem = preserveAssignedOrderItemState(existing, { + skuCode: existing.sku_code, + skuName: input.skuName, + quantity: existing.quantity, + specJson: input.specJson || existing.spec_json || '{}', + itemSnapshotJson: input.itemSnapshotJson, + createdAt: String(existing.created_at || input.updatedAt), + updatedAt: input.updatedAt, + }) + const specClause = input.specJson ? ', spec_json = $5::jsonb' : '' + const params: unknown[] = [ + effectiveItem.skuName, + effectiveItem.itemSnapshotJson, + effectiveItem.updatedAt, + Number(orderItemId), + ] + if (input.specJson) { + params.push(safeJsonStringify(effectiveItem.specJson || '{}')) + } + + const result = await client.query( + ` + UPDATE order_items + SET sku_name = $1, item_snapshot_json = $2::jsonb, updated_at = $3 ${specClause} + WHERE id = $4 + RETURNING * + `, + params, + ) + return result.rows[0] || null + }) } diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index 67368388..7edd1284 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -35,6 +35,7 @@ test( resolveInternalSkuMapping, upsertInternalSkuMapping, } = await import('./internal-sku-mapping-service.js') + const { updateOrderItemSourceSnapshot } = await import('../../repositories/order-item-repo.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const shopId = `it-routing-${suffix}` const manualSku = `集成测试人工-${suffix}` @@ -167,6 +168,15 @@ test( assert.equal(manualItem.rows[0]?.fulfillment_channel, 'manual') assert.ok(Number(manualItem.rows[0]?.fulfillment_config_id) > 0) + const sourceSyncedItem = await updateOrderItemSourceSnapshot(manualItemId, { + skuName: `平台覆盖-${suffix}`, + specJson: JSON.stringify({ skuNick: `平台覆盖-${suffix}` }), + itemSnapshotJson: JSON.stringify({ externalSkuName: `平台覆盖-${suffix}` }), + updatedAt: new Date().toISOString(), + }) + assert.equal(sourceSyncedItem?.sku_name, manualSku) + assert.equal(sourceSyncedItem?.spec_json?.skuNick, manualSku) + const switched = await switchChannelForOrderItems({ orderItemIds: [manualItemId], channel: 'third_party_voucher',