重构:硬切履约状态唯一真源

This commit is contained in:
yml2213
2026-11-08 19:44:27 +08:00
parent 6bff1971dd
commit bae9a5ae5e
54 changed files with 2096 additions and 437 deletions
@@ -55,7 +55,7 @@ export async function runAffiliateFallback(
cfg.id AS config_id,
cfg.canonical_product_id AS config_product_id,
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date
FROM order_items oi
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
LEFT JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku(
@@ -44,7 +44,7 @@ export async function loadActiveCarrierEvidence(
AND ft.executor_key = 'manual_dispatch'
AND ft.task_status <> ALL($2::text[])
) AS has_manual_task
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.id = ANY($1::bigint[])
`,
[ids, INACTIVE_CARRIER_TASK_STATUSES],
@@ -105,7 +105,7 @@ async function loadCandidateItems(input: {
oi.order_id,
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku,
capability.channels
FROM order_items oi
FROM v_order_item_current oi
JOIN LATERAL (
SELECT COALESCE(
jsonb_agg(
@@ -296,9 +296,10 @@ export async function reconcilePendingCarriers(
for (const [orderId, candidates] of byOrder) {
const order = await getOrderById(orderId)
if (!order) continue
const itemRows = await query<OrderItemRow>(`SELECT * FROM order_items WHERE order_id = $1`, [
orderId,
])
const itemRows = await query<OrderItemRow>(
`SELECT * FROM v_order_item_current WHERE order_id = $1`,
[orderId],
)
const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item]))
for (const candidate of candidates) {
@@ -424,7 +425,7 @@ export async function reconcilePendingCarriers(
const remainingResult = await query<{ count: number }>(
`
SELECT count(*)::int AS count
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.fulfillment_status IN ('pending', 'assigned')
AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order')
AND NOT EXISTS (
@@ -50,8 +50,8 @@ import {
/**
* 管道 A:渠道判定。
*
* 只写 `fulfillment_channel / fulfillment_status / fulfillment_source /
* fulfillment_decided_at`,绝不写商品、成本、事实,也绝不改旧 channel。
* 只写 `fulfillments.channel / status / source / decided_at`,
* 绝不写商品、成本、事实,也绝不改冻结的订单项旧列。
*
* 判定顺序:
* 1. 运营锁定 → 跳过
@@ -335,7 +335,7 @@ export async function assignPendingOrderItemChannels(
WHERE task.order_item_id = oi.id AND task.executor_key = 'manual_dispatch'
AND ${ACTIVE_CARRIER_TASK_SQL}
) AS has_manual_task
FROM order_items oi
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.fulfillment_status = 'pending'
${options.includeLocked ? '' : 'AND oi.fulfillment_locked = FALSE'}
@@ -493,7 +493,9 @@ export async function assignOrderItemsAfterCarrierReady(input: {
}
const items = (
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
await query<OrderItemRow>(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [
ids,
])
).rows
const orderIds = [...new Set(items.map((item) => Number(item.order_id)))]
const orders = new Map(
@@ -712,9 +714,13 @@ async function syncAssignedChannelSnapshots(
|| jsonb_build_object('channel', $3)
),
updated_at = NOW()
WHERE id = ANY($1::bigint[])
AND fulfillment_status = 'assigned'
AND fulfillment_channel = $3`,
WHERE id IN (
SELECT id
FROM v_order_item_current
WHERE id = ANY($1::bigint[])
AND fulfillment_status = 'assigned'
AND fulfillment_channel = $3
)`,
[ids, JSON.stringify(decision), channel],
)
}
@@ -759,7 +765,7 @@ export async function switchChannelForOrderItems(input: {
const skuRows = await query<{ internal_sku: string }>(
`SELECT DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.id = ANY($1::bigint[])`,
[ids],
)
@@ -783,7 +789,9 @@ export async function switchChannelForOrderItems(input: {
}
const items = (
await query<OrderItemRow>(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids])
await query<OrderItemRow>(`SELECT * FROM v_order_item_current WHERE id = ANY($1::bigint[])`, [
ids,
])
).rows
const orderIds = [...new Set(items.map((item) => Number(item.order_id)))]
const orders = new Map(
@@ -839,7 +847,10 @@ export async function switchChannelForOrderItems(input: {
const now = nowIso()
await withTransaction(async (client) => {
const locked = await client.query<OrderItemRow>(
`SELECT * FROM order_items WHERE id = $1 FOR UPDATE`,
`SELECT status AS fulfillment_status
FROM fulfillments
WHERE order_item_id = $1
FOR UPDATE`,
[item.id],
)
if (!locked.rows[0] || locked.rows[0].fulfillment_status === 'archived') {
@@ -983,7 +994,7 @@ export async function assignChannelToOrderItems(input: {
}
const rows = await query<{ internal_sku: string }>(
`SELECT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.id = ANY($1::bigint[])`,
[ids],
)
@@ -1453,7 +1464,7 @@ export async function retryPendingCarrierGroup(input: {
oi.id AS order_item_id,
cfg.id AS config_id,
cfg.canonical_product_id
FROM order_items oi
FROM v_order_item_current oi
JOIN manual_fulfillment_configs cfg
ON canonicalize_internal_sku(cfg.sku_key) =
resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)
@@ -1489,7 +1500,7 @@ export async function retryPendingCarrierGroup(input: {
`SELECT
count(*) FILTER (WHERE fulfillment_status = 'assigned')::int AS assigned,
count(*) FILTER (WHERE fulfillment_status = 'pending')::int AS remaining
FROM order_items
FROM v_order_item_current
WHERE id = ANY($1::bigint[])`,
[ids],
)
@@ -1611,7 +1622,7 @@ export async function deletePendingChannelGroup(input: {
const result = await withTransaction(async (client) => {
const itemParams: unknown[] = []
const itemWhere = ["oi.fulfillment_status = 'pending'"]
const itemWhere = ["fulfillment.status = 'pending'"]
if (scope.type === 'internal_sku') {
itemParams.push(scope.internalSku)
itemWhere.push(`${INTERNAL_SKU_SQL} = $${itemParams.length}`)
@@ -1637,10 +1648,11 @@ export async function deletePendingChannelGroup(input: {
(COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text
AS basis_date
FROM order_items oi
JOIN fulfillments fulfillment ON fulfillment.order_item_id = oi.id
JOIN orders o ON o.id = oi.order_id
WHERE ${itemWhere.join(' AND ')}
ORDER BY oi.id
FOR UPDATE OF oi
FOR UPDATE OF fulfillment
`,
itemParams,
)
@@ -1783,7 +1795,12 @@ export async function assignPendingChannelGroup(input: {
AND o.provider = $1
AND o.shop_id = $2
AND oi.sku_code = $3
AND oi.fulfillment_status = 'pending'
AND EXISTS (
SELECT 1
FROM fulfillments fulfillment
WHERE fulfillment.order_item_id = oi.id
AND fulfillment.status = 'pending'
)
${issueCategoryFilter}
RETURNING oi.id
`,
@@ -1943,7 +1960,7 @@ export async function assignChannelBySku(input: {
`
SELECT oi.id, ${INTERNAL_SKU_SQL} AS internal_sku
FROM orders o
JOIN order_items oi ON oi.order_id = o.id
JOIN v_order_item_current oi ON oi.order_id = o.id
WHERE o.id = oi.order_id
AND o.provider = $1
AND o.shop_id = $2
@@ -57,7 +57,7 @@ test('创建新履约尝试时自动退休旧当前尝试', () => {
assert.ok(taskRepo.includes('MAX(attempt_no)'))
assert.ok(taskRepo.includes('SET is_current = FALSE'))
assert.ok(taskRepo.includes('$26, TRUE'))
assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE'))
assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE'))
})
test('当前尝试必须是同单元最新任务,统一视图不能读到已退休尝试', () => {
@@ -206,7 +206,7 @@ test(
`UPDATE orders
SET created_at = '2019-01-01T00:00:00+08:00',
updated_at = '2019-01-01T00:00:00+08:00'
WHERE id = (SELECT order_id FROM order_items WHERE id = $1)`,
WHERE id = (SELECT order_id FROM v_order_item_current WHERE id = $1)`,
[cutoffOldItemId],
)
await setRoute({ sku: cutoffTestSku, channel: 'third_party_voucher' })
@@ -220,7 +220,7 @@ test(
assert.equal(cutoffAssignment.assigned, 1)
const cutoffStates = await query<{ id: number; fulfillment_status: string }>(
`SELECT id, fulfillment_status
FROM order_items
FROM v_order_item_current
WHERE id = ANY($1::bigint[])
ORDER BY id DESC`,
[[cutoffOldItemId, cutoffNewItemId]],
@@ -307,7 +307,7 @@ test(
fulfillment_config_id: number | null
}>(
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id
FROM order_items WHERE id = $1`,
FROM v_order_item_current WHERE id = $1`,
[manualItemId],
)
assert.equal(manualItem.rows[0]?.fulfillment_status, 'assigned')
@@ -341,7 +341,7 @@ test(
}>(
`SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel,
oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json
FROM order_items oi
FROM v_order_item_current oi
LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id
WHERE oi.id = $1`,
[manualItemId],
@@ -370,7 +370,7 @@ test(
fulfillment_pending_reason: string | null
}>(
`SELECT fulfillment_status, fulfillment_channel, fulfillment_pending_reason
FROM order_items WHERE id = $1`,
FROM v_order_item_current WHERE id = $1`,
[missingAffiliateItemId],
)
assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending')
@@ -402,7 +402,7 @@ test(
canonical_product_id: number | null
}>(
`SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id, canonical_product_id
FROM order_items WHERE id = $1`,
FROM v_order_item_current WHERE id = $1`,
[autoManualItemId],
)
assert.equal(autoManualItem.rows[0]?.fulfillment_status, 'assigned')
@@ -455,7 +455,7 @@ test(
work_rule_key: string | null
}>(
`SELECT oi.fulfillment_status, oi.fulfillment_channel, wpr.rule_key AS work_rule_key
FROM order_items oi
FROM v_order_item_current oi
LEFT JOIN work_orders wo ON wo.order_item_id = oi.id
LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id
WHERE oi.id = $1`,
@@ -470,11 +470,11 @@ test(
[retryWorkOrderItemId],
)
await query(
`UPDATE order_items
SET fulfillment_status = 'pending', fulfillment_channel = NULL,
fulfillment_source = 'auto_mapping', fulfillment_pending_reason = 'no_carrier',
fulfillment_pending_detail = $2::jsonb, updated_at = NOW()
WHERE id = $1`,
`UPDATE fulfillments
SET status = 'pending', channel = NULL,
source = 'auto_mapping', pending_reason = 'no_carrier',
pending_detail = $2::jsonb, updated_at = NOW()
WHERE order_item_id = $1`,
[
retryWorkOrderItemId,
JSON.stringify({ carrierIssue: 'carrier_task_closed', channel: 'work_order' }),
@@ -569,11 +569,11 @@ test(
shopName: '测试店铺 A',
})
await query(
`UPDATE order_items
SET fulfillment_status = 'assigned', fulfillment_channel = 'manual',
fulfillment_source = 'carrier_evidence', fulfillment_config_id = NULL,
`UPDATE fulfillments
SET status = 'assigned', channel = 'manual',
source = 'carrier_evidence', fulfillment_config_id = NULL,
updated_at = NOW()
WHERE id = $1`,
WHERE order_item_id = $1`,
[manualRemoveItemId],
)
await query(
@@ -596,7 +596,7 @@ test(
const removedManualItem = await query<{
fulfillment_status: string
fulfillment_channel: string | null
}>(`SELECT fulfillment_status, fulfillment_channel FROM order_items WHERE id = $1`, [
}>(`SELECT fulfillment_status, fulfillment_channel FROM v_order_item_current WHERE id = $1`, [
manualRemoveItemId,
])
assert.equal(removedManualItem.rows[0]?.fulfillment_status, 'pending')
@@ -617,11 +617,11 @@ test(
await setRoute({ sku: fallbackSku, channel: 'affiliate_dash' })
await setRoute({ sku: fallbackSku, channel: 'manual', role: 'fallback' })
const fallbackItem = await query<{ order_id: number }>(
`UPDATE order_items
SET fulfillment_status = 'assigned', fulfillment_channel = 'affiliate_dash',
fulfillment_source = 'operator_assigned', fulfillment_locked = FALSE,
`UPDATE fulfillments
SET status = 'assigned', channel = 'affiliate_dash',
source = 'operator_assigned', locked = FALSE,
updated_at = NOW()
WHERE id = $1
WHERE order_item_id = $1
RETURNING order_id`,
[fallbackItemId],
)
@@ -672,7 +672,7 @@ test(
assert.equal(
(
await query<{ fulfillment_channel: string }>(
`SELECT fulfillment_channel FROM order_items WHERE id = $1`,
`SELECT fulfillment_channel FROM v_order_item_current WHERE id = $1`,
[fallbackItemId],
)
).rows[0]?.fulfillment_channel,
@@ -694,7 +694,7 @@ test(
item_snapshot_json: Record<string, unknown>
}>(
`SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json
FROM order_items WHERE id = $1`,
FROM v_order_item_current WHERE id = $1`,
[fallbackItemId],
)
assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual')
@@ -715,7 +715,7 @@ test(
platform_order_id: string
}>(
`SELECT oi.order_id, o.provider, o.platform, o.shop_id, o.shop_name, o.platform_order_id
FROM order_items oi
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = $1`,
[retryTaskItemId],
@@ -926,14 +926,22 @@ test(
configJson: { affiliateSku: retryPendingAffiliateSku },
})
await query(
`UPDATE order_items
SET fulfillment_status = 'assigned',
fulfillment_channel = 'affiliate_dash',
fulfillment_source = 'carrier_evidence',
`UPDATE fulfillments
SET status = 'assigned',
channel = 'affiliate_dash',
source = 'carrier_evidence',
updated_at = NOW()
WHERE id = $1`,
WHERE order_item_id = $1`,
[retryPendingItemId],
)
const existingRetryTasks = await query<{ id: number; task_status: string }>(
`SELECT id, task_status
FROM fulfillment_tasks
WHERE order_item_id = $1
ORDER BY id`,
[retryPendingItemId],
)
assert.deepEqual(existingRetryTasks.rows, [], `测试订单项已有任务:${retryPendingItemId}`)
await query(
`INSERT INTO fulfillment_tasks (
order_id, order_item_id, unit_index, task_no, provider, platform,
@@ -943,16 +951,23 @@ test(
SELECT oi.order_id, oi.id, 1, $2, o.provider, o.platform,
o.shop_id, o.shop_name, o.platform_order_id, profile.id, 'affiliate_dash',
'retry_pending', NOW(), NOW()
FROM order_items oi
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
JOIN LATERAL (
SELECT id FROM fulfillment_profiles
WHERE profile_key = 'affiliate_dash' AND executor_key = 'affiliate_dash'
ORDER BY id LIMIT 1
) profile ON TRUE
JOIN fulfillment_profiles profile
ON profile.id = $3
AND profile.executor_key = 'affiliate_dash'
WHERE oi.id = $1`,
[retryPendingItemId, `IT-RETRY-PENDING-${suffix}`],
[retryPendingItemId, `IT-RETRY-PENDING-${suffix}`, fallbackProfileId],
)
const retryPendingTask = await query<{ task_status: string }>(
`SELECT task_status
FROM fulfillment_tasks
WHERE order_item_id = $1 AND executor_key = 'affiliate_dash'
ORDER BY id DESC
LIMIT 1`,
[retryPendingItemId],
)
assert.equal(retryPendingTask.rows[0]?.task_status, 'retry_pending')
const blockedRetry = await reconcilePendingCarriers({
orderItemIds: [retryPendingItemId],
includeAssignedMissingCarrier: true,
@@ -972,7 +987,7 @@ test(
oi.fulfillment_pending_reason,
oi.fulfillment_pending_detail,
(SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.id = $1`,
[retryPendingItemId],
)
@@ -985,7 +1000,7 @@ test(
)
assert.equal(retryPendingState.rows[0]?.task_count, 1)
await query(
`UPDATE order_items SET fulfillment_locked = TRUE, updated_at = NOW() WHERE id = $1`,
`UPDATE fulfillments SET locked = TRUE, updated_at = NOW() WHERE order_item_id = $1`,
[retryPendingItemId],
)
const unlockedPendingGroup = await unlockPendingChannelGroup({
@@ -993,7 +1008,7 @@ test(
})
assert.equal(unlockedPendingGroup.updated, 1)
const unlockedPendingItem = await query<{ fulfillment_locked: boolean }>(
`SELECT fulfillment_locked FROM order_items WHERE id = $1`,
`SELECT fulfillment_locked FROM v_order_item_current WHERE id = $1`,
[retryPendingItemId],
)
assert.equal(unlockedPendingItem.rows[0]?.fulfillment_locked, false)
@@ -1007,14 +1022,14 @@ test(
[retryPendingItemId],
)
await query(
`UPDATE order_items
SET fulfillment_status = 'pending',
fulfillment_channel = NULL,
fulfillment_source = 'auto_mapping',
fulfillment_pending_reason = NULL,
fulfillment_pending_detail = NULL,
`UPDATE fulfillments
SET status = 'pending',
channel = NULL,
source = 'auto_mapping',
pending_reason = NULL,
pending_detail = NULL,
updated_at = NOW()
WHERE id = $1`,
WHERE order_item_id = $1`,
[retryPendingItemId],
)
await assert.rejects(
@@ -1035,7 +1050,7 @@ test(
oi.fulfillment_status,
oi.fulfillment_pending_detail,
(SELECT count(*)::int FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) AS task_count
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.id = $1`,
[retryPendingItemId],
)
@@ -1,73 +0,0 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const migration = readFileSync(
new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url),
'utf8',
)
const auditScript = readFileSync(
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
'utf8',
)
const configSyncMigration = readFileSync(
new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url),
'utf8',
)
const routeCutoverMigration = readFileSync(
new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', import.meta.url),
'utf8',
)
test('正式履约影子模型包含履约、尝试和异常三张表', () => {
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts'))
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues'))
assert.ok(migration.includes('uq_fulfillment_attempts_current_unit'))
assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)'))
})
test('影子模型从现有真源回填并持续双写', () => {
assert.ok(migration.includes('SELECT sync_fulfillment_shadow(state.order_item_id)'))
assert.ok(migration.includes('trg_order_items_fulfillment_shadow'))
assert.ok(migration.includes('trg_fulfillment_tasks_shadow'))
assert.ok(migration.includes('trg_work_orders_fulfillment_shadow'))
assert.ok(migration.includes('assign_fulfillment_event_shadow'))
})
test('人工配置和统一渠道规则变化会刷新履约影子', () => {
assert.ok(configSyncMigration.includes('trg_manual_fulfillment_config_shadow'))
assert.ok(configSyncMigration.includes('sync_fulfillment_shadow(item.id)'))
assert.ok(routeCutoverMigration.includes('trg_sku_route_fulfillment_shadow'))
assert.ok(routeCutoverMigration.includes('sync_fulfillment_shadow_for_sku_route'))
assert.ok(
routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'),
)
})
test('人工履约配置不能关联其他来源商品', () => {
assert.ok(configSyncMigration.includes('trg_manual_config_product_source'))
assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'"))
assert.ok(configSyncMigration.includes("ERRCODE = '23514'"))
assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs'))
assert.ok(configSyncMigration.includes('config.enabled = TRUE'))
assert.ok(configSyncMigration.includes("SET status = 'active'"))
})
test('明确渠道即视为已路由,不用渠道配置缺失覆盖已选渠道', () => {
assert.ok(migration.includes('v_fulfillment_item_state_raw'))
assert.ok(migration.includes("WHEN raw.intended_channel IS NOT NULL THEN 'configured'"))
assert.ok(
migration.includes(
"WHEN raw.route_status = 'missing_channel'\n AND raw.intended_channel IS NOT NULL\n THEN ''",
),
)
})
test('审计脚本核对影子表、执行尝试、开放异常和事件关联', () => {
assert.ok(auditScript.includes('影子履约缺失'))
assert.ok(auditScript.includes('影子履约状态差异'))
assert.ok(auditScript.includes('影子执行尝试缺失'))
assert.ok(auditScript.includes('影子开放异常缺失'))
assert.ok(auditScript.includes('履约事件未关联影子记录'))
})
@@ -116,10 +116,10 @@ test(
assert.equal((await listFulfillmentStateEvents(orderItemId)).length, 1)
await query(
`UPDATE order_items
SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE),
`UPDATE fulfillments
SET pending_detail = jsonb_build_object('legacyWrite', TRUE),
updated_at = NOW()
WHERE id = $1`,
WHERE order_item_id = $1`,
[orderItemId],
)
events = await listFulfillmentStateEvents(orderItemId)
@@ -128,10 +128,10 @@ test(
assert.equal(events[0]?.actor_type, 'system')
await query(
`UPDATE order_items
SET fulfillment_pending_detail = jsonb_build_object('legacyWrite', TRUE),
`UPDATE fulfillments
SET pending_detail = jsonb_build_object('legacyWrite', TRUE),
updated_at = NOW()
WHERE id = $1`,
WHERE order_item_id = $1`,
[orderItemId],
)
events = await listFulfillmentStateEvents(orderItemId)
@@ -193,7 +193,7 @@ test(
attributes_json, created_at, updated_at
) VALUES ($1, $2, '测试', '件', 'active', '{}'::jsonb, NOW(), NOW())
RETURNING id`,
[`test:ff-state-${suffix}`, `状态事件商品-${suffix}`],
[`man:ff-state-${suffix}`, `状态事件商品-${suffix}`],
)
productId = Number(product.rows[0]!.id)
const config = await query<{ id: number }>(
@@ -7,6 +7,7 @@ function read(relativePath: string) {
}
const migration = read('../../db/migrations/140_fulfillment_state_events.sql')
const cutoverMigration = read('../../db/migrations/163_cutover_fulfillment_truth.sql')
const stateService = read('./fulfillment-state-service.ts')
const channelAssign = read('./channel-assign-service.ts')
const carrierReconciliation = read('./carrier-reconciliation-service.ts')
@@ -32,8 +33,10 @@ test('履约状态事件表记录前后状态并禁止应用修改', () => {
assert.ok(migration.includes('fulfillment_state_events is append-only'))
})
test('状态写入口串行锁订单项,并在写入前校验期望状态', () => {
assert.ok(stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE'))
test('状态写入口串行锁正式履约记录,并在写入前校验期望状态', () => {
assert.ok(stateService.includes('FROM fulfillments'))
assert.ok(stateService.includes('FOR UPDATE'))
assert.ok(!stateService.includes('SELECT * FROM order_items WHERE id = $1 FOR UPDATE'))
assert.ok(stateService.includes('expected.status'))
assert.ok(stateService.includes('expected.channel'))
assert.ok(stateService.includes('sourceNotIn'))
@@ -69,8 +72,18 @@ test('履约配置和标准商品关联统一通过状态服务的映射接口
assert.ok(stateService.includes('onlyWhenCanonicalProductNull'))
})
test('任务创建先锁订单项并复用已有活跃尝试', () => {
assert.ok(taskRepo.includes('SELECT id FROM order_items WHERE id = $1 FOR UPDATE'))
test('任务创建先锁正式履约记录并复用已有活跃尝试', () => {
assert.ok(taskRepo.includes('SELECT id FROM fulfillments WHERE order_item_id = $1 FOR UPDATE'))
assert.ok(taskRepo.includes('task_status NOT IN ('))
assert.ok(taskRepo.includes('ORDER BY is_current DESC, id DESC'))
})
test('订单项状态列冻结,读模型直接投影正式履约表', () => {
assert.ok(
cutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'),
)
assert.ok(cutoverMigration.includes('CREATE VIEW v_order_item_current'))
assert.ok(cutoverMigration.includes('FROM fulfillments fulfillment'))
assert.ok(cutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)'))
assert.ok(cutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event'))
})
@@ -97,7 +97,6 @@ type StateColumn = {
column: string
value: unknown
cast?: 'jsonb'
mergeSnapshot?: boolean
coalesceCurrent?: boolean
preserveCarrierIssue?: boolean
}
@@ -119,46 +118,46 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] {
if (!['pending', 'assigned', 'archived'].includes(patch.status)) {
throw new TypeError(`invalid fulfillment status: ${patch.status}`)
}
columns.push({ column: 'fulfillment_status', value: patch.status })
columns.push({ column: 'status', value: patch.status })
}
if (patch.channel !== undefined) {
if (patch.channel !== null && !isFulfillmentChannel(patch.channel)) {
throw new TypeError(`invalid fulfillment channel: ${patch.channel}`)
}
columns.push({ column: 'fulfillment_channel', value: patch.channel })
columns.push({ column: 'channel', value: patch.channel })
}
if (patch.source !== undefined) {
columns.push({ column: 'fulfillment_source', value: patch.source })
columns.push({ column: 'source', value: patch.source })
}
if (patch.locked !== undefined) {
columns.push({ column: 'fulfillment_locked', value: Boolean(patch.locked) })
columns.push({ column: 'locked', value: Boolean(patch.locked) })
}
if (patch.pendingReason !== undefined) {
columns.push({ column: 'fulfillment_pending_reason', value: patch.pendingReason || null })
columns.push({ column: 'pending_reason', value: patch.pendingReason || null })
}
if (patch.pendingDetail !== undefined) {
columns.push({
column: 'fulfillment_pending_detail',
column: 'pending_detail',
value: patch.pendingDetail,
cast: 'jsonb',
})
}
if (patch.pendingDetailUnlessCarrierIssue !== undefined) {
columns.push({
column: 'fulfillment_pending_detail',
column: 'pending_detail',
value: patch.pendingDetailUnlessCarrierIssue,
cast: 'jsonb',
preserveCarrierIssue: true,
})
}
if (patch.reason !== undefined) {
columns.push({ column: 'fulfillment_reason', value: patch.reason || null })
columns.push({ column: 'reason', value: patch.reason || null })
}
if (patch.decidedBy !== undefined) {
columns.push({ column: 'fulfillment_decided_by', value: patch.decidedBy })
columns.push({ column: 'decided_by', value: patch.decidedBy })
}
if (patch.decidedAt !== undefined) {
columns.push({ column: 'fulfillment_decided_at', value: patch.decidedAt })
columns.push({ column: 'decided_at', value: patch.decidedAt })
}
if (patch.originalChannelIfNull !== undefined) {
if (!isFulfillmentChannel(patch.originalChannelIfNull)) {
@@ -197,26 +196,49 @@ function buildPatchColumns(patch: FulfillmentStatePatch): StateColumn[] {
})
}
if (patch.itemSnapshotJson !== undefined) {
columns.push({
column: 'item_snapshot_json',
value: patch.itemSnapshotJson,
cast: 'jsonb',
})
// 原订单项快照属于订单事实,不进入履约状态表。
}
if (patch.itemSnapshotPatch !== undefined) {
columns.push({
column: 'item_snapshot_json',
value: patch.itemSnapshotPatch,
cast: 'jsonb',
mergeSnapshot: true,
})
// 同上,在状态更新后单独合并。
}
if (columns.length === 0) {
if (
columns.length === 0 &&
patch.itemSnapshotJson === undefined &&
patch.itemSnapshotPatch === undefined
) {
throw new TypeError('fulfillment state patch must change at least one column')
}
return columns
}
async function applyOrderItemSnapshotPatch(
executor: FulfillmentStateExecutor,
orderItemId: number,
patch: FulfillmentStatePatch,
) {
if (patch.itemSnapshotJson === undefined && patch.itemSnapshotPatch === undefined) {
return
}
if (patch.itemSnapshotJson !== undefined) {
await executor.query(
`UPDATE order_items
SET item_snapshot_json = $2::jsonb,
updated_at = $3::timestamptz
WHERE id = $1`,
[orderItemId, patch.itemSnapshotJson, new Date().toISOString()],
)
return
}
await executor.query(
`UPDATE order_items
SET item_snapshot_json =
COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb,
updated_at = $3::timestamptz
WHERE id = $1`,
[orderItemId, patch.itemSnapshotPatch, new Date().toISOString()],
)
}
async function setEventContext(
executor: FulfillmentStateExecutor,
transition: Pick<FulfillmentStateTransition, 'eventType'> & {
@@ -261,71 +283,84 @@ export async function transitionFulfillmentStateWithExecutor(
const columns = buildPatchColumns(input.patch)
await setEventContext(executor, input)
try {
const lockResult = await executor.query<OrderItemRow>(
`SELECT * FROM order_items WHERE id = $1 FOR UPDATE`,
const lockResult = await executor.query<{
id: number
order_item_id: number
status: string
channel: string | null
source: string
locked: boolean
}>(
`SELECT id, order_item_id, status, channel, source, locked
FROM fulfillments
WHERE order_item_id = $1
FOR UPDATE`,
[orderItemId],
)
const current = lockResult.rows[0]
if (!current) return null
if (
input.expected?.status !== undefined &&
current.fulfillment_status !== input.expected.status
) {
if (input.expected?.status !== undefined && current.status !== input.expected.status) {
return null
}
if (
input.expected?.channel !== undefined &&
(current.fulfillment_channel ?? null) !== input.expected.channel
(current.channel ?? null) !== input.expected.channel
) {
return null
}
if (
input.expected?.source !== undefined &&
(current.fulfillment_source || '') !== (input.expected.source || '')
(current.source || '') !== (input.expected.source || '')
) {
return null
}
if (
input.expected?.sourceNotIn &&
input.expected.sourceNotIn.includes(String(current.fulfillment_source || ''))
input.expected.sourceNotIn.includes(String(current.source || ''))
) {
return null
}
if (
input.expected?.locked !== undefined &&
Boolean(current.fulfillment_locked) !== Boolean(input.expected.locked)
Boolean(current.locked) !== Boolean(input.expected.locked)
) {
return null
}
const params: unknown[] = []
const assignments = columns.map((entry) => {
params.push(entry.value)
let expression = `$${params.length}`
if (entry.cast) expression += `::${entry.cast}`
if (entry.mergeSnapshot) {
expression = `COALESCE(item_snapshot_json, '{}'::jsonb) || ${expression}`
} else if (entry.preserveCarrierIssue) {
expression =
`CASE WHEN COALESCE(fulfillment_pending_detail->>'carrierIssue', '') ` +
`NOT IN ('', 'carrier_not_attempted') ` +
`THEN fulfillment_pending_detail ELSE ${expression} END`
} else if (entry.coalesceCurrent) {
expression = `COALESCE(${entry.column}, ${expression})`
}
return `${entry.column} = ${expression}`
})
params.push(new Date().toISOString())
assignments.push(`updated_at = $${params.length}::timestamptz`)
params.push(orderItemId)
if (columns.length > 0) {
const params: unknown[] = []
const assignments = columns.map((entry) => {
params.push(entry.value)
let expression = `$${params.length}`
if (entry.cast) expression += `::${entry.cast}`
if (entry.preserveCarrierIssue) {
expression =
`CASE WHEN COALESCE(pending_detail->>'carrierIssue', '') ` +
`NOT IN ('', 'carrier_not_attempted') ` +
`THEN pending_detail ELSE ${expression} END`
} else if (entry.coalesceCurrent) {
expression = `COALESCE(${entry.column}, ${expression})`
}
return `${entry.column} = ${expression}`
})
params.push(new Date().toISOString())
assignments.push(`updated_at = $${params.length}::timestamptz`)
params.push(current.id)
await executor.query(
`UPDATE fulfillments
SET ${assignments.join(', ')}
WHERE id = $${params.length}`,
params,
)
}
await applyOrderItemSnapshotPatch(executor, orderItemId, input.patch)
await executor.query(`SELECT refresh_fulfillment_derived($1)`, [orderItemId])
const updated = await executor.query<OrderItemRow>(
`UPDATE order_items
SET ${assignments.join(', ')}
WHERE id = $${params.length}
RETURNING *`,
params,
`SELECT * FROM v_order_item_current WHERE id = $1`,
[orderItemId],
)
return updated.rows[0] || null
} finally {
@@ -409,21 +444,21 @@ export async function assignFulfillmentChannelsWithExecutor(
...(input.reason ? { reason: input.reason } : {}),
})
try {
const result = await executor.query<OrderItemRow>(
`UPDATE order_items item
SET fulfillment_channel = input.channel,
fulfillment_status = 'assigned',
fulfillment_source = input.source,
fulfillment_decided_at = NOW(),
fulfillment_pending_reason = NULL,
fulfillment_pending_detail = NULL,
const result = await executor.query<{ order_item_id: number }>(
`UPDATE fulfillments fulfillment
SET channel = input.channel,
status = 'assigned',
source = input.source,
decided_at = NOW(),
pending_reason = NULL,
pending_detail = NULL,
updated_at = NOW()
FROM unnest($1::bigint[], $2::text[], $3::text[])
AS input(order_item_id, channel, source)
WHERE item.id = input.order_item_id
AND item.fulfillment_status = 'pending'
AND (item.fulfillment_locked = FALSE OR $4::boolean)
RETURNING item.*`,
WHERE fulfillment.order_item_id = input.order_item_id
AND fulfillment.status = 'pending'
AND (fulfillment.locked = FALSE OR $4::boolean)
RETURNING fulfillment.order_item_id`,
[
assignments.map((row) => row.orderItemId),
assignments.map((row) => row.channel),
@@ -431,7 +466,21 @@ export async function assignFulfillmentChannelsWithExecutor(
input.includeLocked === true,
],
)
return result.rows
const updatedIds = result.rows.map((row) => Number(row.order_item_id))
if (updatedIds.length === 0) return []
await executor.query(
`SELECT refresh_fulfillment_derived(order_item_id)
FROM fulfillments
WHERE order_item_id = ANY($1::bigint[])`,
[updatedIds],
)
const updated = await executor.query<OrderItemRow>(
`SELECT * FROM v_order_item_current
WHERE id = ANY($1::bigint[])
ORDER BY id`,
[updatedIds],
)
return updated.rows
} finally {
await clearEventContext(executor)
}
@@ -487,14 +536,14 @@ export async function attachFulfillmentConfigurationsWithExecutor(
mappings.map((row) => row.requireExpectedProduct),
mappings.map((row) => row.expectedCanonicalProductId),
]
const where = ['item.id = input.order_item_id']
const where = ['item.order_item_id = input.order_item_id']
if (input.expected?.status !== undefined) {
params.push(input.expected.status)
where.push(`item.fulfillment_status IS NOT DISTINCT FROM $${params.length}`)
where.push(`item.status IS NOT DISTINCT FROM $${params.length}`)
}
if (input.expected?.channel !== undefined) {
params.push(input.expected.channel)
where.push(`item.fulfillment_channel IS NOT DISTINCT FROM $${params.length}`)
where.push(`item.channel IS NOT DISTINCT FROM $${params.length}`)
}
if (input.onlyWhenConfigNull) {
where.push('item.fulfillment_config_id IS NULL')
@@ -514,8 +563,8 @@ export async function attachFulfillmentConfigurationsWithExecutor(
params.push(new Date().toISOString())
const updatedAtParam = params.length
const result = await executor.query<OrderItemRow>(
`UPDATE order_items item
const updatedIds = await executor.query<{ order_item_id: number }>(
`UPDATE fulfillments item
SET fulfillment_config_id = CASE
WHEN input.set_config THEN input.config_id
ELSE item.fulfillment_config_id
@@ -545,9 +594,23 @@ export async function attachFulfillmentConfigurationsWithExecutor(
expected_canonical_product_id
)
WHERE ${where.join(' AND ')}
RETURNING item.*`,
RETURNING item.order_item_id`,
params,
)
const ids = updatedIds.rows.map((row) => Number(row.order_item_id))
if (ids.length === 0) return []
await executor.query(
`SELECT refresh_fulfillment_derived(order_item_id)
FROM fulfillments
WHERE order_item_id = ANY($1::bigint[])`,
[ids],
)
const result = await executor.query<OrderItemRow>(
`SELECT * FROM v_order_item_current
WHERE id = ANY($1::bigint[])
ORDER BY id`,
[ids],
)
return result.rows
}
@@ -0,0 +1,89 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
const migration = readFileSync(
new URL('../../db/migrations/141_fulfillment_shadow_model.sql', import.meta.url),
'utf8',
)
const auditScript = readFileSync(
new URL('../../../scripts/audit-fulfillment-state.ts', import.meta.url),
'utf8',
)
const configSyncMigration = readFileSync(
new URL('../../db/migrations/144_fulfillment_shadow_configuration_sync.sql', import.meta.url),
'utf8',
)
const routeCutoverMigration = readFileSync(
new URL('../../db/migrations/161_move_shadow_sync_to_sku_routes.sql', import.meta.url),
'utf8',
)
const truthCutoverMigration = readFileSync(
new URL('../../db/migrations/163_cutover_fulfillment_truth.sql', import.meta.url),
'utf8',
)
const insertSkuMigration = readFileSync(
new URL('../../db/migrations/166_fulfillment_insert_resolved_sku.sql', import.meta.url),
'utf8',
)
test('正式履约模型包含履约、尝试和异常三张表', () => {
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillments'))
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_attempts'))
assert.ok(migration.includes('CREATE TABLE IF NOT EXISTS fulfillment_issues'))
assert.ok(migration.includes('uq_fulfillment_attempts_current_unit'))
assert.ok(migration.includes('UNIQUE (fulfillment_id, issue_code)'))
})
test('迁移 163 删除影子双写并直接维护正式履约真源', () => {
assert.ok(
truthCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_order_items_fulfillment_shadow'),
)
assert.ok(
truthCutoverMigration.includes('DROP FUNCTION IF EXISTS sync_fulfillment_shadow(BIGINT)'),
)
assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillments_state_event'))
assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_fulfillment_tasks_attempt'))
assert.ok(truthCutoverMigration.includes('CREATE TRIGGER trg_work_orders_attempt'))
assert.ok(truthCutoverMigration.includes('CREATE VIEW v_order_item_current'))
})
test('人工配置和路由变化直接刷新正式履约投影', () => {
assert.ok(truthCutoverMigration.includes('trg_manual_config_refresh_fulfillments'))
assert.ok(truthCutoverMigration.includes('trg_sku_route_refresh_fulfillments'))
assert.ok(truthCutoverMigration.includes('refresh_fulfillment_derived'))
assert.ok(
routeCutoverMigration.includes('DROP TRIGGER IF EXISTS trg_internal_sku_channel_shadow'),
)
})
test('人工履约配置不能关联其他来源商品', () => {
assert.ok(configSyncMigration.includes('trg_manual_config_product_source'))
assert.ok(configSyncMigration.includes("product.canonical_sku LIKE 'man:%'"))
assert.ok(configSyncMigration.includes("ERRCODE = '23514'"))
assert.ok(configSyncMigration.includes('BEFORE INSERT OR UPDATE ON manual_fulfillment_configs'))
assert.ok(configSyncMigration.includes('config.enabled = TRUE'))
assert.ok(configSyncMigration.includes("SET status = 'active'"))
})
test('履约读模型直接投影正式表,订单项旧列冻结', () => {
assert.ok(
truthCutoverMigration.includes('RENAME COLUMN fulfillment_status TO legacy_fulfillment_status'),
)
assert.ok(
truthCutoverMigration.includes(
'RENAME COLUMN canonical_product_id TO legacy_canonical_product_id',
),
)
assert.ok(truthCutoverMigration.includes('FROM fulfillments fulfillment'))
assert.ok(truthCutoverMigration.includes('FROM fulfillment_attempts attempt'))
assert.ok(insertSkuMigration.includes('COALESCE(resolve_internal_sku'))
})
test('审计脚本核对正式履约、执行尝试、开放异常和事件关联', () => {
assert.ok(auditScript.includes('正式履约缺失'))
assert.ok(auditScript.includes('正式履约状态差异'))
assert.ok(auditScript.includes('执行尝试缺失'))
assert.ok(auditScript.includes('开放异常缺失'))
assert.ok(auditScript.includes('履约事件未关联正式记录'))
})
@@ -30,7 +30,7 @@ test(
WHERE trigger_name IN (
'trg_sync_sku_channel_routes',
'trg_internal_sku_channel_shadow',
'trg_sku_route_fulfillment_shadow'
'trg_sku_route_refresh_fulfillments'
)
ORDER BY event_object_table, trigger_name
`,
@@ -38,7 +38,7 @@ test(
assert.deepEqual(triggers.rows, [
{
event_object_table: 'sku_channel_routes',
trigger_name: 'trg_sku_route_fulfillment_shadow',
trigger_name: 'trg_sku_route_refresh_fulfillments',
},
])
@@ -35,7 +35,7 @@ export async function retryPendingWorkOrderRouting(
if (internalSku) params.push(internalSku)
const pending = await query<OrderItemRow>(
`SELECT oi.*
FROM order_items oi
FROM v_order_item_current oi
WHERE oi.fulfillment_status = 'pending'
AND EXISTS (
SELECT 1
@@ -98,7 +98,7 @@ export async function retryPendingWorkOrderRouting(
})
const remaining = await query<{ count: number }>(
`SELECT COUNT(*)::int AS count
FROM order_items
FROM v_order_item_current
WHERE id = ANY($1::bigint[]) AND fulfillment_status = 'pending'`,
[ids],
)
@@ -212,7 +212,7 @@ export async function scheduleVoucherConsumeForOrderItems(
o.provider,
o.platform,
o.platform_order_id
FROM order_items oi
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = ANY($1::bigint[])
AND oi.fulfillment_status = 'assigned'
@@ -275,7 +275,7 @@ export async function scheduleVoucherConsumeFallbackForOrderItems(
o.provider,
o.platform,
o.platform_order_id
FROM order_items oi
FROM v_order_item_current oi
JOIN orders o ON o.id = oi.order_id
WHERE oi.id = ANY($1::bigint[])
AND oi.fulfillment_status = 'pending'