From 07c6a1ce22a9d9d1189af55c27986c7e300e7fc9 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 14:00:38 +0800 Subject: [PATCH] =?UTF-8?q?fix(fulfillment):=20=E6=8E=A5=E5=8D=95=E6=A8=A1?= =?UTF-8?q?=E6=9D=BF=E5=91=BD=E4=B8=AD=E5=90=8E=E8=87=AA=E5=8A=A8=E8=A1=A5?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B8=A0=E9=81=93=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sync-worker-orders-service.test.ts | 34 ++++++- .../sync-worker-orders-service.ts | 92 ++++++++++++++----- 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts index 80236dd6..9136275d 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.test.ts @@ -5,7 +5,10 @@ import type { InternalSkuChannelPlan, InternalSkuChannelRow, } from '../fulfillment/internal-sku-channel-service.js' -import { resolveWorkOrderChannelGate } from './sync-worker-orders-service.js' +import { + resolveWorkOrderChannelGate, + shouldBootstrapWorkOrderChannel, +} from './sync-worker-orders-service.js' function channel(patch: Partial = {}): InternalSkuChannelRow { return { @@ -96,3 +99,32 @@ test('工单门禁:已分配其它渠道或已有其它真实载体时不放 true, ) }) + +test('统一渠道未配置时允许用唯一命中的接单模板自动补齐 work_order', () => { + assert.equal( + shouldBootstrapWorkOrderChannel(undefined, { + allowed: false, + reason: 'no_channel_capability', + }), + true, + ) + assert.equal( + shouldBootstrapWorkOrderChannel( + { + primary: null, + fallbacks: [], + attempts: [], + conflicts: [channel()], + }, + { allowed: false, reason: 'routing_conflict' }, + ), + false, + ) + assert.equal( + shouldBootstrapWorkOrderChannel(plan(channel({ channel: 'manual' })), { + allowed: false, + reason: 'internal_sku_channel_manual', + }), + false, + ) +}) diff --git a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts index c699ba4f..323bed0b 100644 --- a/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts +++ b/apps/backend/src/services/worker-platform/sync-worker-orders-service.ts @@ -12,6 +12,7 @@ import { getLatestWorkProductMatchLogByOrderItemId, getWorkOrderByOrderItemId, listWorkOrdersByPlatformOrderId, + listWorkProductRuleMappings, listWorkProductRules, type WorkOrderRow, } from '../../repositories/worker-platform/index.js' @@ -37,6 +38,7 @@ import { mapWorkOrderAdmin, normalizeRequirementFields, resolveKuaishouWorkProductMatchContext, + resolveMatchingProductRuleDecision, resolveSkuNameQuantity, resolveWorkOrderRulePricing, resolveWorkOrderSharingEnabled, @@ -45,6 +47,7 @@ import { registerWorkOrderShopName } from './work-order-shop-options-cache.js' import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js' import { loadInternalSkuChannelPlans, + upsertInternalSkuChannel, type InternalSkuChannelPlan, } from '../fulfillment/internal-sku-channel-service.js' import { @@ -52,6 +55,19 @@ import { resolveInternalSkuKey, } from '../fulfillment/internal-sku-resolver.js' import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js' +import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' + +export function shouldBootstrapWorkOrderChannel( + plan: InternalSkuChannelPlan | undefined, + gate: { allowed: boolean; reason: string }, +): boolean { + return ( + !gate.allowed && + gate.reason === 'no_channel_capability' && + !plan?.primary && + !plan?.conflicts.length + ) +} export function resolveWorkOrderChannelGate( plan: InternalSkuChannelPlan | undefined, @@ -107,7 +123,11 @@ export async function syncWorkerOrdersForSourceOrder( preferredWorkRuleKey?: string } = {}, ) { - const rules = await listWorkProductRules({ enabled: true }) + const [rules, ruleMappings] = await Promise.all([ + listWorkProductRules({ enabled: true }), + listWorkProductRuleMappings({ enabled: true }), + ]) + const legacyFallbackEnabled = getWorkerProductMatchConfig().legacyFallbackEnabled const created: WorkOrderRow[] = [] const skipped: Array<{ orderItemId: number; reason: string }> = [] const decisions: Array> = [] @@ -141,11 +161,35 @@ export async function syncWorkerOrdersForSourceOrder( continue } const assignedChannel = String(item.fulfillment_channel || '').trim() - const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey, { + let channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey, { fulfillmentStatus: item.fulfillment_status || '', assignedChannel, existingCarrierChannels: carrierEvidence.get(Number(item.id))?.channels || [], }) + let matchedDecision: ReturnType | undefined + + if (shouldBootstrapWorkOrderChannel(channelPlan, channelGate)) { + matchedDecision = resolveMatchingProductRuleDecision(order, item, rules, ruleMappings, { + legacyFallbackEnabled, + }) + if (matchedDecision.rule) { + await upsertInternalSkuChannel({ + internalSku, + channel: 'work_order', + priority: 10, + isFallback: false, + enabled: true, + configJson: { workRuleKey: matchedDecision.rule.rule_key }, + source: 'template', + note: `自动匹配接单模板 ${matchedDecision.rule.rule_key}`, + }) + channelGate = { + allowed: true, + reason: '', + workRuleKey: matchedDecision.rule.rule_key, + } + } + } if (!channelGate.allowed) { skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason }) decisions.push( @@ -162,27 +206,29 @@ export async function syncWorkerOrdersForSourceOrder( const preferredRule = preferredRuleKey ? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null : null - const match = !preferredRule - ? { - rule: null, - mappingId: null, - reason: 'unmatched' as const, - score: 0, - candidates: [], - diagnosis: [ - { - dimension: 'internal_sku_channel', - reason: 'configured_work_rule_missing', - }, - ], - } - : { - rule: preferredRule, - mappingId: null, - reason: 'matched' as const, - score: 1, - candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }], - } + const match = + matchedDecision || + (!preferredRule + ? { + rule: null, + mappingId: null, + reason: 'unmatched' as const, + score: 0, + candidates: [], + diagnosis: [ + { + dimension: 'internal_sku_channel', + reason: 'configured_work_rule_missing', + }, + ], + } + : { + rule: preferredRule, + mappingId: null, + reason: 'matched' as const, + score: 1, + candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }], + }) const matchContext = resolveKuaishouWorkProductMatchContext(item) const snapshot = safeParseJson(item.item_snapshot_json) const sellerId =