From e6763882f5ef748e519ae382e29e9bee5bd78219 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Tue, 3 Nov 2026 12:28:05 +0800 Subject: [PATCH] =?UTF-8?q?fix(fulfillment):=20=E5=B0=81=E5=A0=B5=E9=87=8D?= =?UTF-8?q?=E5=A4=8D=E8=BD=BD=E4=BD=93=E5=B9=B6=E5=AE=8C=E5=96=84=E7=BB=9F?= =?UTF-8?q?=E4=B8=80SKU=E6=98=A0=E5=B0=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/backend/package.json | 1 + .../audit-fulfillment-finance-alignment.ts | 2 +- .../scripts/backfill-internal-sku-mappings.ts | 8 +- .../scripts/cleanup-legacy-sku-code-rules.ts | 2 +- .../scripts/normalize-internal-sku-configs.ts | 201 ++++++++++++++ .../scripts/preflight-fulfillment-routing.ts | 54 ++++ .../126_internal_sku_canonical_routing.sql | 4 +- .../src/repositories/order-item-repo.test.ts | 51 +++- .../src/repositories/order-item-repo.ts | 77 +++++- .../src/routes/admin/fulfillment-channel.ts | 70 +++++ .../affiliate-fallback-service.test.ts | 9 +- .../fulfillment/affiliate-fallback-service.ts | 26 +- .../fulfillment/carrier-creation-service.ts | 16 ++ .../carrier-evidence-service.test.ts | 31 +++ .../fulfillment/carrier-evidence-service.ts | 87 ++++++ .../fulfillment/channel-assign-service.ts | 91 ++++++- .../fulfillment-routing.integration.test.ts | 166 +++++++++++- .../internal-sku-mapping-service.ts | 129 ++++++++- .../order/delivery-task-service.test.ts | 76 ++++++ .../services/order/delivery-task-service.ts | 45 +++- .../src/services/order/order-service.ts | 31 ++- .../sync-worker-orders-service.test.ts | 25 ++ .../sync-worker-orders-service.ts | 24 +- .../admin/AdminFulfillmentChannelPage.tsx | 249 +++++++++++++++++- .../src/services/admin/fulfillment-channel.ts | 61 +++++ docs/履约与财务分流统一方案-2026-11-02.md | 4 + docs/生产部署核对清单-2026-11-02.md | 11 +- 27 files changed, 1506 insertions(+), 45 deletions(-) create mode 100644 apps/backend/scripts/normalize-internal-sku-configs.ts create mode 100644 apps/backend/src/services/fulfillment/carrier-evidence-service.test.ts create mode 100644 apps/backend/src/services/fulfillment/carrier-evidence-service.ts diff --git a/apps/backend/package.json b/apps/backend/package.json index fb25d538..b3ec2500 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -10,6 +10,7 @@ "db:migrate:create": "tsx scripts/create-migration.ts", "db:migrate:status": "tsx scripts/migration-status.ts", "finance:sync": "tsx scripts/sync-cost-finance.ts", + "fulfillment:normalize-sku-configs": "tsx scripts/normalize-internal-sku-configs.ts", "finance:rebuild-cutover": "tsx scripts/rebuild-after-cutover.ts", "revenue:backfill": "tsx scripts/backfill-revenue-basis.ts", "cost-exceptions:archive": "tsx scripts/archive-cost-exceptions.ts", diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts index da618e37..1cdd3811 100644 --- a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts +++ b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts @@ -84,7 +84,7 @@ async function main() { ), '')) ~ '^[0-9]+$')::int AS numeric_only, count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku( manual_sku_key, sku_name, sku_code - ), '')) ~ '[一-龥]')::int AS with_cjk + ), '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]')::int AS with_cjk FROM order_items WHERE fulfillment_status = 'assigned' `, diff --git a/apps/backend/scripts/backfill-internal-sku-mappings.ts b/apps/backend/scripts/backfill-internal-sku-mappings.ts index c5a0c912..8890ebab 100644 --- a/apps/backend/scripts/backfill-internal-sku-mappings.ts +++ b/apps/backend/scripts/backfill-internal-sku-mappings.ts @@ -21,8 +21,8 @@ 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 COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]' - AND btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]' + WHERE COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]' + AND btrim(COALESCE(oi.sku_name, '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]' GROUP BY 1, 2, 3 ORDER BY 5 DESC `, @@ -37,8 +37,8 @@ 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 COALESCE(canonicalize_internal_sku(oi.manual_sku_key), '') !~ '[一-龥]' - AND btrim(COALESCE(oi.sku_name, '')) !~ '[一-龥]' + 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/cleanup-legacy-sku-code-rules.ts b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts index e3c3b6d7..19e84961 100644 --- a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts +++ b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts @@ -44,7 +44,7 @@ async function resolveInternalSku(rule: LegacyRule): Promise { FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE oi.sku_code = $1 - AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[一-龥]' + AND btrim(COALESCE(oi.manual_sku_key, '')) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]' ${scope} LIMIT 2 `, diff --git a/apps/backend/scripts/normalize-internal-sku-configs.ts b/apps/backend/scripts/normalize-internal-sku-configs.ts new file mode 100644 index 00000000..89523e00 --- /dev/null +++ b/apps/backend/scripts/normalize-internal-sku-configs.ts @@ -0,0 +1,201 @@ +/** + * 合并规范化后重复的内部 SKU 渠道配置。 + * + * 默认只读;确认后加 --apply。 + * 用法:npx tsx scripts/normalize-internal-sku-configs.ts [--apply] + */ +import { closeDb, query, withTransaction } from '../src/db/client.js' +import { canonicalizeInternalSku } from '../src/services/fulfillment/internal-sku-resolver.js' + +type ChannelRow = { + id: number + internal_sku: string + channel: string + priority: number + is_fallback: boolean + enabled: boolean + canonical_product_id: number | null + config_json: Record + source: string + note: string + created_by_admin_user_id: number | null +} + +type DuplicateGroup = { + internalSku: string + channel: string + rows: ChannelRow[] +} + +type MappingRow = { + id: number + provider: string + shop_id: string + match_type: string + match_value: string + internal_sku: string + priority: number + enabled: boolean + source: string + note: string +} + +async function main() { + const apply = process.argv.includes('--apply') + 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 + ORDER BY canonicalize_internal_sku(internal_sku), channel, is_fallback, priority, id`, + ) + + const groups = new Map() + for (const row of result.rows) { + const internalSku = canonicalizeInternalSku(row.internal_sku) + const key = `${internalSku}|${row.channel}` + const group = groups.get(key) || { internalSku, channel: row.channel, rows: [] } + group.rows.push({ ...row, id: Number(row.id), priority: Number(row.priority) }) + groups.set(key, group) + } + + const duplicates = [...groups.values()].filter((group) => group.rows.length > 1) + const mappingRows = await query( + `SELECT id, provider, shop_id, match_type, match_value, internal_sku, + priority, enabled, source, note + FROM internal_sku_mappings + ORDER BY provider, shop_id, match_type, canonicalize_internal_sku(match_value), enabled DESC, priority, id`, + ) + const duplicateMappings = new Map() + for (const row of mappingRows.rows) { + const key = [ + row.provider, + row.shop_id, + row.match_type, + canonicalizeInternalSku(row.match_value), + ].join('|') + const list = duplicateMappings.get(key) || [] + list.push({ ...row, id: Number(row.id), priority: Number(row.priority) }) + duplicateMappings.set(key, list) + } + const mappingDuplicates = [...duplicateMappings.values()].filter((rows) => rows.length > 1) + if (apply) { + for (const group of duplicates) { + await withTransaction(async (client) => { + const rows = ( + await client.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 id = ANY($1::bigint[]) + ORDER BY is_fallback, priority, id + FOR UPDATE`, + [group.rows.map((row) => row.id)], + ) + ).rows.map((row) => ({ ...row, id: Number(row.id), priority: Number(row.priority) })) + if (rows.length <= 1) return + + const keeper = rows[0]! + const primaryRows = rows.filter((row) => !row.is_fallback) + const configJson = rows.reduce>( + (merged, row) => ({ ...merged, ...(row.config_json || {}) }), + {}, + ) + const source = rows.find((row) => row.source === 'operator')?.source || keeper.source + const note = [ + ...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean)), + ].join(' / ') + await client.query(`DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[])`, [ + rows.slice(1).map((row) => row.id), + ]) + await client.query( + `UPDATE internal_sku_channels + SET internal_sku = $2, + priority = $3, + is_fallback = $4, + enabled = $5, + canonical_product_id = $6, + config_json = $7::jsonb, + source = $8, + note = $9, + created_by_admin_user_id = $10, + updated_at = NOW() + WHERE id = $1`, + [ + keeper.id, + group.internalSku, + Math.min(...(primaryRows.length > 0 ? primaryRows : rows).map((row) => row.priority)), + primaryRows.length === 0, + rows.some((row) => row.enabled), + rows.find((row) => row.canonical_product_id != null)?.canonical_product_id || null, + JSON.stringify(configJson), + source, + note, + rows.find((row) => row.created_by_admin_user_id != null)?.created_by_admin_user_id || + null, + ], + ) + }) + } + for (const rows of mappingDuplicates) { + await withTransaction(async (client) => { + const keeper = rows[0]! + await client.query(`DELETE FROM internal_sku_mappings WHERE id = ANY($1::bigint[])`, [ + rows.slice(1).map((row) => row.id), + ]) + await client.query( + `UPDATE internal_sku_mappings + SET match_value = $2, internal_sku = $3, priority = $4, + enabled = $5, source = $6, note = $7, updated_at = NOW() + WHERE id = $1`, + [ + keeper.id, + canonicalizeInternalSku(keeper.match_value), + canonicalizeInternalSku(keeper.internal_sku), + Math.min(...rows.map((row) => row.priority)), + rows.some((row) => row.enabled), + keeper.source, + [...new Set(rows.map((row) => String(row.note || '').trim()).filter(Boolean))].join( + ' / ', + ), + ], + ) + }) + } + } + + const duplicateManualConfigs = await query<{ label: string; total: number }>( + `SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total + FROM manual_fulfillment_configs + GROUP BY canonicalize_internal_sku(sku_key) + HAVING count(*) > 1 + ORDER BY count(*) DESC, label + LIMIT 20`, + ) + + console.log( + JSON.stringify( + { + apply, + duplicateGroups: duplicates.length, + duplicateRows: duplicates.reduce((total, group) => total + group.rows.length, 0), + duplicateMappingGroups: mappingDuplicates.length, + duplicateMappingRows: mappingDuplicates.reduce((total, rows) => total + rows.length, 0), + sample: duplicates.slice(0, 20).map((group) => ({ + internalSku: group.internalSku, + channel: group.channel, + ids: group.rows.map((row) => row.id), + })), + duplicateManualConfigs: duplicateManualConfigs.rows, + }, + null, + 2, + ), + ) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(() => closeDb()) diff --git a/apps/backend/scripts/preflight-fulfillment-routing.ts b/apps/backend/scripts/preflight-fulfillment-routing.ts index 90fcee4f..1255bdb0 100644 --- a/apps/backend/scripts/preflight-fulfillment-routing.ts +++ b/apps/backend/scripts/preflight-fulfillment-routing.ts @@ -160,6 +160,54 @@ async function main() { ) ).rows + checks.ambiguousCarrierConfigs = ( + await query( + `SELECT canonicalize_internal_sku(canonical_name) || '/' || channel AS label, + count(DISTINCT canonical_sku)::int AS total + FROM ( + SELECT canonical_name, canonical_sku, + CASE + WHEN canonical_sku LIKE 'aff:%' THEN 'affiliate_dash' + WHEN canonical_sku LIKE 'tpl:%' OR canonical_sku LIKE 'wo:%' THEN 'work_order' + WHEN canonical_sku LIKE 'tpv:%' THEN 'third_party_voucher' + END AS channel + FROM product_catalog + WHERE status = 'active' + AND (canonical_sku LIKE 'aff:%' OR canonical_sku LIKE 'tpl:%' + OR canonical_sku LIKE 'wo:%' OR canonical_sku LIKE 'tpv:%') + ) carrier + WHERE channel IS NOT NULL + GROUP BY canonicalize_internal_sku(canonical_name), channel + HAVING count(DISTINCT canonical_sku) > 1 + ORDER BY count(DISTINCT canonical_sku) DESC, label`, + ) + ).rows + + checks.ambiguousManualConfigs = ( + await query( + `SELECT canonicalize_internal_sku(sku_key) AS label, count(*)::int AS total + FROM manual_fulfillment_configs + WHERE enabled = TRUE + GROUP BY canonicalize_internal_sku(sku_key) + HAVING count(*) > 1 + ORDER BY count(*) DESC, label`, + ) + ).rows + + checks.ambiguousInternalSkuMappings = ( + await query( + `SELECT provider || '/' || shop_id || '/' || match_type || '/' || + canonicalize_internal_sku(match_value) AS label, + count(*)::int AS total + FROM internal_sku_mappings + WHERE enabled = TRUE + GROUP BY provider, shop_id, match_type, canonicalize_internal_sku(match_value) + HAVING count(*) > 1 + ORDER BY count(*) DESC, label + LIMIT 200`, + ) + ).rows + checks.pendingWithoutChineseInternalSku = ( await query( `SELECT 'pending_without_chinese_internal_sku' AS label, count(*)::int AS total @@ -262,6 +310,12 @@ async function main() { failed.push('存在规范化后重复的内部 SKU 渠道配置') if ((checks.incompleteCarrierConfigs as CountRow[]).length > 0) failed.push('存在缺少 workRuleKey 或人工履约配置的启用渠道') + if ((checks.ambiguousCarrierConfigs as CountRow[]).length > 0) + failed.push('存在同一中文内部 SKU 的多个载体配置') + if ((checks.ambiguousManualConfigs as CountRow[]).length > 0) + failed.push('存在规范化后重复的人工履约配置') + if ((checks.ambiguousInternalSkuMappings as CountRow[]).length > 0) + failed.push('存在规范化后重复的内部 SKU 映射') if ((checks.pendingWithoutChineseInternalSku as CountRow).total > 0) failed.push('存在无法归一到中文内部 SKU 的 pending 订单项') if ((checks.enabledLegacyRules as CountRow[]).length > 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 index 1ce36e3c..fdddb140 100644 --- a/apps/backend/src/db/migrations/126_internal_sku_canonical_routing.sql +++ b/apps/backend/src/db/migrations/126_internal_sku_canonical_routing.sql @@ -36,11 +36,11 @@ PARALLEL SAFE AS $$ SELECT COALESCE( CASE - WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[一-龥]' + WHEN canonicalize_internal_sku(p_manual_sku_key) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]' THEN canonicalize_internal_sku(p_manual_sku_key) END, CASE - WHEN canonicalize_internal_sku(p_sku_name) ~ '[一-龥]' + WHEN canonicalize_internal_sku(p_sku_name) ~ '[㐀-䶿一-鿿豈-﫿𠀀-𯨟𰀀-𲎯]' THEN canonicalize_internal_sku(p_sku_name) END ) diff --git a/apps/backend/src/repositories/order-item-repo.test.ts b/apps/backend/src/repositories/order-item-repo.test.ts index 071b6ea9..bb19fd31 100644 --- a/apps/backend/src/repositories/order-item-repo.test.ts +++ b/apps/backend/src/repositories/order-item-repo.test.ts @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { resolveOrderItemSyncPlan } from './order-item-repo.js' +import { preserveAssignedOrderItemState, resolveOrderItemSyncPlan } from './order-item-repo.js' test('resolveOrderItemSyncPlan preserves matching order item ids across repeated source syncs', () => { const existingItems = [ @@ -98,3 +98,52 @@ test('resolveOrderItemSyncPlan keeps surplus existing ids for conditional cleanu assert.deepEqual(plan.creates, []) assert.deepEqual(plan.deletes, [32]) }) + +test('已分配订单重推时冻结原渠道决策与中文内部 SKU', () => { + const existing = { + id: 41, + fulfillment_status: 'assigned', + fulfillment_channel: 'affiliate_dash', + sku_code: 'lucky_coin_x2', + sku_name: '幸运币2个', + quantity: 1, + spec_json: { skuNick: '幸运币2个', sourceSpec: 'old' }, + item_snapshot_json: { + internalSku: '幸运币2个', + internalSkuChannel: 'affiliate_dash', + internalSkuChannelSource: 'operator', + isConfigured: true, + fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' }, + affiliateDash: { sku: 'lucky_coin_x2' }, + externalSkuName: 'old title', + }, + } + const incoming = { + skuCode: 'platform-new', + skuName: '幸运币2个', + quantity: 1, + specJson: { skuNick: '新映射', sourceSpec: 'new' }, + itemSnapshotJson: { + internalSku: '新映射', + internalSkuChannel: 'work_order', + isConfigured: false, + fulfillmentRoute: null, + externalSkuName: 'new title', + }, + createdAt: '2026-11-03T00:00:00.000Z', + updatedAt: '2026-11-03T00:00:00.000Z', + } + + const stabilized = preserveAssignedOrderItemState(existing as never, incoming) + + assert.deepEqual(stabilized.specJson, { skuNick: '幸运币2个', sourceSpec: 'new' }) + assert.deepEqual(stabilized.itemSnapshotJson, { + externalSkuName: 'new title', + internalSku: '幸运币2个', + internalSkuChannel: 'affiliate_dash', + internalSkuChannelSource: 'operator', + isConfigured: true, + fulfillmentRoute: { selectedExecutorKey: 'affiliate_dash' }, + affiliateDash: { sku: 'lucky_coin_x2' }, + }) +}) diff --git a/apps/backend/src/repositories/order-item-repo.ts b/apps/backend/src/repositories/order-item-repo.ts index cc687faa..3b4b6ddc 100644 --- a/apps/backend/src/repositories/order-item-repo.ts +++ b/apps/backend/src/repositories/order-item-repo.ts @@ -87,8 +87,13 @@ export async function replaceOrderItemsWithExecutor( ): Promise { const existingItems = await listOrderItemsByOrderIdWithExecutor(executor, orderId) const plan = resolveOrderItemSyncPlan(existingItems, items) + const existingById = new Map(existingItems.map((item) => [Number(item.id), item])) for (const update of plan.updates) { + const effectiveItem = preserveAssignedOrderItemState( + existingById.get(update.orderItemId), + update.item, + ) await executor( ` UPDATE order_items @@ -105,9 +110,9 @@ export async function replaceOrderItemsWithExecutor( update.item.skuCode, update.item.skuName, update.item.quantity, - safeJsonStringify(update.item.specJson || '{}'), - safeJsonStringify(update.item.itemSnapshotJson || update.item.specJson || '{}'), - update.item.updatedAt, + safeJsonStringify(effectiveItem.specJson || '{}'), + safeJsonStringify(effectiveItem.itemSnapshotJson || effectiveItem.specJson || '{}'), + effectiveItem.updatedAt, update.orderItemId, ], ) @@ -151,6 +156,72 @@ export async function replaceOrderItemsWithExecutor( return listOrderItemsByOrderIdWithExecutor(executor, orderId) } +const FROZEN_FULFILLMENT_SNAPSHOT_KEYS = [ + 'fulfillmentRoute', + 'affiliateDash', + 'classification', + 'internalSku', + 'internalSkuSource', + 'internalSkuMappingId', + 'internalSkuChannel', + 'internalSkuChannelSource', + 'internalSkuChannelReason', + 'isConfigured', +] as const + +export function preserveAssignedOrderItemState( + existing: OrderItemRow | undefined, + incoming: OrderItemReplaceInput, +): OrderItemReplaceInput { + if ( + !existing || + (existing.fulfillment_status !== 'assigned' && existing.fulfillment_status !== 'archived') + ) { + return incoming + } + + const existingSnapshot = parseJsonObject(existing.item_snapshot_json) + const incomingSnapshot = parseJsonObject(incoming.itemSnapshotJson) + const frozenSnapshot: Record = {} + for (const key of FROZEN_FULFILLMENT_SNAPSHOT_KEYS) { + if (Object.prototype.hasOwnProperty.call(existingSnapshot, key)) { + frozenSnapshot[key] = existingSnapshot[key] + } + } + + const existingSpec = parseJsonObject(existing.spec_json) + const incomingSpec = parseJsonObject(incoming.specJson) + const frozenInternalSku = String( + existingSpec.skuNick || existingSnapshot.internalSku || incomingSpec.skuNick || '', + ).trim() + + return { + ...incoming, + specJson: { + ...incomingSpec, + ...(frozenInternalSku ? { skuNick: frozenInternalSku } : {}), + }, + itemSnapshotJson: { + ...incomingSnapshot, + ...frozenSnapshot, + }, + } +} + +function parseJsonObject(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record + } + try { + const parsed = JSON.parse(String(value || '{}')) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + export function resolveOrderItemSyncPlan( existingItems: OrderItemRow[], nextItems: OrderItemReplaceInput[], diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 023a79ba..6d6f6e47 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -19,6 +19,12 @@ import { unlockChannel, upsertChannelRule, } from '../../services/fulfillment/channel-assign-service.js' +import { + deleteInternalSkuMapping, + listInternalSkuMappingsPage, + upsertInternalSkuMapping, + type InternalSkuMappingMatchType, +} from '../../services/fulfillment/internal-sku-mapping-service.js' import { listExceptionQueue } from '../../services/cost-center/cost-exception-service.js' import { runFinancePipelineOnce, @@ -53,6 +59,70 @@ router.get( ), ) +router.get( + '/fulfillment/internal-sku-mappings', + requireAdminPermission('finance.view'), + createJsonHandler( + (req) => + listInternalSkuMappingsPage({ + keyword: String(req.query.keyword || ''), + provider: String(req.query.provider || ''), + shopId: String(req.query.shopId || ''), + matchType: String(req.query.matchType || ''), + page: Number(req.query.page), + pageSize: Number(req.query.pageSize), + }), + { + successMessage: 'ok', + errorMessage: '读取 SKU 映射失败', + scope: '[admin/fulfillment/internal-sku-mappings]', + }, + ), +) + +router.post( + '/fulfillment/internal-sku-mappings', + requireAdminPermission('finance.config'), + createJsonHandler( + async (req) => { + const body = bodyOf(req) + const id = await upsertInternalSkuMapping({ + provider: String(body.provider || ''), + shopId: String(body.shopId || ''), + matchType: String(body.matchType || 'sku_code') as InternalSkuMappingMatchType, + matchValue: String(body.matchValue || ''), + internalSku: String(body.internalSku || ''), + priority: Number(body.priority), + source: String(body.source || 'manual'), + note: String(body.note || ''), + enabled: body.enabled !== false, + }) + return { id } + }, + { + successMessage: 'SKU 映射已保存', + errorMessage: '保存 SKU 映射失败', + scope: '[admin/fulfillment/internal-sku-mappings/save]', + audit: (req, data) => ({ + action: 'fulfillment.upsert_internal_sku_mapping', + targetType: 'internal_sku_mapping', + targetId: String((data as { id?: number })?.id || ''), + data: { payload: bodyOf(req) }, + }), + }, + ), +) + +router.delete( + '/fulfillment/internal-sku-mappings/:id', + requireAdminPermission('finance.config'), + createJsonHandler((req) => deleteInternalSkuMapping(Number(req.params.id)), { + successMessage: 'SKU 映射已删除', + errorMessage: '删除 SKU 映射失败', + scope: '[admin/fulfillment/internal-sku-mappings/delete]', + }), +) + router.get( '/fulfillment/exceptions', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.test.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.test.ts index 73604758..38a39673 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.test.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.test.ts @@ -4,7 +4,14 @@ import test from 'node:test' import { AFFILIATE_FAILURE_STATUSES } from './affiliate-fallback-service.js' test('自动发货失败状态全部纳入转人工', () => { - for (const status of ['closed', 'expired', 'manual_review', 'retry_pending', 'cancelled']) { + for (const status of [ + 'failed', + 'closed', + 'expired', + 'manual_review', + 'retry_pending', + 'cancelled', + ]) { assert.ok(AFFILIATE_FAILURE_STATUSES.includes(status as never)) } assert.ok(!AFFILIATE_FAILURE_STATUSES.includes('redeemed' as never)) diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index 0b9dec51..9cb83715 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -10,6 +10,7 @@ import { refreshFactsForDates } from '../finance/refresh-facts.js' * 自动任务本身不改状态,只记录订单项的切换原因,避免重复触发。 */ export const AFFILIATE_FAILURE_STATUSES = [ + 'failed', 'closed', 'expired', 'manual_review', @@ -55,6 +56,7 @@ export async function runAffiliateFallback( WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'affiliate_dash' AND oi.fulfillment_locked = FALSE + AND cfg.id IS NOT NULL AND EXISTS ( SELECT 1 FROM internal_sku_channels channel WHERE channel.enabled = TRUE @@ -99,7 +101,29 @@ export async function runAffiliateFallback( channel_switch_reason = 'affiliate_failed', channel_switched_at = NOW(), fulfillment_config_id = COALESCE(fulfillment_config_id, $2), - canonical_product_id = $3, + canonical_product_id = COALESCE($3, canonical_product_id), + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + fulfillment_decided_at = NOW(), + item_snapshot_json = COALESCE(item_snapshot_json, '{}'::jsonb) || jsonb_build_object( + 'internalSkuChannel', 'manual', + 'internalSkuChannelSource', 'affiliate_fallback', + 'internalSkuChannelReason', '自动发货失败,转人工兜底', + 'isConfigured', TRUE, + 'affiliateDash', NULL, + 'fulfillmentRoute', jsonb_build_object( + 'selectedExecutorKey', 'manual_dispatch', + 'selectedRuleId', '', + 'reason', '自动发货失败,转人工兜底', + 'matchedRule', NULL, + 'candidates', '[]'::jsonb, + 'skipped', '[]'::jsonb + ), + 'classification', jsonb_build_object( + 'channel', 'manual', + 'canonicalSku', resolve_internal_sku(manual_sku_key, sku_name, sku_code) + ) + ), updated_at = NOW() WHERE id = $1 AND fulfillment_status = 'assigned' diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index 4f5e70b1..ec3958a6 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -6,6 +6,10 @@ import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './chann import { applyIntakeChannelDecision } from './intake-channel-decision-service.js' import type { ResolvedFulfillmentItem } from './product-resolution-service.js' import { canonicalizeInternalSku, resolveInternalSkuKey } from './internal-sku-resolver.js' +import { + findConflictingCarrier, + loadActiveCarrierEvidenceForItem, +} from './carrier-evidence-service.js' function parseObject(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { @@ -77,6 +81,18 @@ export async function ensureCarrierForOrderItem(input: { channel: FulfillmentChannel }): Promise { const { order, item, channel } = input + const existingCarrier = findConflictingCarrier( + await loadActiveCarrierEvidenceForItem(Number(item.id)), + channel, + ) + if (existingCarrier) { + return { + ready: false, + created: false, + reason: 'existing_carrier_conflict', + message: `订单项已有其它真实载体:${existingCarrier}`, + } + } if (channel === 'manual') { const config = await query<{ id: number; canonical_product_id: number | null }>( diff --git a/apps/backend/src/services/fulfillment/carrier-evidence-service.test.ts b/apps/backend/src/services/fulfillment/carrier-evidence-service.test.ts new file mode 100644 index 00000000..7d2c6ede --- /dev/null +++ b/apps/backend/src/services/fulfillment/carrier-evidence-service.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { findConflictingCarrier } from './carrier-evidence-service.js' + +const empty = { + workOrder: false, + affiliateTask: false, + manualTask: false, + channels: [], +} + +test('真实载体冲突:同渠道不算冲突', () => { + assert.equal( + findConflictingCarrier( + { ...empty, affiliateTask: true, channels: ['affiliate_dash'] }, + 'affiliate_dash', + ), + null, + ) +}) + +test('真实载体冲突:其它渠道算冲突', () => { + assert.equal( + findConflictingCarrier( + { ...empty, workOrder: true, channels: ['work_order'] }, + 'affiliate_dash', + ), + 'work_order', + ) +}) diff --git a/apps/backend/src/services/fulfillment/carrier-evidence-service.ts b/apps/backend/src/services/fulfillment/carrier-evidence-service.ts new file mode 100644 index 00000000..d8633c8b --- /dev/null +++ b/apps/backend/src/services/fulfillment/carrier-evidence-service.ts @@ -0,0 +1,87 @@ +import { query } from '../../db/client.js' +import { INACTIVE_CARRIER_TASK_STATUSES, type FulfillmentChannel } from './channel-types.js' + +export type ActiveCarrierEvidence = { + workOrder: boolean + affiliateTask: boolean + manualTask: boolean + channels: FulfillmentChannel[] +} + +/** + * 批量读取订单项的真实履约载体证据。 + * 关闭、失败、过期等任务不算载体,规则与渠道判定管道保持一致。 + */ +export async function loadActiveCarrierEvidence( + orderItemIds: number[], +): Promise> { + const ids = [...new Set(orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + const result = new Map() + if (ids.length === 0) return result + + const rows = await query<{ + id: number + has_work_order: boolean + has_affiliate_task: boolean + has_manual_task: boolean + }>( + ` + SELECT + oi.id, + EXISTS ( + SELECT 1 FROM work_orders wo + WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled' + ) AS has_work_order, + EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id + AND ft.executor_key = 'affiliate_dash' + AND ft.task_status <> ALL($2::text[]) + ) AS has_affiliate_task, + EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id + AND ft.executor_key = 'manual_dispatch' + AND ft.task_status <> ALL($2::text[]) + ) AS has_manual_task + FROM order_items oi + WHERE oi.id = ANY($1::bigint[]) + `, + [ids, INACTIVE_CARRIER_TASK_STATUSES], + ) + + for (const row of rows.rows) { + const channels: FulfillmentChannel[] = [] + if (row.has_work_order) channels.push('work_order') + if (row.has_affiliate_task) channels.push('affiliate_dash') + if (row.has_manual_task) channels.push('manual') + result.set(Number(row.id), { + workOrder: row.has_work_order, + affiliateTask: row.has_affiliate_task, + manualTask: row.has_manual_task, + channels, + }) + } + return result +} + +export async function loadActiveCarrierEvidenceForItem( + orderItemId: number, +): Promise { + const evidence = await loadActiveCarrierEvidence([orderItemId]) + return ( + evidence.get(Number(orderItemId)) || { + workOrder: false, + affiliateTask: false, + manualTask: false, + channels: [], + } + ) +} + +export function findConflictingCarrier( + evidence: ActiveCarrierEvidence | undefined, + targetChannel: FulfillmentChannel, +): FulfillmentChannel | null { + return evidence?.channels.find((channel) => channel !== targetChannel) || null +} diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 0243858b..c80313f1 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -545,9 +545,72 @@ async function assignOrderItemsAfterCarrierReady(input: { SELECT count(*)::int AS count FROM updated`, [ids, input.channel, input.source, input.lock], ) + await syncAssignedChannelSnapshots(ids, input.channel, input.source) return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed } } +async function syncAssignedChannelSnapshots( + ids: number[], + channel: FulfillmentChannel, + source: FulfillmentSource, +) { + if (ids.length === 0) return + const configured = channel === 'affiliate_dash' || channel === 'manual' + const route = + channel === 'manual' + ? { + selectedExecutorKey: 'manual_dispatch', + selectedRuleId: '', + reason: '运营指定人工客服', + matchedRule: null, + candidates: [], + skipped: [], + } + : channel === 'affiliate_dash' + ? { + selectedExecutorKey: 'affiliate_dash', + selectedRuleId: '', + reason: '运营指定自动发货', + matchedRule: null, + candidates: [], + skipped: [], + } + : { + selectedExecutorKey: '', + selectedRuleId: '', + reason: + channel === 'work_order' + ? '内部 SKU 渠道配置指定接单平台' + : '内部 SKU 渠道配置指定第三方卡券', + matchedRule: null, + candidates: [], + skipped: [], + } + const decision = { + internalSkuChannel: channel, + internalSkuChannelSource: source, + internalSkuChannelReason: '', + isConfigured: configured, + fulfillmentRoute: route, + ...(channel !== 'affiliate_dash' ? { affiliateDash: null } : {}), + } + await query( + `UPDATE order_items + SET item_snapshot_json = ( + COALESCE(item_snapshot_json, '{}'::jsonb) || $2::jsonb + ) || jsonb_build_object( + 'classification', + COALESCE(item_snapshot_json->'classification', '{}'::jsonb) + || jsonb_build_object('channel', $3) + ), + updated_at = NOW() + WHERE id = ANY($1::bigint[]) + AND fulfillment_status = 'assigned' + AND fulfillment_channel = $3`, + [ids, JSON.stringify(decision), channel], + ) +} + const WORK_ORDER_SWITCH_BLOCKED = new Set([ 'open', 'in_progress', @@ -1131,10 +1194,15 @@ async function resolveInternalSkuCarrierConfig( `SELECT id, canonical_product_id FROM manual_fulfillment_configs WHERE canonicalize_internal_sku(sku_key) = $1 AND enabled = TRUE - ORDER BY id - LIMIT 1`, + ORDER BY id`, [key], ) + if (manual.rows.length > 1) { + throw createHttpError(`内部 SKU「${key}」存在多份人工履约配置`, { + statusCode: 409, + errorCode: 'carrier_config_ambiguous', + }) + } return { canonicalProductId: Number(manual.rows[0]?.canonical_product_id || 0) || null, configJson: manual.rows[0] ? { manualConfigId: Number(manual.rows[0].id) } : {}, @@ -1149,12 +1217,27 @@ async function resolveInternalSkuCarrierConfig( WHERE status = 'active' AND (canonical_sku LIKE $2 OR canonical_sku LIKE $3) AND canonicalize_internal_sku(canonical_name) = $1 - ORDER BY id - LIMIT 1`, + ORDER BY id`, [key, `${prefixes[0]}%`, `${prefixes[1] || prefixes[0]}%`], ) const row = product.rows[0] if (!row) return { canonicalProductId: null, configJson: {} } + const distinctCarrierKeys = new Set( + product.rows.map((candidate) => { + const rawSku = String(candidate.canonical_sku) + const prefix = prefixes.find((item) => rawSku.startsWith(item)) || prefixes[0]! + return rawSku.slice(prefix.length) + }), + ) + if (distinctCarrierKeys.size > 1) { + throw createHttpError( + `内部 SKU「${key}」在 ${channel} 渠道存在多个载体配置:${[...distinctCarrierKeys].join(' / ')}`, + { + statusCode: 409, + errorCode: 'carrier_config_ambiguous', + }, + ) + } const rawSku = String(row.canonical_sku) const matchedPrefix = prefixes.find((prefix) => rawSku.startsWith(prefix)) || prefixes[0]! const carrierKey = rawSku.slice(matchedPrefix.length) 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 24b4eb26..67368388 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -28,17 +28,33 @@ test( switchChannelForOrderItems, upsertChannelRule, } = await import('./channel-assign-service.js') + const { runAffiliateFallback } = await import('./affiliate-fallback-service.js') + const { + deleteInternalSkuMapping, + listInternalSkuMappingsPage, + resolveInternalSkuMapping, + upsertInternalSkuMapping, + } = await import('./internal-sku-mapping-service.js') const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` const shopId = `it-routing-${suffix}` const manualSku = `集成测试人工-${suffix}` const missingAffiliateSku = `集成测试自动-${suffix}` const voucherSku = `集成测试卡券-${suffix}` const triggerSku = `集成测试规范-${suffix}` + const fallbackSku = `集成测试兜底-${suffix}` const orderIds: number[] = [] const orderItemIds: number[] = [] const crossShopSku = `集成测试跨店-${suffix}` - const internalSkus = [manualSku, missingAffiliateSku, voucherSku, crossShopSku, triggerSku] + const internalSkus = [ + manualSku, + missingAffiliateSku, + voucherSku, + crossShopSku, + triggerSku, + fallbackSku, + ] let generatedVoucherProductId: number | null = null + let fallbackProfileId: number | null = null async function createPendingOrderItem( sku: string, @@ -75,13 +91,16 @@ test( const canonicalResolution = await query<{ internal_sku: string | null platform_only: string | null + cjk_extension: string | null }>( `SELECT resolve_internal_sku('platform-code', ' 套装-浪漫 天命 ', 'platform-code') AS internal_sku, - resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only`, + resolve_internal_sku('platform-code', 'Platform Title', 'platform-code') AS platform_only, + resolve_internal_sku('', '𠀀', '') AS cjk_extension`, ) assert.equal(canonicalResolution.rows[0]?.internal_sku, '套装-浪漫天命') assert.equal(canonicalResolution.rows[0]?.platform_only, null) + assert.equal(canonicalResolution.rows[0]?.cjk_extension, '𠀀') await query( `INSERT INTO internal_sku_channels (internal_sku, channel, priority, is_fallback, enabled, source) @@ -93,6 +112,32 @@ test( ) assert.equal(normalizedInsert.rows[0]?.internal_sku, triggerSku) + const mappingValue = `MAP-${suffix}` + const mappingSku = `集成映射-${suffix}` + const mappingId = await upsertInternalSkuMapping({ + provider: '91kaquan', + shopId, + matchType: 'sku_code', + matchValue: ` ${mappingValue} `, + internalSku: mappingSku, + priority: 10, + source: 'integration_test', + note: '集成测试映射', + }) + const resolvedMapping = await resolveInternalSkuMapping({ + provider: '91kaquan', + shopId, + candidates: [{ matchType: 'sku_code', value: mappingValue }], + }) + assert.equal(resolvedMapping?.internalSku, mappingSku) + const mappingPage = await listInternalSkuMappingsPage({ + keyword: mappingSku, + page: 1, + pageSize: 10, + }) + assert.equal(mappingPage.total, 1) + assert.equal(await deleteInternalSkuMapping(mappingId), true) + const manualItemId = await createPendingOrderItem(manualSku) await query( `INSERT INTO manual_fulfillment_configs ( @@ -136,9 +181,10 @@ test( original_channel: string | null canonical_product_id: number | null canonical_sku: string | null + item_snapshot_json: Record }>( `SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel, - oi.canonical_product_id, product.canonical_sku + oi.canonical_product_id, product.canonical_sku, oi.item_snapshot_json FROM order_items oi LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id WHERE oi.id = $1`, @@ -148,6 +194,11 @@ test( assert.equal(switchedItem.rows[0]?.fulfillment_channel, 'third_party_voucher') assert.equal(switchedItem.rows[0]?.original_channel, 'manual') assert.ok(switchedItem.rows[0]?.canonical_sku?.startsWith('tpv:')) + assert.equal( + switchedItem.rows[0]?.item_snapshot_json.internalSkuChannel, + 'third_party_voucher', + ) + assert.equal(switchedItem.rows[0]?.item_snapshot_json.isConfigured, false) generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku) @@ -191,6 +242,104 @@ test( assert.deepEqual(pendingGroups.items[0]?.providers.sort(), ['91kaquan', 'pdd']) assert.deepEqual(pendingGroups.items[0]?.shop_ids.sort(), [crossShopA, crossShopB]) + const fallbackItemId = await createPendingOrderItem(fallbackSku) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, config_json, source + ) VALUES + ($1, 'affiliate_dash', 10, FALSE, TRUE, '{}'::jsonb, 'integration_test'), + ($1, 'manual', 90, TRUE, TRUE, '{}'::jsonb, 'integration_test')`, + [fallbackSku], + ) + const fallbackItem = await query<{ order_id: number }>( + `UPDATE order_items + SET fulfillment_status = 'assigned', fulfillment_channel = 'affiliate_dash', + fulfillment_source = 'operator_assigned', fulfillment_locked = FALSE, + updated_at = NOW() + WHERE id = $1 + RETURNING order_id`, + [fallbackItemId], + ) + const fallbackOrderId = Number(fallbackItem.rows[0]!.order_id) + const fallbackProfile = await query<{ id: number }>( + `INSERT INTO fulfillment_profiles ( + profile_key, name, executor_key, requires_claim, auto_dispatch, + inventory_strategy, config_json, created_at, updated_at + ) VALUES ($1, '集成测试自动发货', 'affiliate_dash', TRUE, FALSE, + 'static_pool', '{}'::jsonb, NOW(), NOW()) + RETURNING id`, + [`it-affiliate-${suffix}`], + ) + fallbackProfileId = Number(fallbackProfile.rows[0]!.id) + const fallbackOrder = await query<{ + provider: string + platform: string + shop_id: string + shop_name: string + platform_order_id: string + }>( + `SELECT provider, platform, shop_id, shop_name, platform_order_id + FROM orders WHERE id = $1`, + [fallbackOrderId], + ) + const orderRow = fallbackOrder.rows[0]! + await query( + `INSERT INTO fulfillment_tasks ( + order_id, order_item_id, unit_index, task_no, provider, platform, + shop_id, shop_name, platform_order_id, profile_id, executor_key, + task_status, created_at, updated_at + ) VALUES ($1, $2, 1, $3, $4, $5, $6, $7, $8, $9, 'affiliate_dash', 'failed', NOW(), NOW())`, + [ + fallbackOrderId, + fallbackItemId, + `IT-AFF-FAIL-${suffix}`, + orderRow.provider, + orderRow.platform, + orderRow.shop_id, + orderRow.shop_name, + orderRow.platform_order_id, + fallbackProfileId, + ], + ) + + const fallbackWithoutConfig = await runAffiliateFallback({ limit: 50 }) + assert.equal(fallbackWithoutConfig.switched, 0) + assert.equal( + ( + await query<{ fulfillment_channel: string }>( + `SELECT fulfillment_channel FROM order_items WHERE id = $1`, + [fallbackItemId], + ) + ).rows[0]?.fulfillment_channel, + 'affiliate_dash', + ) + + const manualFallbackConfig = await query<{ id: number }>( + `INSERT INTO manual_fulfillment_configs ( + sku_key, platform_key, credit_quantity, cost_mode, enabled, created_at, updated_at + ) VALUES ($1, 'nanhang', 0, 'variable', TRUE, NOW(), NOW()) + RETURNING id`, + [fallbackSku], + ) + const fallbackWithConfig = await runAffiliateFallback({ limit: 50 }) + assert.equal(fallbackWithConfig.switched, 1) + const fallbackAssigned = await query<{ + fulfillment_channel: string + fulfillment_config_id: number | null + item_snapshot_json: Record + }>( + `SELECT fulfillment_channel, fulfillment_config_id, item_snapshot_json + FROM order_items WHERE id = $1`, + [fallbackItemId], + ) + assert.equal(fallbackAssigned.rows[0]?.fulfillment_channel, 'manual') + assert.equal(fallbackAssigned.rows[0]?.item_snapshot_json.internalSkuChannel, 'manual') + assert.equal(fallbackAssigned.rows[0]?.item_snapshot_json.isConfigured, true) + assert.equal( + Number(fallbackAssigned.rows[0]?.fulfillment_config_id), + Number(manualFallbackConfig.rows[0]!.id), + ) + await ensureVoucherRuleForSku(voucherSku) const voucherChannel = await query<{ channel: string; source: string }>( `SELECT channel, source FROM internal_sku_channels @@ -244,7 +393,13 @@ test( await query(`DELETE FROM orders WHERE id = ANY($1::bigint[])`, [orderIds]) } await query(`DELETE FROM cost_exceptions WHERE scope_key LIKE $1`, [`%${suffix}%`]) - await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = $1`, [manualSku]) + await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = ANY($1::text[])`, [ + internalSkus, + ]) + await query( + `DELETE FROM internal_sku_mappings WHERE provider = '91kaquan' AND shop_id = $1`, + [shopId], + ) await query(`DELETE FROM internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [ internalSkus, ]) @@ -271,6 +426,9 @@ test( `tpv:${manualSku}`, ]) } + if (fallbackProfileId) { + await query(`DELETE FROM fulfillment_profiles WHERE id = $1`, [fallbackProfileId]) + } await closeDb() } }, 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 3d2f404d..dab582b7 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,6 @@ import { query, withTransaction } from '../../db/client.js' import { canonicalizeInternalSku, containsCjk } from './internal-sku-resolver.js' +import { createHttpError } from '../../utils/http.js' export type InternalSkuMappingMatchType = | 'sku_code' @@ -13,12 +14,34 @@ export type InternalSkuMappingCandidate = { value: unknown } +const INTERNAL_SKU_MAPPING_MATCH_TYPES = new Set([ + 'sku_code', + 'sku_nick', + 'out_sku_sn', + 'spec', + 'sku_name', +]) + export type ResolvedInternalSkuMapping = { id: number internalSku: string source: string } +export type InternalSkuMappingRow = { + id: number + provider: string + shop_id: string + match_type: InternalSkuMappingMatchType + match_value: string + internal_sku: string + priority: number + enabled: boolean + source: string + note: string + updated_at: string +} + export function normalizeInternalSkuMappingValue(value: unknown): string { return canonicalizeInternalSku(value) } @@ -83,14 +106,28 @@ export async function upsertInternalSkuMapping(input: { priority?: number source?: string note?: string + enabled?: boolean }): Promise { const provider = String(input.provider || '').trim() const shopId = String(input.shopId || '').trim() + if (!INTERNAL_SKU_MAPPING_MATCH_TYPES.has(input.matchType)) { + throw createHttpError('映射类型无效', { + statusCode: 400, + errorCode: 'internal_sku_mapping_type_invalid', + }) + } const matchValue = normalizeInternalSkuMappingValue(input.matchValue) const internalSku = canonicalizeInternalSku(input.internalSku) const priority = Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100 const source = String(input.source || 'manual').trim() || 'manual' const note = String(input.note || '').trim() + const enabled = input.enabled !== false + if (!matchValue || !internalSku || !containsCjk(internalSku)) { + throw createHttpError('内部 SKU 和匹配值不能为空,且内部 SKU 必须包含中文', { + statusCode: 400, + errorCode: 'internal_sku_mapping_invalid', + }) + } return withTransaction(async (client) => { const existing = await client.query<{ id: number }>( @@ -98,32 +135,114 @@ export async function upsertInternalSkuMapping(input: { WHERE provider = $1 AND shop_id = $2 AND match_type = $3 AND canonicalize_internal_sku(match_value) = $4 ORDER BY id - LIMIT 1 FOR UPDATE`, [provider, shopId, input.matchType, matchValue], ) + if (existing.rows.length > 1) { + throw createHttpError('同一平台/店铺/字段存在多条规范化重复映射', { + statusCode: 409, + errorCode: 'internal_sku_mapping_ambiguous', + }) + } const existingId = Number(existing.rows[0]?.id || 0) const result = existingId ? await client.query<{ id: number }>( `UPDATE internal_sku_mappings SET match_value = $2, internal_sku = $3, priority = $4, - enabled = TRUE, source = $5, note = $6, updated_at = NOW() + enabled = $5, source = $6, note = $7, updated_at = NOW() WHERE id = $1 RETURNING id`, - [existingId, matchValue, internalSku, priority, source, note], + [existingId, matchValue, internalSku, priority, enabled, source, note], ) : await client.query<{ id: number }>( `INSERT INTO internal_sku_mappings ( provider, shop_id, match_type, match_value, internal_sku, priority, enabled, source, note, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, TRUE, $7, $8, NOW(), NOW()) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, NOW(), NOW()) RETURNING id`, - [provider, shopId, input.matchType, matchValue, internalSku, priority, source, note], + [ + provider, + shopId, + input.matchType, + matchValue, + internalSku, + priority, + enabled, + source, + note, + ], ) return Number(result.rows[0]?.id || 0) }) } +export async function listInternalSkuMappingsPage(input: { + keyword?: string + provider?: string + shopId?: string + matchType?: string + page?: number + pageSize?: number +}): Promise<{ items: InternalSkuMappingRow[]; total: number; page: number; pageSize: number }> { + const page = Math.max(1, Math.trunc(Number(input.page) || 1)) + const pageSize = Math.min(200, Math.max(1, Math.trunc(Number(input.pageSize) || 30))) + const params: unknown[] = [] + const clauses: string[] = [] + const keyword = String(input.keyword || '').trim() + if (keyword) { + params.push(`%${keyword}%`) + clauses.push( + `(match_value ILIKE $${params.length} OR internal_sku ILIKE $${params.length} OR note ILIKE $${params.length})`, + ) + } + if (input.provider) { + params.push(String(input.provider).trim()) + clauses.push(`provider = $${params.length}`) + } + if (input.shopId) { + params.push(String(input.shopId).trim()) + clauses.push(`shop_id = $${params.length}`) + } + if (input.matchType) { + params.push(String(input.matchType).trim()) + clauses.push(`match_type = $${params.length}`) + } + const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + const pageParams = [...params, pageSize, (page - 1) * pageSize] + const [countResult, rows] = await Promise.all([ + query<{ total: number }>( + `SELECT COUNT(*)::int AS total FROM internal_sku_mappings ${where}`, + params, + ), + query( + `SELECT id, provider, shop_id, match_type, match_value, internal_sku, + priority, enabled, source, note, updated_at + FROM internal_sku_mappings + ${where} + ORDER BY enabled DESC, priority ASC, id DESC + LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length}`, + pageParams, + ), + ]) + return { + items: rows.rows.map((row) => ({ + ...row, + id: Number(row.id), + priority: Number(row.priority), + })), + total: Number(countResult.rows[0]?.total || 0), + page, + pageSize, + } +} + +export async function deleteInternalSkuMapping(mappingId: number): Promise { + const id = Math.trunc(Number(mappingId) || 0) + if (id <= 0) return false + const result = await query(`DELETE FROM internal_sku_mappings WHERE id = $1`, [id]) + return (result.rowCount || 0) > 0 +} + /** * 订单项级内部 SKU 补全: * - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库; diff --git a/apps/backend/src/services/order/delivery-task-service.test.ts b/apps/backend/src/services/order/delivery-task-service.test.ts index ee1a2278..4bcfe8f1 100644 --- a/apps/backend/src/services/order/delivery-task-service.test.ts +++ b/apps/backend/src/services/order/delivery-task-service.test.ts @@ -120,6 +120,7 @@ test('syncDeliveryTasksForOrder 为 affiliate_dash 商品创建任务上下文', }), ], { + loadActiveCarrierEvidence: async () => new Map(), listTasksByOrderId: async () => [], getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async (input) => { @@ -167,6 +168,7 @@ test('syncDeliveryTasksForOrder 已有部分任务时按订单项补建缺失任 }), ], { + loadActiveCarrierEvidence: async () => new Map(), listTasksByOrderId: async () => [existingTask], getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async (input) => { @@ -216,6 +218,7 @@ test('syncDeliveryTasksForOrder 数量增加时只补建缺失单元', async () }), ], { + loadActiveCarrierEvidence: async () => new Map(), listTasksByOrderId: async () => [existingTask], getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async (input) => { @@ -263,6 +266,7 @@ test('syncDeliveryTasksForOrder 规划失败时记录可定位的路由诊断', }), ], { + loadActiveCarrierEvidence: async () => new Map(), listTasksByOrderId: async () => [], getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTaskEvent: async () => null, @@ -318,6 +322,7 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy }), ], { + loadActiveCarrierEvidence: async () => new Map(), listTasksByOrderId: async () => (listCount++ === 0 ? [] : [concurrentTask]), getFulfillmentProfileByKey: async () => buildAffiliateProfile(), createTask: async () => { @@ -341,6 +346,77 @@ test('syncDeliveryTasksForOrder 并发创建冲突时复用已落库任务', asy assert.equal(eventCount, 0) }) +test('来源重推时已分配其它渠道的订单不会补建 affiliate 任务', async () => { + let createdCount = 0 + const tasks = await syncDeliveryTasksForOrderWithDeps( + stubOrder({ id: 7, platform_order_id: 'assigned-other-channel' }), + [ + stubOrderItem({ + id: 60, + order_id: 7, + sku_code: 'SKU-assigned', + fulfillment_status: 'assigned', + fulfillment_channel: 'work_order', + item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'), + }), + ], + { + loadActiveCarrierEvidence: async () => new Map(), + listTasksByOrderId: async () => [], + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), + createTask: async (input) => { + createdCount += 1 + return stubCreatedTask(501, input as CreatedTaskInput) + }, + logInfo: () => null, + logWarn: () => null, + }, + ) + + assert.equal(createdCount, 0) + assert.deepEqual(tasks, []) +}) + +test('来源重推时已有其它真实载体的订单不会补建 affiliate 任务', async () => { + let createdCount = 0 + const tasks = await syncDeliveryTasksForOrderWithDeps( + stubOrder({ id: 8, platform_order_id: 'existing-carrier' }), + [ + stubOrderItem({ + id: 70, + order_id: 8, + sku_code: 'SKU-existing-carrier', + item_snapshot_json: buildAffiliateSnapshot('AFF-A', '商品 A'), + }), + ], + { + loadActiveCarrierEvidence: async () => + new Map([ + [ + 70, + { + workOrder: true, + affiliateTask: false, + manualTask: false, + channels: ['work_order'], + }, + ], + ]), + listTasksByOrderId: async () => [], + getFulfillmentProfileByKey: async () => buildAffiliateProfile(), + createTask: async (input) => { + createdCount += 1 + return stubCreatedTask(502, input as CreatedTaskInput) + }, + logInfo: () => null, + logWarn: () => null, + }, + ) + + assert.equal(createdCount, 0) + assert.deepEqual(tasks, []) +}) + function buildAffiliateSnapshot(sku: string, productName: string): JsonObject { return { isConfigured: true, diff --git a/apps/backend/src/services/order/delivery-task-service.ts b/apps/backend/src/services/order/delivery-task-service.ts index 47f2949d..ba1c1595 100644 --- a/apps/backend/src/services/order/delivery-task-service.ts +++ b/apps/backend/src/services/order/delivery-task-service.ts @@ -8,13 +8,21 @@ import { type FulfillmentBindingLike, } from '../fulfillment/planner.js' import { preparePaidFulfillmentTask } from '../fulfillment/executors/registry.js' -import type { FulfillmentPrepareDeps } from '../fulfillment/executors/types.js' +import { + FULFILLMENT_EXECUTOR_KEYS, + type FulfillmentPrepareDeps, +} from '../fulfillment/executors/types.js' import { nowIso } from '../../utils/time.js' import { randomId } from '../../utils/random.js' import { logInfo, logWarn } from '../../utils/logger.js' import { TASK_STATUS, resolveInitialPaidTaskStatus } from '../../domain/task-status.js' import type { JsonObject } from '../../types/json.js' import type { OrderItemRow, OrderRow, TaskRow } from '../../types/repository/rows.js' +import { + findConflictingCarrier, + loadActiveCarrierEvidence, +} from '../fulfillment/carrier-evidence-service.js' +import type { FulfillmentChannel } from '../fulfillment/channel-types.js' type ClaimTokenLike = { token: string @@ -30,6 +38,7 @@ type DeliveryTaskRow = TaskRow & { type DeliveryTaskDeps = { createTask?: typeof createTask listTasksByOrderId?: typeof listTasksByOrderId + loadActiveCarrierEvidence?: typeof loadActiveCarrierEvidence updateTask?: typeof updateTask getFulfillmentProfileByKey?: (profileKey: string) => Promise createTaskClaimToken?: (taskId: number | string) => Promise @@ -60,6 +69,7 @@ export async function syncDeliveryTasksForOrderWithDeps( const { createTask: createDeliveryTask = createTask, listTasksByOrderId: listTasks = listTasksByOrderId, + loadActiveCarrierEvidence: loadCarrierEvidence = loadActiveCarrierEvidence, updateTask: updateDeliveryTask = updateTask, getFulfillmentProfileByKey: getProfileByKey = getFulfillmentProfileByKey, createTaskClaimToken: createClaimToken = createTaskClaimToken, @@ -81,6 +91,7 @@ export async function syncDeliveryTasksForOrderWithDeps( const existingTasks = await listTasks(order.id) const tasks: DeliveryTaskRow[] = [...existingTasks] const existingTaskKeys = new Set(existingTasks.map(buildTaskUnitKey)) + const carrierEvidence = await loadCarrierEvidence(orderItems.map((item) => Number(item.id))) writeInfoLog('[delivery-task-service]', '开始核对订单履约任务完整性', { ...buildOrderLogContext(order), @@ -126,6 +137,32 @@ export async function syncDeliveryTasksForOrderWithDeps( continue } + const plannedChannel = executorKeyToFulfillmentChannel(plan.executorKey) + if (plannedChannel) { + const assignedChannel = String(item.fulfillment_channel || '').trim() + if (item.fulfillment_status === 'assigned' && assignedChannel !== plannedChannel) { + writeInfoLog('[delivery-task-service]', '订单项已分配其它渠道,跳过载体补建', { + ...buildOrderItemLogContext(item), + assignedChannel, + plannedChannel, + }) + continue + } + + const conflictingCarrier = findConflictingCarrier( + carrierEvidence.get(Number(item.id)), + plannedChannel, + ) + if (conflictingCarrier) { + writeWarnLog('[delivery-task-service]', '订单项已有其它真实载体,跳过载体补建', { + ...buildOrderItemLogContext(item), + existingCarrier: conflictingCarrier, + plannedChannel, + }) + continue + } + } + for (const unitIndex of missingUnitIndexes) { const createdAt = getNowIso() const initialStatus = @@ -256,6 +293,12 @@ export async function syncDeliveryTasksForOrderWithDeps( return preparedTasks.filter(isTaskRow) } +function executorKeyToFulfillmentChannel(executorKey: string): FulfillmentChannel | null { + if (executorKey === FULFILLMENT_EXECUTOR_KEYS.AFFILIATE_DASH) return 'affiliate_dash' + if (executorKey === FULFILLMENT_EXECUTOR_KEYS.MANUAL_DISPATCH) return 'manual' + return null +} + async function preparePaidTaskWithDiagnostics( order: OrderRow, task: TaskRow, diff --git a/apps/backend/src/services/order/order-service.ts b/apps/backend/src/services/order/order-service.ts index 3ef5a81a..04b3ee4d 100644 --- a/apps/backend/src/services/order/order-service.ts +++ b/apps/backend/src/services/order/order-service.ts @@ -10,7 +10,13 @@ import { import { withTransaction } from '../../db/client.js' import { syncDeliveryTasksForOrder } from './delivery-task-service.js' import { resolveOrderItemForFulfillment } from '../fulfillment/product-resolution-service.js' -import { applyIntakeChannelDecision } from '../fulfillment/intake-channel-decision-service.js' +import { applyIntakeChannelPlan } from '../fulfillment/intake-channel-decision-service.js' +import { + loadInternalSkuChannelPlans, + planInternalSkuChannels, + type InternalSkuChannelPlan, +} from '../fulfillment/internal-sku-channel-service.js' +import { canonicalizeInternalSku } from '../fulfillment/internal-sku-resolver.js' import { resolveOrderFulfillmentReadiness, syncOrderFulfillmentAttachments, @@ -114,21 +120,24 @@ export async function upsertOrderFromSource( }) const resolvedItems = await Promise.all( - event.items.map(async (item) => { - const resolved = await resolveOrderItemForFulfillment({ + event.items.map((item) => + resolveOrderItemForFulfillment({ provider: event.provider, platform: event.platform, shopId: event.shopId, item, - }) - return applyIntakeChannelDecision(resolved, { - provider: event.provider, - platform: event.platform, - shopId: event.shopId, - }) - }), + }), + ), ) - const resolvedFulfillmentItems = resolvedItems as FulfillmentOrderItem[] + const channelPlans = await loadInternalSkuChannelPlans( + resolvedItems.map((item) => String(item.snapshot.internalSku || '')), + ) + const emptyPlan = planInternalSkuChannels([]) + const resolvedFulfillmentItems = resolvedItems.map((item) => { + const internalSku = canonicalizeInternalSku(item.snapshot.internalSku) + const plan: InternalSkuChannelPlan = channelPlans.get(internalSku) || emptyPlan + return applyIntakeChannelPlan(item, plan) + }) as FulfillmentOrderItem[] const configuredItems = resolvedFulfillmentItems.filter((item) => item.isConfigured) const persisted = await withTransaction(async (client) => { 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 18417935..80236dd6 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 @@ -71,3 +71,28 @@ test('工单门禁:无配置、冲突、缺模板、模板不一致都不放 'work_rule_mismatch', ) }) + +test('工单门禁:已分配其它渠道或已有其它真实载体时不放行', () => { + assert.equal( + resolveWorkOrderChannelGate(plan(channel()), undefined, { + fulfillmentStatus: 'assigned', + assignedChannel: 'affiliate_dash', + }).reason, + 'already_assigned_other_channel', + ) + assert.equal( + resolveWorkOrderChannelGate(plan(channel()), undefined, { + fulfillmentStatus: 'pending', + existingCarrierChannels: ['affiliate_dash'], + }).reason, + 'existing_carrier_taken', + ) + assert.equal( + resolveWorkOrderChannelGate(plan(channel()), undefined, { + fulfillmentStatus: 'assigned', + assignedChannel: 'work_order', + existingCarrierChannels: ['work_order'], + }).allowed, + true, + ) +}) 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 3f48dfc9..c699ba4f 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 @@ -51,11 +51,27 @@ import { canonicalizeInternalSku, resolveInternalSkuKey, } from '../fulfillment/internal-sku-resolver.js' +import { loadActiveCarrierEvidence } from '../fulfillment/carrier-evidence-service.js' export function resolveWorkOrderChannelGate( plan: InternalSkuChannelPlan | undefined, preferredWorkRuleKey?: string, + context: { + fulfillmentStatus?: string + assignedChannel?: string + existingCarrierChannels?: string[] + } = {}, ): { allowed: boolean; reason: string; workRuleKey: string } { + if ( + context.fulfillmentStatus === 'assigned' && + context.assignedChannel && + context.assignedChannel !== 'work_order' + ) { + return { allowed: false, reason: 'already_assigned_other_channel', workRuleKey: '' } + } + if (context.existingCarrierChannels?.some((channel) => channel !== 'work_order')) { + return { allowed: false, reason: 'existing_carrier_taken', workRuleKey: '' } + } if (!plan?.primary) { return { allowed: false, @@ -109,6 +125,7 @@ export async function syncWorkerOrdersForSourceOrder( ) } const channelPlans = await loadInternalSkuChannelPlans([...internalSkuByItemId.values()]) + const carrierEvidence = await loadActiveCarrierEvidence(orderItems.map((item) => Number(item.id))) for (const item of orderItems) { const internalSku = canonicalizeInternalSku(internalSkuByItemId.get(Number(item.id))) @@ -123,7 +140,12 @@ export async function syncWorkerOrdersForSourceOrder( ) continue } - const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey) + const assignedChannel = String(item.fulfillment_channel || '').trim() + const channelGate = resolveWorkOrderChannelGate(channelPlan, options.preferredWorkRuleKey, { + fulfillmentStatus: item.fulfillment_status || '', + assignedChannel, + existingCarrierChannels: carrierEvidence.get(Number(item.id))?.channels || [], + }) if (!channelGate.allowed) { skipped.push({ orderItemId: Number(item.id), reason: channelGate.reason }) decisions.push( diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 3b82596b..9080af82 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -35,14 +35,19 @@ import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' import { assignChannelByInternalSku, + deleteInternalSkuMapping, deleteChannelRules, fetchChannelExceptions, fetchChannelRules, + fetchInternalSkuMappings, fetchPendingChannelGroups, fetchPipelineStatus, runFinancePipeline, type FinancePipelineStatus, + type InternalSkuMapping, + type InternalSkuMappingMatchType, saveChannelRule, + saveInternalSkuMapping, type ChannelRule, type PendingChannelGroup, } from '@/services/admin' @@ -74,6 +79,17 @@ const CHANNEL_SOURCE_LABEL: Record = { const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }] +const INTERNAL_SKU_MAPPING_TYPES: Array<{ + value: InternalSkuMappingMatchType + label: string +}> = [ + { value: 'sku_code', label: '平台 SKU 编码' }, + { value: 'sku_nick', label: 'SKU 昵称' }, + { value: 'out_sku_sn', label: '外部 SKU 编码' }, + { value: 'spec', label: '规格值' }, + { value: 'sku_name', label: '商品名称' }, +] + export default function AdminFulfillmentChannelPage() { return (
@@ -82,6 +98,7 @@ export default function AdminFulfillmentChannelPage() { defaultActiveKey="pending" items={[ { key: 'pending', label: '待分类队列', children: }, + { key: 'mappings', label: 'SKU 映射', children: }, { key: 'rules', label: '渠道规则', children: }, { key: 'exceptions', label: '问题队列', children: }, { key: 'pipelines', label: '管道状态', children: }, @@ -119,7 +136,7 @@ function PendingPanel() { } void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) }, - onError: () => message.error('指定渠道失败'), + onError: (error) => message.error(error instanceof Error ? error.message : '指定渠道失败'), }) const pipelineMutation = useMutation({ @@ -260,6 +277,234 @@ function PendingPanel() { ) } +function InternalSkuMappingsPanel() { + const queryClient = useQueryClient() + const [form] = Form.useForm() + const [keyword, setKeyword] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const query = useQuery({ + queryKey: ['fulfillment-internal-sku-mappings', keyword, page, pageSize], + queryFn: () => fetchInternalSkuMappings({ keyword, page, pageSize }), + }) + + const saveMutation = useMutation({ + mutationFn: (values: Record) => + saveInternalSkuMapping({ + provider: String(values.provider || '').trim(), + shopId: String(values.shopId || '').trim(), + matchType: values.matchType as InternalSkuMappingMatchType, + matchValue: String(values.matchValue || ''), + internalSku: String(values.internalSku || ''), + priority: Number(values.priority || 100), + note: String(values.note || ''), + enabled: values.enabled !== false, + }), + onSuccess: () => { + message.success('SKU 映射已保存,后续管道将按新映射归一') + setOpen(false) + setEditing(null) + form.resetFields() + void queryClient.invalidateQueries({ queryKey: ['fulfillment-internal-sku-mappings'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '保存 SKU 映射失败'), + }) + + const deleteMutation = useMutation({ + mutationFn: (id: number) => deleteInternalSkuMapping(id), + onSuccess: () => { + message.success('SKU 映射已删除') + void queryClient.invalidateQueries({ queryKey: ['fulfillment-internal-sku-mappings'] }) + }, + onError: (error) => message.error(error instanceof Error ? error.message : '删除 SKU 映射失败'), + }) + + function openEditor(row?: InternalSkuMapping) { + setEditing(row || null) + form.resetFields() + form.setFieldsValue( + row + ? { + provider: row.provider, + shopId: row.shop_id, + matchType: row.match_type, + matchValue: row.match_value, + internalSku: row.internal_sku, + priority: row.priority, + note: row.note, + enabled: row.enabled, + } + : { + provider: '', + shopId: '', + matchType: 'sku_code', + priority: 100, + enabled: true, + }, + ) + setOpen(true) + } + + const columns = [ + { + title: '来源平台', + dataIndex: 'provider', + width: 120, + render: (value: string) => value || 全部平台, + }, + { + title: '店铺', + dataIndex: 'shop_id', + width: 150, + ellipsis: true, + render: (value: string) => value || 全部店铺, + }, + { + title: '匹配字段', + dataIndex: 'match_type', + width: 130, + render: (value: InternalSkuMappingMatchType) => + INTERNAL_SKU_MAPPING_TYPES.find((item) => item.value === value)?.label || value, + }, + { title: '平台匹配值', dataIndex: 'match_value', ellipsis: true }, + { title: '中文内部 SKU', dataIndex: 'internal_sku', ellipsis: true }, + { title: '优先级', dataIndex: 'priority', width: 90 }, + { + title: '状态', + dataIndex: 'enabled', + width: 80, + render: (value: boolean) => (value ? 启用 : 停用), + }, + { + title: '操作', + width: 150, + render: (_: unknown, row: InternalSkuMapping) => ( + + + + + ), + }, + ] + + return ( + + { + setKeyword(value.trim()) + setPage(1) + }} + /> + + + + } + > + + { + setPage(nextPageSize !== pageSize ? 1 : nextPage) + setPageSize(nextPageSize) + }, + })} + /> + + { + setOpen(false) + setEditing(null) + }} + onOk={() => form.submit()} + confirmLoading={saveMutation.isPending} + destroyOnHidden + > +
saveMutation.mutate(values)} + > + + + + + + + + + + + + + + + + + + + + + +
+
+
+ ) +} + function RulesPanel() { const queryClient = useQueryClient() const [open, setOpen] = useState(false) @@ -287,7 +532,7 @@ function RulesPanel() { form.resetFields() void queryClient.invalidateQueries({ queryKey: ['fulfillment-rules'] }) }, - onError: () => message.error('保存规则失败'), + onError: (error) => message.error(error instanceof Error ? error.message : '保存规则失败'), }) const deleteMutation = useMutation({ diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index 5a6618f9..6dc31c12 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -42,6 +42,34 @@ export type ChannelRuleListResult = { pageSize: number } +export type InternalSkuMappingMatchType = + | 'sku_code' + | 'sku_nick' + | 'out_sku_sn' + | 'spec' + | 'sku_name' + +export type InternalSkuMapping = { + id: number + provider: string + shop_id: string + match_type: InternalSkuMappingMatchType + match_value: string + internal_sku: string + priority: number + enabled: boolean + source: string + note: string + updated_at: string +} + +export type InternalSkuMappingListResult = { + items: InternalSkuMapping[] + total: number + page: number + pageSize: number +} + export type ChannelException = { id: number reason: string @@ -142,6 +170,39 @@ export function fetchChannelRules( return apiGet('/api/v1/admin/fulfillment/rules', params) } +export function fetchInternalSkuMappings( + params: { + keyword?: string + provider?: string + shopId?: string + matchType?: string + page?: number + pageSize?: number + } = {}, +) { + return apiGet( + '/api/v1/admin/fulfillment/internal-sku-mappings', + params, + ) +} + +export function saveInternalSkuMapping(payload: { + provider?: string + shopId?: string + matchType: InternalSkuMappingMatchType + matchValue: string + internalSku: string + priority?: number + note?: string + enabled?: boolean +}) { + return apiPost<{ id: number }>('/api/v1/admin/fulfillment/internal-sku-mappings', payload) +} + +export function deleteInternalSkuMapping(id: number) { + return apiDelete(`/api/v1/admin/fulfillment/internal-sku-mappings/${id}`) +} + export function saveChannelRule(payload: { id?: number matchKeyType: string diff --git a/docs/履约与财务分流统一方案-2026-11-02.md b/docs/履约与财务分流统一方案-2026-11-02.md index ecc336d8..e2a20512 100644 --- a/docs/履约与财务分流统一方案-2026-11-02.md +++ b/docs/履约与财务分流统一方案-2026-11-02.md @@ -155,6 +155,9 @@ internal_sku_channels ( 下单入口、财务、成本中心都读这一份配置。 +后台「履约渠道 → SKU 映射」维护平台字段到中文内部 SKU 的映射: +店铺级优先于平台级,平台级优先于全局;渠道规则仍只按中文内部 SKU 全局生效。 + 规范化规则固定为 NFKC → 去全部空白 → 小写。没有中文内部 SKU 的订单项保持 pending, 不再回退到平台 SKU 或英文码。 @@ -220,6 +223,7 @@ pending 只保留: - 三平台订单:下单 → 内部 SKU → 渠道 → 载体 → 成本 → 销售事实全链路可追踪; - 同名不同平台码不再拆 pending; - 同一内部 SKU 跨平台/跨店铺只展示一条待分类、一条渠道配置和一个问题聚合; +- 已分配订单再次收到平台状态推送时冻结原渠道决策,只更新平台字段,不重复创建第二种载体; - 财务渠道与真实载体一致; - pending 仅三类语义; - 审计脚本 0 空内部 SKU、0 纯数字(映射后)、0 载体/渠道矛盾; diff --git a/docs/生产部署核对清单-2026-11-02.md b/docs/生产部署核对清单-2026-11-02.md index 2e824b21..091ea337 100644 --- a/docs/生产部署核对清单-2026-11-02.md +++ b/docs/生产部署核对清单-2026-11-02.md @@ -150,6 +150,8 @@ npx tsx scripts/preflight-fulfillment-routing.ts --strict --include-demo - 规范化后重复的内部 SKU 渠道配置; - `work_order` 缺 `workRuleKey` 或规则不存在; - 人工渠道缺启用的人工履约配置; +- 同一中文内部 SKU 存在多个载体配置; +- 规范化后重复的人工履约配置或 SKU 映射; - pending 订单项无法解析出中文内部 SKU; - 仍启用的 `sku_code` 兼容规则。 @@ -160,20 +162,23 @@ npx tsx scripts/preflight-fulfillment-routing.ts --strict --include-demo npx tsx scripts/merge-duplicate-products.ts npx tsx scripts/cleanup-legacy-sku-code-rules.ts npx tsx scripts/reclassify-post-cutover.ts +npx tsx scripts/normalize-internal-sku-configs.ts # 确认后 apply npx tsx scripts/merge-duplicate-products.ts --apply npx tsx scripts/cleanup-legacy-sku-code-rules.ts --apply npx tsx scripts/reclassify-post-cutover.ts --apply +npx tsx scripts/normalize-internal-sku-configs.ts --apply ``` 顺序: 1. 合并重复商品; 2. 清理旧 sku_code 规则; -3. 重判断代后订单; -4. 跑一次完整管道; -5. 核对成本/销售事实。 +3. 合并规范化重复的渠道配置与 SKU 映射; +4. 重判断代后订单; +5. 跑一次完整管道; +6. 核对成本/销售事实。 ## 6. 回滚