fix(fulfillment): 冻结已分配订单的内部SKU事实

This commit is contained in:
yml2213
2026-11-03 12:38:40 +08:00
parent e6763882f5
commit ac3bb1f686
3 changed files with 109 additions and 24 deletions
@@ -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个')
})
@@ -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<OrderItemRow | null> {
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<OrderItemRow>(
`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<OrderItemRow>(
`
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
})
}
@@ -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',