重构:硬切履约状态唯一真源
This commit is contained in:
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user