fix(fulfillment): 封堵重复载体并完善统一SKU映射

This commit is contained in:
yml2213
2026-11-03 12:28:05 +08:00
parent 3ba808b814
commit e6763882f5
27 changed files with 1506 additions and 45 deletions
@@ -36,11 +36,11 @@ PARALLEL SAFE
AS $$
SELECT COALESCE(
CASE
WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[一-龥]'
WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
THEN canonicalize_internal_sku(p_manual_sku_key)
END,
CASE
WHEN canonicalize_internal_sku(p_sku_name) ~ '[一-龥]'
WHEN canonicalize_internal_sku(p_sku_name) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]'
THEN canonicalize_internal_sku(p_sku_name)
END
)
@@ -1,7 +1,7 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { resolveOrderItemSyncPlan } from './order-item-repo.js'
import { preserveAssignedOrderItemState, resolveOrderItemSyncPlan } from './order-item-repo.js'
test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => {
const existingItems = [
@@ -98,3 +98,52 @@ test('resolveOrderItemSyncPlan keeps surplus existing ids for conditional cleanu
assert.deepEqual(plan.creates, [])
assert.deepEqual(plan.deletes, [32])
})
test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => {
const existing = {
id: 41,
fulfillment_status: 'assigned',
fulfillment_channel: 'affiliate_dash',
sku_code: 'lucky_coin_x2',
sku_name: '幸运币2个',
quantity: 1,
spec_json: { skuNick: '幸运币2个', sourceSpec: 'old' },
item_snapshot_json: {
internalSku: '幸运币2个',
internalSkuChannel: 'affiliate_dash',
internalSkuChannelSource: 'operator',
isConfigured: true,
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
affiliateDash: { sku: 'lucky_coin_x2' },
externalSkuName: 'old title',
},
}
const incoming = {
skuCode: 'platform-new',
skuName: '幸运币2个',
quantity: 1,
specJson: { skuNick: '新映射', sourceSpec: 'new' },
itemSnapshotJson: {
internalSku: '新映射',
internalSkuChannel: 'work_order',
isConfigured: false,
fulfillmentRoute: null,
externalSkuName: 'new title',
},
createdAt: '2026-11-03T00:00:00.000Z',
updatedAt: '2026-11-03T00:00:00.000Z',
}
const stabilized = preserveAssignedOrderItemState(existing as never, incoming)
assert.deepEqual(stabilized.specJson, { skuNick: '幸运币2个', sourceSpec: 'new' })
assert.deepEqual(stabilized.itemSnapshotJson, {
externalSkuName: 'new title',
internalSku: '幸运币2个',
internalSkuChannel: 'affiliate_dash',
internalSkuChannelSource: 'operator',
isConfigured: true,
fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' },
affiliateDash: { sku: 'lucky_coin_x2' },
})
})
@@ -87,8 +87,13 @@ export async function replaceOrderItemsWithExecutor(
): Promise<OrderItemRow[]> {
const existingItems = await listOrderItemsByOrderIdWithExecutor(executor, orderId)
const plan = resolveOrderItemSyncPlan(existingItems, items)
const existingById = new Map(existingItems.map((item) => [Number(item.id), item]))
for (const update of plan.updates) {
const effectiveItem = preserveAssignedOrderItemState(
existingById.get(update.orderItemId),
update.item,
)
await executor(
`
UPDATE order_items
@@ -105,9 +110,9 @@ export async function replaceOrderItemsWithExecutor(
update.item.skuCode,
update.item.skuName,
update.item.quantity,
safeJsonStringify(update.item.specJson || '{}'),
safeJsonStringify(update.item.itemSnapshotJson || update.item.specJson || '{}'),
update.item.updatedAt,
safeJsonStringify(effectiveItem.specJson || '{}'),
safeJsonStringify(effectiveItem.itemSnapshotJson || effectiveItem.specJson || '{}'),
effectiveItem.updatedAt,
update.orderItemId,
],
)
@@ -151,6 +156,72 @@ export async function replaceOrderItemsWithExecutor(
return listOrderItemsByOrderIdWithExecutor(executor, orderId)
}
const FROZEN_FULFILLMENT_SNAPSHOT_KEYS = [
'fulfillmentRoute',
'affiliateDash',
'classification',
'internalSku',
'internalSkuSource',
'internalSkuMappingId',
'internalSkuChannel',
'internalSkuChannelSource',
'internalSkuChannelReason',
'isConfigured',
] as const
export function preserveAssignedOrderItemState(
existing: OrderItemRow | undefined,
incoming: OrderItemReplaceInput,
): OrderItemReplaceInput {
if (
!existing ||
(existing.fulfillment_status !== 'assigned' && existing.fulfillment_status !== 'archived')
) {
return incoming
}
const existingSnapshot = parseJsonObject(existing.item_snapshot_json)
const incomingSnapshot = parseJsonObject(incoming.itemSnapshotJson)
const frozenSnapshot: Record<string, unknown> = {}
for (const key of FROZEN_FULFILLMENT_SNAPSHOT_KEYS) {
if (Object.prototype.hasOwnProperty.call(existingSnapshot, key)) {
frozenSnapshot[key] = existingSnapshot[key]
}
}
const existingSpec = parseJsonObject(existing.spec_json)
const incomingSpec = parseJsonObject(incoming.specJson)
const frozenInternalSku = String(
existingSpec.skuNick || existingSnapshot.internalSku || incomingSpec.skuNick || '',
).trim()
return {
...incoming,
specJson: {
...incomingSpec,
...(frozenInternalSku ? { skuNick: frozenInternalSku } : {}),
},
itemSnapshotJson: {
...incomingSnapshot,
...frozenSnapshot,
},
}
}
function parseJsonObject(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value as Record<string, unknown>
}
try {
const parsed = JSON.parse(String(value || '{}'))
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {}
} catch {
return {}
}
}
export function resolveOrderItemSyncPlan(
existingItems: OrderItemRow[],
nextItems: OrderItemReplaceInput[],
@@ -19,6 +19,12 @@ import {
unlockChannel,
upsertChannelRule,
} from '../../services/fulfillment/channel-assign-service.js'
import {
deleteInternalSkuMapping,
listInternalSkuMappingsPage,
upsertInternalSkuMapping,
type InternalSkuMappingMatchType,
} from '../../services/fulfillment/internal-sku-mapping-service.js'
import { listExceptionQueue } from '../../services/cost-center/cost-exception-service.js'
import {
runFinancePipelineOnce,
@@ -53,6 +59,70 @@ router.get(
),
)
router.get(
'/fulfillment/internal-sku-mappings',
requireAdminPermission('finance.view'),
createJsonHandler(
(req) =>
listInternalSkuMappingsPage({
keyword: String(req.query.keyword || ''),
provider: String(req.query.provider || ''),
shopId: String(req.query.shopId || ''),
matchType: String(req.query.matchType || ''),
page: Number(req.query.page),
pageSize: Number(req.query.pageSize),
}),
{
successMessage: 'ok',
errorMessage: '读取 SKU 映射失败',
scope: '[admin/fulfillment/internal-sku-mappings]',
},
),
)
router.post(
'/fulfillment/internal-sku-mappings',
requireAdminPermission('finance.config'),
createJsonHandler(
async (req) => {
const body = bodyOf(req)
const id = await upsertInternalSkuMapping({
provider: String(body.provider || ''),
shopId: String(body.shopId || ''),
matchType: String(body.matchType || 'sku_code') as InternalSkuMappingMatchType,
matchValue: String(body.matchValue || ''),
internalSku: String(body.internalSku || ''),
priority: Number(body.priority),
source: String(body.source || 'manual'),
note: String(body.note || ''),
enabled: body.enabled !== false,
})
return { id }
},
{
successMessage: 'SKU 映射已保存',
errorMessage: '保存 SKU 映射失败',
scope: '[admin/fulfillment/internal-sku-mappings/save]',
audit: (req, data) => ({
action: 'fulfillment.upsert_internal_sku_mapping',
targetType: 'internal_sku_mapping',
targetId: String((data as { id?: number })?.id || ''),
data: { payload: bodyOf(req) },
}),
},
),
)
router.delete(
'/fulfillment/internal-sku-mappings/:id',
requireAdminPermission('finance.config'),
createJsonHandler((req) => deleteInternalSkuMapping(Number(req.params.id)), {
successMessage: 'SKU 映射已删除',
errorMessage: '删除 SKU 映射失败',
scope: '[admin/fulfillment/internal-sku-mappings/delete]',
}),
)
router.get(
'/fulfillment/exceptions',
requireAdminPermission('finance.view'),
@@ -4,7 +4,14 @@ import test from 'node:test'
import { AFFILIATE_FAILURE_STATUSES } from './affiliate-fallback-service.js'
test('自动发货失败状态全部纳入转人工', () => {
for (const status of ['closed', 'expired', 'manual_review', 'retry_pending', 'cancelled']) {
for (const status of [
'failed',
'closed',
'expired',
'manual_review',
'retry_pending',
'cancelled',
]) {
assert.ok(AFFILIATE_FAILURE_STATUSES.includes(status as never))
}
assert.ok(!AFFILIATE_FAILURE_STATUSES.includes('redeemed' as never))
@@ -10,6 +10,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js'
* 自动任务本身不改状态,只记录订单项的切换原因,避免重复触发。
*/
export const AFFILIATE_FAILURE_STATUSES = [
'failed',
'closed',
'expired',
'manual_review',
@@ -55,6 +56,7 @@ export async function runAffiliateFallback(
WHERE oi.fulfillment_status = 'assigned'
AND oi.fulfillment_channel = 'affiliate_dash'
AND oi.fulfillment_locked = FALSE
AND cfg.id IS NOT NULL
AND EXISTS (
SELECT 1 FROM internal_sku_channels channel
WHERE channel.enabled = TRUE
@@ -99,7 +101,29 @@ export async function runAffiliateFallback(
channel_switch_reason = 'affiliate_failed',
channel_switched_at = NOW(),
fulfillment_config_id = COALESCE(fulfillment_config_id, $2),
canonical_product_id = $3,
canonical_product_id = COALESCE($3, canonical_product_id),
fulfillment_pending_reason = NULL,
fulfillment_pending_detail = NULL,
fulfillment_decided_at = NOW(),
item_snapshot_json = COALESCE(item_snapshot_json, '{}'::jsonb) || jsonb_build_object(
'internalSkuChannel', 'manual',
'internalSkuChannelSource', 'affiliate_fallback',
'internalSkuChannelReason', '自动发货失败,转人工兜底',
'isConfigured', TRUE,
'affiliateDash', NULL,
'fulfillmentRoute', jsonb_build_object(
'selectedExecutorKey', 'manual_dispatch',
'selectedRuleId', '',
'reason', '自动发货失败,转人工兜底',
'matchedRule', NULL,
'candidates', '[]'::jsonb,
'skipped', '[]'::jsonb
),
'classification', jsonb_build_object(
'channel', 'manual',
'canonicalSku', resolve_internal_sku(manual_sku_key, sku_name, sku_code)
)
),
updated_at = NOW()
WHERE id = $1
AND fulfillment_status = 'assigned'
@@ -6,6 +6,10 @@ import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './chann
import { applyIntakeChannelDecision } from './intake-channel-decision-service.js'
import type { ResolvedFulfillmentItem } from './product-resolution-service.js'
import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js'
import {
findConflictingCarrier,
loadActiveCarrierEvidenceForItem,
} from './carrier-evidence-service.js'
function parseObject(value: unknown): Record<string, unknown> {
if (value && typeof value === 'object' && !Array.isArray(value)) {
@@ -77,6 +81,18 @@ export async function ensureCarrierForOrderItem(input: {
channel: FulfillmentChannel
}): Promise<EnsureCarrierResult> {
const { order, item, channel } = input
const existingCarrier = findConflictingCarrier(
await loadActiveCarrierEvidenceForItem(Number(item.id)),
channel,
)
if (existingCarrier) {
return {
ready: false,
created: false,
reason: 'existing_carrier_conflict',
message: `订单项已有其它真实载体:${existingCarrier}`,
}
}
if (channel === 'manual') {
const config = await query<{ id: number; canonical_product_id: number | null }>(
@@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { findConflictingCarrier } from './carrier-evidence-service.js'
const empty = {
workOrder: false,
affiliateTask: false,
manualTask: false,
channels: [],
}
test('真实载体冲突:同渠道不算冲突', () => {
assert.equal(
findConflictingCarrier(
{ ...empty, affiliateTask: true, channels: ['affiliate_dash'] },
'affiliate_dash',
),
null,
)
})
test('真实载体冲突:其它渠道算冲突', () => {
assert.equal(
findConflictingCarrier(
{ ...empty, workOrder: true, channels: ['work_order'] },
'affiliate_dash',
),
'work_order',
)
})
@@ -0,0 +1,87 @@
import { query } from '../../db/client.js'
import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './channel-types.js'
export type ActiveCarrierEvidence = {
workOrder: boolean
affiliateTask: boolean
manualTask: boolean
channels: FulfillmentChannel[]
}
/**
* 批量读取订单项的真实履约载体证据。
* 关闭、失败、过期等任务不算载体,规则与渠道判定管道保持一致。
*/
export async function loadActiveCarrierEvidence(
orderItemIds: number[],
): Promise<Map<number, ActiveCarrierEvidence>> {
const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))]
const result = new Map<number, ActiveCarrierEvidence>()
if (ids.length === 0) return result
const rows = await query<{
id: number
has_work_order: boolean
has_affiliate_task: boolean
has_manual_task: boolean
}>(
`
SELECT
oi.id,
EXISTS (
SELECT 1 FROM work_orders wo
WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'
) AS has_work_order,
EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'affiliate_dash'
AND ft.task_status <> ALL($2::text[])
) AS has_affiliate_task,
EXISTS (
SELECT 1 FROM fulfillment_tasks ft
WHERE ft.order_item_id = oi.id
AND ft.executor_key = 'manual_dispatch'
AND ft.task_status <> ALL($2::text[])
) AS has_manual_task
FROM order_items oi
WHERE oi.id = ANY($1::bigint[])
`,
[ids, INACTIVE_CARRIER_TASK_STATUSES],
)
for (const row of rows.rows) {
const channels: FulfillmentChannel[] = []
if (row.has_work_order) channels.push('work_order')
if (row.has_affiliate_task) channels.push('affiliate_dash')
if (row.has_manual_task) channels.push('manual')
result.set(Number(row.id), {
workOrder: row.has_work_order,
affiliateTask: row.has_affiliate_task,
manualTask: row.has_manual_task,
channels,
})
}
return result
}
export async function loadActiveCarrierEvidenceForItem(
orderItemId: number,
): Promise<ActiveCarrierEvidence> {
const evidence = await loadActiveCarrierEvidence([orderItemId])
return (
evidence.get(Number(orderItemId)) || {
workOrder: false,
affiliateTask: false,
manualTask: false,
channels: [],
}
)
}
export function findConflictingCarrier(
evidence: ActiveCarrierEvidence | undefined,
targetChannel: FulfillmentChannel,
): FulfillmentChannel | null {
return evidence?.channels.find((channel) => channel !== targetChannel) || null
}
@@ -545,9 +545,72 @@ async function assignOrderItemsAfterCarrierReady(input: {
SELECT count(*)::int AS count FROM updated`,
[ids, input.channel, input.source, input.lock],
)
await syncAssignedChannelSnapshots(ids, input.channel, input.source)
return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed }
}
async function syncAssignedChannelSnapshots(
ids: number[],
channel: FulfillmentChannel,
source: FulfillmentSource,
) {
if (ids.length === 0) return
const configured = channel === 'affiliate_dash' || channel === 'manual'
const route =
channel === 'manual'
? {
selectedExecutorKey: 'manual_dispatch',
selectedRuleId: '',
reason: '运营指定人工客服',
matchedRule: null,
candidates: [],
skipped: [],
}
: channel === 'affiliate_dash'
? {
selectedExecutorKey: 'affiliate_dash',
selectedRuleId: '',
reason: '运营指定自动发货',
matchedRule: null,
candidates: [],
skipped: [],
}
: {
selectedExecutorKey: '',
selectedRuleId: '',
reason:
channel === 'work_order'
? '内部 SKU 渠道配置指定接单平台'
: '内部 SKU 渠道配置指定第三方卡券',
matchedRule: null,
candidates: [],
skipped: [],
}
const decision = {
internalSkuChannel: channel,
internalSkuChannelSource: source,
internalSkuChannelReason: '',
isConfigured: configured,
fulfillmentRoute: route,
...(channel !== 'affiliate_dash' ? { affiliateDash: null } : {}),
}
await query(
`UPDATE order_items
SET item_snapshot_json = (
COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb
) || jsonb_build_object(
'classification',
COALESCE(item_snapshot_json->'classification', '{}'::jsonb)
|| jsonb_build_object('channel', $3)
),
updated_at = NOW()
WHERE id = ANY($1::bigint[])
AND fulfillment_status = 'assigned'
AND fulfillment_channel = $3`,
[ids, JSON.stringify(decision), channel],
)
}
const WORK_ORDER_SWITCH_BLOCKED = new Set([
'open',
'in_progress',
@@ -1131,10 +1194,15 @@ async function resolveInternalSkuCarrierConfig(
`SELECT id, canonical_product_id
FROM manual_fulfillment_configs
WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE
ORDER BY id
LIMIT 1`,
ORDER BY id`,
[key],
)
if (manual.rows.length > 1) {
throw createHttpError(`内部 SKU「${key}」存在多份人工履约配置`, {
statusCode: 409,
errorCode: 'carrier_config_ambiguous',
})
}
return {
canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null,
configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {},
@@ -1149,12 +1217,27 @@ async function resolveInternalSkuCarrierConfig(
WHERE status = 'active'
AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3)
AND canonicalize_internal_sku(canonical_name) = $1
ORDER BY id
LIMIT 1`,
ORDER BY id`,
[key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`],
)
const row = product.rows[0]
if (!row) return { canonicalProductId: null, configJson: {} }
const distinctCarrierKeys = new Set(
product.rows.map((candidate) => {
const rawSku = String(candidate.canonical_sku)
const prefix = prefixes.find((item) => rawSku.startsWith(item)) || prefixes[0]!
return rawSku.slice(prefix.length)
}),
)
if (distinctCarrierKeys.size > 1) {
throw createHttpError(
`内部 SKU「${key}」在 ${channel} 渠道存在多个载体配置:${[...distinctCarrierKeys].join(' / ')}`,
{
statusCode: 409,
errorCode: 'carrier_config_ambiguous',
},
)
}
const rawSku = String(row.canonical_sku)
const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]!
const carrierKey = rawSku.slice(matchedPrefix.length)
@@ -28,17 +28,33 @@ test(
switchChannelForOrderItems,
upsertChannelRule,
} = await import('./channel-assign-service.js')
const { runAffiliateFallback } = await import('./affiliate-fallback-service.js')
const {
deleteInternalSkuMapping,
listInternalSkuMappingsPage,
resolveInternalSkuMapping,
upsertInternalSkuMapping,
} = await import('./internal-sku-mapping-service.js')
const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}`
const shopId = `it-routing-${suffix}`
const manualSku = `集成测试人工-${suffix}`
const missingAffiliateSku = `集成测试自动-${suffix}`
const voucherSku = `集成测试卡券-${suffix}`
const triggerSku = `集成测试规范-${suffix}`
const fallbackSku = `集成测试兜底-${suffix}`
const orderIds: number[] = []
const orderItemIds: number[] = []
const crossShopSku = `集成测试跨店-${suffix}`
const internalSkus = [manualSku, missingAffiliateSku, voucherSku, crossShopSku, triggerSku]
const internalSkus = [
manualSku,
missingAffiliateSku,
voucherSku,
crossShopSku,
triggerSku,
fallbackSku,
]
let generatedVoucherProductId: number | null = null
let fallbackProfileId: number | null = null
async function createPendingOrderItem(
sku: string,
@@ -75,13 +91,16 @@ test(
const canonicalResolution = await query<{
internal_sku: string | null
platform_only: string | null
cjk_extension: string | null
}>(
`SELECT
resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku,
resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only`,
resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only,
resolve_internal_sku('', '𠀀', '') AS cjk_extension`,
)
assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命')
assert.equal(canonicalResolution.rows[0]?.platform_only, null)
assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀')
await query(
`INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source)
@@ -93,6 +112,32 @@ test(
)
assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku)
const mappingValue = `MAP-${suffix}`
const mappingSku = `集成映射-${suffix}`
const mappingId = await upsertInternalSkuMapping({
provider: '91kaquan',
shopId,
matchType: 'sku_code',
matchValue: ` ${mappingValue} `,
internalSku: mappingSku,
priority: 10,
source: 'integration_test',
note: '集成测试映射',
})
const resolvedMapping = await resolveInternalSkuMapping({
provider: '91kaquan',
shopId,
candidates: [{ matchType: 'sku_code', value: mappingValue }],
})
assert.equal(resolvedMapping?.internalSku, mappingSku)
const mappingPage = await listInternalSkuMappingsPage({
keyword: mappingSku,
page: 1,
pageSize: 10,
})
assert.equal(mappingPage.total, 1)
assert.equal(await deleteInternalSkuMapping(mappingId), true)
const manualItemId = await createPendingOrderItem(manualSku)
await query(
`INSERT INTO manual_fulfillment_configs (
@@ -136,9 +181,10 @@ test(
original_channel: string | null
canonical_product_id: number | null
canonical_sku: string | null
item_snapshot_json: Record<string, unknown>
}>(
`SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel,
oi.canonical_product_id, product.canonical_sku
oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json
FROM order_items oi
LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id
WHERE oi.id = $1`,
@@ -148,6 +194,11 @@ test(
assert.equal(switchedItem.rows[0]?.fulfillment_channel, 'third_party_voucher')
assert.equal(switchedItem.rows[0]?.original_channel, 'manual')
assert.ok(switchedItem.rows[0]?.canonical_sku?.startsWith('tpv:'))
assert.equal(
switchedItem.rows[0]?.item_snapshot_json.internalSkuChannel,
'third_party_voucher',
)
assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false)
generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null
const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku)
@@ -191,6 +242,104 @@ test(
assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd'])
assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB])
const fallbackItemId = await createPendingOrderItem(fallbackSku)
await query(
`INSERT INTO internal_sku_channels (
internal_sku, channel, priority, is_fallback, enabled, config_json, source
) VALUES
($1, 'affiliate_dash', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test'),
($1, 'manual', 90, TRUE, TRUE, '{}'::jsonb, 'integration_test')`,
[fallbackSku],
)
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,
updated_at = NOW()
WHERE id = $1
RETURNING order_id`,
[fallbackItemId],
)
const fallbackOrderId = Number(fallbackItem.rows[0]!.order_id)
const fallbackProfile = await query<{ id: number }>(
`INSERT INTO fulfillment_profiles (
profile_key, name, executor_key, requires_claim, auto_dispatch,
inventory_strategy, config_json, created_at, updated_at
) VALUES ($1, '集成测试自动发货', 'affiliate_dash', TRUE, FALSE,
'static_pool', '{}'::jsonb, NOW(), NOW())
RETURNING id`,
[`it-affiliate-${suffix}`],
)
fallbackProfileId = Number(fallbackProfile.rows[0]!.id)
const fallbackOrder = await query<{
provider: string
platform: string
shop_id: string
shop_name: string
platform_order_id: string
}>(
`SELECT provider, platform, shop_id, shop_name, platform_order_id
FROM orders WHERE id = $1`,
[fallbackOrderId],
)
const orderRow = fallbackOrder.rows[0]!
await query(
`INSERT INTO fulfillment_tasks (
order_id, order_item_id, unit_index, task_no, provider, platform,
shop_id, shop_name, platform_order_id, profile_id, executor_key,
task_status, created_at, updated_at
) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', 'failed', NOW(), NOW())`,
[
fallbackOrderId,
fallbackItemId,
`IT-AFF-FAIL-${suffix}`,
orderRow.provider,
orderRow.platform,
orderRow.shop_id,
orderRow.shop_name,
orderRow.platform_order_id,
fallbackProfileId,
],
)
const fallbackWithoutConfig = await runAffiliateFallback({ limit: 50 })
assert.equal(fallbackWithoutConfig.switched, 0)
assert.equal(
(
await query<{ fulfillment_channel: string }>(
`SELECT fulfillment_channel FROM order_items WHERE id = $1`,
[fallbackItemId],
)
).rows[0]?.fulfillment_channel,
'affiliate_dash',
)
const manualFallbackConfig = await query<{ id: number }>(
`INSERT INTO manual_fulfillment_configs (
sku_key, platform_key, credit_quantity, cost_mode, enabled, created_at, updated_at
) VALUES ($1, 'nanhang', 0, 'variable', TRUE, NOW(), NOW())
RETURNING id`,
[fallbackSku],
)
const fallbackWithConfig = await runAffiliateFallback({ limit: 50 })
assert.equal(fallbackWithConfig.switched, 1)
const fallbackAssigned = await query<{
fulfillment_channel: string
fulfillment_config_id: number | null
item_snapshot_json: Record<string, unknown>
}>(
`SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json
FROM order_items WHERE id = $1`,
[fallbackItemId],
)
assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual')
assert.equal(fallbackAssigned.rows[0]?.item_snapshot_json.internalSkuChannel, 'manual')
assert.equal(fallbackAssigned.rows[0]?.item_snapshot_json.isConfigured, true)
assert.equal(
Number(fallbackAssigned.rows[0]?.fulfillment_config_id),
Number(manualFallbackConfig.rows[0]!.id),
)
await ensureVoucherRuleForSku(voucherSku)
const voucherChannel = await query<{ channel: string; source: string }>(
`SELECT channel, source FROM internal_sku_channels
@@ -244,7 +393,13 @@ test(
await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds])
}
await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`])
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1`, [manualSku])
await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = ANY($1::text[])`, [
internalSkus,
])
await query(
`DELETE FROM internal_sku_mappings WHERE provider = '91kaquan' AND shop_id = $1`,
[shopId],
)
await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [
internalSkus,
])
@@ -271,6 +426,9 @@ test(
`tpv:${manualSku}`,
])
}
if (fallbackProfileId) {
await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [fallbackProfileId])
}
await closeDb()
}
},
@@ -1,5 +1,6 @@
import { query, withTransaction } from '../../db/client.js'
import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js'
import { createHttpError } from '../../utils/http.js'
export type InternalSkuMappingMatchType =
| 'sku_code'
@@ -13,12 +14,34 @@ export type InternalSkuMappingCandidate = {
value: unknown
}
const INTERNAL_SKU_MAPPING_MATCH_TYPES = new Set<InternalSkuMappingMatchType>([
'sku_code',
'sku_nick',
'out_sku_sn',
'spec',
'sku_name',
])
export type ResolvedInternalSkuMapping = {
id: number
internalSku: string
source: string
}
export type InternalSkuMappingRow = {
id: number
provider: string
shop_id: string
match_type: InternalSkuMappingMatchType
match_value: string
internal_sku: string
priority: number
enabled: boolean
source: string
note: string
updated_at: string
}
export function normalizeInternalSkuMappingValue(value: unknown): string {
return canonicalizeInternalSku(value)
}
@@ -83,14 +106,28 @@ export async function upsertInternalSkuMapping(input: {
priority?: number
source?: string
note?: string
enabled?: boolean
}): Promise<number> {
const provider = String(input.provider || '').trim()
const shopId = String(input.shopId || '').trim()
if (!INTERNAL_SKU_MAPPING_MATCH_TYPES.has(input.matchType)) {
throw createHttpError('映射类型无效', {
statusCode: 400,
errorCode: 'internal_sku_mapping_type_invalid',
})
}
const matchValue = normalizeInternalSkuMappingValue(input.matchValue)
const internalSku = canonicalizeInternalSku(input.internalSku)
const priority = Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100
const source = String(input.source || 'manual').trim() || 'manual'
const note = String(input.note || '').trim()
const enabled = input.enabled !== false
if (!matchValue || !internalSku || !containsCjk(internalSku)) {
throw createHttpError('内部 SKU 和匹配值不能为空,且内部 SKU 必须包含中文', {
statusCode: 400,
errorCode: 'internal_sku_mapping_invalid',
})
}
return withTransaction(async (client) => {
const existing = await client.query<{ id: number }>(
@@ -98,32 +135,114 @@ export async function upsertInternalSkuMapping(input: {
WHERE provider = $1 AND shop_id = $2 AND match_type = $3
AND canonicalize_internal_sku(match_value) = $4
ORDER BY id
LIMIT 1
FOR UPDATE`,
[provider, shopId, input.matchType, matchValue],
)
if (existing.rows.length > 1) {
throw createHttpError('同一平台/店铺/字段存在多条规范化重复映射', {
statusCode: 409,
errorCode: 'internal_sku_mapping_ambiguous',
})
}
const existingId = Number(existing.rows[0]?.id || 0)
const result = existingId
? await client.query<{ id: number }>(
`UPDATE internal_sku_mappings
SET match_value = $2, internal_sku = $3, priority = $4,
enabled = TRUE, source = $5, note = $6, updated_at = NOW()
enabled = $5, source = $6, note = $7, updated_at = NOW()
WHERE id = $1
RETURNING id`,
[existingId, matchValue, internalSku, priority, source, note],
[existingId, matchValue, internalSku, priority, enabled, source, note],
)
: await client.query<{ id: number }>(
`INSERT INTO internal_sku_mappings (
provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note, created_at, updated_at
) VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, NOW(), NOW())
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW())
RETURNING id`,
[provider, shopId, input.matchType, matchValue, internalSku, priority, source, note],
[
provider,
shopId,
input.matchType,
matchValue,
internalSku,
priority,
enabled,
source,
note,
],
)
return Number(result.rows[0]?.id || 0)
})
}
export async function listInternalSkuMappingsPage(input: {
keyword?: string
provider?: string
shopId?: string
matchType?: string
page?: number
pageSize?: number
}): Promise<{ items: InternalSkuMappingRow[]; total: number; page: number; pageSize: number }> {
const page = Math.max(1, Math.trunc(Number(input.page) || 1))
const pageSize = Math.min(200, Math.max(1, Math.trunc(Number(input.pageSize) || 30)))
const params: unknown[] = []
const clauses: string[] = []
const keyword = String(input.keyword || '').trim()
if (keyword) {
params.push(`%${keyword}%`)
clauses.push(
`(match_value ILIKE $${params.length} OR internal_sku ILIKE $${params.length} OR note ILIKE $${params.length})`,
)
}
if (input.provider) {
params.push(String(input.provider).trim())
clauses.push(`provider = $${params.length}`)
}
if (input.shopId) {
params.push(String(input.shopId).trim())
clauses.push(`shop_id = $${params.length}`)
}
if (input.matchType) {
params.push(String(input.matchType).trim())
clauses.push(`match_type = $${params.length}`)
}
const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : ''
const pageParams = [...params, pageSize, (page - 1) * pageSize]
const [countResult, rows] = await Promise.all([
query<{ total: number }>(
`SELECT COUNT(*)::int AS total FROM internal_sku_mappings ${where}`,
params,
),
query<InternalSkuMappingRow>(
`SELECT id, provider, shop_id, match_type, match_value, internal_sku,
priority, enabled, source, note, updated_at
FROM internal_sku_mappings
${where}
ORDER BY enabled DESC, priority ASC, id DESC
LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`,
pageParams,
),
])
return {
items: rows.rows.map((row) => ({
...row,
id: Number(row.id),
priority: Number(row.priority),
})),
total: Number(countResult.rows[0]?.total || 0),
page,
pageSize,
}
}
export async function deleteInternalSkuMapping(mappingId: number): Promise<boolean> {
const id = Math.trunc(Number(mappingId) || 0)
if (id <= 0) return false
const result = await query(`DELETE FROM internal_sku_mappings WHERE id = $1`, [id])
return (result.rowCount || 0) > 0
}
/**
* 订单项级内部 SKU 补全:
* - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库;
@@ -120,6 +120,7 @@ test('syncDeliveryTasksForOrder 为 affiliate_dash 商品创建任务上下文',
}),
],
{
loadActiveCarrierEvidence: async () => new Map(),
listTasksByOrderId: async () => [],
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTask: async (input) => {
@@ -167,6 +168,7 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任
}),
],
{
loadActiveCarrierEvidence: async () => new Map(),
listTasksByOrderId: async () => [existingTask],
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTask: async (input) => {
@@ -216,6 +218,7 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async ()
}),
],
{
loadActiveCarrierEvidence: async () => new Map(),
listTasksByOrderId: async () => [existingTask],
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTask: async (input) => {
@@ -263,6 +266,7 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断',
}),
],
{
loadActiveCarrierEvidence: async () => new Map(),
listTasksByOrderId: async () => [],
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTaskEvent: async () => null,
@@ -318,6 +322,7 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
}),
],
{
loadActiveCarrierEvidence: async () => new Map(),
listTasksByOrderId: async () => (listCount++ === 0 ? [] : [concurrentTask]),
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTask: async () => {
@@ -341,6 +346,77 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy
assert.equal(eventCount, 0)
})
test('来源重推时已分配其它渠道的订单不会补建 affiliate 任务', async () => {
let createdCount = 0
const tasks = await syncDeliveryTasksForOrderWithDeps(
stubOrder({ id: 7, platform_order_id: 'assigned-other-channel' }),
[
stubOrderItem({
id: 60,
order_id: 7,
sku_code: 'SKU-assigned',
fulfillment_status: 'assigned',
fulfillment_channel: 'work_order',
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
}),
],
{
loadActiveCarrierEvidence: async () => new Map(),
listTasksByOrderId: async () => [],
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTask: async (input) => {
createdCount += 1
return stubCreatedTask(501, input as CreatedTaskInput)
},
logInfo: () => null,
logWarn: () => null,
},
)
assert.equal(createdCount, 0)
assert.deepEqual(tasks, [])
})
test('来源重推时已有其它真实载体的订单不会补建 affiliate 任务', async () => {
let createdCount = 0
const tasks = await syncDeliveryTasksForOrderWithDeps(
stubOrder({ id: 8, platform_order_id: 'existing-carrier' }),
[
stubOrderItem({
id: 70,
order_id: 8,
sku_code: 'SKU-existing-carrier',
item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'),
}),
],
{
loadActiveCarrierEvidence: async () =>
new Map([
[
70,
{
workOrder: true,
affiliateTask: false,
manualTask: false,
channels: ['work_order'],
},
],
]),
listTasksByOrderId: async () => [],
getFulfillmentProfileByKey: async () => buildAffiliateProfile(),
createTask: async (input) => {
createdCount += 1
return stubCreatedTask(502, input as CreatedTaskInput)
},
logInfo: () => null,
logWarn: () => null,
},
)
assert.equal(createdCount, 0)
assert.deepEqual(tasks, [])
})
function buildAffiliateSnapshot(sku: string, productName: string): JsonObject {
return {
isConfigured: true,
@@ -8,13 +8,21 @@ import {
type FulfillmentBindingLike,
} from '../fulfillment/planner.js'
import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js'
import type { FulfillmentPrepareDeps } from '../fulfillment/executors/types.js'
import {
FULFILLMENT_EXECUTOR_KEYS,
type FulfillmentPrepareDeps,
} from '../fulfillment/executors/types.js'
import { nowIso } from '../../utils/time.js'
import { randomId } from '../../utils/random.js'
import { logInfo, logWarn } from '../../utils/logger.js'
import { TASK_STATUS, resolveInitialPaidTaskStatus } from '../../domain/task-status.js'
import type { JsonObject } from '../../types/json.js'
import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js'
import {
findConflictingCarrier,
loadActiveCarrierEvidence,
} from '../fulfillment/carrier-evidence-service.js'
import type { FulfillmentChannel } from '../fulfillment/channel-types.js'
type ClaimTokenLike = {
token: string
@@ -30,6 +38,7 @@ type DeliveryTaskRow = TaskRow & {
type DeliveryTaskDeps = {
createTask?: typeof createTask
listTasksByOrderId?: typeof listTasksByOrderId
loadActiveCarrierEvidence?: typeof loadActiveCarrierEvidence
updateTask?: typeof updateTask
getFulfillmentProfileByKey?: (profileKey: string) => Promise<FulfillmentBindingLike | null>
createTaskClaimToken?: (taskId: number | string) => Promise<ClaimTokenLike>
@@ -60,6 +69,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
const {
createTask: createDeliveryTask = createTask,
listTasksByOrderId: listTasks = listTasksByOrderId,
loadActiveCarrierEvidence: loadCarrierEvidence = loadActiveCarrierEvidence,
updateTask: updateDeliveryTask = updateTask,
getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey,
createTaskClaimToken: createClaimToken = createTaskClaimToken,
@@ -81,6 +91,7 @@ export async function syncDeliveryTasksForOrderWithDeps(
const existingTasks = await listTasks(order.id)
const tasks: DeliveryTaskRow[] = [...existingTasks]
const existingTaskKeys = new Set(existingTasks.map(buildTaskUnitKey))
const carrierEvidence = await loadCarrierEvidence(orderItems.map((item) => Number(item.id)))
writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', {
...buildOrderLogContext(order),
@@ -126,6 +137,32 @@ export async function syncDeliveryTasksForOrderWithDeps(
continue
}
const plannedChannel = executorKeyToFulfillmentChannel(plan.executorKey)
if (plannedChannel) {
const assignedChannel = String(item.fulfillment_channel || '').trim()
if (item.fulfillment_status === 'assigned' && assignedChannel !== plannedChannel) {
writeInfoLog('[delivery-task-service]', '订单项已分配其它渠道,跳过载体补建', {
...buildOrderItemLogContext(item),
assignedChannel,
plannedChannel,
})
continue
}
const conflictingCarrier = findConflictingCarrier(
carrierEvidence.get(Number(item.id)),
plannedChannel,
)
if (conflictingCarrier) {
writeWarnLog('[delivery-task-service]', '订单项已有其它真实载体,跳过载体补建', {
...buildOrderItemLogContext(item),
existingCarrier: conflictingCarrier,
plannedChannel,
})
continue
}
}
for (const unitIndex of missingUnitIndexes) {
const createdAt = getNowIso()
const initialStatus =
@@ -256,6 +293,12 @@ export async function syncDeliveryTasksForOrderWithDeps(
return preparedTasks.filter(isTaskRow)
}
function executorKeyToFulfillmentChannel(executorKey: string): FulfillmentChannel | null {
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) return 'affiliate_dash'
if (executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) return 'manual'
return null
}
async function preparePaidTaskWithDiagnostics(
order: OrderRow,
task: TaskRow,
@@ -10,7 +10,13 @@ import {
import { withTransaction } from '../../db/client.js'
import { syncDeliveryTasksForOrder } from './delivery-task-service.js'
import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js'
import { applyIntakeChannelDecision } from '../fulfillment/intake-channel-decision-service.js'
import { applyIntakeChannelPlan } from '../fulfillment/intake-channel-decision-service.js'
import {
loadInternalSkuChannelPlans,
planInternalSkuChannels,
type InternalSkuChannelPlan,
} from '../fulfillment/internal-sku-channel-service.js'
import { canonicalizeInternalSku } from '../fulfillment/internal-sku-resolver.js'
import {
resolveOrderFulfillmentReadiness,
syncOrderFulfillmentAttachments,
@@ -114,21 +120,24 @@ export async function upsertOrderFromSource(
})
const resolvedItems = await Promise.all(
event.items.map(async (item) => {
const resolved = await resolveOrderItemForFulfillment({
event.items.map((item) =>
resolveOrderItemForFulfillment({
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
item,
})
return applyIntakeChannelDecision(resolved, {
provider: event.provider,
platform: event.platform,
shopId: event.shopId,
})
}),
}),
),
)
const resolvedFulfillmentItems = resolvedItems as FulfillmentOrderItem[]
const channelPlans = await loadInternalSkuChannelPlans(
resolvedItems.map((item) => String(item.snapshot.internalSku || '')),
)
const emptyPlan = planInternalSkuChannels([])
const resolvedFulfillmentItems = resolvedItems.map((item) => {
const internalSku = canonicalizeInternalSku(item.snapshot.internalSku)
const plan: InternalSkuChannelPlan = channelPlans.get(internalSku) || emptyPlan
return applyIntakeChannelPlan(item, plan)
}) as FulfillmentOrderItem[]
const configuredItems = resolvedFulfillmentItems.filter((item) => item.isConfigured)
const persisted = await withTransaction(async (client) => {
@@ -71,3 +71,28 @@ test('工单门禁:无配置、冲突、缺模板、模板不一致都不放
'work_rule_mismatch',
)
})
test('工单门禁:已分配其它渠道或已有其它真实载体时不放行', () => {
assert.equal(
resolveWorkOrderChannelGate(plan(channel()), undefined, {
fulfillmentStatus: 'assigned',
assignedChannel: 'affiliate_dash',
}).reason,
'already_assigned_other_channel',
)
assert.equal(
resolveWorkOrderChannelGate(plan(channel()), undefined, {
fulfillmentStatus: 'pending',
existingCarrierChannels: ['affiliate_dash'],
}).reason,
'existing_carrier_taken',
)
assert.equal(
resolveWorkOrderChannelGate(plan(channel()), undefined, {
fulfillmentStatus: 'assigned',
assignedChannel: 'work_order',
existingCarrierChannels: ['work_order'],
}).allowed,
true,
)
})
@@ -51,11 +51,27 @@ import {
canonicalizeInternalSku,
resolveInternalSkuKey,
} from '../fulfillment/internal-sku-resolver.js'
import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js'
export function resolveWorkOrderChannelGate(
plan: InternalSkuChannelPlan | undefined,
preferredWorkRuleKey?: string,
context: {
fulfillmentStatus?: string
assignedChannel?: string
existingCarrierChannels?: string[]
} = {},
): { allowed: boolean; reason: string; workRuleKey: string } {
if (
context.fulfillmentStatus === 'assigned' &&
context.assignedChannel &&
context.assignedChannel !== 'work_order'
) {
return { allowed: false, reason: 'already_assigned_other_channel', workRuleKey: '' }
}
if (context.existingCarrierChannels?.some((channel) => channel !== 'work_order')) {
return { allowed: false, reason: 'existing_carrier_taken', workRuleKey: '' }
}
if (!plan?.primary) {
return {
allowed: false,
@@ -109,6 +125,7 @@ export async function syncWorkerOrdersForSourceOrder(
)
}
const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()])
const carrierEvidence = await loadActiveCarrierEvidence(orderItems.map((item) => Number(item.id)))
for (const item of orderItems) {
const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id)))
@@ -123,7 +140,12 @@ export async function syncWorkerOrdersForSourceOrder(
)
continue
}
const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey)
const assignedChannel = String(item.fulfillment_channel || '').trim()
const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey, {
fulfillmentStatus: item.fulfillment_status || '',
assignedChannel,
existingCarrierChannels: carrierEvidence.get(Number(item.id))?.channels || [],
})
if (!channelGate.allowed) {
skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason })
decisions.push(