From 3ba808b814b310860fb32fcebce0e0c9141f494e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 11:49:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(fulfillment):=20=E7=BB=9F=E4=B8=80?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=86=85=E9=83=A8=20SKU=20=E5=B1=A5=E7=BA=A6?= =?UTF-8?q?=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audit-fulfillment-finance-alignment.ts | 28 + .../scripts/backfill-internal-sku-mappings.ts | 9 +- .../scripts/preflight-fulfillment-routing.ts | 62 +++ .../126_internal_sku_canonical_routing.sql | 134 +++++ .../src/routes/admin/fulfillment-channel.ts | 2 - .../src/routes/admin/platform-config.ts | 2 - .../platform-config/fulfillment-routing.ts | 53 -- .../fulfillment-routing-service.ts | 50 -- .../cost-center/cost-center-mappers.ts | 3 +- .../cost-center-overview-service.ts | 6 +- .../services/cost-center/cost-center.test.ts | 5 +- .../cost-center/cost-exception-service.ts | 2 +- .../manual-fulfillment-config-service.ts | 128 +++-- .../cost-center/product-catalog-service.ts | 9 +- .../product-normalize-service.test.ts | 25 +- .../cost-center/product-normalize-service.ts | 60 +- .../finance/manual-fulfillment-service.ts | 10 +- .../fulfillment/affiliate-fallback-service.ts | 6 +- .../fulfillment/carrier-creation-service.ts | 37 +- .../carrier-reconciliation-service.ts | 2 +- .../channel-assign-service.test.ts | 184 ++---- .../fulfillment/channel-assign-service.ts | 338 ++++++----- .../fulfillment-routing.integration.test.ts | 61 +- .../intake-channel-decision-service.test.ts | 148 +++++ .../intake-channel-decision-service.ts | 322 +++++++---- .../internal-sku-channel-service.test.ts | 10 + .../internal-sku-channel-service.ts | 64 ++- .../internal-sku-mapping-service.ts | 97 ++-- .../fulfillment/internal-sku-resolver.test.ts | 23 +- .../fulfillment/internal-sku-resolver.ts | 41 +- .../product-resolution-service.test.ts | 206 +++---- .../fulfillment/product-resolution-service.ts | 433 ++------------- .../scheduler/finance-pipeline-jobs.ts | 43 +- .../sync-worker-orders-service.test.ts | 91 ++- .../sync-worker-orders-service.ts | 152 +++-- apps/frontend/src/App.tsx | 8 +- apps/frontend/src/layouts/AdminLayout.tsx | 2 - .../admin/AdminFulfillmentChannelPage.tsx | 71 ++- .../platform/AdminPlatformFulfillmentPage.tsx | 525 ------------------ .../src/services/admin/fulfillment-channel.ts | 12 +- .../platform-config/fulfillment-routing.ts | 23 - .../services/admin/platform-config/index.ts | 1 - apps/frontend/src/types/admin/index.ts | 4 - .../platform-config/fulfillment-routing.ts | 53 -- .../src/types/admin/platform-config/index.ts | 7 - docs/履约与财务分流统一方案-2026-11-02.md | 24 +- docs/成本中心与履约模型.md | 11 +- docs/生产部署核对清单-2026-11-02.md | 14 +- docs/财务流程总览-2026-11-01.md | 5 +- 49 files changed, 1643 insertions(+), 1963 deletions(-) create mode 100644 apps/backend/src/db/migrations/126_internal_sku_canonical_routing.sql delete mode 100644 apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts delete mode 100644 apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts create mode 100644 apps/backend/src/services/fulfillment/intake-channel-decision-service.test.ts delete mode 100644 apps/frontend/src/pages/admin/platform/AdminPlatformFulfillmentPage.tsx delete mode 100644 apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts delete mode 100644 apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts index b054b1db..da618e37 100644 --- a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts +++ b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts @@ -132,6 +132,32 @@ async function main() { ORDER BY count(*) DESC, internal_sku`, ) + const internalChannels = await query<{ + channel: string + source: string + total: number + }>( + `SELECT channel, source, count(*)::int AS total + FROM internal_sku_channels + WHERE enabled = TRUE + GROUP BY channel, source + ORDER BY channel, source`, + ) + + const canonicalChannelDuplicates = await query<{ + internal_sku: string + channel: string + total: number + }>( + `SELECT canonicalize_internal_sku(internal_sku) AS internal_sku, + channel, count(*)::int AS total + FROM internal_sku_channels + WHERE enabled = TRUE + GROUP BY canonicalize_internal_sku(internal_sku), channel + HAVING count(*) > 1 + ORDER BY count(*) DESC, internal_sku, channel`, + ) + const assignedCarrierConflicts = await query<{ order_item_id: number channel: string @@ -197,7 +223,9 @@ async function main() { rules: rules.rows, duplicateProducts: duplicateProducts.rows[0], capabilities: capabilities.rows, + internalChannels: internalChannels.rows, internalChannelConflicts: internalChannelConflicts.rows, + canonicalChannelDuplicates: canonicalChannelDuplicates.rows, assignedCarrierConflicts: assignedCarrierConflicts.rows, byProvider: byProvider.rows, }, diff --git a/apps/backend/scripts/backfill-internal-sku-mappings.ts b/apps/backend/scripts/backfill-internal-sku-mappings.ts index 5285ff7e..c5a0c912 100644 --- a/apps/backend/scripts/backfill-internal-sku-mappings.ts +++ b/apps/backend/scripts/backfill-internal-sku-mappings.ts @@ -1,8 +1,7 @@ /** - * 从 PDD 纯数字内部 SKU 的订单项回填中文映射(只读扫描 + 幂等写入)。 + * 从平台 SKU 非中文、但商品名含中文的订单项回填中文内部 SKU 映射。 * - * 只处理 sku_name 含中文、sku_code 为纯数字的情况; - * 找不到中文名的纯数字码会列出来,等运营补映射。 + * 数字码、英文码一视同仁;找不到中文名的平台码会列出来,等运营补映射。 * * 用法:npx tsx scripts/backfill-internal-sku-mappings.ts [--apply] */ @@ -22,7 +21,7 @@ async function main() { SELECT o.provider, o.shop_id, oi.sku_code, MIN(oi.sku_name) AS sku_name, count(*)::int AS total FROM order_items oi JOIN orders o ON o.id = oi.order_id - WHERE btrim(COALESCE(oi.manual_sku_key, '')) ~ '^[0-9]+$' + WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]' AND btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]' GROUP BY 1, 2, 3 ORDER BY 5 DESC @@ -38,7 +37,7 @@ async function main() { SELECT o.provider, o.shop_id, oi.sku_code, count(*)::int AS total FROM order_items oi JOIN orders o ON o.id = oi.order_id - WHERE btrim(COALESCE(oi.manual_sku_key, '')) ~ '^[0-9]+$' + WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]' AND btrim(COALESCE(oi.sku_name, '')) !~ '[一-龥]' GROUP BY 1, 2, 3 ORDER BY 4 DESC diff --git a/apps/backend/scripts/preflight-fulfillment-routing.ts b/apps/backend/scripts/preflight-fulfillment-routing.ts index 1e8d5bfa..90fcee4f 100644 --- a/apps/backend/scripts/preflight-fulfillment-routing.ts +++ b/apps/backend/scripts/preflight-fulfillment-routing.ts @@ -118,6 +118,62 @@ async function main() { ) ).rows + checks.canonicalChannelDuplicates = ( + await query( + `SELECT canonicalize_internal_sku(internal_sku) AS label, count(*)::int AS total + FROM internal_sku_channels + WHERE enabled = TRUE + GROUP BY canonicalize_internal_sku(internal_sku), channel + HAVING count(*) > 1 + ORDER BY count(*) DESC, label`, + ) + ).rows + + checks.incompleteCarrierConfigs = ( + await query( + `SELECT channel AS label, count(*)::int AS total + FROM internal_sku_channels + WHERE enabled = TRUE + AND ( + ( + channel = 'work_order' + AND ( + COALESCE(config_json->>'workRuleKey', '') = '' + OR NOT EXISTS ( + SELECT 1 FROM work_product_rules rule + WHERE rule.rule_key = config_json->>'workRuleKey' + AND rule.enabled = TRUE + ) + ) + ) + OR ( + channel = 'manual' + AND NOT EXISTS ( + SELECT 1 FROM manual_fulfillment_configs cfg + WHERE cfg.enabled = TRUE + AND canonicalize_internal_sku(cfg.sku_key) + = canonicalize_internal_sku(internal_sku_channels.internal_sku) + ) + ) + ) + GROUP BY channel ORDER BY count(*) DESC`, + ) + ).rows + + checks.pendingWithoutChineseInternalSku = ( + await query( + `SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'pending' + AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') + AND COALESCE(resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ), '') = ''`, + [includeDemo], + ) + ).rows[0] + checks.enabledLegacyRules = ( await query( `SELECT match_key_type || '/' || channel AS label, count(*)::int AS total @@ -202,6 +258,12 @@ async function main() { failed.push('存在同时挂载多个真实履约载体的已分配订单项') if ((checks.internalChannelConflicts as CountRow[]).length > 0) failed.push('存在多个非兜底主渠道') + if ((checks.canonicalChannelDuplicates as CountRow[]).length > 0) + failed.push('存在规范化后重复的内部 SKU 渠道配置') + if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0) + failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道') + if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0) + failed.push('存在无法归一到中文内部 SKU 的 pending 订单项') if ((checks.enabledLegacyRules as CountRow[]).length > 0) failed.push('仍有启用的 sku_code 兼容规则,未完全收敛到内部 SKU') if ((checks.pendingWithoutReason as CountRow).total > 0) diff --git a/apps/backend/src/db/migrations/126_internal_sku_canonical_routing.sql b/apps/backend/src/db/migrations/126_internal_sku_canonical_routing.sql new file mode 100644 index 00000000..1ce36e3c --- /dev/null +++ b/apps/backend/src/db/migrations/126_internal_sku_canonical_routing.sql @@ -0,0 +1,134 @@ +-- 126_internal_sku_canonical_routing.sql +-- +-- 履约渠道的唯一标准键是规范化后的中文内部 SKU。 +-- 平台 SKU 只能用于入口映射,不能再成为渠道判定或兜底键。 + +CREATE OR REPLACE FUNCTION canonicalize_internal_sku(p_value TEXT) +RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +PARALLEL SAFE +AS $$ + SELECT NULLIF( + LOWER( + REGEXP_REPLACE( + BTRIM(NORMALIZE(COALESCE(p_value, ''), NFKC)), + '\s+', + '', + 'g' + ) + ), + '' + ) +$$; + +COMMENT ON FUNCTION canonicalize_internal_sku(TEXT) IS + '内部 SKU 唯一规范化:NFKC、去全部空白、转小写'; + +CREATE OR REPLACE FUNCTION resolve_internal_sku( + p_manual_sku_key TEXT, + p_sku_name TEXT, + p_sku_code TEXT +) RETURNS TEXT +LANGUAGE SQL +IMMUTABLE +PARALLEL SAFE +AS $$ + SELECT COALESCE( + CASE + 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) ~ '[一-龥]' + THEN canonicalize_internal_sku(p_sku_name) + END + ) +$$; + +COMMENT ON FUNCTION resolve_internal_sku(TEXT, TEXT, TEXT) IS + '订单项唯一内部 SKU 解析:只接受规范化后的中文 SKU,平台 SKU 不参与兜底'; + +COMMENT ON COLUMN order_items.manual_sku_key IS + '原始内部 SKU 匹配键(skuNick -> outSkuSn -> sku_code -> sku_name);渠道判定统一使用 resolve_internal_sku() 的中文规范值'; + +CREATE OR REPLACE FUNCTION normalize_internal_sku_channel_key() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.internal_sku := canonicalize_internal_sku(NEW.internal_sku); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION normalize_manual_fulfillment_config_key() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.sku_key := canonicalize_internal_sku(NEW.sku_key); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION normalize_manual_sku_registry_key() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.sku_key := canonicalize_internal_sku(NEW.sku_key); + RETURN NEW; +END; +$$; + +CREATE OR REPLACE FUNCTION normalize_internal_sku_mapping_keys() +RETURNS TRIGGER +LANGUAGE plpgsql +AS $$ +BEGIN + NEW.match_value := canonicalize_internal_sku(NEW.match_value); + NEW.internal_sku := canonicalize_internal_sku(NEW.internal_sku); + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_normalize_internal_sku_channel_key ON internal_sku_channels; +CREATE TRIGGER trg_normalize_internal_sku_channel_key + BEFORE INSERT OR UPDATE OF internal_sku ON internal_sku_channels + FOR EACH ROW EXECUTE FUNCTION normalize_internal_sku_channel_key(); + +DROP TRIGGER IF EXISTS trg_normalize_manual_fulfillment_config_key ON manual_fulfillment_configs; +CREATE TRIGGER trg_normalize_manual_fulfillment_config_key + BEFORE INSERT OR UPDATE OF sku_key ON manual_fulfillment_configs + FOR EACH ROW EXECUTE FUNCTION normalize_manual_fulfillment_config_key(); + +DROP TRIGGER IF EXISTS trg_normalize_manual_sku_registry_key ON manual_sku_registry; +CREATE TRIGGER trg_normalize_manual_sku_registry_key + BEFORE INSERT OR UPDATE OF sku_key ON manual_sku_registry + FOR EACH ROW EXECUTE FUNCTION normalize_manual_sku_registry_key(); + +DROP TRIGGER IF EXISTS trg_normalize_internal_sku_mapping_keys ON internal_sku_mappings; +CREATE TRIGGER trg_normalize_internal_sku_mapping_keys + BEFORE INSERT OR UPDATE OF match_value, internal_sku ON internal_sku_mappings + FOR EACH ROW EXECUTE FUNCTION normalize_internal_sku_mapping_keys(); + +CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_canonical + ON internal_sku_channels (canonicalize_internal_sku(internal_sku), priority, channel) + WHERE enabled = TRUE; + +CREATE INDEX IF NOT EXISTS idx_manual_fulfillment_configs_canonical_sku + ON manual_fulfillment_configs (canonicalize_internal_sku(sku_key)) + WHERE enabled = TRUE; + +CREATE INDEX IF NOT EXISTS idx_internal_sku_mappings_canonical_match + ON internal_sku_mappings (match_type, canonicalize_internal_sku(match_value)) + WHERE enabled = TRUE; + +CREATE INDEX IF NOT EXISTS idx_product_catalog_canonical_name + ON product_catalog (canonicalize_internal_sku(canonical_name)) + WHERE status = 'active'; + +CREATE INDEX IF NOT EXISTS idx_order_items_pending_internal_sku + ON order_items (resolve_internal_sku(manual_sku_key, sku_name, sku_code)) + WHERE fulfillment_status = 'pending'; diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index e34dfbdf..023a79ba 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -84,8 +84,6 @@ router.post( if (body.internalSku) { return assignChannelByInternalSku({ - provider: String(body.provider || ''), - shopId: String(body.shopId || ''), internalSku: String(body.internalSku), channel, adminUserId, diff --git a/apps/backend/src/routes/admin/platform-config.ts b/apps/backend/src/routes/admin/platform-config.ts index 16e40be1..7204b921 100644 --- a/apps/backend/src/routes/admin/platform-config.ts +++ b/apps/backend/src/routes/admin/platform-config.ts @@ -4,7 +4,6 @@ import { requireAdminRoles } from './session.js' import kuaishouIndustryRouter from './platform-config/kuaishou-industry.js' import ninetyoneRouter from './platform-config/ninetyone.js' import notificationsRouter from './platform-config/notifications.js' -import fulfillmentRoutingRouter from './platform-config/fulfillment-routing.js' import affiliateDashRouter from './platform-config/affiliate-dash.js' import douyinMashidaRouter from './platform-config/douyin-mashida.js' import pddRouter from './platform-config/pdd.js' @@ -15,7 +14,6 @@ router.use('/platform-config', kuaishouIndustryRouter) router.use('/platform-config', requireAdminRoles(['admin'])) router.use('/platform-config', notificationsRouter) router.use('/platform-config', ninetyoneRouter) -router.use('/platform-config', fulfillmentRoutingRouter) router.use('/platform-config', affiliateDashRouter) router.use('/platform-config', pddRouter) // 抖音码速达:注册在 requireAdminRoles(['admin']) 之后,自动继承 admin 守卫 diff --git a/apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts b/apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts deleted file mode 100644 index 6fa8448b..00000000 --- a/apps/backend/src/routes/admin/platform-config/fulfillment-routing.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { Router } from 'express' - -import { - getAdminFulfillmentRoutingConfig, - previewAdminFulfillmentRouting, - updateAdminFulfillmentRoutingConfig, -} from '../../../services/admin/platform-config/fulfillment-routing-service.js' -import { createJsonHandler } from '../session.js' -import type { JsonRecord } from '../../../types/json.js' - -const router = Router() - -router.get( - '/fulfillment-routing', - createJsonHandler(() => getAdminFulfillmentRoutingConfig(), { - successMessage: 'ok', - errorMessage: '读取履约路由配置失败', - scope: '[admin/platform-config/fulfillment-routing]', - }), -) - -router.post( - '/fulfillment-routing', - createJsonHandler((req) => updateAdminFulfillmentRoutingConfig(req.body as JsonRecord), { - successMessage: '履约路由配置已保存', - errorMessage: '保存履约路由配置失败', - scope: '[admin/platform-config/fulfillment-routing]', - audit: (_req, data) => { - const result = data as JsonRecord - return { - action: 'platform_fulfillment_routing_updated', - targetType: 'platform_config', - targetId: 'fulfillment_routing', - data: { - filePath: String(result.filePath || '').trim(), - enabled: result.enabled !== false, - ruleCount: Array.isArray(result.rules) ? result.rules.length : 0, - }, - } - }, - }), -) - -router.post( - '/fulfillment-routing/preview', - createJsonHandler((req) => previewAdminFulfillmentRouting(req.body as JsonRecord), { - successMessage: 'ok', - errorMessage: '预览履约路由失败', - scope: '[admin/platform-config/fulfillment-routing/preview]', - }), -) - -export default router diff --git a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts b/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts deleted file mode 100644 index 98717135..00000000 --- a/apps/backend/src/services/admin/platform-config/fulfillment-routing-service.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - getFulfillmentRoutingConfig, - getFulfillmentRoutingConfigFilePath, - saveFulfillmentRoutingConfig, -} from '../../fulfillment/routing-config-service.js' -import { resolveOrderItemForFulfillment } from '../../fulfillment/product-resolution-service.js' -import { normalizeProductMatchName } from '../../order/product-match-utils.js' -import type { JsonRecord } from '../../../types/json.js' - -export function getAdminFulfillmentRoutingConfig() { - return { - filePath: getFulfillmentRoutingConfigFilePath(), - ...getFulfillmentRoutingConfig(), - } -} - -export async function updateAdminFulfillmentRoutingConfig(payload: JsonRecord = {}) { - return { - filePath: getFulfillmentRoutingConfigFilePath(), - ...(await saveFulfillmentRoutingConfig(payload)), - } -} - -export async function previewAdminFulfillmentRouting(payload: JsonRecord = {}) { - const productName = String(payload.productName || payload.skuName || '').trim() - const item = await resolveOrderItemForFulfillment({ - provider: '91kaquan', - platform: 'kuaishou', - item: { - itemId: productName, - externalItemId: productName, - skuCode: productName, - skuName: productName, - externalSkuCode: productName, - externalSkuName: productName, - quantity: 1, - }, - }) - const snapshot = item.snapshot || {} - - return { - productName, - normalizedProductName: normalizeProductMatchName(productName), - resolvedSkuCode: item.skuCode, - resolvedSkuName: item.skuName, - isConfigured: item.isConfigured, - fulfillmentRoute: snapshot.fulfillmentRoute || null, - affiliateDash: snapshot.affiliateDash || null, - } -} diff --git a/apps/backend/src/services/cost-center/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 0e33fb50..7e5abc56 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -52,7 +52,8 @@ export function productSourceFilterSql( WHERE live_channel.enabled = TRUE AND live_channel.is_fallback = FALSE AND live_channel.channel <> 'manual' - AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn}) + AND canonicalize_internal_sku(live_channel.internal_sku) + = canonicalize_internal_sku(${nameColumn}) ) ) )` diff --git a/apps/backend/src/services/cost-center/cost-center-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index afc47bf1..55dc539f 100644 --- a/apps/backend/src/services/cost-center/cost-center-overview-service.ts +++ b/apps/backend/src/services/cost-center/cost-center-overview-service.ts @@ -51,7 +51,8 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} WHERE live_channel.enabled = TRUE AND live_channel.is_fallback = FALSE AND live_channel.channel <> 'manual' - AND BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name) + AND canonicalize_internal_sku(live_channel.internal_sku) + = canonicalize_internal_sku(p.canonical_name) ) ) ) @@ -116,7 +117,8 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} FROM internal_sku_channels c WHERE c.enabled = TRUE AND c.is_fallback = FALSE - AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + AND canonicalize_internal_sku(c.internal_sku) + = canonicalize_internal_sku(p.canonical_name) ORDER BY c.priority, c.channel LIMIT 1 ) AS active_primary_channel, diff --git a/apps/backend/src/services/cost-center/cost-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index c65bbe97..7633261d 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -295,7 +295,10 @@ test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => { const sql = productSourceFilterSql(1) assert.ok(sql.includes(`$1 = 'manual'`)) assert.ok(sql.includes("live_channel.channel <> 'manual'")) - assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)')) + assert.ok( + sql.includes('canonicalize_internal_sku(live_channel.internal_sku)') && + sql.includes('canonicalize_internal_sku(p.canonical_name)'), + ) }) test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => { diff --git a/apps/backend/src/services/cost-center/cost-exception-service.ts b/apps/backend/src/services/cost-center/cost-exception-service.ts index 3321b47f..27c84d37 100644 --- a/apps/backend/src/services/cost-center/cost-exception-service.ts +++ b/apps/backend/src/services/cost-center/cost-exception-service.ts @@ -9,7 +9,7 @@ import { nowIso } from '../../utils/time.js' * · 新:同一 scope 聚合为一条问题头,明细放 cost_exception_items, * 新单进来只加计数,解决后自动关闭。 * - * scope_key 约定:`reason|provider|shop_id|sku|channel`,由调用方拼好。 + * scope_key 统一以中文内部 SKU 为聚合维度;平台 SKU/店铺只放 detail,不参与分组。 */ export const COST_EXCEPTION_REASONS = [ diff --git a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index f7a62b4f..f15205ae 100644 --- a/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts +++ b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts @@ -5,6 +5,7 @@ import { createHttpError } from '../../utils/http.js' import { formatShanghaiDate, shiftDate } from '../../utils/finance-date-range.js' import { nowIso } from '../../utils/time.js' import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js' +import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' /** * 人工履约 SKU 配置服务。 @@ -244,7 +245,8 @@ export async function listManualSkus(input: Record = {}) { JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND o.provider = $${params.length} - AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = r.sku_key + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + = canonicalize_internal_sku(r.sku_key) )`) } @@ -253,7 +255,8 @@ export async function listManualSkus(input: Record = {}) { ` SELECT COUNT(*)::int AS total FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key + LEFT JOIN manual_fulfillment_configs cfg + ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) WHERE ${where} `, params, @@ -272,7 +275,8 @@ export async function listManualSkus(input: Record = {}) { cfg.canonical_product_id AS config_canonical_product_id, cfg.enabled AS config_enabled FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key + LEFT JOIN manual_fulfillment_configs cfg + ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) WHERE ${where} ORDER BY r.item_count DESC, r.sku_key ASC LIMIT $${listParams.length - 1} OFFSET $${listParams.length} @@ -309,7 +313,8 @@ export async function getManualSkuOverview() { AS unconfigured_items, MAX(r.updated_at) AS last_scan_at FROM manual_sku_registry r - LEFT JOIN manual_fulfillment_configs cfg ON cfg.sku_key = r.sku_key + LEFT JOIN manual_fulfillment_configs cfg + ON canonicalize_internal_sku(cfg.sku_key) = canonicalize_internal_sku(r.sku_key) `, ) const row = result.rows[0] @@ -326,9 +331,9 @@ export async function getManualSkuOverview() { /** 保存(新增或更新)一个人工 SKU 的履约配置。 */ export async function saveManualFulfillmentConfig(payload: JsonObject) { - const skuKey = String(payload.skuKey ?? payload.sku_key ?? '').trim() - if (!skuKey) { - throw createHttpError('内部 SKU 不能为空', { + const skuKey = canonicalizeInternalSku(payload.skuKey ?? payload.sku_key) + if (!skuKey || !containsCjk(skuKey)) { + throw createHttpError('必须使用包含中文的内部 SKU', { statusCode: 400, errorCode: 'manual_config_sku_key_required', }) @@ -346,39 +351,47 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { const unitPrices = await loadPlatformUnitPriceFen() const now = nowIso() - const result = await query<{ - id: number - sku_key: string - canonical_product_id: number | null - platform_key: string - credit_quantity: number - cost_mode: string - enabled: boolean - note: string - created_at: string - updated_at: string - }>( - ` - INSERT INTO manual_fulfillment_configs ( - sku_key, canonical_product_id, platform_key, credit_quantity, - cost_mode, enabled, note, created_at, updated_at - ) VALUES ( - $1, $2, $3, $4, $5, $6, $7, $8, $8 - ) - ON CONFLICT (sku_key) DO UPDATE - SET canonical_product_id = EXCLUDED.canonical_product_id, - platform_key = EXCLUDED.platform_key, - credit_quantity = EXCLUDED.credit_quantity, - cost_mode = EXCLUDED.cost_mode, - enabled = EXCLUDED.enabled, - note = EXCLUDED.note, - updated_at = EXCLUDED.updated_at - RETURNING * - `, - [skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now], - ) + const config = await withTransaction(async (client) => { + const existing = await client.query<{ id: number }>( + `SELECT id FROM manual_fulfillment_configs + WHERE canonicalize_internal_sku(sku_key) = $1 + ORDER BY id + LIMIT 1 + FOR UPDATE`, + [skuKey], + ) + const existingId = Number(existing.rows[0]?.id || 0) + const result = existingId + ? await client.query( + `UPDATE manual_fulfillment_configs + SET sku_key = $2, canonical_product_id = $3, platform_key = $4, + credit_quantity = $5, cost_mode = $6, enabled = $7, note = $8, + updated_at = $9 + WHERE id = $1 + RETURNING *`, + [ + existingId, + skuKey, + canonicalProductId, + platformKey, + creditQuantity, + costMode, + enabled, + note, + now, + ], + ) + : await client.query( + `INSERT INTO manual_fulfillment_configs ( + sku_key, canonical_product_id, platform_key, credit_quantity, + cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $8) + RETURNING *`, + [skuKey, canonicalProductId, platformKey, creditQuantity, costMode, enabled, note, now], + ) + return requiredRow(result.rows[0], '保存人工履约配置失败') + }) - const config = requiredRow(result.rows[0], '保存人工履约配置失败') await syncRegistryConfigId(config.sku_key, config.id) // 同步渠道规则:人工配置就是「这个内部 SKU 走人工渠道」的唯一声明, // 少了这一步,新配的 SKU 会一直停在待分类。 @@ -397,7 +410,7 @@ export async function saveManualFulfillmentConfig(payload: JsonObject) { /** 删除一份人工履约配置(SKU 回到未配置状态)。 */ export async function deleteManualFulfillmentConfig(skuKey: string) { - const key = String(skuKey || '').trim() + const key = canonicalizeInternalSku(skuKey) if (!key) { throw createHttpError('内部 SKU 不能为空', { statusCode: 400, @@ -405,7 +418,9 @@ export async function deleteManualFulfillmentConfig(skuKey: string) { }) } const deleted = await query( - `DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`, + `DELETE FROM manual_fulfillment_configs + WHERE canonicalize_internal_sku(sku_key) = $1 + RETURNING id`, [key], ) await upsertInternalSkuChannel({ @@ -444,10 +459,12 @@ export async function rematchManualFulfillments(input: Record = FROM order_items oi JOIN orders o ON o.id = oi.order_id JOIN manual_fulfillment_configs cfg - ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND cfg.enabled = TRUE - AND ($1 = '' OR cfg.sku_key = $1) + AND ($1 = '' OR canonicalize_internal_sku(cfg.sku_key) = $1) AND ($2 = FALSE OR oi.fulfillment_config_id IS NULL) AND ( $3::date IS NULL @@ -532,17 +549,18 @@ async function attachCostVersionsForManualConfigs() { /** 配置保存/删除后同步登记表上的 config_id。 */ async function syncRegistryConfigId(skuKey: string, configId: number | null) { - await query(`UPDATE manual_sku_registry SET config_id = $2, updated_at = $3 WHERE sku_key = $1`, [ - skuKey, - configId, - nowIso(), - ]) + await query( + `UPDATE manual_sku_registry + SET config_id = $2, updated_at = $3 + WHERE canonicalize_internal_sku(sku_key) = $1`, + [canonicalizeInternalSku(skuKey), configId, nowIso()], + ) } /** 读取单个 SKU 的配置详情(含平台标签与成本折算)。 */ export async function getManualFulfillmentConfig(skuKey: string) { const unitPrices = await loadPlatformUnitPriceFen() - const key = String(skuKey || '').trim() + const key = canonicalizeInternalSku(skuKey) const result = await query<{ id: number sku_key: string @@ -554,7 +572,13 @@ export async function getManualFulfillmentConfig(skuKey: string) { note: string created_at: string updated_at: string - }>(`SELECT * FROM manual_fulfillment_configs WHERE sku_key = $1 LIMIT 1`, [key]) + }>( + `SELECT * FROM manual_fulfillment_configs + WHERE canonicalize_internal_sku(sku_key) = $1 + ORDER BY id + LIMIT 1`, + [key], + ) const row = result.rows[0] if (!row) return null return mapManualConfig(row, unitPrices) @@ -737,7 +761,9 @@ async function syncManualChannelRule(config: { ` SELECT EXISTS ( SELECT 1 FROM internal_sku_channels - WHERE btrim(internal_sku) = $1 AND enabled = TRUE AND channel <> 'manual' + WHERE canonicalize_internal_sku(internal_sku) = $1 + AND enabled = TRUE + AND channel <> 'manual' ) AS exists `, [skuKey], diff --git a/apps/backend/src/services/cost-center/product-catalog-service.ts b/apps/backend/src/services/cost-center/product-catalog-service.ts index f317601f..92956dce 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -161,7 +161,8 @@ export async function listAdminProducts(payload: JsonObject = {}) { FROM internal_sku_channels c WHERE c.enabled = TRUE AND c.is_fallback = FALSE - AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + AND canonicalize_internal_sku(c.internal_sku) + = canonicalize_internal_sku(p.canonical_name) ORDER BY c.priority, c.channel LIMIT 1 ) AS active_primary_channel, @@ -521,7 +522,8 @@ export async function archiveSupersededManualProducts(payload: JsonObject = {}) WHERE c.enabled = TRUE AND c.is_fallback = FALSE AND c.channel <> 'manual' - AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + AND canonicalize_internal_sku(c.internal_sku) + = canonicalize_internal_sku(p.canonical_name) ORDER BY c.priority, c.channel LIMIT 1 ) live ON TRUE @@ -557,7 +559,8 @@ export async function archiveSupersededManualProducts(payload: JsonObject = {}) updated_at = NOW() FROM unnest($1::text[], $2::bigint[], $3::bigint[]) AS input(internal_sku, source_product_id, target_product_id) - WHERE c.internal_sku = input.internal_sku + WHERE canonicalize_internal_sku(c.internal_sku) + = canonicalize_internal_sku(input.internal_sku) AND c.channel = 'manual' AND c.canonical_product_id = input.source_product_id `, diff --git a/apps/backend/src/services/cost-center/product-normalize-service.test.ts b/apps/backend/src/services/cost-center/product-normalize-service.test.ts index 39b7125d..5463dacf 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.test.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.test.ts @@ -50,7 +50,8 @@ const index = buildMappingIndex( mappings, [{ sku: 'aff-sku', productId: 21 }], [{ ruleKey: 'rule-abc', productId: 31 }], - [{ sku: 'voucher-sku', productId: 41 }], + [{ sku: '中文卡券', productId: 41 }], + [{ canonicalName: '套装-测试', productId: 51 }], ) function item(patch: Record = {}) { @@ -132,13 +133,31 @@ test('商品归一:人工渠道直接使用配置里指定的商品', () => { test('商品归一:第三方卡券按 tpv:SKU 直接命中', () => { assert.equal( resolveProductId( - item({ fulfillment_channel: 'third_party_voucher', sku_code: 'voucher-sku' }), + item({ + fulfillment_channel: 'third_party_voucher', + sku_code: 'platform-voucher-code', + internal_sku: '中文卡券', + }), index, ), 41, ) }) +test('商品归一:中文内部 SKU 命中标准商品,不依赖平台 SKU', () => { + assert.equal( + resolveProductId( + item({ + fulfillment_channel: 'affiliate_dash', + sku_code: 'unknown', + internal_sku: '套装-测试', + }), + index, + ), + 51, + ) +}) + test('三方卡券自动建商品时优先使用内部中文 SKU', () => { assert.equal( voucherCanonicalSku({ @@ -156,6 +175,6 @@ test('三方卡券自动建商品时优先使用内部中文 SKU', () => { }), 'tpv:11月改名卡', ) - assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), 'tpv:x') + assert.equal(voucherCanonicalSku({ manual_sku_key: '', sku_name: '', sku_code: 'x' }), null) assert.equal(voucherCanonicalSku({}), null) }) diff --git a/apps/backend/src/services/cost-center/product-normalize-service.ts b/apps/backend/src/services/cost-center/product-normalize-service.ts index 693474e1..e938bdd7 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -2,6 +2,7 @@ import { query } from '../../db/client.js' import { resolveExceptionsForItems, upsertAggregatedException } from './cost-exception-service.js' import { readWatermarkItemId } from '../watermark-store.js' import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-service.js' +import { canonicalizeInternalSku, containsCjk } from '../fulfillment/internal-sku-resolver.js' /** * 管道 B:商品归一。 @@ -9,12 +10,10 @@ import { upsertInternalSkuChannel } from '../fulfillment/internal-sku-channel-se * 只写 `order_items.canonical_product_id`,不改渠道、不碰成本; * 例外:三方卡券缺商品时按内部中文 SKU 自动建 `tpv:` 商品并补渠道规则。 * - * 解析顺序(与旧的四条回填路径统一): - * 1. 自动发货渠道:`aff:` 商品直接命中(该渠道的商品就是 SKU 本身); - * 2. 精确映射:provider + shop + sku_code; - * 3. 精确映射(店铺无关); - * 4. 编码+名称映射:provider + shop + normalized_sku_name; - * 5. 编码系列 → 名称系列。 + * 解析顺序: + * 1. 人工/接单/自动发货/卡券的明确载体参数; + * 2. 中文内部 SKU 直接命中 `product_catalog.canonical_name`; + * 3. 平台 SKU 映射只作为历史数据兜底。 * * 全部未命中 → 保持为空,写一条 SKU 级 `product_mapping_missing`。 */ @@ -37,6 +36,7 @@ export type MappingIndex = { affSkus: Map voucherSkus: Map templateRules: Map + byCanonicalName: Map } type ItemRow = { @@ -46,6 +46,7 @@ type ItemRow = { sku_code: string sku_name: string manual_sku_key: string | null + internal_sku?: string normalized_sku_name: string | null normalized_sku_series: string | null fulfillment_channel: string | null @@ -62,6 +63,7 @@ export function buildMappingIndex( affiliateProducts: Array<{ sku: string; productId: number }>, templateProducts: Array<{ ruleKey: string; productId: number }> = [], voucherProducts: Array<{ sku: string; productId: number }> = [], + canonicalProducts: Array<{ canonicalName: string; productId: number }> = [], ): MappingIndex { const index: MappingIndex = { bySku: new Map(), @@ -71,6 +73,7 @@ export function buildMappingIndex( affSkus: new Map(), voucherSkus: new Map(), templateRules: new Map(), + byCanonicalName: new Map(), } for (const mapping of mappings) { @@ -110,19 +113,26 @@ export function buildMappingIndex( index.voucherSkus.set(String(product.sku).trim(), product.productId) } + for (const product of canonicalProducts) { + const canonicalName = canonicalizeInternalSku(product.canonicalName) + if (canonicalName && !index.byCanonicalName.has(canonicalName)) { + index.byCanonicalName.set(canonicalName, product.productId) + } + } + return index } -/** 三方卡券的内部 SKU:优先中文,其次名称/平台码。 */ +/** 三方卡券只接受中文内部 SKU;平台码不能作为标准商品键。 */ export function pickVoucherInternalSku(item: { manual_sku_key?: string | null sku_name?: string | null sku_code?: string | null }): string { const candidates = [item.manual_sku_key, item.sku_name, item.sku_code] - .map((value) => String(value || '').trim()) + .map(canonicalizeInternalSku) .filter(Boolean) - return candidates.find((value) => /[\u4e00-\u9fff]/.test(value)) || candidates[0] || '' + return candidates.find(containsCjk) || '' } export function voucherCanonicalSku(item: { @@ -212,6 +222,7 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n const skuCode = String(item.sku_code || '').trim() const nameKey = String(item.normalized_sku_name || '').trim() const seriesKey = String(item.normalized_sku_series || '').trim() + const internalSku = canonicalizeInternalSku(item.internal_sku || item.manual_sku_key) // 人工渠道:配置里已经指定商品,直接用,不需要再建映射。 if (item.fulfillment_channel === 'manual' && item.manual_config_product_id) { @@ -230,11 +241,17 @@ export function resolveProductId(item: ItemRow, index: MappingIndex): number | n } // 第三方卡券:与自动发货同构,商品编码 tpv:。 - if (item.fulfillment_channel === 'third_party_voucher' && skuCode) { - const voucher = index.voucherSkus.get(skuCode) + if (item.fulfillment_channel === 'third_party_voucher' && internalSku) { + const voucher = index.voucherSkus.get(internalSku) if (voucher) return voucher } + // 中文内部 SKU 是标准商品键;平台 SKU 映射只作为历史数据兜底。 + if (internalSku) { + const canonical = index.byCanonicalName.get(internalSku) + if (canonical) return canonical + } + const exactWithShop = index.bySku.get(key(provider, shopId, skuCode)) if (exactWithShop) return exactWithShop const exactGlobal = index.bySku.get(key(provider, '', skuCode)) @@ -300,6 +317,7 @@ export async function normalizeOrderItemProducts( oi.sku_code, oi.sku_name, oi.manual_sku_key, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, oi.normalized_sku_name, oi.normalized_sku_series, oi.fulfillment_channel, @@ -339,7 +357,7 @@ export async function normalizeOrderItemProducts( result.unmatched += 1 await upsertAggregatedException({ reason: 'product_mapping_missing', - scopeKey: `product_mapping_missing|${item.provider}|${item.shop_id}|${item.sku_code}|${item.fulfillment_channel || '-'}`, + scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`, orderItemId: item.id, detail: { skuName: item.sku_name, channel: item.fulfillment_channel }, }) @@ -402,6 +420,17 @@ async function loadMappingIndex(): Promise { `, ) + const canonicalProducts = await query<{ canonical_name: string; id: number }>( + ` + SELECT canonicalize_internal_sku(canonical_name) AS canonical_name, + MIN(id)::bigint AS id + FROM product_catalog + WHERE status = 'active' AND btrim(COALESCE(canonical_name, '')) <> '' + GROUP BY canonicalize_internal_sku(canonical_name) + HAVING canonicalize_internal_sku(canonical_name) IS NOT NULL + `, + ) + return buildMappingIndex( mappings.rows, affiliateProducts.rows.map((row) => ({ @@ -416,6 +445,10 @@ async function loadMappingIndex(): Promise { sku: String(row.canonical_sku).slice(4), productId: Number(row.id), })), + canonicalProducts.rows.map((row) => ({ + canonicalName: row.canonical_name, + productId: Number(row.id), + })), ) } @@ -438,6 +471,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ oi.sku_code, oi.sku_name, oi.manual_sku_key, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, oi.normalized_sku_name, oi.normalized_sku_series, oi.fulfillment_channel, @@ -465,7 +499,7 @@ export async function normalizeProductsForItems(itemIds: number[]): Promise<{ unmatched += 1 await upsertAggregatedException({ reason: 'product_mapping_missing', - scopeKey: `product_mapping_missing|${item.provider}|${item.shop_id}|${item.sku_code}|${item.fulfillment_channel || '-'}`, + scopeKey: `product_mapping_missing|-|-|${canonicalizeInternalSku(item.internal_sku || item.manual_sku_key || item.sku_code)}|${item.fulfillment_channel || '-'}`, orderItemId: item.id, detail: { skuName: item.sku_name, channel: item.fulfillment_channel }, }) diff --git a/apps/backend/src/services/finance/manual-fulfillment-service.ts b/apps/backend/src/services/finance/manual-fulfillment-service.ts index ae12cbd5..25c996a8 100644 --- a/apps/backend/src/services/finance/manual-fulfillment-service.ts +++ b/apps/backend/src/services/finance/manual-fulfillment-service.ts @@ -619,8 +619,7 @@ async function resolveCreditOwnerAdminUserId( /** * 确认前按 SKU 匹配人工履约配置。 * - * 与 `rematchManualFulfillments` 使用同一套键: - * spec_json.skuNick -> spec_json.outSkuSn -> sku_code -> sku_name。 + * 与 `rematchManualFulfillments` 使用同一套键:规范中文内部 SKU。 * 这样即使配置晚于订单创建,新单在确认时也能自动挂上渠道。 */ async function matchManualFulfillmentConfigWithClient( @@ -637,11 +636,8 @@ async function matchManualFulfillmentConfigWithClient( FROM order_items oi JOIN manual_fulfillment_configs cfg ON cfg.enabled = TRUE AND ( - cfg.sku_key = COALESCE( - NULLIF(oi.spec_json->>'skuNick', ''), - NULLIF(oi.spec_json->>'outSkuSn', ''), - NULLIF(oi.sku_code, ''), - NULLIF(oi.sku_name, '') + canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code ) OR ( cfg.canonical_product_id IS NOT NULL diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index ad72624b..0b9dec51 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -48,7 +48,9 @@ export async function runAffiliateFallback( FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN manual_fulfillment_configs cfg - ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) AND cfg.enabled WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'affiliate_dash' @@ -58,7 +60,7 @@ export async function runAffiliateFallback( WHERE channel.enabled = TRUE AND channel.channel = 'manual' AND channel.is_fallback = TRUE - AND btrim(channel.internal_sku) = resolve_internal_sku( + AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code ) ) diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index 179ccdb7..4f5e70b1 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -5,7 +5,7 @@ import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-o import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './channel-types.js' import { applyIntakeChannelDecision } from './intake-channel-decision-service.js' import type { ResolvedFulfillmentItem } from './product-resolution-service.js' -import { resolveInternalSkuKey } from './internal-sku-resolver.js' +import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js' function parseObject(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { @@ -82,7 +82,8 @@ export async function ensureCarrierForOrderItem(input: { const config = await query<{ id: number; canonical_product_id: number | null }>( `SELECT id, canonical_product_id FROM manual_fulfillment_configs - WHERE sku_key = resolve_internal_sku($1, $2, $3) AND enabled = TRUE + WHERE canonicalize_internal_sku(sku_key) = resolve_internal_sku($1, $2, $3) + AND enabled = TRUE LIMIT 1`, [item.manual_sku_key || '', item.sku_name, item.sku_code], ) @@ -99,10 +100,10 @@ export async function ensureCarrierForOrderItem(input: { return { ready: true, created: false, reason: 'manual_config_ready', message: '' } } return { - ready: true, + ready: false, created: false, reason: 'manual_config_missing', - message: '人工渠道已选定,但缺少人工履约配置', + message: '人工渠道已指定,但该内部 SKU 尚未配置人工履约', } } @@ -114,23 +115,33 @@ export async function ensureCarrierForOrderItem(input: { if (await hasWorkOrder(Number(item.id))) { return { ready: true, created: false, reason: 'work_order_exists', message: '' } } - const internalSku = resolveInternalSkuKey({ - manualSkuKey: item.manual_sku_key, - skuCode: item.sku_code, - skuName: item.sku_name, - spec: parseObject(item.spec_json), - snapshot: parseObject(item.item_snapshot_json), - }) + const internalSku = canonicalizeInternalSku( + resolveInternalSkuKey({ + manualSkuKey: item.manual_sku_key, + skuCode: item.sku_code, + skuName: item.sku_name, + spec: parseObject(item.spec_json), + snapshot: parseObject(item.item_snapshot_json), + }), + ) const channelConfig = await query<{ config_json: Record }>( `SELECT config_json FROM internal_sku_channels - WHERE btrim(internal_sku) = $1 AND channel = 'work_order' AND enabled = TRUE + WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = 'work_order' AND enabled = TRUE LIMIT 1`, [internalSku], ) const preferredWorkRuleKey = String( channelConfig.rows[0]?.config_json?.workRuleKey || '', ).trim() + if (!preferredWorkRuleKey) { + return { + ready: false, + created: false, + reason: 'work_rule_missing', + message: '接单平台渠道缺少 workRuleKey,请先补全统一 SKU 配置', + } + } const snapshot = parseObject(item.item_snapshot_json) const classification = parseObject(snapshot.classification) const route = parseObject(snapshot.fulfillmentRoute) @@ -151,7 +162,7 @@ export async function ensureCarrierForOrderItem(input: { source: 'carrier_creation', autoOnly: true, skipChannelAssign: true, - ...(preferredWorkRuleKey ? { preferredWorkRuleKey } : {}), + preferredWorkRuleKey, }) const ready = (await hasWorkOrder(Number(item.id))) || result.created.length > 0 if (ready) { diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 8b10d04b..3bb6285e 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -100,7 +100,7 @@ async function loadCandidateItems(input: { ) AS channels FROM internal_sku_channels channel WHERE channel.enabled = TRUE - AND btrim(channel.internal_sku) = resolve_internal_sku( + AND canonicalize_internal_sku(channel.internal_sku) = resolve_internal_sku( oi.manual_sku_key, oi.sku_name, oi.sku_code ) ) capability ON jsonb_array_length(capability.channels) > 0 diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.test.ts b/apps/backend/src/services/fulfillment/channel-assign-service.test.ts index 8e9420ab..e6760692 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.test.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.test.ts @@ -1,11 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { - resolveChannelDecision, - type ChannelCandidateItem, - type ChannelRule, -} from './channel-assign-service.js' +import { resolveChannelDecision, type ChannelCandidateItem } from './channel-assign-service.js' import type { InternalSkuChannelRow } from './internal-sku-channel-service.js' function buildItem(patch: Partial = {}): ChannelCandidateItem { @@ -36,7 +32,7 @@ function buildChannel(patch: Partial = {}): InternalSkuCh is_fallback: false, enabled: true, canonical_product_id: null, - config_json: {}, + config_json: { manualConfigId: 1 }, source: 'test', note: '', created_by_admin_user_id: null, @@ -44,151 +40,79 @@ function buildChannel(patch: Partial = {}): InternalSkuCh } } -function buildRule(patch: Partial = {}): ChannelRule { - return { - id: 1, - provider: '', - shop_id: '', - match_key_type: 'sku_code', - match_key: 'suit_xxx', - channel: 'affiliate_dash', - priority: 60, - enabled: true, - ...patch, - } -} - -test('渠道判定:真实载体优先于冲突的旧规则', () => { - const decision = resolveChannelDecision(buildItem({ has_work_order: true }), [ - buildRule({ channel: 'third_party_voucher', priority: 10 }), - ]) - +test('渠道判定:真实载体优先于统一渠道配置', () => { + const decision = resolveChannelDecision( + buildItem({ + has_work_order: true, + channels: [buildChannel({ channel: 'third_party_voucher' })], + }), + ) assert.equal(decision.channel, 'work_order') assert.equal(decision.source, 'carrier_evidence') assert.equal(decision.conflict, false) }) -test('渠道判定:无规则时按载体证据判定', () => { - const workOrder = resolveChannelDecision(buildItem({ has_work_order: true }), []) +test('渠道判定:无配置时按真实载体判定', () => { + const workOrder = resolveChannelDecision(buildItem({ has_work_order: true })) assert.equal(workOrder.channel, 'work_order') - assert.equal(workOrder.source, 'carrier_evidence') - const affiliate = resolveChannelDecision(buildItem({ has_affiliate_task: true }), []) + const affiliate = resolveChannelDecision(buildItem({ has_affiliate_task: true })) assert.equal(affiliate.channel, 'affiliate_dash') - assert.equal(affiliate.source, 'carrier_evidence') + + const manual = resolveChannelDecision(buildItem({ has_manual_task: true })) + assert.equal(manual.channel, 'manual') }) -test('渠道判定:既无规则也无载体时保持 pending,不兜底人工', () => { - const decision = resolveChannelDecision(buildItem(), []) +test('渠道判定:无配置、无载体时保持 pending,不再看平台 SKU 规则', () => { + const decision = resolveChannelDecision(buildItem()) assert.equal(decision.channel, null) assert.equal(decision.conflict, false) + assert.equal(decision.pendingReason, 'no_channel_capability') }) -test('渠道判定:同优先级多渠道判为冲突,不静默选择', () => { - const decision = resolveChannelDecision(buildItem(), [ - buildRule({ id: 1, channel: 'affiliate_dash', priority: 10 }), - buildRule({ id: 2, channel: 'manual', priority: 10 }), - ]) - - assert.equal(decision.channel, null) - assert.equal(decision.conflict, true) - assert.deepEqual(decision.conflictChannels.sort(), ['affiliate_dash', 'manual']) -}) - -test('渠道判定:不同优先级时取优先级更小的', () => { - const decision = resolveChannelDecision(buildItem(), [ - buildRule({ id: 1, channel: 'third_party_voucher', priority: 60 }), - buildRule({ id: 2, channel: 'manual', priority: 70 }), - ]) - - assert.equal(decision.channel, 'third_party_voucher') -}) - -test('渠道判定:provider / shop 限定生效,空串表示不限', () => { - const scoped = buildRule({ provider: 'pdd', shop_id: 'shop-b' }) - assert.equal(resolveChannelDecision(buildItem(), [scoped]).channel, null) - - const wide = buildRule({ provider: '', shop_id: '', channel: 'third_party_voucher' }) - assert.equal(resolveChannelDecision(buildItem(), [wide]).channel, 'third_party_voucher') -}) - -test('渠道判定:内部 SKU 与 SKU 名称两种键都能命中', () => { - const byInternal = buildRule({ - match_key_type: 'internal_sku', - match_key: 'nick-001', - channel: 'manual', - }) - assert.equal( - resolveChannelDecision(buildItem({ manual_sku_key: 'nick-001', internal_sku: 'nick-001' }), [ - byInternal, - ]).channel, - 'manual', - ) - - const byName = buildRule({ - match_key_type: 'sku_name', - match_key: '套装-测试', - channel: 'third_party_voucher', - }) - assert.equal( - resolveChannelDecision(buildItem({ normalized_sku_name: '套装-测试' }), [byName]).channel, - 'third_party_voucher', - ) -}) - -test('渠道判定:停用的规则不参与', () => { - const decision = resolveChannelDecision(buildItem(), [buildRule({ enabled: false })]) - assert.equal(decision.channel, null) -}) - -test('渠道判定:卡券/人工能力可兜底,自动发货/接单必须靠载体', () => { - const voucher = resolveChannelDecision( - buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }), - [], - ) - assert.equal(voucher.channel, 'third_party_voucher') - assert.equal(voucher.source, 'auto_mapping') - - const manual = resolveChannelDecision(buildItem({ channels: [buildChannel()] }), []) - assert.equal(manual.channel, 'manual') - assert.equal(manual.source, 'auto_mapping') - - const affiliate = resolveChannelDecision( - buildItem({ channels: [buildChannel({ channel: 'affiliate_dash' })] }), - [], - ) - assert.equal(affiliate.channel, null, '自动发货能力没有任务时不能只改财务口径') - - const workOrder = resolveChannelDecision( - buildItem({ channels: [buildChannel({ channel: 'work_order' })] }), - [], - ) - assert.equal(workOrder.channel, null, '接单能力没有工单时不能只改财务口径') -}) - -test('渠道判定:内部 SKU 配置优先于旧 sku_code 规则', () => { - const decision = resolveChannelDecision( - buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }), - [buildRule({ channel: 'manual' })], - ) - assert.equal(decision.channel, 'third_party_voucher') - assert.equal(decision.source, 'auto_mapping') -}) - -test('渠道判定:内部 SKU 同级多渠道判为冲突', () => { +test('渠道判定:统一配置同优先级多渠道判为冲突', () => { const decision = resolveChannelDecision( buildItem({ - channels: [buildChannel({ channel: 'work_order' }), buildChannel({ channel: 'manual' })], + channels: [ + buildChannel({ channel: 'work_order' }), + buildChannel({ id: 2, channel: 'manual' }), + ], }), - [], ) assert.equal(decision.channel, null) assert.equal(decision.conflict, true) assert.deepEqual(decision.conflictChannels.sort(), ['manual', 'work_order']) }) -test('渠道判定:人工任务作为人工载体证据', () => { - const decision = resolveChannelDecision(buildItem({ has_manual_task: true }), []) - assert.equal(decision.channel, 'manual') - assert.equal(decision.source, 'carrier_evidence') +test('渠道判定:卡券能力可以直接落到财务渠道', () => { + const decision = resolveChannelDecision( + buildItem({ channels: [buildChannel({ channel: 'third_party_voucher', config_json: {} })] }), + ) + assert.equal(decision.channel, 'third_party_voucher') + assert.equal(decision.source, 'auto_mapping') +}) + +test('渠道判定:人工能力只有配置存在时才算载体就绪', () => { + const ready = resolveChannelDecision(buildItem({ channels: [buildChannel()] })) + assert.equal(ready.channel, 'manual') + + const missing = resolveChannelDecision( + buildItem({ channels: [buildChannel({ config_json: {} })] }), + ) + assert.equal(missing.channel, null) + assert.equal(missing.pendingReason, 'no_carrier') +}) + +test('渠道判定:自动发货/接单能力没有真实载体时继续保持 pending', () => { + const affiliate = resolveChannelDecision( + buildItem({ channels: [buildChannel({ channel: 'affiliate_dash', config_json: {} })] }), + ) + assert.equal(affiliate.channel, null) + assert.equal(affiliate.pendingReason, 'no_carrier') + + const workOrder = resolveChannelDecision( + buildItem({ channels: [buildChannel({ channel: 'work_order', config_json: {} })] }), + ) + assert.equal(workOrder.channel, null) + assert.equal(workOrder.pendingReason, 'no_carrier') }) diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index eae8b531..0243858b 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -14,13 +14,11 @@ import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-fa import { FULFILLMENT_CHANNELS, INACTIVE_CARRIER_TASK_STATUSES, - MATCH_KEY_TYPES, RULE_SOURCES, isFulfillmentChannel, isRuleSource, type FulfillmentChannel, type FulfillmentSource, - type MatchKeyType, type RuleSource, } from './channel-types.js' import { @@ -33,6 +31,7 @@ import { } from './internal-sku-channel-service.js' import { ensureCarrierForOrderItem } from './carrier-creation-service.js' import { TASK_STATUS } from '../../domain/task-status.js' +import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js' /** * 管道 A:渠道判定。 @@ -44,8 +43,7 @@ import { TASK_STATUS } from '../../domain/task-status.js' * 1. 运营锁定 → 跳过 * 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突 * 3. internal_sku_channels:一个主渠道 + 显式兜底;多主渠道 → 冲突 - * 4. 旧 sku_channel_rules 仅作兼容回退,且自动发货/接单仍必须有真实载体 - * 5. 都不满足 → 保留 pending 并写明确原因 + * 4. 没有统一配置或载体不满足 → 保留 pending 并写明确原因 */ /** 数据库函数 `resolve_internal_sku` 是订单项内部 SKU 的唯一 SQL 口径。 */ @@ -54,13 +52,15 @@ const ACTIVE_CARRIER_TASK_SQL = `task.task_status NOT IN (${INACTIVE_CARRIER_TAS export type ChannelRule = { id: number - provider: string - shop_id: string - match_key_type: MatchKeyType + match_key_type: 'internal_sku' match_key: string channel: FulfillmentChannel priority: number enabled: boolean + is_fallback: boolean + source: string + config_json: Record + note: string } export type ChannelRulePage = { @@ -96,22 +96,6 @@ export type ChannelDecision = { pendingReason: 'no_internal_sku' | 'no_channel_capability' | 'no_carrier' | '' } -function normalizeKey(value: unknown): string { - return String(value ?? '').trim() -} - -function matchesScope(rule: ChannelRule, item: ChannelCandidateItem): boolean { - if (rule.provider && rule.provider !== item.provider) return false - if (rule.shop_id && rule.shop_id !== item.shop_id) return false - return true -} - -function itemKey(item: ChannelCandidateItem, keyType: MatchKeyType): string { - if (keyType === 'sku_code') return normalizeKey(item.sku_code) - if (keyType === 'internal_sku') return normalizeKey(item.internal_sku || item.manual_sku_key) - return normalizeKey(item.normalized_sku_name) || normalizeKey(item.sku_name).toLowerCase() -} - function resolvePendingReason(item: ChannelCandidateItem): { reason: string detail: Record @@ -155,10 +139,7 @@ function emptyDecision(pendingReason: ChannelDecision['pendingReason'] = '') { } /** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */ -export function resolveChannelDecision( - item: ChannelCandidateItem, - rules: ChannelRule[], -): ChannelDecision { +export function resolveChannelDecision(item: ChannelCandidateItem): ChannelDecision { const carrierChannels: FulfillmentChannel[] = [ ...(item.has_work_order ? ['work_order' as const] : []), ...(item.has_affiliate_task ? ['affiliate_dash' as const] : []), @@ -204,7 +185,21 @@ export function resolveChannelDecision( } if (plan.primary) { - if (plan.primary.channel === 'manual' || plan.primary.channel === 'third_party_voucher') { + if (plan.primary.channel === 'manual') { + if (!Number(plan.primary.config_json.manualConfigId || 0)) { + return emptyDecision('no_carrier') + } + return { + channel: plan.primary.channel, + source: 'auto_mapping', + conflict: false, + conflictChannels: [], + matchedRuleIds: [], + pendingChannel: plan.primary.channel, + pendingReason: '', + } + } + if (plan.primary.channel === 'third_party_voucher') { return { channel: plan.primary.channel, source: 'auto_mapping', @@ -217,44 +212,6 @@ export function resolveChannelDecision( } return emptyDecision('no_carrier') } - - const hits = rules.filter( - (rule) => - rule.enabled && - matchesScope(rule, item) && - itemKey(item, rule.match_key_type) === normalizeKey(rule.match_key), - ) - - if (hits.length > 0) { - const minPriority = Math.min(...hits.map((rule) => rule.priority)) - const best = hits.filter((rule) => rule.priority === minPriority) - const channels = [...new Set(best.map((rule) => rule.channel))] - - if (channels.length > 1) { - return { - channel: null, - source: '', - conflict: true, - conflictChannels: channels, - matchedRuleIds: best.map((rule) => rule.id), - pendingChannel: null, - pendingReason: 'no_channel_capability', - } - } - const channel = channels[0]! - if (channel === 'affiliate_dash' || channel === 'work_order') { - return emptyDecision('no_carrier') - } - return { - channel, - source: 'auto_rule', - conflict: false, - conflictChannels: [], - matchedRuleIds: best.map((rule) => rule.id), - pendingChannel: channel, - pendingReason: '', - } - } return emptyDecision('no_channel_capability') } @@ -287,7 +244,6 @@ export async function assignPendingOrderItemChannels( : await readWatermarkItemId('channel_assign') let reachedEnd = false - const rules = await loadEnabledRules() const result: AssignChannelsResult = { scanned: 0, assigned: 0, @@ -362,9 +318,9 @@ export async function assignPendingOrderItemChannels( for (const row of rows.rows) { const item: ChannelCandidateItem = { ...row, - channels: channelsByInternalSku.get(normalizeKey(row.internal_sku)) || [], + channels: channelsByInternalSku.get(canonicalizeInternalSku(row.internal_sku)) || [], } - const decision = resolveChannelDecision(item, rules) + const decision = resolveChannelDecision(item) if (decision.channel) affectedDates.add(item.basis_date) if (decision.conflict) { result.conflicted += 1 @@ -384,7 +340,7 @@ export async function assignPendingOrderItemChannels( ) await upsertAggregatedException({ reason: 'routing_conflict', - scopeKey: `routing_conflict|${item.provider}|${item.shop_id}|${item.sku_code}|-`, + scopeKey: `routing_conflict|-|-|${item.internal_sku}|-`, orderItemId: item.id, detail: { channels: decision.conflictChannels, ruleIds: decision.matchedRuleIds }, }) @@ -630,6 +586,23 @@ export async function switchChannelForOrderItems(input: { throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' }) } + 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 + WHERE oi.id = ANY($1::bigint[])`, + [ids], + ) + const switchInternalSkus = skuRows.rows + .map((row) => canonicalizeInternalSku(row.internal_sku)) + .filter(Boolean) + if (switchInternalSkus.length !== 1) { + throw createHttpError('切换渠道必须基于同一个中文内部 SKU', { + statusCode: 400, + errorCode: + switchInternalSkus.length === 0 ? 'internal_sku_required' : 'multiple_internal_skus', + }) + } + const items = ( await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) ).rows @@ -784,6 +757,19 @@ export async function switchChannelForOrderItems(input: { WHERE id = ANY($1::bigint[])`, [readyIds, input.adminUserId], ) + if (assigned.updated > 0) { + const internalSku = switchInternalSkus[0]! + const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) + await setInternalSkuPrimaryChannel({ + internalSku, + channel: input.channel, + canonicalProductId: carrier.canonicalProductId, + configJson: carrier.configJson, + source: 'operator', + note: input.note, + adminUserId: input.adminUserId, + }) + } return { switched: assigned.updated, failed: errors.length + assigned.carrierFailed, @@ -807,21 +793,34 @@ export async function assignChannelToOrderItems(input: { if (ids.length === 0) { throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' }) } - - const result = await assignOrderItemsAfterCarrierReady({ - orderItemIds: ids, + 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 + WHERE oi.id = ANY($1::bigint[])`, + [ids], + ) + const internalSkus = [ + ...new Set(rows.rows.map((row) => canonicalizeInternalSku(row.internal_sku)).filter(Boolean)), + ] + if (internalSkus.length === 0) { + throw createHttpError('所选订单项没有中文内部 SKU', { + statusCode: 400, + errorCode: 'internal_sku_required', + }) + } + if (internalSkus.length > 1) { + throw createHttpError('所选订单项包含多个内部 SKU,请按内部 SKU 指定渠道', { + statusCode: 400, + errorCode: 'multiple_internal_skus', + }) + } + const result = await assignChannelByInternalSku({ + internalSku: internalSkus[0]!, channel: input.channel, - source: 'operator_assigned', - lock: true, + adminUserId: input.adminUserId, note: input.note, }) - await query( - `UPDATE order_items - SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW() - WHERE id = ANY($1::bigint[])`, - [ids, input.adminUserId], - ) - return result + return { updated: result.updated, carrierFailed: result.carrierFailed } } /** 运营解锁:允许自动判定重新接管。 */ @@ -836,10 +835,10 @@ export async function unlockChannel(orderItemIds: number[]): Promise<{ updated: } export type PendingGroupRow = { - provider: string - shop_id: string internal_sku: string sku_name: string + providers: string[] + shop_ids: string[] sku_codes: string[] pending_reasons: string[] item_count: number @@ -848,7 +847,7 @@ export type PendingGroupRow = { last_seen_at: string } -/** 待分类队列:按内部 SKU 聚合(同一中文 SKU 的不同平台码合并为一行)。 */ +/** 待分类队列:按全局内部 SKU 聚合(跨平台、跨店铺合并为一行)。 */ export async function listPendingChannelGroups(input: { page?: number pageSize?: number @@ -862,7 +861,7 @@ export async function listPendingChannelGroups(input: { if (keyword) { params.push(`%${keyword}%`) clauses.push( - `(oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`, + `(${INTERNAL_SKU_SQL} ILIKE $${params.length} OR oi.sku_code ILIKE $${params.length} OR oi.sku_name ILIKE $${params.length} OR oi.manual_sku_key ILIKE $${params.length})`, ) } const where = clauses.join(' AND ') @@ -874,7 +873,7 @@ export async function listPendingChannelGroups(input: { FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE ${where} - GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL} + GROUP BY ${INTERNAL_SKU_SQL} ) grouped `, params, @@ -884,10 +883,10 @@ export async function listPendingChannelGroups(input: { const rows = await query( ` SELECT - o.provider, - o.shop_id, ${INTERNAL_SKU_SQL} AS internal_sku, MIN(oi.sku_name) AS sku_name, + array_agg(DISTINCT o.provider) AS providers, + array_agg(DISTINCT o.shop_id) AS shop_ids, array_agg(DISTINCT oi.sku_code) AS sku_codes, array_remove(array_agg(DISTINCT oi.fulfillment_pending_reason), NULL) AS pending_reasons, COUNT(*)::int AS item_count, @@ -897,7 +896,7 @@ export async function listPendingChannelGroups(input: { FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE ${where} - GROUP BY o.provider, o.shop_id, ${INTERNAL_SKU_SQL} + GROUP BY ${INTERNAL_SKU_SQL} ORDER BY item_count DESC, internal_sku LIMIT $${params.length - 1} OFFSET $${params.length} `, @@ -909,8 +908,6 @@ export async function listPendingChannelGroups(input: { /** 按内部 SKU 批量指定(同一中文 SKU 跨平台码一起分流)。 */ export async function assignChannelByInternalSku(input: { - provider: string - shopId: string internalSku: string channel: string adminUserId: number | null @@ -920,9 +917,9 @@ export async function assignChannelByInternalSku(input: { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } - const internalSku = String(input.internalSku || '').trim() - if (!internalSku) { - throw createHttpError('内部 SKU 不能为空', { + const internalSku = canonicalizeInternalSku(input.internalSku) + if (!internalSku || !containsCjk(internalSku)) { + throw createHttpError('必须使用包含中文的内部 SKU', { statusCode: 400, errorCode: 'internal_sku_required', }) @@ -940,29 +937,25 @@ export async function assignChannelByInternalSku(input: { FROM orders o JOIN order_items oi ON oi.order_id = o.id WHERE o.id = oi.order_id - AND o.provider = $1 - AND o.shop_id = $2 - AND ${INTERNAL_SKU_SQL} = $3 + AND ${INTERNAL_SKU_SQL} = $1 AND ( oi.fulfillment_status = 'pending' - OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $4) + OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $2) ) `, - [input.provider, input.shopId, internalSku, input.channel], + [internalSku, input.channel], ) - const product = await query<{ id: number | null }>( - `SELECT MIN(id) AS id - FROM product_catalog - WHERE status = 'active' AND btrim(canonical_name) = $1`, - [internalSku], - ) + const carrier = await resolveInternalSkuCarrierConfig(internalSku, input.channel) await setInternalSkuPrimaryChannel({ internalSku, channel: input.channel, - canonicalProductId: product.rows[0]?.id == null ? null : Number(product.rows[0].id), + canonicalProductId: carrier.canonicalProductId, source: 'operator', - configJson: { note: String(input.note || '').slice(0, 200) }, + configJson: { + ...carrier.configJson, + note: String(input.note || '').slice(0, 200), + }, note: input.note, adminUserId: input.adminUserId, }) @@ -987,7 +980,7 @@ export async function assignChannelByInternalSku(input: { return { ...applied, ruleCreated } } -/** 按 SKU 批量指定(待分类队列的主操作)。 */ +/** 按平台 SKU 定位内部 SKU 后全局指定渠道。 */ export async function assignChannelBySku(input: { provider: string shopId: string @@ -1018,7 +1011,9 @@ export async function assignChannelBySku(input: { ) const internalSkus = [ - ...new Set(idsResult.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)), + ...new Set( + idsResult.rows.map((row) => canonicalizeInternalSku(row.internal_sku)).filter(Boolean), + ), ] if (internalSkus.length === 0) { return { updated: 0, ruleCreated: false, carrierFailed: 0 } @@ -1030,8 +1025,6 @@ export async function assignChannelBySku(input: { }) } return assignChannelByInternalSku({ - provider: input.provider, - shopId: input.shopId, internalSku: internalSkus[0]!, channel: input.channel, adminUserId: input.adminUserId, @@ -1062,9 +1055,10 @@ function buildChannelRuleFilter(input: ChannelRuleFilterInput) { const CHANNEL_RULE_FROM_SQL = ` FROM ( - SELECT id, ''::text AS provider, ''::text AS shop_id, - 'internal_sku'::text AS match_key_type, internal_sku AS match_key, - channel, priority, enabled + SELECT id, + 'internal_sku'::text AS match_key_type, + canonicalize_internal_sku(internal_sku) AS match_key, + channel, priority, enabled, is_fallback, source, config_json, note FROM internal_sku_channels ) internal_rules ` @@ -1081,7 +1075,8 @@ export async function listChannelRules(input: ChannelRuleFilterInput = {}): Prom const { where, params } = buildChannelRuleFilter(input) const rows = await query( ` - SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled + SELECT id, match_key_type, match_key, channel, priority, enabled, + is_fallback, source, config_json, note ${CHANNEL_RULE_FROM_SQL} ${where} ORDER BY priority, channel, match_key, id @@ -1106,7 +1101,8 @@ export async function listChannelRulesPage( ), query( ` - SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled + SELECT id, match_key_type, match_key, channel, priority, enabled, + is_fallback, source, config_json, note ${CHANNEL_RULE_FROM_SQL} ${where} ORDER BY priority, channel, match_key, id @@ -1123,6 +1119,56 @@ export async function listChannelRulesPage( } } +async function resolveInternalSkuCarrierConfig( + internalSku: string, + channel: string, +): Promise<{ canonicalProductId: number | null; configJson: Record }> { + const key = canonicalizeInternalSku(internalSku) + if (!key) return { canonicalProductId: null, configJson: {} } + + if (channel === 'manual') { + const manual = await query<{ id: number; canonical_product_id: number | null }>( + `SELECT id, canonical_product_id + FROM manual_fulfillment_configs + WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE + ORDER BY id + LIMIT 1`, + [key], + ) + return { + canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null, + configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {}, + } + } + + const prefixes = + channel === 'affiliate_dash' ? ['aff:'] : channel === 'work_order' ? ['tpl:', 'wo:'] : ['tpv:'] + const product = await query<{ id: number; canonical_sku: string }>( + `SELECT id, canonical_sku + FROM product_catalog + 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`, + [key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`], + ) + const row = product.rows[0] + if (!row) return { canonicalProductId: null, configJson: {} } + const rawSku = String(row.canonical_sku) + const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]! + const carrierKey = rawSku.slice(matchedPrefix.length) + return { + canonicalProductId: Number(row.id), + configJson: + channel === 'affiliate_dash' + ? { affiliateSku: carrierKey } + : channel === 'work_order' + ? { workRuleKey: carrierKey } + : { voucherSku: carrierKey }, + } +} + export async function upsertChannelRule(input: { id?: number matchKeyType: string @@ -1144,12 +1190,16 @@ export async function upsertChannelRule(input: { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } const source: RuleSource = isRuleSource(input.source) ? input.source : 'operator' - const matchKey = String(input.matchKey || '').trim() - if (!matchKey) { - throw createHttpError('匹配键不能为空', { statusCode: 400, errorCode: 'match_key_required' }) + const matchKey = canonicalizeInternalSku(input.matchKey) + if (!matchKey || !containsCjk(matchKey)) { + throw createHttpError('匹配键必须是包含中文的内部 SKU', { + statusCode: 400, + errorCode: 'internal_sku_required', + }) } const priority = Math.trunc(Number(input.priority) || 100) + const carrier = await resolveInternalSkuCarrierConfig(matchKey, input.channel) const id = await upsertInternalSkuChannel({ internalSku: matchKey, channel: input.channel, @@ -1160,6 +1210,8 @@ export async function upsertChannelRule(input: { note: String(input.note || '').slice(0, 300), adminUserId: input.adminUserId, replacePriority: true, + canonicalProductId: carrier.canonicalProductId, + configJson: carrier.configJson, }) return { id } } @@ -1169,6 +1221,7 @@ export async function deleteChannelRule(ruleId: number): Promise<{ deleted: bool if (id <= 0) { throw createHttpError('规则 id 无效', { statusCode: 400, errorCode: 'invalid_rule_id' }) } + await assertRulesDeletable([id]) return { deleted: await deleteInternalSkuChannelById(id) } } @@ -1183,29 +1236,34 @@ export async function deleteChannelRules(idsInput: number[]): Promise<{ errorCode: 'channel_rule_ids_required', }) } + await assertRulesDeletable(idsInput) const result = await deleteInternalSkuChannelsByIds(idsInput) return { deletedCount: result.deletedIds.length, ...result } } -async function loadEnabledRules(): Promise { - const rows = await query( - ` - SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled - FROM sku_channel_rules - WHERE enabled = TRUE - `, +async function assertRulesDeletable(idsInput: number[]): Promise { + const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + if (ids.length === 0) return + const rows = await query<{ id: number; source: string; match_key: string; channel: string }>( + `SELECT id, source, canonicalize_internal_sku(internal_sku) AS match_key, channel + FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, + [ids], ) - return rows.rows.map((row) => ({ - ...row, - id: Number(row.id), - priority: Number(row.priority), - })) + const protectedRow = rows.rows.find( + (row) => row.source !== 'operator' && !row.source.startsWith('rule:'), + ) + if (protectedRow) { + throw createHttpError( + `「${protectedRow.match_key}」的 ${protectedRow.channel} 配置由 ${protectedRow.source} 生成,请到对应配置页停用,不能在渠道页直接删除`, + { statusCode: 409, errorCode: 'system_channel_rule_protected' }, + ) + } } async function loadInternalSkuChannelsForKeys( keys: string[], ): Promise> { - const normalized = [...new Set(keys.map(normalizeKey).filter(Boolean))] + const normalized = [...new Set(keys.map(canonicalizeInternalSku).filter(Boolean))] const result = new Map() if (normalized.length === 0) return result @@ -1214,13 +1272,13 @@ async function loadInternalSkuChannelsForKeys( SELECT id, internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, config_json, source, note, created_by_admin_user_id FROM internal_sku_channels - WHERE enabled = TRUE AND btrim(internal_sku) = ANY($1::text[]) - ORDER BY internal_sku, priority, channel + WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[]) + ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id `, [normalized], ) for (const row of rows.rows) { - const key = normalizeKey(row.internal_sku) + const key = canonicalizeInternalSku(row.internal_sku) const list = result.get(key) || [] list.push(row) result.set(key, list) @@ -1228,13 +1286,13 @@ async function loadInternalSkuChannelsForKeys( return result } -export const CHANNEL_RULE_KEY_TYPES = MATCH_KEY_TYPES +export const CHANNEL_RULE_KEY_TYPES = ['internal_sku'] as const export const CHANNEL_RULE_SOURCES = RULE_SOURCES export const CHANNEL_VALUES = FULFILLMENT_CHANNELS /** 为单个卡券 SKU 建规则(商品创建时调用,幂等)。 */ export async function ensureVoucherRuleForSku(skuCode: string): Promise { - const key = String(skuCode || '').trim() + const key = canonicalizeInternalSku(skuCode) if (!key) return false const product = await query<{ id: number }>( `SELECT id FROM product_catalog @@ -1273,7 +1331,7 @@ export async function ensureVoucherChannelRules(): Promise<{ FROM product_catalog product WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpv:%' - AND BTRIM(channel.internal_sku) = BTRIM(product.canonical_name) + AND canonicalize_internal_sku(channel.internal_sku) = canonicalize_internal_sku(product.canonical_name) AND channel.channel <> 'third_party_voucher' AND channel.enabled = TRUE AND channel.is_fallback = FALSE`, @@ -1285,7 +1343,7 @@ export async function ensureVoucherChannelRules(): Promise<{ canonical_product_id, config_json, source, created_at, updated_at ) SELECT - BTRIM(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE, + canonicalize_internal_sku(p.canonical_name), 'third_party_voucher', 10, FALSE, TRUE, p.id, jsonb_build_object('voucherSku', substring(p.canonical_sku from 5)), 'voucher', NOW(), NOW() FROM product_catalog p diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts index b32dae6b..24b4eb26 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -24,6 +24,7 @@ test( ensureVoucherRuleForSku, listChannelRules, listChannelRulesPage, + listPendingChannelGroups, switchChannelForOrderItems, upsertChannelRule, } = await import('./channel-assign-service.js') @@ -32,20 +33,28 @@ test( const manualSku = `集成测试人工-${suffix}` const missingAffiliateSku = `集成测试自动-${suffix}` const voucherSku = `集成测试卡券-${suffix}` + const triggerSku = `集成测试规范-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] - const internalSkus = [manualSku, missingAffiliateSku, voucherSku] + const crossShopSku = `集成测试跨店-${suffix}` + const internalSkus = [manualSku, missingAffiliateSku, voucherSku, crossShopSku, triggerSku] let generatedVoucherProductId: number | null = null - async function createPendingOrderItem(sku: string) { + async function createPendingOrderItem( + sku: string, + options: { provider?: string; platform?: string; shopId?: string } = {}, + ) { + const provider = options.provider || '91kaquan' + const platform = options.platform || 'kuaishou-91' + const targetShopId = options.shopId || shopId const order = await query<{ id: number }>( `INSERT INTO orders ( provider, platform, shop_id, platform_order_id, order_status, pay_status, total_amount, paid_at, created_at, updated_at ) VALUES ( - '91kaquan', 'kuaishou-91', $1, $2, 'paid', 'paid', 1000, NOW(), NOW(), NOW() + $1, $2, $3, $4, 'paid', 'paid', 1000, NOW(), NOW(), NOW() ) RETURNING id`, - [shopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`], + [provider, platform, targetShopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`], ) const orderId = Number(order.rows[0]!.id) orderIds.push(orderId) @@ -63,6 +72,27 @@ test( } try { + const canonicalResolution = await query<{ + internal_sku: string | null + platform_only: 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`, + ) + assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命') + assert.equal(canonicalResolution.rows[0]?.platform_only, null) + + await query( + `INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source) + VALUES ($1, 'manual', 90, TRUE, TRUE, 'integration_test')`, + [` 集成测试 规范-${suffix} `], + ) + const normalizedInsert = await query<{ internal_sku: string }>( + `SELECT internal_sku FROM internal_sku_channels WHERE source = 'integration_test'`, + ) + assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku) + const manualItemId = await createPendingOrderItem(manualSku) await query( `INSERT INTO manual_fulfillment_configs ( @@ -72,8 +102,6 @@ test( ) const manualAssigned = await assignChannelByInternalSku({ - provider: '91kaquan', - shopId, internalSku: manualSku, channel: 'manual', adminUserId: null, @@ -124,8 +152,6 @@ test( const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku) const affiliateFailed = await assignChannelByInternalSku({ - provider: '91kaquan', - shopId, internalSku: missingAffiliateSku, channel: 'affiliate_dash', adminUserId: null, @@ -146,6 +172,25 @@ test( assert.equal(pendingItem.rows[0]?.fulfillment_channel, null) assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier') + const crossShopA = `it-routing-a-${suffix}` + const crossShopB = `it-routing-b-${suffix}` + await createPendingOrderItem(crossShopSku, { shopId: crossShopA }) + await createPendingOrderItem(crossShopSku, { + provider: 'pdd', + platform: 'pdd', + shopId: crossShopB, + }) + const pendingGroups = await listPendingChannelGroups({ + keyword: crossShopSku, + page: 1, + pageSize: 10, + }) + assert.equal(pendingGroups.total, 1) + assert.equal(pendingGroups.items[0]?.internal_sku, crossShopSku) + assert.equal(pendingGroups.items[0]?.item_count, 2) + assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd']) + assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB]) + await ensureVoucherRuleForSku(voucherSku) const voucherChannel = await query<{ channel: string; source: string }>( `SELECT channel, source FROM internal_sku_channels diff --git a/apps/backend/src/services/fulfillment/intake-channel-decision-service.test.ts b/apps/backend/src/services/fulfillment/intake-channel-decision-service.test.ts new file mode 100644 index 00000000..5b14c983 --- /dev/null +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.test.ts @@ -0,0 +1,148 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { applyIntakeChannelPlan } from './intake-channel-decision-service.js' +import type { + InternalSkuChannelPlan, + InternalSkuChannelRow, +} from './internal-sku-channel-service.js' +import type { ResolvedFulfillmentItem } from './product-resolution-service.js' + +function buildItem(): ResolvedFulfillmentItem { + return { + itemId: '1', + externalItemId: '1', + externalSkuCode: 'platform-code', + externalSkuName: '幸运币2个', + skuCode: 'platform-code', + skuName: '幸运币2个', + spec: { skuNick: '幸运币2个' }, + snapshot: { internalSku: '幸运币2个' }, + isConfigured: false, + } +} + +function channel(patch: Partial = {}): InternalSkuChannelRow { + return { + id: 1, + internal_sku: '幸运币2个', + channel: 'affiliate_dash', + priority: 10, + is_fallback: false, + enabled: true, + canonical_product_id: null, + config_json: {}, + source: 'operator', + note: '', + created_by_admin_user_id: null, + ...patch, + } +} + +function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan { + return { + primary, + fallbacks: [], + attempts: primary ? [primary] : [], + conflicts: [], + } +} + +test('下单决策:自动发货只从统一 SKU 配置生成任务路由', () => { + const item = applyIntakeChannelPlan( + buildItem(), + plan(channel({ config_json: { affiliateSku: 'lucky_coin_x2' } })), + ) + + assert.equal(item.isConfigured, true) + assert.equal(item.skuCode, 'lucky_coin_x2') + assert.equal(item.snapshot.internalSkuChannel, 'affiliate_dash') + assert.deepEqual(item.snapshot.fulfillmentRoute, { + selectedExecutorKey: 'affiliate_dash', + selectedRuleId: '', + reason: '内部 SKU 渠道配置指定自动发货', + matchedRule: null, + candidates: [], + skipped: [], + }) + assert.deepEqual(item.snapshot.affiliateDash, { + matchMode: 'affiliate_dash_sku_mapping', + productNo: '幸运币2个', + sku: 'lucky_coin_x2', + skuName: '幸运币2个', + }) +}) + +test('下单决策:自动发货缺载体参数时不建任何任务', () => { + const item = applyIntakeChannelPlan( + { + ...buildItem(), + skuName: '不存在商品', + snapshot: { internalSku: '不存在商品' }, + spec: { skuNick: '不存在商品' }, + }, + plan(channel({ internal_sku: '不存在商品' })), + ) + assert.equal(item.isConfigured, false) + assert.equal(item.snapshot.internalSkuChannelReason, 'missing_affiliate_dash_mapping') +}) + +test('下单决策:人工渠道只有配置存在时才建人工任务', () => { + const ready = applyIntakeChannelPlan( + buildItem(), + plan(channel({ channel: 'manual', config_json: { manualConfigId: 9 } })), + ) + assert.equal(ready.isConfigured, true) + assert.equal( + (ready.snapshot.fulfillmentRoute as Record).selectedExecutorKey, + 'manual_dispatch', + ) + + const missing = applyIntakeChannelPlan( + buildItem(), + plan(channel({ channel: 'manual', config_json: {} })), + ) + assert.equal(missing.isConfigured, false) + assert.equal(missing.snapshot.internalSkuChannelReason, 'manual_config_missing') +}) + +test('下单决策:接单平台和卡券不提前建任务', () => { + for (const targetChannel of ['work_order', 'third_party_voucher'] as const) { + const item = applyIntakeChannelPlan( + buildItem(), + plan(channel({ channel: targetChannel, config_json: {} })), + ) + assert.equal(item.isConfigured, false) + assert.equal(item.snapshot.internalSkuChannel, targetChannel) + assert.equal( + (item.snapshot.fulfillmentRoute as Record).selectedExecutorKey, + '', + ) + assert.deepEqual(item.snapshot.classification, { + channel: targetChannel, + canonicalSku: '幸运币2个', + }) + } +}) + +test('下单决策:冲突或无配置时清空旧路由,不能由旧逻辑抢占', () => { + const base = buildItem() + base.snapshot = { + ...base.snapshot, + fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' }, + affiliateDash: { sku: 'legacy' }, + } + const conflict = applyIntakeChannelPlan(base, { + primary: null, + fallbacks: [], + attempts: [], + conflicts: [channel()], + }) + assert.equal(conflict.isConfigured, false) + assert.equal(conflict.snapshot.internalSkuChannelReason, 'routing_conflict') + assert.equal( + (conflict.snapshot.fulfillmentRoute as Record).selectedExecutorKey, + '', + ) + assert.equal(conflict.snapshot.affiliateDash, null) +}) diff --git a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts index 908d5ae4..0eae7da7 100644 --- a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts @@ -1,4 +1,9 @@ -import { listInternalSkuChannels, planInternalSkuChannels } from './internal-sku-channel-service.js' +import { + listInternalSkuChannels, + planInternalSkuChannels, + type InternalSkuChannelPlan, + type InternalSkuChannelRow, +} from './internal-sku-channel-service.js' import type { ResolvedFulfillmentItem } from './product-resolution-service.js' import { matchAffiliateDashSkuByInternalSku } from './product-resolution-service.js' import { logWarn } from '../../utils/logger.js' @@ -11,140 +16,227 @@ type IntakeChannelDecisionInput = { shopId?: string } +type IntakeChannelDecisionOptions = { + preferredChannel?: FulfillmentChannel +} + function parseObject(value: unknown): Record { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) : {} } +function emptyRoute(reason: string) { + return { + selectedExecutorKey: '', + selectedRuleId: '', + reason, + matchedRule: null, + candidates: [], + skipped: [], + } +} + +function classification(channel: FulfillmentChannel | 'unmatched', internalSku: string) { + return { channel, canonicalSku: internalSku } +} + +function withChannelSnapshot(input: { + item: ResolvedFulfillmentItem + internalSku: string + channel: FulfillmentChannel | 'unmatched' + source?: string + reason?: string + route?: Record + affiliateDash?: Record | null + configured: boolean + skuCode?: string +}): ResolvedFulfillmentItem { + const snapshot = parseObject(input.item.snapshot) + return { + ...input.item, + ...(input.skuCode ? { skuCode: input.skuCode } : {}), + spec: { + ...(input.item.spec && typeof input.item.spec === 'object' ? input.item.spec : {}), + skuNick: input.internalSku, + }, + isConfigured: input.configured, + snapshot: { + ...snapshot, + internalSku: input.internalSku, + ...(input.source ? { internalSkuChannelSource: input.source } : {}), + ...(input.channel !== 'unmatched' ? { internalSkuChannel: input.channel } : {}), + ...(input.reason ? { internalSkuChannelReason: input.reason } : {}), + fulfillmentRoute: input.route || emptyRoute(input.reason || ''), + affiliateDash: input.affiliateDash ?? null, + classification: classification(input.channel, input.internalSku), + }, + } +} + /** - * 下单入口读取内部 SKU 渠道决策。 - * - * 目前只补“自动发货”载体:若内部 SKU 能力是 affiliate_dash 且能解析出 dash sku, - * 直接写 fulfillmentRoute + affiliateDash,让 delivery-task-service 建任务。 - * 接单平台仍由 work_order 匹配器创建;卡券/人工无任务,由财务侧兜底。 + * 纯函数:把内部 SKU 渠道计划应用到订单项。 + * 渠道选择只允许来自 internal_sku_channels,旧路由不能抢占。 */ +export function applyIntakeChannelPlan( + item: ResolvedFulfillmentItem, + plan: InternalSkuChannelPlan, + options: IntakeChannelDecisionOptions = {}, +): ResolvedFulfillmentItem { + const internalSku = resolveInternalSkuKey(item) + if (!internalSku) { + return withChannelSnapshot({ + item, + internalSku: '', + channel: 'unmatched', + reason: 'no_internal_sku', + configured: false, + }) + } + + if (plan.conflicts.length > 0) { + return withChannelSnapshot({ + item, + internalSku, + channel: 'unmatched', + reason: 'routing_conflict', + configured: false, + }) + } + + const preferred = options.preferredChannel + ? plan.attempts.find((candidate) => candidate.channel === options.preferredChannel) + : undefined + const primary = preferred || plan.primary + if (!primary) { + return withChannelSnapshot({ + item, + internalSku, + channel: 'unmatched', + reason: 'no_channel_capability', + configured: false, + }) + } + + const channel = primary.channel + const carrierParams = parseObject(primary.config_json) + + if (channel === 'affiliate_dash') { + const dashSku = + String(carrierParams.affiliateSku || '').trim() || + matchAffiliateDashSkuByInternalSku(internalSku) + if (!dashSku) { + return withChannelSnapshot({ + item, + internalSku, + channel, + source: primary.source, + reason: 'missing_affiliate_dash_mapping', + configured: false, + }) + } + return withChannelSnapshot({ + item, + internalSku, + channel, + source: primary.source, + reason: '内部 SKU 渠道配置指定自动发货', + configured: true, + skuCode: dashSku, + route: { + selectedExecutorKey: 'affiliate_dash', + selectedRuleId: '', + reason: '内部 SKU 渠道配置指定自动发货', + matchedRule: null, + candidates: [], + skipped: [], + }, + affiliateDash: { + matchMode: 'affiliate_dash_sku_mapping', + productNo: internalSku, + sku: dashSku, + skuName: internalSku, + }, + }) + } + + if (channel === 'manual') { + if (!Number(primary.config_json.manualConfigId || 0)) { + return withChannelSnapshot({ + item, + internalSku, + channel, + source: primary.source, + reason: 'manual_config_missing', + configured: false, + }) + } + return withChannelSnapshot({ + item, + internalSku, + channel, + source: primary.source, + reason: '内部 SKU 渠道配置指定人工客服', + configured: true, + route: { + selectedExecutorKey: 'manual_dispatch', + selectedRuleId: '', + reason: '内部 SKU 渠道配置指定人工客服', + matchedRule: null, + candidates: [], + skipped: [], + }, + }) + } + + // 接单平台由发单入口读取同一份 internal_sku_channels 创建工单; + // 卡券无执行任务。两者都不能留下 affiliate/manual 路由。 + return withChannelSnapshot({ + item, + internalSku, + channel, + source: primary.source, + reason: + channel === 'work_order' + ? '内部 SKU 渠道配置指定接单平台' + : '内部 SKU 渠道配置指定第三方卡券', + configured: false, + }) +} + +/** 下单入口读取唯一渠道源:internal_sku_channels。 */ export async function applyIntakeChannelDecision( item: ResolvedFulfillmentItem, _input: IntakeChannelDecisionInput, - options: { preferredChannel?: FulfillmentChannel } = {}, + options: IntakeChannelDecisionOptions = {}, ): Promise { const internalSku = resolveInternalSkuKey(item) - if (!internalSku) return item + if (!internalSku) { + return withChannelSnapshot({ + item, + internalSku: '', + channel: 'unmatched', + reason: 'no_internal_sku', + configured: false, + }) + } - let plan + let channels: InternalSkuChannelRow[] try { - plan = planInternalSkuChannels(await listInternalSkuChannels(internalSku)) + channels = await listInternalSkuChannels(internalSku) } catch (error) { - // 单测/旧库没有 118 迁移时不影响下单主流程,生产由迁移保证。 logWarn('[intake-channel-decision]', '读取内部 SKU 渠道失败', { internalSku, error: error instanceof Error ? error.message : String(error), }) - return item - } - if (plan.conflicts.length > 0) { - return { - ...item, - snapshot: { - ...parseObject(item.snapshot), - internalSku, - internalSkuChannelReason: 'routing_conflict', - internalSkuConflictChannels: plan.conflicts.map((channel) => channel.channel), - }, - } + return withChannelSnapshot({ + item, + internalSku, + channel: 'unmatched', + reason: 'channel_config_unavailable', + configured: false, + }) } - const preferred = options.preferredChannel - ? plan.attempts.find((candidate) => candidate.channel === options.preferredChannel) || - (options.preferredChannel === 'manual' || options.preferredChannel === 'third_party_voucher' - ? null - : undefined) - : undefined - const primary = preferred === null ? null : preferred || plan.primary - if (!primary) return item - - const snapshot = parseObject(item.snapshot) - const route = parseObject(snapshot.fulfillmentRoute) - const selected = String(route.selectedExecutorKey || '').trim() - // 路由配置未命中时会默认落 manual_dispatch;这不是运营显式规则,不能覆盖内部 SKU 能力。 - const isDefaultManualFallback = selected === 'manual_dispatch' && !route.matchedRule - const nextSnapshot: Record = { - ...snapshot, - internalSku: String(snapshot.internalSku || internalSku), - internalSkuChannel: primary.channel, - internalSkuChannelSource: primary.source, - } - - const canOverrideDefaultManual = !selected || isDefaultManualFallback - const existingAffiliateSku = String(parseObject(snapshot.affiliateDash).sku || '').trim() - const configuredAffiliateSku = String(primary.config_json.affiliateSku || '').trim() - - const desiredChannel = options.preferredChannel || primary.channel - if (desiredChannel === 'affiliate_dash' || selected === 'affiliate_dash') { - const dashSku = - existingAffiliateSku || - configuredAffiliateSku || - matchAffiliateDashSkuByInternalSku(internalSku) - if (dashSku) { - if (canOverrideDefaultManual || selected === 'affiliate_dash') { - nextSnapshot.fulfillmentRoute = { - ...route, - selectedExecutorKey: 'affiliate_dash', - selectedRuleId: String(route.selectedRuleId || ''), - reason: canOverrideDefaultManual - ? '内部 SKU 渠道能力指定自动发货' - : String(route.reason || '自动发货路由'), - matchedRule: route.matchedRule ?? null, - candidates: Array.isArray(route.candidates) ? route.candidates : [], - skipped: Array.isArray(route.skipped) ? route.skipped : [], - } - nextSnapshot.affiliateDash = { - matchMode: 'affiliate_dash_sku_mapping', - productNo: internalSku, - sku: dashSku, - skuName: internalSku, - } - // 商品归一按 aff: 找商品;内部中文 SKU 固定落 spec.skuNick。 - return { - ...item, - skuCode: dashSku, - spec: { - ...(item.spec && typeof item.spec === 'object' ? item.spec : {}), - skuNick: internalSku, - }, - isConfigured: true, - snapshot: nextSnapshot, - } - } - } - nextSnapshot.internalSkuChannelReason = 'missing_affiliate_dash_mapping' - return { ...item, snapshot: nextSnapshot } - } - - // 内部能力是接单/卡券,但路由只是默认人工兜底:清掉人工任务,交给工单或财务能力兜底。 - if ( - (desiredChannel === 'work_order' || desiredChannel === 'third_party_voucher') && - isDefaultManualFallback - ) { - nextSnapshot.fulfillmentRoute = { - ...route, - selectedExecutorKey: '', - reason: `内部 SKU 渠道能力为 ${desiredChannel},不使用默认人工兜底`, - matchedRule: null, - } - return { ...item, isConfigured: false, snapshot: nextSnapshot } - } - - if (desiredChannel === 'manual' && isDefaultManualFallback) { - nextSnapshot.fulfillmentRoute = { - ...route, - selectedExecutorKey: 'manual_dispatch', - reason: '内部 SKU 渠道能力指定人工客服', - matchedRule: null, - } - return { ...item, isConfigured: true, snapshot: nextSnapshot } - } - - return { ...item, snapshot: nextSnapshot } + return applyIntakeChannelPlan(item, planInternalSkuChannels(channels), options) } diff --git a/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts b/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts index 2447ad19..0c949b5f 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts @@ -63,3 +63,13 @@ test('渠道计划:仅有一个兜底渠道时仍可作为实际主渠道', () ['manual'], ) }) + +test('渠道计划:历史规范化重复的同一渠道会去重,不误判为冲突', () => { + const plan = planInternalSkuChannels([ + channel({ id: 1, channel: 'manual', priority: 90, is_fallback: true }), + channel({ id: 2, channel: 'manual', priority: 10 }), + ]) + assert.equal(plan.primary?.channel, 'manual') + assert.equal(plan.primary?.id, 2) + assert.deepEqual(plan.conflicts, []) +}) diff --git a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts index 88cca624..d97c9b13 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -1,6 +1,6 @@ import { query, withTransaction } from '../../db/client.js' import type { FulfillmentChannel } from './channel-types.js' -import { compareFulfillmentChannel } from './internal-sku-resolver.js' +import { canonicalizeInternalSku, compareFulfillmentChannel } from './internal-sku-resolver.js' export type InternalSkuChannelRow = { id: number @@ -26,14 +26,14 @@ export type InternalSkuChannelPlan = { export async function listInternalSkuChannels( internalSku: string, ): Promise { - const key = String(internalSku || '').trim() + const key = canonicalizeInternalSku(internalSku) if (!key) return [] const result = await query( ` SELECT id, internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, config_json, source, note, created_by_admin_user_id FROM internal_sku_channels - WHERE btrim(internal_sku) = $1 AND enabled = TRUE + WHERE canonicalize_internal_sku(internal_sku) = $1 AND enabled = TRUE ORDER BY priority ASC, channel ASC @@ -43,14 +43,58 @@ export async function listInternalSkuChannels( return result.rows } +/** 批量读取内部 SKU 渠道计划,供下单入口与发单入口共用。 */ +export async function loadInternalSkuChannelPlans( + internalSkus: string[], +): Promise> { + const keys = [...new Set(internalSkus.map(canonicalizeInternalSku).filter(Boolean))] + const result = new Map() + if (keys.length === 0) return result + + const rows = await query( + ` + SELECT id, internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, note, created_by_admin_user_id + FROM internal_sku_channels + WHERE enabled = TRUE AND canonicalize_internal_sku(internal_sku) = ANY($1::text[]) + ORDER BY canonicalize_internal_sku(internal_sku), priority, channel, id + `, + [keys], + ) + const grouped = new Map() + for (const row of rows.rows) { + const key = canonicalizeInternalSku(row.internal_sku) + const channels = grouped.get(key) || [] + channels.push(row) + grouped.set(key, channels) + } + for (const [key, channels] of grouped) result.set(key, planInternalSkuChannels(channels)) + return result +} + /** * 渠道计划:同一时间只允许一个主渠道;同级多渠道视为配置冲突。 * is_fallback 渠道只在主渠道不可用或失败后按 priority 顺序尝试。 */ export function planInternalSkuChannels(input: InternalSkuChannelRow[]): InternalSkuChannelPlan { - const channels = input + // 历史数据可能因空格/全半角差异残留同一渠道的多行。按规范化 SKU 读取后, + // 同一渠道只保留优先级最高的一行,避免把同一能力误判成冲突。 + const byChannel = new Map() + for (const row of input.filter((item) => item.enabled)) { + const current = byChannel.get(row.channel) + if ( + !current || + Number(row.is_fallback) < Number(current.is_fallback) || + (row.is_fallback === current.is_fallback && row.priority < current.priority) || + (row.is_fallback === current.is_fallback && + row.priority === current.priority && + row.id < current.id) + ) { + byChannel.set(row.channel, row) + } + } + const channels = [...byChannel.values()] .filter((row) => row.enabled) - .slice() .sort( (a, b) => a.priority - b.priority || @@ -96,7 +140,7 @@ export async function upsertInternalSkuChannel(input: { adminUserId?: number | null | undefined replacePriority?: boolean }): Promise { - const internalSku = String(input.internalSku || '').trim() + const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku) return 0 return withTransaction(async (client) => { if (input.enabled !== false && input.isFallback !== true) { @@ -105,7 +149,7 @@ export async function upsertInternalSkuChannel(input: { SET is_fallback = TRUE, priority = GREATEST(priority, 90), updated_at = NOW() - WHERE btrim(internal_sku) = $1 + WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel <> $2 AND enabled = TRUE AND is_fallback = FALSE`, @@ -164,7 +208,7 @@ export async function setInternalSkuPrimaryChannel(input: { note?: string | undefined adminUserId?: number | null }): Promise { - const internalSku = String(input.internalSku || '').trim() + const internalSku = canonicalizeInternalSku(input.internalSku) if (!internalSku) return await upsertInternalSkuChannel({ @@ -190,9 +234,9 @@ export async function disableInternalSkuChannel(input: { ` UPDATE internal_sku_channels SET enabled = FALSE, updated_at = NOW() - WHERE btrim(internal_sku) = $1 AND channel = $2 + WHERE canonicalize_internal_sku(internal_sku) = $1 AND channel = $2 `, - [String(input.internalSku || '').trim(), input.channel], + [canonicalizeInternalSku(input.internalSku), input.channel], ) return result.rowCount || 0 } diff --git a/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts index 19b7245b..3d2f404d 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts @@ -1,5 +1,5 @@ -import { query } from '../../db/client.js' -import { containsCjk } from './internal-sku-resolver.js' +import { query, withTransaction } from '../../db/client.js' +import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js' export type InternalSkuMappingMatchType = | 'sku_code' @@ -20,9 +20,7 @@ export type ResolvedInternalSkuMapping = { } export function normalizeInternalSkuMappingValue(value: unknown): string { - return String(value ?? '') - .replace(/\s+/g, '') - .trim() + return canonicalizeInternalSku(value) } /** @@ -51,7 +49,8 @@ export async function resolveInternalSkuMapping(input: { SELECT m.id, m.internal_sku, m.source FROM internal_sku_mappings m JOIN unnest($3::text[], $4::text[]) AS c(match_type, match_value) - ON m.match_type = c.match_type AND m.match_value = c.match_value + ON m.match_type = c.match_type + AND canonicalize_internal_sku(m.match_value) = c.match_value WHERE m.enabled = TRUE AND (m.provider = '' OR m.provider = $1) AND (m.shop_id = '' OR m.shop_id = $2) @@ -70,7 +69,7 @@ export async function resolveInternalSkuMapping(input: { if (!row) return null return { id: Number(row.id), - internalSku: String(row.internal_sku || '').trim(), + internalSku: canonicalizeInternalSku(row.internal_sku), source: String(row.source || 'manual'), } } @@ -85,40 +84,50 @@ export async function upsertInternalSkuMapping(input: { source?: string note?: string }): Promise { - const result = await 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()) - ON CONFLICT (provider, shop_id, match_type, match_value) - DO UPDATE SET - internal_sku = EXCLUDED.internal_sku, - priority = EXCLUDED.priority, - enabled = TRUE, - source = EXCLUDED.source, - note = EXCLUDED.note, - updated_at = NOW() - RETURNING id - `, - [ - String(input.provider || '').trim(), - String(input.shopId || '').trim(), - input.matchType, - normalizeInternalSkuMappingValue(input.matchValue), - String(input.internalSku || '').trim(), - Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100, - String(input.source || 'manual').trim() || 'manual', - String(input.note || '').trim(), - ], - ) - return Number(result.rows[0]?.id || 0) + const provider = String(input.provider || '').trim() + const shopId = String(input.shopId || '').trim() + 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() + + return withTransaction(async (client) => { + const existing = await client.query<{ id: number }>( + `SELECT id FROM internal_sku_mappings + 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], + ) + 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() + WHERE id = $1 + RETURNING id`, + [existingId, matchValue, internalSku, priority, 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()) + RETURNING id`, + [provider, shopId, input.matchType, matchValue, internalSku, priority, source, note], + ) + return Number(result.rows[0]?.id || 0) + }) } /** * 订单项级内部 SKU 补全: * - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库; - * - 只有纯数字/空值 → 查 internal_sku_mappings,命中则返回中文内部 SKU。 + * - 没有中文时 → 查 internal_sku_mappings,把任何平台码映射成中文内部 SKU。 */ export async function resolveInternalSkuForOrderItem(input: { provider?: string | null @@ -128,12 +137,10 @@ export async function resolveInternalSkuForOrderItem(input: { skuName?: unknown }): Promise<{ internalSku: string; source: string; mappingId: number } | null> { const spec = input.spec && typeof input.spec === 'object' ? input.spec : {} - const current = normalizeInternalSkuMappingValue( - spec.skuNick || spec.outSkuSn || input.skuCode || input.skuName || '', - ) - if (current && containsCjk(current)) return null - // 只有纯数字/空值才需要查映射;英文 SKU 是合法的内部键,不能误当平台码。 - if (current && !/^[0-9]+$/.test(current)) return null + const directCandidates = [spec.skuNick, spec.outSkuSn, input.skuCode, input.skuName] + .map(normalizeInternalSkuMappingValue) + .filter(Boolean) + if (directCandidates.some(containsCjk)) return null const mapping = await resolveInternalSkuMapping({ provider: input.provider ?? null, @@ -146,5 +153,9 @@ export async function resolveInternalSkuForOrderItem(input: { ], }) if (!mapping) return null - return { internalSku: mapping.internalSku, source: mapping.source, mappingId: mapping.id } + return { + internalSku: canonicalizeInternalSku(mapping.internalSku), + source: mapping.source, + mappingId: mapping.id, + } } diff --git a/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts index a77b0a26..1562c437 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts @@ -1,16 +1,16 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { resolveInternalSkuKey } from './internal-sku-resolver.js' +import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js' -test('内部 SKU:入口显式值最优先', () => { +test('内部 SKU:中文显式值最优先', () => { assert.equal( resolveInternalSkuKey({ - snapshot: { internalSku: '映射后的内部SKU' }, + snapshot: { internalSku: '映射后的内部SKU' }, manualSkuKey: '平台码123', skuName: '商品名', }), - '映射后的内部SKU', + '映射后的内部sku', ) }) @@ -19,7 +19,7 @@ test('内部 SKU:纯数字平台码不会压过中文商品名', () => { resolveInternalSkuKey({ manualSkuKey: '5003', skuCode: '5003', - skuName: '幸运币2个', + skuName: '幸运币2个', spec: { outSkuSn: '5003' }, }), '幸运币2个', @@ -31,13 +31,13 @@ test('内部 SKU:中文 skuNick 优先于 outSkuSn 和平台码', () => { resolveInternalSkuKey({ skuCode: 'english-platform-code', skuName: '展示标题', - spec: { skuNick: '内部中文SKU', outSkuSn: '规格码' }, + spec: { skuNick: '内部 中文 SKU', outSkuSn: '规格码' }, }), - '内部中文SKU', + '内部中文sku', ) }) -test('内部 SKU:没有中文时按历史生成列顺序回退', () => { +test('内部 SKU:没有中文时返回空,平台码和英文码不能作为兜底', () => { assert.equal( resolveInternalSkuKey({ manualSkuKey: 'manual-key', @@ -45,6 +45,11 @@ test('内部 SKU:没有中文时按历史生成列顺序回退', () => { skuName: 'sku-name', spec: { skuNick: 'nick', outSkuSn: 'out' }, }), - 'manual-key', + '', ) }) + +test('内部 SKU 规范化:NFKC + 去全部空白 + 小写', () => { + assert.equal(canonicalizeInternalSku(' 套装-浪漫天命 '), '套装-浪漫天命') + assert.equal(canonicalizeInternalSku(' M416-仓鼠 灰灰 '), 'm416-仓鼠灰灰') +}) diff --git a/apps/backend/src/services/fulfillment/internal-sku-resolver.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts index 56705824..a7b5ac4c 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-resolver.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts @@ -14,38 +14,41 @@ function asObject(value: unknown): JsonObject { return value && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {} } -export function normalizeInternalSku(value: unknown): string { - return String(value ?? '').trim() +/** + * 内部 SKU 的唯一文本归一:NFKC、去全部空白、转小写。 + * 与数据库函数 canonicalize_internal_sku() 必须保持同口径。 + */ +export function canonicalizeInternalSku(value: unknown): string { + return String(value ?? '') + .normalize('NFKC') + .replace(/\s+/gu, '') + .trim() + .toLowerCase() } export function containsCjk(value: string): boolean { - return /[\u4e00-\u9fa5]/.test(value) + return /\p{Script=Han}/u.test(value) } /** * 内部 SKU 的唯一解析口径。 * - * 1. 入口显式写入的 snapshot.internalSku 最权威; - * 2. 旧数据优先使用中文键,避免纯数字平台码压过中文 skuNick/sku_name; - * 3. 没有中文时按生成列的历史顺序回退。 + * 只接受包含中文的内部 SKU。平台码、英文码和空值都不能成为渠道判定键; + * 它们必须先经过 internal_sku_mappings / 中文商品名归一。 */ export function resolveInternalSkuKey(input: InternalSkuResolverInput): string { const snapshot = asObject(input.snapshot) const spec = asObject(input.spec) - const explicit = normalizeInternalSku(snapshot.internalSku) - if (explicit) return explicit + const candidates = [ + snapshot.internalSku, + input.manualSkuKey, + spec.skuNick, + spec.outSkuSn, + input.skuCode, + input.skuName, + ].map(canonicalizeInternalSku) - const manual = normalizeInternalSku(input.manualSkuKey) - const skuNick = normalizeInternalSku(spec.skuNick) - const outSkuSn = normalizeInternalSku(spec.outSkuSn) - const skuCode = normalizeInternalSku(input.skuCode) - const skuName = normalizeInternalSku(input.skuName) - - return ( - [manual, skuNick, outSkuSn, skuName].find((value) => value && containsCjk(value)) || - [manual, skuNick, outSkuSn, skuCode, skuName].find(Boolean) || - '' - ) + return candidates.find((value) => value && containsCjk(value)) || '' } /** 渠道选择里使用的稳定顺序,仅用于同优先级时生成诊断,不用于静默改渠道。 */ diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts index 5ffdfd5e..e52c9e02 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.test.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.test.ts @@ -3,7 +3,6 @@ import test from 'node:test' import { matchAffiliateDashSku, - matchAffiliateDashSkuPassthrough, matchDouyinMashidaAffiliateDashSku, normalizeAffiliateDashMappingKey, resolveOrderItemForFulfillment, @@ -64,24 +63,6 @@ test('normalizeAffiliateDashMappingKey NFKC + 去空白 + 小写', () => { assert.equal(normalizeAffiliateDashMappingKey('M416-仓鼠灰灰'), 'm416-仓鼠灰灰') }) -test('matchAffiliateDashSkuPassthrough sku 一致时命中', () => { - const skus = new Set(['suit_alan_walker', 'lucky_coin_x90']) - assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', skus), true) - assert.equal(matchAffiliateDashSkuPassthrough('lucky_coin_x90', skus), true) -}) - -test('matchAffiliateDashSkuPassthrough 不在商品列表时未命中', () => { - const skus = new Set(['suit_alan_walker']) - assert.equal(matchAffiliateDashSkuPassthrough('not_a_real_sku', skus), false) - assert.equal(matchAffiliateDashSkuPassthrough('', skus), false) - assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', null), false) - assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', undefined), false) -}) - -test('matchAffiliateDashSkuPassthrough 空集合未命中', () => { - assert.equal(matchAffiliateDashSkuPassthrough('suit_alan_walker', new Set()), false) -}) - test('resolveAffiliateDashDisplayProductName 英文 sku 反查中文', () => { assert.equal( resolveAffiliateDashDisplayProductName('suit_romantic_destiny', 'suit_romantic_destiny'), @@ -100,136 +81,81 @@ test('reverseAffiliateDashSkuToProductNo 未命中返回空串', () => { assert.equal(reverseAffiliateDashSkuToProductNo(''), '') }) -// ---- 抖音码速达(抖店)订单分流:复用 affiliate_dash 通道 ---- +// ---- 所有平台统一:入口只归一到中文内部 SKU ---- -const DOUYIN_SKIN_TITLE = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用' - -function buildDouyinItem(internalSku: string, productName = DOUYIN_SKIN_TITLE) { +function buildPlatformItem(input: { + itemId?: string + skuCode: string + skuName: string + productTitle?: string +}) { return { - itemId: '6930318258716179555', - externalItemId: '6930318258716179555', - externalSkuCode: internalSku, - externalSkuName: productName, - skuCode: internalSku, - skuName: productName, + itemId: input.itemId || 'item-1', + externalItemId: input.itemId || 'item-1', + externalSkuCode: input.skuCode, + externalSkuName: input.skuName, + skuCode: input.skuCode, + skuName: input.skuName, quantity: 1, } } -function selectedExecutorKeyOf( - item: Awaited>, -): string { - const route = item.snapshot.fulfillmentRoute as - | { selectedExecutorKey?: string } - | null - | undefined - return String(route?.selectedExecutorKey || '') -} - -test('matchDouyinMashidaAffiliateDashSku 只认权威映射表并做归一化', () => { - assert.equal(matchDouyinMashidaAffiliateDashSku('幸运币2个'), 'lucky_coin_x2') - assert.equal(matchDouyinMashidaAffiliateDashSku(' 幸运币2个 '), 'lucky_coin_x2') - assert.equal(matchDouyinMashidaAffiliateDashSku('不存在的皮肤'), '') - assert.equal(matchDouyinMashidaAffiliateDashSku(''), '') -}) - -test('抖音订单:内部 SKU 命中映射表 → 复用 affiliate_dash 通道自动发货', async () => { - const item = await resolveOrderItemForFulfillment({ - provider: 'douyin_mashida', - platform: 'doudian', - item: buildDouyinItem('幸运币2个'), - }) - - assert.equal(item.isConfigured, true) - assert.equal(selectedExecutorKeyOf(item), 'affiliate_dash') - // 抖音的 sku_code 是**分类键(内部 SKU)**:命中映射后也不再被英文 sku 覆写 - assert.equal(item.skuCode, '幸运币2个') - assert.equal(item.skuName, '幸运币2个') - // 英文 sku 只落在快照里(affiliate_dash 执行器/规划器要用它) - assert.deepEqual(item.snapshot.affiliateDash, { - matchMode: 'affiliate_dash_sku_mapping', - productNo: '幸运币2个', - sku: 'lucky_coin_x2', - skuName: '幸运币2个', - }) - // 引流长标题另存 productTitle(不再占 skuName) - assert.equal(item.snapshot.productTitle, DOUYIN_SKIN_TITLE) - // 分类结果(发单层据此维护「一个单子只有一个执行载体」) - assert.deepEqual(item.snapshot.classification, { - channel: 'affiliate_dash', - canonicalSku: '幸运币2个', - matchMode: 'affiliate_dash_sku_mapping', - }) - assert.equal(resolveProductImageUrl('幸运币2个'), '/images/products/lucky_coin_x2.webp') -}) - -test('抖音订单:内部 SKU 未命中映射表 → 无执行载体(既不建任务也不写死人工)', async () => { - const item = await resolveOrderItemForFulfillment({ - provider: 'douyin_mashida', - platform: 'doudian', - item: buildDouyinItem('不存在的皮肤'), - }) - - // 错误流程(已废弃):未命中写死 manual_dispatch → 强行建人工任务、抢占工单判定。 - // 正确流程:未命中不选执行器 → 由发单层先判接单模板(命中 → 工单),都不中即「待人工」。 - assert.equal(selectedExecutorKeyOf(item), '') - assert.equal(item.isConfigured, false) - assert.equal(item.snapshot.affiliateDash, null) - assert.equal(item.skuCode, '不存在的皮肤') - assert.equal(item.skuName, '不存在的皮肤') - assert.deepEqual(item.snapshot.classification, { - channel: 'unmatched', - canonicalSku: '不存在的皮肤', - matchMode: '', - }) -}) - -test('抖音订单:平台闸门只认 douyin_mashida + doudian,其它组合仍不配置', async () => { - const wrongPlatform = await resolveOrderItemForFulfillment({ - provider: 'douyin_mashida', - platform: 'kuaishou', - item: buildDouyinItem('幸运币2个'), - }) - assert.equal(wrongPlatform.isConfigured, false) - assert.equal(wrongPlatform.snapshot.fulfillmentRoute, null) - - const wrongProvider = await resolveOrderItemForFulfillment({ - provider: 'other', - platform: 'doudian', - item: buildDouyinItem('幸运币2个'), - }) - assert.equal(wrongProvider.isConfigured, false) - assert.equal(wrongProvider.snapshot.fulfillmentRoute, null) -}) - -test('抖音订单:不触发 affiliate-dash 平台的 sku 透传(严格按内部 SKU 分流)', async () => { - const item = await resolveOrderItemForFulfillment({ - provider: 'douyin_mashida', - platform: 'doudian', - item: buildDouyinItem('lucky_coin_x2'), - }) - - // 英文 sku 不是内部 SKU,抖音侧不做透传匹配 → 无执行载体 - assert.equal(selectedExecutorKeyOf(item), '') - assert.equal(item.isConfigured, false) - assert.equal(item.snapshot.affiliateDash, null) -}) - -test('91 订单:不写抖音分类字段(历史快照形状与路由行为不变)', async () => { +test('入口归一:91 平台先得到中文内部 SKU,不在此处决定渠道', async () => { const item = await resolveOrderItemForFulfillment({ provider: '91kaquan', platform: 'kuaishou', - item: { - itemId: '幸运币2个', - externalItemId: '幸运币2个', - externalSkuCode: '幸运币2个', - externalSkuName: '幸运币2个', - skuCode: '幸运币2个', - skuName: '幸运币2个', - quantity: 1, - }, + item: buildPlatformItem({ skuCode: 'external-001', skuName: '幸运币2个' }), }) - assert.equal('classification' in item.snapshot, false) - assert.equal('productTitle' in item.snapshot, false) + assert.equal(item.skuName, '幸运币2个') + assert.equal(item.snapshot.internalSku, '幸运币2个') + assert.equal(item.spec?.skuNick, '幸运币2个') + assert.equal(item.snapshot.fulfillmentRoute, undefined) + assert.equal(item.snapshot.affiliateDash, undefined) + assert.equal(item.isConfigured, false) +}) + +test('入口归一:PDD 数字平台码由中文商品名归一,不把数字当内部 SKU', async () => { + const item = await resolveOrderItemForFulfillment({ + provider: 'pdd', + platform: 'pdd', + item: buildPlatformItem({ skuCode: '5003', skuName: '幸运币2个' }), + }) + + assert.equal(item.snapshot.internalSku, '幸运币2个') + assert.equal(item.skuName, '幸运币2个') + assert.equal(item.skuCode, '5003') +}) + +test('入口归一:抖音长标题不进内部 SKU,使用 sku_code 中的中文内部 SKU', async () => { + const title = '【测试专用】和平精英皮肤小店入口出售返场吃鸡皮肤浪漫天命专用' + const item = await resolveOrderItemForFulfillment({ + provider: 'douyin_mashida', + platform: 'doudian', + item: buildPlatformItem({ skuCode: '浪漫天命', skuName: title }), + }) + + assert.equal(item.snapshot.internalSku, '浪漫天命') + assert.equal(item.skuName, '浪漫天命') + assert.equal(item.snapshot.productTitle, title) + assert.equal(item.snapshot.fulfillmentRoute, undefined) + assert.equal(item.snapshot.affiliateDash, undefined) + assert.equal(item.isConfigured, false) +}) + +test('入口归一:所有平台使用同一条中文 SKU 逻辑', async () => { + const cases = [ + { provider: '91kaquan', platform: 'kuaishou' }, + { provider: 'pdd', platform: 'pdd' }, + { provider: 'douyin_mashida', platform: 'doudian' }, + { provider: 'unknown', platform: 'unknown' }, + ] + for (const source of cases) { + const item = await resolveOrderItemForFulfillment({ + ...source, + item: buildPlatformItem({ skuCode: 'platform-code', skuName: ' 套装-浪漫天命 ' }), + }) + assert.equal(item.snapshot.internalSku, '套装-浪漫天命') + assert.equal(item.isConfigured, false) + } }) diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index 0db636c6..b4422ddb 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -1,18 +1,10 @@ -import { - getFulfillmentRoutingConfig, - normalizeInternalSkuMatchKey, - resolveFulfillmentRoute, - type FulfillmentRouteResult, -} from './routing-config-service.js' import { getAffiliateDashConfig } from '../platforms/affiliate-dash/config.js' -import { listAllAffiliateDashProducts } from '../platforms/affiliate-dash/product-service.js' import { mergeAffiliateDashSkuMapping } from '../platforms/affiliate-dash/sku-mapping.js' import type { AffiliateDashSkuMapping } from '../../types/runtime-config.js' -import { FULFILLMENT_EXECUTOR_KEYS } from './executors/types.js' import { normalizeProductMatchName } from '../order/product-match-utils.js' import { resolveInternalSkuForOrderItem } from './internal-sku-mapping-service.js' -import { resolveInternalSkuKey } from './internal-sku-resolver.js' -import { logIntegration } from '../../utils/logger.js' +import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js' +import { loadInternalSkuChannelPlans } from './internal-sku-channel-service.js' export type FulfillmentItem = { itemId?: string @@ -42,33 +34,7 @@ export type AffiliateDashSkuMatch = { sku: string productNo: string skuName: string - matchMode: 'affiliate_dash_sku_mapping' | 'affiliate_dash_sku_passthrough' -} - -/** - * 抖音按 SKU 分类的结果(写进 `order_items.item_snapshot_json.classification`)。 - * - * 只表达**履约侧**的判定:`affiliate_dash` = 这个单子的执行载体已定为自动发货; - * `unmatched` = 没命中自动发货(后续由发单层决定是否派打手,都不中就是人工)。 - * 发单层据此维护「一个单子只有一个执行载体」的不变式。 - */ -export type FulfillmentItemClassification = { - channel: 'affiliate_dash' | 'unmatched' - /** 分类键:抖音取内部 SKU(canonical SKU) */ - canonicalSku: string - matchMode: string -} - -type FulfillmentItemCandidate = { - externalItemId: string - externalSkuCode: string - externalSkuName: string - externalSkuNameNormalized: string - resolvedSkuCode: string - affiliateDashMatch: AffiliateDashSkuMatch | null - fulfillmentRoute: FulfillmentRouteResult | null - isConfigured: boolean - classification?: FulfillmentItemClassification | null + matchMode: 'affiliate_dash_sku_mapping' } export type ResolvedFulfillmentItem = FulfillmentItem & { @@ -82,89 +48,54 @@ export type ResolvedFulfillmentItem = FulfillmentItem & { isConfigured: boolean } +/** + * 订单入口只负责把各种平台字段统一归一到中文内部 SKU。 + * + * 渠道选择、载体配置和路由优先级都不在这里判断;它们统一读取 + * `internal_sku_channels`,避免平台 SKU 或旧路由抢先创建载体。 + */ export async function resolveOrderItemForFulfillment({ provider = '', platform = '', shopId = '', item = {}, }: ResolveOrderItemForFulfillmentInput): Promise { - const candidate = await resolveConfiguredItemCandidate({ - provider, - platform, - item, - }) - const { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - affiliateDashMatch, - fulfillmentRoute, - classification, - } = candidate + const externalItemId = pickFirstNonEmpty([item.externalItemId, item.itemId]) + const externalSkuCode = pickFirstNonEmpty([item.externalSkuCode, item.skuCode, externalItemId]) + const externalSkuName = pickFirstNonEmpty([item.externalSkuName, item.skuName, externalSkuCode]) + const externalSkuNameNormalized = normalizeProductName(externalSkuName) + const spec = isPlainObject(item.spec) ? item.spec : {} - const selectedExecutorKey = fulfillmentRoute?.selectedExecutorKey || '' - // 抖音:`sku_code` 是**分类键**(内部 SKU),必须稳定,不能被英文 sku 覆写; - // 91 保持历史行为(命中映射后用英文 sku 覆盖),避免影响线上 32.9 万单。 - const isDouyinMashida = isDouyinMashidaOrder(provider, platform) - const selectedAffiliateDash = - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH && !isDouyinMashida - const resolvedSkuCode = pickFirstNonEmpty([ - selectedAffiliateDash ? affiliateDashMatch?.sku : '', - externalSkuCode, - externalItemId, - ]) - // 运营侧展示用中文商品名;英文 sku 只写在 skuCode。 - // 抖音:展示用内部 SKU(商品标识),引流长标题另存 snapshot.productTitle。 - const resolvedSkuName = pickFirstNonEmpty([ - selectedAffiliateDash ? affiliateDashMatch?.productNo || affiliateDashMatch?.skuName : '', - isDouyinMashida ? externalSkuCode : '', - item.skuName, - externalSkuName, - resolvedSkuCode, - ]) const internalSkuMapping = await resolveInternalSkuForOrderItem({ provider, shopId, - spec: item.spec ?? null, + spec, skuCode: externalSkuCode, skuName: externalSkuName, }) const mappedSpec = internalSkuMapping - ? { ...(isPlainObject(item.spec) ? item.spec : {}), skuNick: internalSkuMapping.internalSku } - : item.spec + ? { ...spec, skuNick: internalSkuMapping.internalSku } + : spec const resolvedInternalSku = resolveInternalSkuKey({ spec: mappedSpec, snapshot: item.snapshot, skuCode: externalSkuCode, skuName: externalSkuName, }) - // 统一把内部 SKU 固化到 spec.skuNick,使 manual_sku_key 生成列和所有后续管道使用同一键。 const nextSpec = resolvedInternalSku - ? { ...(isPlainObject(mappedSpec) ? mappedSpec : {}), skuNick: resolvedInternalSku } + ? { ...mappedSpec, skuNick: resolvedInternalSku } : mappedSpec - const snapshot = { + const snapshot: Record = { ...(isPlainObject(item.snapshot) ? item.snapshot : {}), externalItemId, externalSkuCode, externalSkuName, externalSkuNameNormalized, - resolvedSkuCode, - matchMode: - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH - ? affiliateDashMatch?.matchMode || '' - : '', - fulfillmentRoute, - affiliateDash: affiliateDashMatch - ? { - matchMode: affiliateDashMatch.matchMode, - productNo: affiliateDashMatch.productNo, - sku: affiliateDashMatch.sku, - skuName: affiliateDashMatch.skuName, - } - : null, - isConfigured: candidate.isConfigured, + resolvedSkuCode: externalSkuCode, + ...(externalSkuName && externalSkuName !== resolvedInternalSku + ? { productTitle: externalSkuName } + : {}), ...(resolvedInternalSku ? { internalSku: resolvedInternalSku } : {}), ...(internalSkuMapping ? { @@ -172,12 +103,6 @@ export async function resolveOrderItemForFulfillment({ internalSkuMappingId: internalSkuMapping.mappingId, } : {}), - // 抖音:引流长标题单独存档(skuName 已改存内部 SKU,标题不能丢) - ...(isDouyinMashida - ? { productTitle: pickFirstNonEmpty([item.skuName, externalSkuName]) } - : {}), - // 抖音:分类结果,供发单层维护「一个单子只有一个执行载体」与后台诊断 - ...(classification ? { classification } : {}), } return { @@ -187,29 +112,36 @@ export async function resolveOrderItemForFulfillment({ externalItemId, externalSkuCode, externalSkuName, - skuCode: resolvedSkuCode, - skuName: resolvedSkuName, + skuCode: externalSkuCode, + skuName: resolvedInternalSku || externalSkuName, snapshot, - isConfigured: candidate.isConfigured, + isConfigured: false, } } export async function hasConfiguredOrderItems({ provider = '', platform = '', + shopId = '', items = [], }: HasConfiguredOrderItemsInput): Promise { - const candidates = await Promise.all( - (Array.isArray(items) ? items : []).map((item) => - resolveConfiguredItemCandidate({ + const resolved = await Promise.all( + items.map((item) => + resolveOrderItemForFulfillment({ provider, platform, + shopId, item, }), ), ) - - return candidates.some((item) => item.isConfigured) + const plans = await loadInternalSkuChannelPlans( + resolved.map((item) => String(item.snapshot.internalSku || '')), + ) + return resolved.some((item) => { + const key = canonicalizeInternalSku(item.snapshot.internalSku) + return Boolean(key && plans.get(key)?.primary) + }) } export function normalizeProductName(value: unknown): string { @@ -219,177 +151,19 @@ export function normalizeProductName(value: unknown): string { function pickFirstNonEmpty(values: unknown[]): string { for (const value of values) { const normalized = String(value || '').trim() - if (normalized) { - return normalized - } + if (normalized) return normalized } - return '' } -async function resolveConfiguredItemCandidate({ - provider = '', - platform = '', - item = {}, -}: ResolveOrderItemForFulfillmentInput): Promise { - const externalItemId = pickFirstNonEmpty([item.externalItemId, item.itemId]) - const externalSkuCode = pickFirstNonEmpty([item.externalSkuCode, item.skuCode, externalItemId]) - const externalSkuName = pickFirstNonEmpty([item.externalSkuName, item.skuName, externalSkuCode]) - const externalSkuNameNormalized = normalizeProductName(externalSkuName) - - if (isOpen91KuaishouOrder(provider, platform)) { - const affiliateDashMatch = await resolveAffiliateDashSkuByProductNo(item) - const affiliateDashConfig = getAffiliateDashConfig() - const preferredExecutorKey = - affiliateDashMatch && affiliateDashConfig.preferredMatchEnabled !== false - ? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH - : '' - const fulfillmentRoute = resolveFulfillmentRoute({ - productName: externalSkuName, - preferredExecutorKey, - candidates: [ - { - executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, - available: Boolean(affiliateDashMatch), - reason: affiliateDashMatch ? '' : '未命中 affiliate-dash sku 映射', - }, - ], - }) - const selectedExecutorKey = fulfillmentRoute.selectedExecutorKey - const resolvedSkuCode = pickFirstNonEmpty([ - selectedExecutorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH - ? affiliateDashMatch?.sku - : '', - externalSkuCode, - externalItemId, - ]) - - return { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - resolvedSkuCode, - affiliateDashMatch, - fulfillmentRoute, - isConfigured: Boolean(selectedExecutorKey), - } - } - - if (isDouyinMashidaOrder(provider, platform)) { - return resolveDouyinMashidaItemCandidate({ - item, - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - }) - } - - const resolvedSkuCode = pickFirstNonEmpty([externalSkuCode, externalItemId]) - - return { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - resolvedSkuCode, - affiliateDashMatch: null, - fulfillmentRoute: null, - isConfigured: false, - } -} - -/** - * 抖音码速达(抖店)订单分流 —— **按 SKU 分类**。 - * - * 平台字段口径:`sku_order_list[].code` = **内部 SKU**(如「幸运币2个」,才是真正的商品), - * `product_name` = 引流长标题(如「【测试专用】和平精英皮肤小店入口…」)。 - * - * 判定顺序(与 91/快手 同一套框架): - * 1. 内部 SKU 命中映射(常量表 + 后台增量)→ 指定 `affiliate_dash`(皮肤/道具自动发货); - * 2. 未命中 → **不写死兜底**,交给 `resolveFulfillmentRoute`:商品路由规则 → 全局优先级 - * → `unmatchedExecutorKey`(生产配置为空串 → 不选执行器 = 不建履约任务); - * 3. 都不命中 → 分类为 `unmatched`:由发单层决定是否派打手(命中接单模板 → 建工单), - * 两者都不中就是人工(订单进「待人工」队列,判据 = 无任务且无工单)。 - * - * ⚠️ 历史实现(错误流程,已废弃):未命中时**写死** `manual_dispatch`, - * 导致未命中单被强行建人工任务(91 是「不建任务」),且抢占了工单层的判定。 - */ -function resolveDouyinMashidaItemCandidate({ - item, - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, -}: { - item: FulfillmentItem - externalItemId: string - externalSkuCode: string - externalSkuName: string - externalSkuNameNormalized: string -}): FulfillmentItemCandidate { - const internalSku = pickFirstNonEmpty([externalSkuCode, externalItemId]) - const productTitle = pickFirstNonEmpty([externalSkuName, internalSku]) - // 内部 SKU(平台 code,如「幸运币2个」)→ affiliate-dash 英文 SKU(与商品图同一张权威表)。 - // 皮肤/礼包类商品的交付**直接复用现成的 affiliate_dash 通道**: - // 买家输 UID → 出二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」→ 发货完成。 - const dashSku = matchDouyinMashidaAffiliateDashSku(internalSku) - const affiliateDashMatch: AffiliateDashSkuMatch | null = dashSku - ? { - sku: dashSku, - productNo: internalSku, - skuName: internalSku, - matchMode: 'affiliate_dash_sku_mapping', - } - : null - const fulfillmentRoute = resolveFulfillmentRoute({ - productName: productTitle, - internalSku, - // 命中映射 = 运营显式意图 → 指定 affiliate_dash; - // 未命中**不设 preferred**,让规则/优先级决定。 - preferredExecutorKey: affiliateDashMatch ? FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH : '', - candidates: [ - { - executorKey: FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH, - available: Boolean(affiliateDashMatch), - reason: affiliateDashMatch ? '' : '内部 SKU 未命中商品映射', - }, - ], - // ⚠️ 抖音**不允许未命中兜底 `manual_dispatch`**(覆盖配置里的 unmatchedExecutorKey): - // 履约判定发生在发单判定**之前**,若这里先建人工任务,命中接单模板的单就会出现 - // 「人工任务 + 工单」双载体,破坏「一个单子只有一个执行载体」的不变式。 - // 抖音的「人工」= 无执行载体(无任务无工单)→ 订单进「待人工」队列; - // 运营仍可用商品路由规则显式把某个 SKU 指到人工(规则优先于本兜底设置)。 - config: { ...getFulfillmentRoutingConfig(), unmatchedExecutorKey: '' }, - }) - - return { - externalItemId, - externalSkuCode, - externalSkuName, - externalSkuNameNormalized, - // 内部 SKU 就是「商品」:保留中文内部 SKU 作为 skuCode,展示与商品图都靠它。 - resolvedSkuCode: internalSku || pickFirstNonEmpty([externalItemId, item.itemId]), - affiliateDashMatch, - fulfillmentRoute, - isConfigured: Boolean(fulfillmentRoute.selectedExecutorKey), - classification: { - channel: affiliateDashMatch ? 'affiliate_dash' : 'unmatched', - canonicalSku: internalSku, - matchMode: affiliateDashMatch?.matchMode || '', - }, - } -} - /** * 内部 SKU → affiliate-dash 英文 sku。 * - * 映射来源 = **权威常量表 + 后台增量覆盖**(`affiliate-dash` 配置面板的 `skuMapping`), - * 与 91 分支同一套来源:新增抖音商品在后台加一行即可,不必改代码发版。 + * 映射来源是权威常量表 + 后台增量覆盖;只用于已确定 affiliate_dash + * 渠道后的载体参数,不参与渠道选择。 */ export function matchAffiliateDashSkuByInternalSku(internalSku: unknown): string { - const raw = String(internalSku ?? '').trim() + const raw = canonicalizeInternalSku(internalSku) if (!raw) return '' return matchAffiliateDashSku( mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping), @@ -398,117 +172,18 @@ export function matchAffiliateDashSkuByInternalSku(internalSku: unknown): string } export function matchDouyinMashidaAffiliateDashSku(internalSku: unknown): string { - const raw = String(internalSku ?? '').trim() - if (!raw) { - return '' - } - return matchAffiliateDashSku( - mergeAffiliateDashSkuMapping(getAffiliateDashConfig().skuMapping), - raw, - ) -} - -async function resolveAffiliateDashSkuByProductNo( - item: FulfillmentItem, -): Promise { - const config = getAffiliateDashConfig() - if (config.enabled === false || !config.baseUrl) { - return null - } - - const snapshot = isPlainObject(item.snapshot) ? item.snapshot : {} - const productNo = pickFirstNonEmpty([ - snapshot.productNo, - item.externalSkuCode, - item.externalItemId, - ]) - if (!productNo) { - return null - } - - // ① 权威中英表(+ 配置增量覆盖)② 英文 sku 透传兜底 - const mapping = mergeAffiliateDashSkuMapping(config.skuMapping) - const mappedSku = matchAffiliateDashSku(mapping, String(productNo)) - if (mappedSku) { - return { - sku: mappedSku, - productNo: String(productNo), - skuName: String(item.externalSkuName || '').trim(), - matchMode: 'affiliate_dash_sku_mapping', - } - } - - const rawProductNo = String(productNo).trim() - const skuSet = await getAffiliateDashSkuSet() - if (matchAffiliateDashSkuPassthrough(rawProductNo, skuSet)) { - return { - sku: rawProductNo, - productNo: rawProductNo, - skuName: String(item.externalSkuName || '').trim(), - matchMode: 'affiliate_dash_sku_passthrough', - } - } - - return null -} - -/** 透传判定:productNo 与 affiliate_dash 商品 sku 完全一致。 */ -export function matchAffiliateDashSkuPassthrough( - productNo: string, - availableSkus: ReadonlySet | null | undefined, -): boolean { - const raw = String(productNo || '').trim() - return Boolean(raw && availableSkus?.has(raw)) -} - -const AFFILIATE_DASH_SKU_CACHE_TTL_MS = 5 * 60 * 1000 -let affiliateDashSkuCache: { skus: Set; fetchedAt: number } | null = null - -async function getAffiliateDashSkuSet(): Promise> { - const now = Date.now() - if ( - affiliateDashSkuCache && - now - affiliateDashSkuCache.fetchedAt < AFFILIATE_DASH_SKU_CACHE_TTL_MS - ) { - return affiliateDashSkuCache.skus - } - - try { - const result = await listAllAffiliateDashProducts() - const skus = new Set( - result.list.map((product) => String(product.sku || '').trim()).filter(Boolean), - ) - affiliateDashSkuCache = { skus, fetchedAt: now } - return skus - } catch (error) { - logIntegration( - '[affiliate-dash]', - 'affiliate-dash 商品列表拉取失败,透传降级为未命中', - { - cachedSkuCount: affiliateDashSkuCache?.skus.size || 0, - error: error instanceof Error ? error.message : String(error), - }, - { level: 'warn' }, - ) - return affiliateDashSkuCache?.skus || new Set() - } + return matchAffiliateDashSkuByInternalSku(internalSku) } /** - * 91 商品编码为中文名(如「幸运币90个」),映射键查询做归一化: - * NFKC(全角→半角)→ 去所有空白 → 小写。先原样精确,再归一化遍历。 - * 权威表见 platforms/affiliate-dash/sku-mapping.ts。 + * 内部中文 SKU 映射键查询:NFKC → 去全部空白 → 小写。 */ export function matchAffiliateDashSku(mapping: AffiliateDashSkuMapping, productNo: string): string { const raw = String(productNo || '').trim() - if (!raw) { - return '' - } + if (!raw) return '' const direct = String(mapping[raw] || '').trim() - if (direct) { - return direct - } + if (direct) return direct const normalized = normalizeAffiliateDashMappingKey(raw) for (const [key, sku] of Object.entries(mapping || {})) { @@ -520,21 +195,7 @@ export function matchAffiliateDashSku(mapping: AffiliateDashSkuMapping, productN } export function normalizeAffiliateDashMappingKey(value: unknown): string { - return normalizeInternalSkuMatchKey(value) -} - -function isOpen91KuaishouOrder(provider: unknown, platform: unknown) { - return ( - String(provider || '').trim() === '91kaquan' && String(platform || '').trim() === 'kuaishou' - ) -} - -/** 抖音码速达:provider=douyin_mashida / platform=doudian(见 douyin-mashida/order-mapper.ts)。 */ -function isDouyinMashidaOrder(provider: unknown, platform: unknown) { - return ( - String(provider || '').trim() === 'douyin_mashida' && - String(platform || '').trim() === 'doudian' - ) + return canonicalizeInternalSku(value) } function isPlainObject(value: unknown): value is Record { diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 475a06a0..37f3758c 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -97,7 +97,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { : await readWatermarkItemId('carrier_dispatch') let maxId = 0 const configKeysResult = await query<{ sku_key: string }>( - 'SELECT sku_key FROM manual_fulfillment_configs WHERE enabled', + 'SELECT canonicalize_internal_sku(sku_key) AS sku_key FROM manual_fulfillment_configs WHERE enabled', ) const configKeys = configKeysResult.rows.map((row) => row.sku_key) @@ -108,7 +108,9 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { cfg.id AS config_id FROM order_items oi JOIN manual_fulfillment_configs cfg - ON cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + ON canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) AND cfg.enabled WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -209,9 +211,15 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { } // 自动发货 / 接单平台被运营指定后却没有载体:不能静默卡住,进问题队列。 - const missingCarrierRaw = await query<{ id: number; channel: string; sku_code: string }>( + const missingCarrierRaw = await query<{ + id: number + channel: string + sku_code: string + internal_sku: string + }>( ` - SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code + SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku FROM order_items oi LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' WHERE oi.fulfillment_status = 'assigned' @@ -249,16 +257,22 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { for (const row of missingCarrier.rows) { exceptionEntries.push({ reason: 'carrier_missing', - scopeKey: `carrier_missing|${row.channel}|${row.sku_code}`, + scopeKey: `carrier_missing|${row.internal_sku}|${row.channel}`, orderItemId: Number(row.id), - detail: { channel: row.channel, skuCode: row.sku_code }, + detail: { channel: row.channel, internalSku: row.internal_sku, platformSku: row.sku_code }, }) } // 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。 - const conflictingCarriers = await query<{ id: number; channel: string; sku_code: string }>( + const conflictingCarriers = await query<{ + id: number + channel: string + sku_code: string + internal_sku: string + }>( ` - SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code + SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku FROM order_items oi WHERE oi.fulfillment_status = 'assigned' AND ( @@ -284,9 +298,14 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { for (const row of conflictingCarriers.rows) { exceptionEntries.push({ reason: 'routing_conflict', - scopeKey: `routing_conflict|${row.channel}|${row.sku_code}|carrier`, + scopeKey: `routing_conflict|${row.internal_sku}|${row.channel}|carrier`, orderItemId: Number(row.id), - detail: { channel: row.channel, skuCode: row.sku_code, issue: 'multiple_carriers' }, + detail: { + channel: row.channel, + internalSku: row.internal_sku, + platformSku: row.sku_code, + issue: 'multiple_carriers', + }, }) } if (exceptionEntries.length > 0) { @@ -338,7 +357,9 @@ async function resolveCarrierDispatchExceptions(): Promise { UPDATE order_items oi SET fulfillment_config_id = cfg.id, updated_at = NOW() FROM manual_fulfillment_configs cfg - WHERE cfg.sku_key = resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) + WHERE canonicalize_internal_sku(cfg.sku_key) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) AND cfg.enabled AND oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' 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 4368229d..18417935 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 @@ -1,34 +1,73 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { isAffiliateDashCarrierSnapshot } from './sync-worker-orders-service.js' +import type { + InternalSkuChannelPlan, + InternalSkuChannelRow, +} from '../fulfillment/internal-sku-channel-service.js' +import { resolveWorkOrderChannelGate } from './sync-worker-orders-service.js' -// 载体唯一不变式:一个单子只有一个执行载体。 -// 命中 affiliate_dash 自动发货的订单项(抖音按 SKU 分类后在 snapshot 标注)不再建工单。 +function channel(patch: Partial = {}): InternalSkuChannelRow { + return { + id: 1, + internal_sku: '幸运币2个', + channel: 'work_order', + priority: 10, + is_fallback: false, + enabled: true, + canonical_product_id: null, + config_json: { workRuleKey: 'rule-lucky-coin' }, + source: 'operator', + note: '', + created_by_admin_user_id: null, + ...patch, + } +} -test('载体唯一守卫:分类为 affiliate_dash 的订单项不建工单', () => { +function plan(primary: InternalSkuChannelRow | null): InternalSkuChannelPlan { + return { + primary, + fallbacks: [], + attempts: primary ? [primary] : [], + conflicts: [], + } +} + +test('工单门禁:只有内部 SKU 明确配置 work_order 且提供模板才放行', () => { + assert.deepEqual(resolveWorkOrderChannelGate(plan(channel())), { + allowed: true, + reason: '', + workRuleKey: 'rule-lucky-coin', + }) +}) + +test('工单门禁:其它统一渠道一律不放行', () => { + for (const targetChannel of ['affiliate_dash', 'manual', 'third_party_voucher'] as const) { + assert.equal( + resolveWorkOrderChannelGate(plan(channel({ channel: targetChannel, config_json: {} }))) + .reason, + `internal_sku_channel_${targetChannel}`, + ) + } +}) + +test('工单门禁:无配置、冲突、缺模板、模板不一致都不放行', () => { + assert.equal(resolveWorkOrderChannelGate(undefined).reason, 'no_channel_capability') assert.equal( - isAffiliateDashCarrierSnapshot({ classification: { channel: 'affiliate_dash' } }), - true, + resolveWorkOrderChannelGate({ + primary: null, + fallbacks: [], + attempts: [], + conflicts: [channel()], + }).reason, + 'routing_conflict', + ) + assert.equal( + resolveWorkOrderChannelGate(plan(channel({ config_json: {} }))).reason, + 'work_rule_missing', + ) + assert.equal( + resolveWorkOrderChannelGate(plan(channel()), 'another-rule').reason, + 'work_rule_mismatch', ) }) - -test('载体唯一守卫:多余的空白/大小写仍然命中(保守判定)', () => { - assert.equal( - isAffiliateDashCarrierSnapshot({ classification: { channel: ' affiliate_dash ' } }), - true, - ) -}) - -test('载体唯一守卫:未命中分类 / 无字段 / 脏数据一律放行(不误伤发单)', () => { - assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: 'unmatched' } }), false) - assert.equal(isAffiliateDashCarrierSnapshot({ classification: { channel: '' } }), false) - assert.equal(isAffiliateDashCarrierSnapshot({ classification: {} }), false) - assert.equal(isAffiliateDashCarrierSnapshot({ classification: 'affiliate_dash' }), false) - assert.equal(isAffiliateDashCarrierSnapshot({ classification: null }), false) - assert.equal(isAffiliateDashCarrierSnapshot({ classification: ['affiliate_dash'] }), false) - assert.equal(isAffiliateDashCarrierSnapshot({}), false) - assert.equal(isAffiliateDashCarrierSnapshot(null), false) - assert.equal(isAffiliateDashCarrierSnapshot(undefined), false) - assert.equal(isAffiliateDashCarrierSnapshot('affiliate_dash'), 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 d420147d..3f48dfc9 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,7 +12,6 @@ import { getLatestWorkProductMatchLogByOrderItemId, getWorkOrderByOrderItemId, listWorkOrdersByPlatformOrderId, - listWorkProductRuleMappings, listWorkProductRules, type WorkOrderRow, } from '../../repositories/worker-platform/index.js' @@ -38,14 +37,48 @@ import { mapWorkOrderAdmin, normalizeRequirementFields, resolveKuaishouWorkProductMatchContext, - resolveMatchingProductRuleDecision, resolveSkuNameQuantity, resolveWorkOrderRulePricing, resolveWorkOrderSharingEnabled, } from './mappers.js' -import { getWorkerProductMatchConfig } from './worker-product-match-config-service.js' import { registerWorkOrderShopName } from './work-order-shop-options-cache.js' import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-service.js' +import { + loadInternalSkuChannelPlans, + type InternalSkuChannelPlan, +} from '../fulfillment/internal-sku-channel-service.js' +import { + canonicalizeInternalSku, + resolveInternalSkuKey, +} from '../fulfillment/internal-sku-resolver.js' + +export function resolveWorkOrderChannelGate( + plan: InternalSkuChannelPlan | undefined, + preferredWorkRuleKey?: string, +): { allowed: boolean; reason: string; workRuleKey: string } { + if (!plan?.primary) { + return { + allowed: false, + reason: plan?.conflicts.length ? 'routing_conflict' : 'no_channel_capability', + workRuleKey: '', + } + } + if (plan.primary.channel !== 'work_order') { + return { + allowed: false, + reason: `internal_sku_channel_${plan.primary.channel}`, + workRuleKey: '', + } + } + const configuredWorkRuleKey = String(plan.primary.config_json.workRuleKey || '').trim() + if (!configuredWorkRuleKey) { + return { allowed: false, reason: 'work_rule_missing', workRuleKey: '' } + } + if (preferredWorkRuleKey && String(preferredWorkRuleKey).trim() !== configuredWorkRuleKey) { + return { allowed: false, reason: 'work_rule_mismatch', workRuleKey: '' } + } + return { allowed: true, reason: '', workRuleKey: configuredWorkRuleKey } +} export async function syncWorkerOrdersForSourceOrder( order: OrderRow, @@ -58,59 +91,76 @@ export async function syncWorkerOrdersForSourceOrder( preferredWorkRuleKey?: string } = {}, ) { - const [rules, mappings] = await Promise.all([ - listWorkProductRules({ enabled: true }), - listWorkProductRuleMappings({ enabled: true }), - ]) - const productMatchConfig = getWorkerProductMatchConfig() + const rules = await listWorkProductRules({ enabled: true }) const created: WorkOrderRow[] = [] const skipped: Array<{ orderItemId: number; reason: string }> = [] const decisions: Array> = [] + const internalSkuByItemId = new Map() + for (const item of orderItems) { + internalSkuByItemId.set( + Number(item.id), + resolveInternalSkuKey({ + manualSkuKey: item.manual_sku_key, + skuCode: item.sku_code, + skuName: item.sku_name, + spec: safeParseJson(item.spec_json), + snapshot: safeParseJson(item.item_snapshot_json), + }), + ) + } + const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()]) for (const item of orderItems) { - // 载体唯一不变式(一个单子只有一个执行载体): - // 命中 affiliate_dash 自动发货的订单项(抖音在 snapshot.classification 标注)不再建工单。 - if (isAffiliateDashCarrierSnapshot(safeParseJson(item.item_snapshot_json))) { - skipped.push({ orderItemId: Number(item.id), reason: 'affiliate_dash_carrier_taken' }) + const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id))) + const channelPlan = channelPlans.get(internalSku) + if (!internalSku) { + skipped.push({ orderItemId: Number(item.id), reason: 'no_internal_sku' }) decisions.push( buildWorkerConsumerDecision(item, { result: 'skipped', - reason: 'affiliate_dash_carrier_taken', + reason: 'no_internal_sku', + }), + ) + continue + } + const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey) + if (!channelGate.allowed) { + skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason }) + decisions.push( + buildWorkerConsumerDecision(item, { + result: 'skipped', + reason: channelGate.reason, + internalSku, }), ) continue } - const preferredRuleKey = String(options.preferredWorkRuleKey || '').trim() + const preferredRuleKey = channelGate.workRuleKey const preferredRule = preferredRuleKey ? rules.find((rule) => String(rule.rule_key || '').trim() === preferredRuleKey) || null : null - const match = - preferredRuleKey && !preferredRule - ? { - rule: null, - mappingId: null, - reason: 'unmatched' as const, - score: 0, - candidates: [], - diagnosis: [ - { - dimension: 'internal_sku_channel', - reason: 'configured_work_rule_missing', - }, - ], - } - : preferredRule - ? { - rule: preferredRule, - mappingId: null, - reason: 'matched' as const, - score: 1, - candidates: [{ ruleKey: preferredRuleKey, score: 1, mappingId: null }], - } - : resolveMatchingProductRuleDecision(order, item, rules, mappings, { - legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled, - }) + 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 matchContext = resolveKuaishouWorkProductMatchContext(item) const snapshot = safeParseJson(item.item_snapshot_json) const sellerId = @@ -176,7 +226,7 @@ export async function syncWorkerOrdersForSourceOrder( }) } if (!match.rule) { - const reason = match.reason === 'ambiguous' ? 'rule_match_ambiguous' : 'rule_not_matched' + const reason = 'work_rule_not_found' skipped.push({ orderItemId: Number(item.id), reason, @@ -435,26 +485,6 @@ async function resolveDouyinMashidaWorkOrderVoucher( } } -/** - * 该订单项的执行载体是否**已被 affiliate_dash 自动发货占用**。 - * - * 依据:抖音按 SKU 分类后写入的 `snapshot.classification.channel === 'affiliate_dash'` - * (见 `fulfillment/product-resolution-service.ts`)。 - * 一个单子只能有一个执行载体:自动发货与派打手互斥,命中前者时不再建工单。 - */ -export function isAffiliateDashCarrierSnapshot(snapshot: unknown): boolean { - if (!snapshot || typeof snapshot !== 'object' || Array.isArray(snapshot)) { - return false - } - - const classification = (snapshot as { classification?: unknown }).classification - if (!classification || typeof classification !== 'object' || Array.isArray(classification)) { - return false - } - - return String((classification as { channel?: unknown }).channel || '').trim() === 'affiliate_dash' -} - function buildWorkerConsumerDecision( item: OrderItemRow, detail: Record, diff --git a/apps/frontend/src/App.tsx b/apps/frontend/src/App.tsx index 15827325..b95b8795 100644 --- a/apps/frontend/src/App.tsx +++ b/apps/frontend/src/App.tsx @@ -35,9 +35,6 @@ const AdminDouyinMashidaCertificatePage = lazy( const AdminLoginPage = lazy(() => import('@/pages/admin/AdminLoginPage')) const AdminOrderDetailPage = lazy(() => import('@/pages/admin/AdminOrderDetailPage')) const AdminOrdersPage = lazy(() => import('@/pages/admin/AdminOrdersPage')) -const AdminPlatformFulfillmentPage = lazy( - () => import('@/pages/admin/platform/AdminPlatformFulfillmentPage'), -) const AdminPlatformShopsPage = lazy(() => import('@/pages/admin/platform/AdminPlatformShopsPage')) const AdminTaskDetailPage = lazy(() => import('@/pages/admin/AdminTaskDetailPage')) const AdminTasksPage = lazy(() => import('@/pages/admin/AdminTasksPage')) @@ -187,7 +184,10 @@ function AdminApplication() { }> } /> } /> - } /> + } + /> } /> diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 4026d75c..adb231e2 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -14,7 +14,6 @@ import { OrderedListOutlined, SafetyCertificateOutlined, SettingOutlined, - ShopOutlined, SoundOutlined, TeamOutlined, TrophyOutlined, @@ -287,7 +286,6 @@ export default function AdminLayout() { children: [ { key: '/admin/users', icon: , label: '后台用户' }, { key: '/admin/platform-shops', icon: , label: '平台配置' }, - { key: '/admin/platform-fulfillment', icon: , label: '履约配置' }, ], }, { diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 02ac192d..3b82596b 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -34,7 +34,7 @@ import { useEffect, useState } from 'react' import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' import { - assignChannelBySku, + assignChannelByInternalSku, deleteChannelRules, fetchChannelExceptions, fetchChannelRules, @@ -64,6 +64,14 @@ const CHANNEL_COLOR: Record = { manual: 'orange', } +const CHANNEL_SOURCE_LABEL: Record = { + operator: '运营配置', + manual_config: '人工 SKU 配置', + voucher: '第三方卡券', + capability: '商品能力迁移', + backfill: '历史回填', +} + const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }] export default function AdminFulfillmentChannelPage() { @@ -97,9 +105,7 @@ function PendingPanel() { const assignMutation = useMutation({ mutationFn: (row: PendingChannelGroup) => - assignChannelBySku({ - provider: row.provider, - shopId: row.shop_id, + assignChannelByInternalSku({ internalSku: row.internal_sku, channel: selections[rowKey(row)] || '', }), @@ -126,8 +132,19 @@ function PendingPanel() { }) const columns = [ - { title: '来源', dataIndex: 'provider', width: 120 }, - { title: '店铺', dataIndex: 'shop_id', width: 140 }, + { + title: '来源', + dataIndex: 'providers', + width: 120, + render: (value: string[] | undefined) => value?.join(' / ') || '-', + }, + { + title: '店铺', + dataIndex: 'shop_ids', + width: 180, + ellipsis: true, + render: (value: string[] | undefined) => value?.join(' / ') || '-', + }, { title: '内部SKU', dataIndex: 'internal_sku', width: 220 }, { title: '平台SKU', @@ -284,7 +301,7 @@ function RulesPanel() { void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] }) void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) }, - onError: () => message.error('删除规则失败'), + onError: (error) => message.error(error instanceof Error ? error.message : '删除规则失败'), }) function confirmDeleteRules(ids: number[]) { @@ -319,6 +336,19 @@ function RulesPanel() { ), }, { title: '优先级', dataIndex: 'priority', width: 90 }, + { + title: '载体参数', + width: 220, + ellipsis: true, + render: (_: unknown, row: ChannelRule) => formatCarrierParams(row), + }, + { + title: '来源', + dataIndex: 'source', + width: 140, + render: (value: string) => + CHANNEL_SOURCE_LABEL[value] || (value.startsWith('rule:') ? '规则迁移' : value || '-'), + }, { title: '启用', dataIndex: 'enabled', @@ -333,6 +363,7 @@ function RulesPanel() { danger size="small" loading={deleteMutation.isPending} + disabled={!canDeleteChannelRule(row)} onClick={() => confirmDeleteRules([row.id])} > 删除 @@ -377,6 +408,7 @@ function RulesPanel() { rowSelection={{ selectedRowKeys: selectedRuleIds, onChange: (keys) => setSelectedRuleIds(keys.map(Number)), + getCheckboxProps: (row: ChannelRule) => ({ disabled: !canDeleteChannelRule(row) }), }} pagination={buildAdminTablePagination({ current: query.data?.data?.page || page, @@ -441,11 +473,12 @@ function ExceptionsPanel() { const REASON_OPTIONS = [ { value: '', label: '全部' }, - { value: 'classification_missing', label: '待分类' }, - { value: 'routing_conflict', label: '规则冲突' }, + { value: 'routing_conflict', label: '渠道冲突' }, { value: 'product_mapping_missing', label: '未归一' }, { value: 'cost_missing', label: '成本缺失' }, { value: 'manual_config_missing', label: '人工 SKU 未配置' }, + { value: 'manual_cost_missing', label: '人工未确认履约' }, + { value: 'carrier_missing', label: '缺少履约载体' }, ] const columns = [ @@ -469,7 +502,7 @@ function ExceptionsPanel() { return ( setUnmatchedExecutorKey(String(value || ''))} - /> - - - - } onClick={addRule}> - 新增规则 - - } - > - - setPreviewInput(event.target.value)} - onPressEnter={previewRoute} - /> - - - - {previewResult ? ( - - - {previewResult.fulfillmentRoute?.reason || previewResult.normalizedProductName} - - - {previewResult.fulfillmentRoute?.candidates.map((candidate) => ( - - {formatRoutingExecutorLabel(candidate.executorKey)} - {candidate.available ? ' 可用' : ' 未命中'} - - ))} - - - } - /> - ) : null} - - - - - - - } - > - {rules.length === 0 ? ( - - ) : ( - - {rules.map((rule, index) => ( -
-
- - updateRule(index, { enabled: checked })} - /> - {rule.productName || '未命名规则'} - - -
-
- updateRule(index, { productName: event.target.value })} - /> - ({ - label: executor.label, - value: executor.key, - }))} - onChange={(executorKey) => - updateRule(index, { executorKey: String(executorKey || '') }) - } - /> - - updateRule(index, { priority: Number(priorityValue || 0) || 0 }) - } - /> -
- updateRule(index, { notes: event.target.value })} - /> -
- ))} -
- )} -
- - ) -} - -function MetricCard({ label, value, detail }: { label: string; value: string; detail?: string }) { - return ( - - {label} - {value} - {detail ? {detail} : null} - - ) -} - -function normalizeRoutingPriority(value: unknown) { - const rawItems = Array.isArray(value) ? value : [] - const allowed = ROUTING_EXECUTORS.filter((executor) => executor.key !== 'manual_dispatch').map( - (executor) => executor.key, - ) - const seen = new Set() - const items = rawItems - .map((item) => String(item || '').trim()) - .filter((item) => allowed.includes(item) && !seen.has(item) && (seen.add(item), true)) - - return items.length > 0 ? items : ['affiliate_dash'] -} - -function normalizeRoutingExecutors(value: unknown): AdminFulfillmentRoutingConfig['executors'] { - const source = - value && typeof value === 'object' && !Array.isArray(value) - ? (value as AdminFulfillmentRoutingConfig['executors']) - : {} - - return ROUTING_EXECUTORS.reduce( - (result, executor) => { - result[executor.key] = { - enabled: source[executor.key]?.enabled !== false, - } - return result - }, - {}, - ) -} - -function normalizeRoutingRule(rule: AdminFulfillmentRoutingRule): EditableRoutingRule { - const productName = String(rule.productName || '').trim() - const executorKey = String(rule.executorKey || '').trim() - - return { - id: String(rule.id || `${productName}-${executorKey}` || createRuleId()).trim(), - enabled: rule.enabled !== false, - productName, - normalizedProductName: String(rule.normalizedProductName || '').trim(), - matchType: rule.matchType === 'contains' ? 'contains' : 'exact', - executorKey: executorKey || 'affiliate_dash', - priority: Number(rule.priority || 0) || 0, - notes: String(rule.notes || '').trim(), - } -} - -function formatRoutingExecutorLabel(executorKey: unknown) { - const key = String(executorKey || '').trim() - return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.label || key || '-' -} - -function resolveRoutingExecutorColor(executorKey: unknown) { - const key = String(executorKey || '').trim() - return ROUTING_EXECUTORS.find((executor) => executor.key === key)?.color || 'default' -} - -function createRuleId() { - return `rule_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` -} diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index a55095af..5a6618f9 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -2,10 +2,10 @@ import { apiDelete, apiGet, apiPost } from '@/lib/http' /** 待分类队列(按内部 SKU 聚合)。 */ export type PendingChannelGroup = { - provider: string - shop_id: string internal_sku: string sku_name: string + providers: string[] + shop_ids: string[] sku_codes: string[] pending_reasons: string[] item_count: number @@ -29,6 +29,10 @@ export type ChannelRule = { channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' priority: number enabled: boolean + is_fallback: boolean + source: string + config_json: Record + note: string } export type ChannelRuleListResult = { @@ -98,9 +102,7 @@ export function fetchChannelExceptions( return apiGet('/api/v1/admin/fulfillment/exceptions', params) } -export function assignChannelBySku(payload: { - provider: string - shopId: string +export function assignChannelByInternalSku(payload: { internalSku: string channel: string note?: string diff --git a/apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts b/apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts deleted file mode 100644 index 0dcddf66..00000000 --- a/apps/frontend/src/services/admin/platform-config/fulfillment-routing.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { apiGet, apiPost } from '@/lib/http' -import type { - AdminFulfillmentRoutingConfig, - AdminFulfillmentRoutingPreviewResult, -} from '@/types/admin' - -export function fetchAdminFulfillmentRoutingConfig() { - return apiGet('/api/v1/admin/platform-config/fulfillment-routing') -} - -export function saveAdminFulfillmentRoutingConfig(payload: Partial) { - return apiPost( - '/api/v1/admin/platform-config/fulfillment-routing', - payload, - ) -} - -export function previewAdminFulfillmentRouting(productName: string) { - return apiPost( - '/api/v1/admin/platform-config/fulfillment-routing/preview', - { productName }, - ) -} diff --git a/apps/frontend/src/services/admin/platform-config/index.ts b/apps/frontend/src/services/admin/platform-config/index.ts index 29fc3710..ef420bfe 100644 --- a/apps/frontend/src/services/admin/platform-config/index.ts +++ b/apps/frontend/src/services/admin/platform-config/index.ts @@ -2,6 +2,5 @@ export * from './notifications' export * from './scheduled-jobs' export * from './kuaishou-industry' export * from './affiliate-dash' -export * from './fulfillment-routing' export * from './pdd' export * from './douyin-mashida' diff --git a/apps/frontend/src/types/admin/index.ts b/apps/frontend/src/types/admin/index.ts index 03191a33..0d57815c 100644 --- a/apps/frontend/src/types/admin/index.ts +++ b/apps/frontend/src/types/admin/index.ts @@ -124,10 +124,6 @@ export type { AdminKuaishouIndustryRefreshTokenResponse, AdminKuaishouIndustryAuthorizationCodePayload, AdminKuaishouIndustryExchangeCodeResponse, - AdminFulfillmentRoutingConfig, - AdminFulfillmentRoutingExecutorConfig, - AdminFulfillmentRoutingPreviewResult, - AdminFulfillmentRoutingRule, AdminAffiliateDashConfig, AdminAffiliateDashEffectiveConfig, AdminAffiliateDashConfigResponse, diff --git a/apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts b/apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts deleted file mode 100644 index 52f42965..00000000 --- a/apps/frontend/src/types/admin/platform-config/fulfillment-routing.ts +++ /dev/null @@ -1,53 +0,0 @@ -export interface AdminFulfillmentRoutingExecutorConfig { - enabled: boolean -} - -export interface AdminFulfillmentRoutingRule { - id: string - enabled: boolean - productName: string - normalizedProductName: string - matchType: 'exact' | 'contains' - executorKey: string - priority: number - notes: string -} - -export interface AdminFulfillmentRoutingConfig { - filePath: string - enabled: boolean - defaultExecutorPriority: string[] - unmatchedExecutorKey: string - executors: Record - rules: AdminFulfillmentRoutingRule[] -} - -export interface AdminFulfillmentRoutingPreviewResult { - productName: string - normalizedProductName: string - resolvedSkuCode: string - resolvedSkuName: string - isConfigured: boolean - fulfillmentRoute: null | { - selectedExecutorKey: string - selectedRuleId: string - reason: string - matchedRule: null | { - id: string - productName: string - executorKey: string - priority: number - } - candidates: Array<{ - executorKey: string - available: boolean - reason?: string - }> - skipped: Array<{ - executorKey: string - reason: string - }> - } - cloudtentacles: Record | null - kuaishouFeifei: Record | null -} diff --git a/apps/frontend/src/types/admin/platform-config/index.ts b/apps/frontend/src/types/admin/platform-config/index.ts index 1477b15d..dd7eb1f3 100644 --- a/apps/frontend/src/types/admin/platform-config/index.ts +++ b/apps/frontend/src/types/admin/platform-config/index.ts @@ -36,13 +36,6 @@ export type { AdminAffiliateDashWallet, } from './affiliate-dash' -export type { - AdminFulfillmentRoutingConfig, - AdminFulfillmentRoutingExecutorConfig, - AdminFulfillmentRoutingPreviewResult, - AdminFulfillmentRoutingRule, -} from './fulfillment-routing' - export type { AdminPddStoreItem, AdminPddConfig, diff --git a/docs/履约与财务分流统一方案-2026-11-02.md b/docs/履约与财务分流统一方案-2026-11-02.md index be60cc23..ecc336d8 100644 --- a/docs/履约与财务分流统一方案-2026-11-02.md +++ b/docs/履约与财务分流统一方案-2026-11-02.md @@ -68,11 +68,9 @@ skuNick → outSkuSn → sku_code → sku_name ### 3.1 自动发货 -1. `resolveOrderItemForFulfillment` - - 91:affiliate-dash SKU 映射 + `fulfillmentRouting`(商品名/内部 SKU)。 - - 抖音:`sku_order_list[].code` 即内部 SKU,查常量/后台映射表。 - - PDD:没有 affiliate 入口。 -2. 命中后写 `item_snapshot_json.fulfillmentRoute.selectedExecutorKey=affiliate_dash` 和 `snapshot.affiliateDash.sku`。 +1. 所有平台先统一归一到规范化中文内部 SKU;平台 SKU 只作为入口映射字段。 +2. `internal_sku_channels` 指定 `affiliate_dash` 后,才解析该内部 SKU 的 affiliate 载体 SKU。 +3. 命中后写 `item_snapshot_json.fulfillmentRoute.selectedExecutorKey=affiliate_dash` 和 `snapshot.affiliateDash.sku`。 3. `syncDeliveryTasksForOrder` 只为 `isConfigured` 的订单项建 `fulfillment_tasks`。 4. 工单层跳过 `classification.channel=affiliate_dash` 的订单项,保证载体唯一。 @@ -152,8 +150,14 @@ internal_sku_channels ( - 接单平台 priority 10; - 卡券 priority 10。 +待分类队列按内部 SKU 全局聚合:同一中文 SKU 即使来自不同平台或店铺,也只展示一行; +运营指定渠道后写入同一份全局配置,并回填该内部 SKU 的全部待分类订单项。 + 下单入口、财务、成本中心都读这一份配置。 +规范化规则固定为 NFKC → 去全部空白 → 小写。没有中文内部 SKU 的订单项保持 pending, +不再回退到平台 SKU 或英文码。 + ### 5.3 载体保证 选定渠道后必须保证载体: @@ -205,15 +209,17 @@ pending 只保留: ### 阶段 4:清理与重判 -1. 清理旧 `sku_code` affiliate/voucher 规则; -2. 按内部 SKU 生成全局规则; -3. 重判断代后 assigned/pending; -4. 本地三平台模拟 → 生产迁移/部署。 +1. 下线旧「平台配置 → 履约配置」入口,旧路由不再参与运行时决策; +2. 清理旧 `sku_code` affiliate/voucher 规则; +3. 按中文内部 SKU 生成全局规则; +4. 重判断代后 assigned/pending; +5. 本地三平台模拟 → 生产迁移/部署。 ## 7. 验收 - 三平台订单:下单 → 内部 SKU → 渠道 → 载体 → 成本 → 销售事实全链路可追踪; - 同名不同平台码不再拆 pending; +- 同一内部 SKU 跨平台/跨店铺只展示一条待分类、一条渠道配置和一个问题聚合; - 财务渠道与真实载体一致; - pending 仅三类语义; - 审计脚本 0 空内部 SKU、0 纯数字(映射后)、0 载体/渠道矛盾; diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 8bc65685..a2e9ca62 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -370,14 +370,15 @@ fulfillment_locked -- 人工指定后为 true,自动判定跳过 fulfillment_decided_at / fulfillment_reason ``` -判定顺序固定:**规则命中 → 载体证据(工单/自动发货任务)→ 无证据则 pending**。 +判定顺序固定:**真实载体证据 → `internal_sku_channels` 统一配置 → 无证据则 pending**。 +渠道选择只认规范化中文内部 SKU;平台 SKU 仅用于入口映射,不能参与路由。 渠道值不再有 `legacy_platform`:断代归档后统一是 `archived`。 决策服务在 `services/fulfillment/channel-assign-service.ts`: -- `resolveChannelDecision()` —— 纯函数,规则与证据的优先级; +- `resolveChannelDecision()` —— 纯函数,真实载体与统一 SKU 配置的优先级; - `assignPendingOrderItemChannels()` —— 按水位游标批量判定; -- `assignChannelBySku()` —— 运营按 SKU 指定,写 `operator` 规则并锁定。 +- `assignChannelByInternalSku()` —— 运营按中文内部 SKU 全局指定并锁定。 ### 6.2 断代与归档 @@ -390,9 +391,9 @@ fulfillment_decided_at / fulfillment_reason ### 6.3 决策边界(写进代码注释与测试) 1. 新订单项默认 `pending`;由 `channel_assign` 管道在分钟级判定,不要求下单瞬间有渠道。 -2. 规则优先级:运营指定(10)> 自动发货(60)> 人工配置(70)> 默认;冲突会记 `routing_conflict` 异常。 +2. 渠道能力只来自 `internal_sku_channels`;同一内部 SKU 只能有一个主渠道,冲突记 `routing_conflict` 异常。 3. 被运营指定的订单项 `fulfillment_locked=true`,自动判定与回填都不得覆盖。 -4. **商品来源不能反推渠道**:`man:` 商品也可能由自动发货履约,判定只认规则与证据。 +4. **商品来源和平台 SKU 都不能反推渠道**:判定只认统一 SKU 配置与真实载体。 5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。 6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。 7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。 diff --git a/docs/生产部署核对清单-2026-11-02.md b/docs/生产部署核对清单-2026-11-02.md index 0d101925..2e824b21 100644 --- a/docs/生产部署核对清单-2026-11-02.md +++ b/docs/生产部署核对清单-2026-11-02.md @@ -1,7 +1,7 @@ # 生产部署核对清单(履约与财务分流统一) 日期:2026-11-02 -适用:生产已应用 116–123(main 合并的履约路由 120–123 已先行上线),本次新增 124–125(工单付款金额审计与价格历史)+ backend/frontend。 +适用:生产已应用 116–125(其中 120–125 已随 main 先行上线),本次新增 126(统一中文内部 SKU)+ backend/frontend。 ## 1. 部署前 @@ -30,6 +30,7 @@ 123_work_product_rule_pricing_mode 124_work_order_user_payment_amount_audit 125_work_order_payment_amount_history +126_internal_sku_canonical_routing ``` > 序号说明:生产在合并 main 后已按 120–123 应用了履约路由四支迁移,因此这四支编号固定不变; @@ -48,11 +49,12 @@ - 123:接单模板增加计价方式并回填历史模板,金额字段继续只由商品与成本中心投影。 - 124:`work_orders` 增加付款金额旧价、修改时间、修改人三列(`ADD COLUMN IF NOT EXISTS`),幂等且不动存量数据。 - 125:新建 `work_order_payment_amount_history` 并回填已有 `payment_amount_updated` 事件,写入按工单级联删除。 -- SQL 护栏默认只保护到生产已发布的迁移 119;部署 120 后应把 `PUBLISHED_MIGRATION_MAX_VERSION` 提升为 120。 +- 126:统一中文内部 SKU 规范化函数,平台 SKU 不再作为渠道兜底;补充统一配置查询索引。 +- 生产已发布到 125;部署 126 后应把 `PUBLISHED_MIGRATION_MAX_VERSION` 提升为 126。 ## 3. 部署顺序 -1. 执行迁移 116–125; +1. 执行迁移 116–126; 2. 部署 backend; 3. 部署 frontend; 4. 启动/恢复定时任务。 @@ -145,6 +147,10 @@ npx tsx scripts/preflight-fulfillment-routing.ts --strict --include-demo - 已分配自动发货/接单但没有真实载体; - 已分配订单同时存在多个真实履约载体; - 多个主渠道; +- 规范化后重复的内部 SKU 渠道配置; +- `work_order` 缺 `workRuleKey` 或规则不存在; +- 人工渠道缺启用的人工履约配置; +- pending 订单项无法解析出中文内部 SKU; - 仍启用的 `sku_code` 兼容规则。 ## 5. 数据清理(确认后执行) @@ -173,6 +179,6 @@ npx tsx scripts/reclassify-post-cutover.ts --apply - 代码:回退到部署前镜像/commit; - 数据库: -- 116–125 均为新增表/列/函数/投影数据,不删旧表,可保留; +- 116–126 均为新增表/列/函数/触发器/投影数据,不删旧表,可保留; - 若必须回滚,恢复到部署前备份; - 迁移 118–121 不改旧字段语义,回滚代码后旧逻辑仍可运行;121 的唯一主渠道索引会阻止旧代码写入并列主渠道。 diff --git a/docs/财务流程总览-2026-11-01.md b/docs/财务流程总览-2026-11-01.md index fc22e8ba..5844ceb9 100644 --- a/docs/财务流程总览-2026-11-01.md +++ b/docs/财务流程总览-2026-11-01.md @@ -35,8 +35,9 @@ ## 三、渠道判定 -- 规则 `sku_channel_rules`:运营指定(10)> 自动发货(60)> 人工配置(70); -- 无规则时看证据:工单 → `work_order`,自动发货任务 → `affiliate_dash`; +- 唯一配置源是 `internal_sku_channels`,按规范化中文内部 SKU 全局生效; +- 平台 SKU 只用于订单入口映射到中文内部 SKU,不参与渠道选择; +- 真实载体证据:工单 → `work_order`,自动发货任务 → `affiliate_dash`; - 仍无证据 → `pending`,进「待分类」; - 运营指定后 `fulfillment_locked=true`,自动判定不再覆盖; - 多渠道证据冲突 → `routing_conflict` 异常,不静默取其一。