From 2f87445ed95feb150e0ab5244e19eff4e8850e09 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 15:01:50 +0800 Subject: [PATCH 01/12] =?UTF-8?q?fix(finance):=20=E6=94=B6=E6=95=9B?= =?UTF-8?q?=E5=86=85=E9=83=A8=20SKU=20=E5=B1=A5=E7=BA=A6=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E4=B8=8E=E8=BD=BD=E4=BD=93=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一内部 SKU 与渠道主/兜底解析,禁止并列主渠道。 运营指定渠道改为先创建真实载体,失败保持明确 pending。 历史缺载体、多载体冲突和自动发货兜底统一由管道修复。 迁移人工配置与旧 sku_code 规则,补充严格预检和集成测试。 --- README.md | 2 +- .../audit-fulfillment-finance-alignment.ts | 69 ++- .../scripts/backfill-internal-sku-mappings.ts | 2 +- .../scripts/cleanup-legacy-sku-code-rules.ts | 12 + .../scripts/merge-duplicate-products.ts | 13 + .../scripts/preflight-fulfillment-routing.ts | 123 +++- .../scripts/reclassify-post-cutover.ts | 6 +- .../scripts/seed-local-finance-demo.ts | 40 +- .../120_fulfillment_routing_invariants.sql | 225 +++++++ .../121_internal_sku_single_primary.sql | 25 + .../manual-fulfillment-config-service.ts | 50 +- .../cost-center/product-normalize-service.ts | 15 - .../fulfillment/affiliate-fallback-service.ts | 25 +- .../fulfillment/carrier-creation-service.ts | 202 ++++++ .../carrier-reconciliation-service.ts | 440 ++++++++----- .../channel-assign-service.test.ts | 66 +- .../fulfillment/channel-assign-service.ts | 576 +++++++++++++----- .../src/services/fulfillment/channel-types.ts | 1 + .../fulfillment-routing.integration.test.ts | 136 +++++ .../intake-channel-decision-service.ts | 61 +- .../internal-sku-channel-service.test.ts | 62 ++ .../internal-sku-channel-service.ts | 175 +++++- .../internal-sku-mapping-service.ts | 5 +- .../fulfillment/internal-sku-resolver.test.ts | 50 ++ .../fulfillment/internal-sku-resolver.ts | 61 ++ .../fulfillment/product-resolution-service.ts | 15 +- .../scheduler/finance-pipeline-jobs.ts | 84 ++- .../sync-worker-orders-service.ts | 27 +- apps/backend/src/types/repository/rows.ts | 4 + .../admin/AdminFulfillmentChannelPage.tsx | 9 +- .../src/services/admin/fulfillment-channel.ts | 10 +- docs/履约与财务分流统一方案-2026-11-02.md | 22 +- docs/生产部署核对清单-2026-11-02.md | 32 +- 33 files changed, 2199 insertions(+), 446 deletions(-) create mode 100644 apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql create mode 100644 apps/backend/src/db/migrations/121_internal_sku_single_primary.sql create mode 100644 apps/backend/src/services/fulfillment/carrier-creation-service.ts create mode 100644 apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts create mode 100644 apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts create mode 100644 apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts create mode 100644 apps/backend/src/services/fulfillment/internal-sku-resolver.ts diff --git a/README.md b/README.md index 334a6109..525ed77a 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ SKIP_CHECKS=1 git push | 文件 | 含义 | | --- | --- | | `001_init.sql` | 空库基线,建立完整业务结构 | -| `002_xxx.sql` ~ `080_xxx.sql` | 后续结构变更的增量 SQL(接单平台 004~009,寄卖相关 077~080) | +| `002_xxx.sql` ~ `121_xxx.sql` | 后续结构与数据增量(接单平台、寄卖、财务、履约路由迁移) | 约定: diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts index 4d536538..e005c2b9 100644 --- a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts +++ b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts @@ -34,6 +34,7 @@ async function main() { work_orders: number affiliate_tasks: number manual_tasks: number + config_missing: number no_carrier: number }>( ` @@ -44,8 +45,19 @@ async function main() { count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks, count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks, count(*) FILTER ( - WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) + WHERE oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL + )::int AS config_missing, + count(*) FILTER ( + WHERE ( + oi.fulfillment_channel = 'affiliate_dash' + AND NOT EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ) + ) OR ( + oi.fulfillment_channel = 'work_order' + AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + ) )::int AS no_carrier FROM order_items oi WHERE oi.fulfillment_status = 'assigned' @@ -63,9 +75,15 @@ async function main() { ` SELECT count(*)::int AS total, - count(*) FILTER (WHERE btrim(COALESCE(manual_sku_key, '')) = '')::int AS null_or_blank, - count(*) FILTER (WHERE btrim(COALESCE(manual_sku_key, '')) ~ '^[0-9]+$')::int AS numeric_only, - count(*) FILTER (WHERE btrim(COALESCE(manual_sku_key, '')) ~ '[一-龥]')::int AS with_cjk + count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku( + manual_sku_key, sku_name, sku_code + ), '')) = '')::int AS null_or_blank, + count(*) FILTER (WHERE btrim(COALESCE(resolve_internal_sku( + manual_sku_key, sku_name, sku_code + ), '')) ~ '^[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 FROM order_items WHERE fulfillment_status = 'assigned' `, @@ -104,6 +122,45 @@ async function main() { `SELECT channel, count(*)::int AS total FROM product_channel_capabilities WHERE enabled GROUP BY 1 ORDER BY 2 DESC`, ) + const internalChannelConflicts = await query<{ internal_sku: string; total: number }>( + `SELECT internal_sku, count(*)::int AS total + FROM internal_sku_channels + WHERE enabled = TRUE AND is_fallback = FALSE + GROUP BY internal_sku + HAVING count(*) > 1 + ORDER BY count(*) DESC, internal_sku`, + ) + + const assignedCarrierConflicts = await query<{ + order_item_id: number + channel: string + carrier_count: number + }>( + `SELECT oi.id AS order_item_id, oi.fulfillment_channel AS channel, + (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int + + (EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ))::int + + (EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + ))::int AS carrier_count + FROM order_items oi + WHERE oi.fulfillment_status = 'assigned' + AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int + + (EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ))::int + + (EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + ))::int > 1 + ORDER BY oi.id + LIMIT 200`, + ) + const byProvider = await query<{ provider: string total: number @@ -135,6 +192,8 @@ async function main() { rules: rules.rows, duplicateProducts: duplicateProducts.rows[0], capabilities: capabilities.rows, + internalChannelConflicts: internalChannelConflicts.rows, + assignedCarrierConflicts: assignedCarrierConflicts.rows, byProvider: byProvider.rows, }, null, diff --git a/apps/backend/scripts/backfill-internal-sku-mappings.ts b/apps/backend/scripts/backfill-internal-sku-mappings.ts index 044a6ed2..5285ff7e 100644 --- a/apps/backend/scripts/backfill-internal-sku-mappings.ts +++ b/apps/backend/scripts/backfill-internal-sku-mappings.ts @@ -59,7 +59,7 @@ async function main() { note: `从 ${row.total} 条订单项回填`, }) } - written += 1 + if (apply) written += 1 } console.log( diff --git a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts index 918e645e..fc8ebda0 100644 --- a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts +++ b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts @@ -88,6 +88,17 @@ async function main() { `, [internalSku, rule.channel, rule.priority, rule.source || 'operator'], ) + await query( + `UPDATE internal_sku_channels + SET is_fallback = TRUE, + priority = GREATEST(priority, 90), + updated_at = NOW() + WHERE btrim(internal_sku) = $1 + AND channel <> $2 + AND enabled = TRUE + AND is_fallback = FALSE`, + [internalSku, rule.channel], + ) await query( ` INSERT INTO internal_sku_channels ( @@ -95,6 +106,7 @@ async function main() { ) VALUES ($1, $2, $3, FALSE, TRUE, 'legacy-rule') ON CONFLICT (internal_sku, channel) DO UPDATE SET priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), + is_fallback = FALSE, enabled = TRUE, updated_at = NOW() `, diff --git a/apps/backend/scripts/merge-duplicate-products.ts b/apps/backend/scripts/merge-duplicate-products.ts index ab7ab141..891cb5b6 100644 --- a/apps/backend/scripts/merge-duplicate-products.ts +++ b/apps/backend/scripts/merge-duplicate-products.ts @@ -147,6 +147,18 @@ async function mergeGroup( [group.keeper_id], ) + await query( + `UPDATE internal_sku_channels + SET is_fallback = TRUE, + priority = GREATEST(priority, 90), + updated_at = NOW() + WHERE btrim(internal_sku) = $1 + AND channel <> 'affiliate_dash' + AND enabled = TRUE + AND is_fallback = FALSE`, + [group.canonical_name], + ) + await query( ` INSERT INTO internal_sku_channels ( @@ -157,6 +169,7 @@ async function mergeGroup( ON CONFLICT (internal_sku, channel) DO UPDATE SET priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), is_fallback = internal_sku_channels.is_fallback OR EXCLUDED.is_fallback, + enabled = TRUE, canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), updated_at = NOW() `, diff --git a/apps/backend/scripts/preflight-fulfillment-routing.ts b/apps/backend/scripts/preflight-fulfillment-routing.ts index 10ed333a..963ec15e 100644 --- a/apps/backend/scripts/preflight-fulfillment-routing.ts +++ b/apps/backend/scripts/preflight-fulfillment-routing.ts @@ -11,6 +11,7 @@ type CountRow = { label: string; total: number } async function main() { const strict = process.argv.includes('--strict') + const includeDemo = process.argv.includes('--include-demo') const checks: Record = {} checks.migrations = ( @@ -54,14 +55,96 @@ async function main() { `SELECT 'pending_no_internal_channel' AS label, count(*)::int AS total FROM order_items oi WHERE oi.fulfillment_status = 'pending' - AND btrim(COALESCE(oi.manual_sku_key, '')) <> '' + AND btrim(COALESCE(resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ), '')) <> '' AND NOT EXISTS ( SELECT 1 FROM internal_sku_channels c - WHERE c.enabled AND btrim(c.internal_sku) = btrim(oi.manual_sku_key) + WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) )`, ) ).rows[0] + checks.assignedWithoutCarrier = ( + await query( + `SELECT 'assigned_without_carrier' AS label, count(*)::int AS total + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') + AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') + AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + )`, + [includeDemo], + ) + ).rows[0] + + checks.assignedWithMultipleCarriers = ( + await query( + `SELECT 'assigned_with_multiple_carriers' AS label, count(*)::int AS total + FROM order_items oi + JOIN orders o ON o.id = oi.order_id + WHERE oi.fulfillment_status = 'assigned' + AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') + AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int + + (EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ))::int + + (EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + ))::int > 1`, + [includeDemo], + ) + ).rows[0] + + checks.internalChannelConflicts = ( + await query( + `SELECT internal_sku AS label, count(*)::int AS total + FROM internal_sku_channels + WHERE enabled = TRUE AND is_fallback = FALSE + GROUP BY internal_sku + HAVING count(*) > 1 + ORDER BY count(*) DESC, internal_sku`, + ) + ).rows + + checks.enabledLegacyRules = ( + await query( + `SELECT match_key_type || '/' || channel AS label, count(*)::int AS total + FROM sku_channel_rules + WHERE enabled = TRUE AND match_key_type = 'sku_code' + GROUP BY 1 ORDER BY 2 DESC`, + ) + ).rows + + checks.demoPending = ( + await query( + `SELECT 'demo_pending' 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 o.shop_id LIKE 'sim-%'`, + ) + ).rows[0] + + checks.pendingWithoutReason = ( + await query( + `SELECT 'pending_without_reason' 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 oi.fulfillment_pending_reason IS NULL`, + [includeDemo], + ) + ).rows[0] + checks.channelCarrier = ( await query<{ channel: string @@ -84,8 +167,42 @@ async function main() { const failed: string[] = [] const rename = checks.renameCardChannels as CountRow[] if (rename.some((row) => row.label === 'pending')) failed.push('11月改名卡仍有 pending') - if ((checks.pendingWithoutChannel as CountRow).total > 0) + if (!includeDemo && (checks.demoPending as CountRow).total > 0) { + checks.excludedDemoPending = (checks.demoPending as CountRow).total + } + if ((checks.pendingWithoutChannel as CountRow).total > 0 && !includeDemo) { + // 本地演示店铺故意保留 pending;严格模式默认只校验非演示数据。 + const productionPending = await query( + `SELECT 'pending_no_internal_channel' 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 o.shop_id NOT LIKE 'sim-%' + AND btrim(COALESCE(resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ), '')) <> '' + AND NOT EXISTS ( + SELECT 1 FROM internal_sku_channels c + WHERE c.enabled AND btrim(c.internal_sku) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) + )`, + ) + if (productionPending.rows[0]!.total > 0) + failed.push('存在有内部 SKU 但无渠道能力的非演示 pending') + } else if ((checks.pendingWithoutChannel as CountRow).total > 0) { failed.push('存在有内部 SKU 但无渠道能力的 pending') + } + if ((checks.assignedWithoutCarrier as CountRow).total > 0) + failed.push('存在已分配自动发货/接单渠道但没有真实载体的订单项') + if ((checks.assignedWithMultipleCarriers as CountRow).total > 0) + failed.push('存在同时挂载多个真实履约载体的已分配订单项') + if ((checks.internalChannelConflicts as CountRow[]).length > 0) + failed.push('存在多个非兜底主渠道') + if ((checks.enabledLegacyRules as CountRow[]).length > 0) + failed.push('仍有启用的 sku_code 兼容规则,未完全收敛到内部 SKU') + if ((checks.pendingWithoutReason as CountRow).total > 0) + failed.push('存在未写入 pending 原因的订单项,请先执行履约/财务管道') console.log(JSON.stringify({ strict, failed, ...checks }, null, 2)) if (strict && failed.length > 0) process.exitCode = 2 diff --git a/apps/backend/scripts/reclassify-post-cutover.ts b/apps/backend/scripts/reclassify-post-cutover.ts index 2c32f19c..c79323d5 100644 --- a/apps/backend/scripts/reclassify-post-cutover.ts +++ b/apps/backend/scripts/reclassify-post-cutover.ts @@ -36,7 +36,9 @@ async function loadCandidates(limit: number): Promise { FROM internal_sku_channels c WHERE c.enabled AND c.channel IN ('affiliate_dash', 'manual', 'third_party_voucher') - AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name)) + AND btrim(c.internal_sku) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) ORDER BY c.priority ASC, CASE c.channel @@ -68,7 +70,7 @@ async function loadCandidates(limit: number): Promise { ) AND NOT EXISTS ( SELECT 1 FROM work_orders wo - WHERE wo.order_item_id = oi.id AND wo.status = 'accepted' + WHERE wo.order_item_id = oi.id ) AND NOT EXISTS ( SELECT 1 FROM cost_snapshots cs diff --git a/apps/backend/scripts/seed-local-finance-demo.ts b/apps/backend/scripts/seed-local-finance-demo.ts index 288f45a5..a683cc17 100644 --- a/apps/backend/scripts/seed-local-finance-demo.ts +++ b/apps/backend/scripts/seed-local-finance-demo.ts @@ -17,24 +17,41 @@ async function cleanup() { [SHOPS], ) await query(`DELETE FROM sales_daily_facts WHERE shop_id = ANY($1)`, [SHOPS]) + await query( + `DELETE FROM internal_sku_channels + WHERE internal_sku IN ('模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`, + ) await query( `DELETE FROM order_items WHERE order_id IN (SELECT id FROM orders WHERE shop_id = ANY($1))`, [SHOPS], ) await query(`DELETE FROM orders WHERE shop_id = ANY($1)`, [SHOPS]) await query( - `DELETE FROM sku_channel_rules WHERE match_key IN ('SIM_AFF_1', 'SIMV001', 'SIMM001')`, + `DELETE FROM sku_channel_rules + WHERE match_key IN ('SIM_AFF_1', 'SIMV001', 'SIMM001', + '模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`, ) await query( `DELETE FROM product_channel_capabilities WHERE product_id IN (SELECT id FROM product_catalog WHERE canonical_sku = 'aff:SIM_AFF_1')`, ) await query( - `DELETE FROM cost_versions WHERE canonical_product_id IN (SELECT id FROM product_catalog WHERE canonical_sku = 'aff:SIM_AFF_1')`, + `DELETE FROM channel_product_mappings + WHERE canonical_product_id IN ( + SELECT id FROM product_catalog + WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B') + )`, + ) + await query( + `DELETE FROM cost_versions + WHERE canonical_product_id IN ( + SELECT id FROM product_catalog + WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B') + )`, ) - await query(`DELETE FROM product_catalog WHERE canonical_sku = 'aff:SIM_AFF_1'`) await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = '模拟人工商品'`) await query( - `DELETE FROM product_catalog WHERE canonical_sku IN ('tpv:模拟三方卡券', 'tpv:模拟三方卡券B')`, + `DELETE FROM product_catalog + WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B')`, ) } @@ -97,15 +114,24 @@ async function main() { [affProductId], ) await query( - `INSERT INTO sku_channel_rules (provider, shop_id, match_key_type, match_key, channel, priority, source, enabled, note, created_at, updated_at) - VALUES ('','','sku_code','SIM_AFF_1','affiliate_dash',60,'operator',TRUE,'本地演示',NOW(),NOW())`, + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, source, created_at, updated_at + ) VALUES + ('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1, 'demo', NOW(), NOW()), + ('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1, 'demo', NOW(), NOW())`, + [affProductId], ) - // 人工演示配置 await query( `INSERT INTO manual_fulfillment_configs (sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at) VALUES ('模拟人工商品','nanhang',100,'fixed',TRUE,'本地演示',NOW(),NOW())`, ) + await query( + `INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, source, created_at, updated_at + ) VALUES ('模拟人工商品', 'manual', 10, FALSE, TRUE, 'demo', NOW(), NOW())`, + ) // 1. 三方卡券:待分类,指定后自动建商品 const voucherOrder = await createOrder('sim-voucher', 'SIM-VOUCHER-1', 1950) diff --git a/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql b/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql new file mode 100644 index 00000000..a9d24a01 --- /dev/null +++ b/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql @@ -0,0 +1,225 @@ +-- 120_fulfillment_routing_invariants.sql +-- +-- 履约路由唯一口径与数据清理: +-- 1. 订单项内部 SKU 由一个不可变 SQL 函数统一解析; +-- 2. 人工配置自动成为 internal_sku_channels 的 manual 能力; +-- 3. manual 与其他渠道并存时统一降为兜底,避免同优先级被静默抢占; +-- 4. 可确定的旧 sku_code 规则迁移为内部 SKU 渠道并停用旧规则。 + +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 BTRIM(COALESCE(p_manual_sku_key, '')) ~ '[一-龥]' + THEN BTRIM(p_manual_sku_key) + END, + CASE + WHEN BTRIM(COALESCE(p_sku_name, '')) ~ '[一-龥]' + THEN BTRIM(p_sku_name) + END, + NULLIF(BTRIM(COALESCE(p_manual_sku_key, '')), ''), + NULLIF(BTRIM(COALESCE(p_sku_name, '')), ''), + NULLIF(BTRIM(COALESCE(p_sku_code, '')), '') + ) +$$; + +COMMENT ON FUNCTION resolve_internal_sku(TEXT, TEXT, TEXT) IS + '订单项内部 SKU 唯一 SQL 口径:中文 manual_sku_key/sku_name 优先,再按历史生成列顺序回退'; + +-- 人工配置本身就是一个 manual 渠道声明。存在其他渠道时默认作为 90 优先级兜底。 +INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at +) +SELECT + BTRIM(cfg.sku_key), + 'manual', + CASE WHEN EXISTS ( + SELECT 1 + FROM internal_sku_channels c + WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key) + AND c.enabled = TRUE + AND c.channel <> 'manual' + ) THEN 90 ELSE 10 END, + CASE WHEN EXISTS ( + SELECT 1 + FROM internal_sku_channels c + WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key) + AND c.enabled = TRUE + AND c.channel <> 'manual' + ) THEN TRUE ELSE FALSE END, + cfg.enabled, + cfg.canonical_product_id, + jsonb_build_object( + 'manualConfigId', cfg.id, + 'platformKey', cfg.platform_key, + 'creditQuantity', cfg.credit_quantity, + 'costMode', cfg.cost_mode + ), + 'manual_config', + NOW(), + NOW() +FROM manual_fulfillment_configs cfg +WHERE BTRIM(COALESCE(cfg.sku_key, '')) <> '' +ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = CASE + WHEN internal_sku_channels.source = 'operator' THEN internal_sku_channels.priority + ELSE EXCLUDED.priority + END, + is_fallback = CASE + WHEN internal_sku_channels.source = 'operator' THEN internal_sku_channels.is_fallback + ELSE EXCLUDED.is_fallback + END, + enabled = EXCLUDED.enabled, + canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), + config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + updated_at = NOW(); + +-- 只要同一内部 SKU 还有自动发货/接单/卡券能力,人工渠道就不能作为并列主渠道。 +UPDATE internal_sku_channels manual_channel +SET priority = GREATEST(manual_channel.priority, 90), + is_fallback = TRUE, + updated_at = NOW() +WHERE manual_channel.channel = 'manual' + AND manual_channel.enabled = TRUE + AND NOT (manual_channel.source = 'operator' AND manual_channel.priority = 10) + AND EXISTS ( + SELECT 1 + FROM internal_sku_channels other_channel + WHERE BTRIM(other_channel.internal_sku) = BTRIM(manual_channel.internal_sku) + AND other_channel.enabled = TRUE + AND other_channel.channel <> 'manual' + ); + +-- 自动发货旧 sku_code 规则能通过 aff: 商品稳定定位内部 SKU,直接迁移。 +INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at +) +SELECT + BTRIM(product.canonical_name), + 'affiliate_dash', + MIN(rule.priority), + FALSE, + TRUE, + MIN(product.id), + jsonb_build_object('legacySkuCode', MIN(rule.match_key)), + 'legacy-rule', + NOW(), + NOW() +FROM sku_channel_rules rule +JOIN product_catalog product + ON product.canonical_sku = CONCAT('aff:', rule.match_key) + AND product.status = 'active' +WHERE rule.match_key_type = 'sku_code' + AND rule.channel = 'affiliate_dash' + AND rule.enabled = TRUE + AND BTRIM(COALESCE(product.canonical_name, '')) <> '' +GROUP BY BTRIM(product.canonical_name) +ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), + enabled = TRUE, + canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), + config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + updated_at = NOW(); + +UPDATE sku_channel_rules rule +SET enabled = FALSE, + note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'), + updated_at = NOW() +WHERE rule.match_key_type = 'sku_code' + AND rule.channel = 'affiliate_dash' + AND rule.enabled = TRUE + AND EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.canonical_sku = CONCAT('aff:', rule.match_key) + AND product.status = 'active' + AND BTRIM(COALESCE(product.canonical_name, '')) <> '' + ); + +-- 卡券旧规则按历史订单中唯一可解析的内部 SKU 迁移;歧义数据保持原规则不动。 +WITH resolved_legacy AS ( + SELECT + rule.id, + matched.internal_sku, + MIN(rule.priority) AS priority + FROM sku_channel_rules rule + CROSS JOIN LATERAL ( + SELECT + MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, + COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count + FROM order_items oi + JOIN orders source_order ON source_order.id = oi.order_id + WHERE oi.sku_code = rule.match_key + AND (rule.provider = '' OR source_order.provider = rule.provider) + AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) + ) matched + WHERE rule.match_key_type = 'sku_code' + AND rule.channel = 'third_party_voucher' + AND rule.enabled = TRUE + AND matched.internal_count = 1 + AND BTRIM(COALESCE(matched.internal_sku, '')) <> '' + GROUP BY rule.id, matched.internal_sku +) +INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + config_json, source, created_at, updated_at +) +SELECT + internal_sku, + 'third_party_voucher', + priority, + FALSE, + TRUE, + '{}'::jsonb, + 'legacy-rule', + NOW(), + NOW() +FROM resolved_legacy +ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), + enabled = TRUE, + updated_at = NOW(); + +WITH resolved_legacy AS ( + SELECT rule.id + FROM sku_channel_rules rule + CROSS JOIN LATERAL ( + SELECT + MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, + COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count + FROM order_items oi + JOIN orders source_order ON source_order.id = oi.order_id + WHERE oi.sku_code = rule.match_key + AND (rule.provider = '' OR source_order.provider = rule.provider) + AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) + ) matched + WHERE rule.match_key_type = 'sku_code' + AND rule.channel = 'third_party_voucher' + AND rule.enabled = TRUE + AND matched.internal_count = 1 + AND BTRIM(COALESCE(matched.internal_sku, '')) <> '' +) +UPDATE sku_channel_rules rule +SET enabled = FALSE, + note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'), + updated_at = NOW() +FROM resolved_legacy +WHERE rule.id = resolved_legacy.id; + +ALTER TABLE internal_sku_channels + DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_priority_nonnegative; +ALTER TABLE internal_sku_channels + ADD CONSTRAINT chk_internal_sku_channels_priority_nonnegative CHECK (priority >= 0); + +CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_fallback + ON internal_sku_channels (internal_sku, priority, channel) + WHERE enabled = TRUE AND is_fallback = TRUE; diff --git a/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql b/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql new file mode 100644 index 00000000..4678b033 --- /dev/null +++ b/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql @@ -0,0 +1,25 @@ +-- 121_internal_sku_single_primary.sql +-- +-- 一个内部 SKU 只能有一个主渠道;其他渠道必须显式标记 is_fallback。 +-- 迁移 120 已把人工并列渠道收敛为兜底;这里在任何剩余歧义上显式阻止部署。 + +DO $$ +DECLARE + conflict_sku TEXT; +BEGIN + SELECT internal_sku INTO conflict_sku + FROM internal_sku_channels + WHERE enabled = TRUE AND is_fallback = FALSE + GROUP BY internal_sku + HAVING count(*) > 1 + ORDER BY internal_sku + LIMIT 1; + + IF conflict_sku IS NOT NULL THEN + RAISE EXCEPTION '内部 SKU % 存在多个主渠道,请先将其余渠道标记为 is_fallback', conflict_sku; + END IF; +END $$; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_internal_sku_channels_single_primary + ON internal_sku_channels (internal_sku) + WHERE enabled = TRUE AND is_fallback = FALSE; 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 989775fc..a8807f18 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 @@ -4,6 +4,7 @@ import type { JsonObject } from '../../types/json.js' 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' /** * 人工履约 SKU 配置服务。 @@ -125,7 +126,7 @@ export async function scanManualSkus(options: ScanOptions) { oi.sku_name, COALESCE(oi.revenue_amount_fen, 0)::bigint AS revenue_fen, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date AS stat_date, - oi.manual_sku_key AS sku_key + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key FROM order_items oi JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -243,7 +244,7 @@ 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 oi.manual_sku_key = r.sku_key + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = r.sku_key )`) } @@ -408,6 +409,15 @@ export async function deleteManualFulfillmentConfig(skuKey: string) { [key], ) await query(`DELETE FROM sku_channel_rules WHERE source = 'manual' AND match_key = $1`, [key]) + await upsertInternalSkuChannel({ + internalSku: key, + channel: 'manual', + priority: 90, + isFallback: true, + enabled: false, + source: 'manual_config', + configJson: { manualConfigId: null }, + }) await syncRegistryConfigId(key, null) return { skuKey: key, deleted: deleted.rowCount || 0 } } @@ -435,7 +445,7 @@ 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 = oi.manual_sku_key + ON 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) @@ -719,27 +729,57 @@ function requiredRow(row: T | undefined, message: string): T { async function syncManualChannelRule(config: { id: number sku_key: string + canonical_product_id: number | null enabled: boolean note: string }): Promise { const skuKey = String(config.sku_key || '').trim() if (!skuKey) return + const otherChannel = await query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 FROM internal_sku_channels + WHERE btrim(internal_sku) = $1 AND enabled = TRUE AND channel <> 'manual' + ) AS exists + `, + [skuKey], + ) + const hasOtherChannel = otherChannel.rows[0]?.exists === true + await upsertInternalSkuChannel({ + internalSku: skuKey, + channel: 'manual', + priority: hasOtherChannel ? 90 : 10, + isFallback: hasOtherChannel, + enabled: config.enabled !== false, + canonicalProductId: config.canonical_product_id, + source: 'manual_config', + configJson: { manualConfigId: config.id, note: String(config.note || '').slice(0, 200) }, + replacePriority: true, + }) + await query( ` INSERT INTO sku_channel_rules ( provider, shop_id, match_key_type, match_key, channel, priority, source, source_ref_id, enabled, note, created_at, updated_at ) - VALUES ('', '', 'internal_sku', $1, 'manual', 70, 'manual', $2, $3, $4, NOW(), NOW()) + VALUES ('', '', 'internal_sku', $1, 'manual', $5, 'manual', $2, $3, $4, NOW(), NOW()) ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO UPDATE SET enabled = EXCLUDED.enabled, + priority = EXCLUDED.priority, source = 'manual', source_ref_id = EXCLUDED.source_ref_id, note = EXCLUDED.note, updated_at = NOW() `, - [skuKey, config.id, config.enabled !== false, String(config.note || '').slice(0, 200)], + [ + skuKey, + config.id, + config.enabled !== false, + String(config.note || '').slice(0, 200), + hasOtherChannel ? 90 : 10, + ], ) } 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 47f1d870..05523ece 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -215,21 +215,6 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise { + 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 type EnsureCarrierResult = { + ready: boolean + created: boolean + reason: string + message: string +} + +async function hasWorkOrder(orderItemId: number): Promise { + const result = await query<{ exists: boolean }>( + `SELECT EXISTS (SELECT 1 FROM work_orders WHERE order_item_id = $1) AS exists`, + [orderItemId], + ) + return result.rows[0]?.exists === true +} + +async function hasAffiliateTask(orderItemId: number): Promise { + const result = await query<{ exists: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM fulfillment_tasks + WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' + ) AS exists`, + [orderItemId], + ) + return result.rows[0]?.exists === true +} + +function toResolvedItem(item: OrderItemRow): ResolvedFulfillmentItem { + return { + ...item, + itemId: String(item.id), + externalItemId: String(item.id), + externalSkuCode: item.sku_code, + externalSkuName: item.sku_name, + skuCode: item.sku_code, + skuName: item.sku_name, + spec: parseObject(item.spec_json), + snapshot: parseObject(item.item_snapshot_json), + isConfigured: false, + } as ResolvedFulfillmentItem +} + +/** + * 幂等创建指定渠道的真实载体。 + * + * 自动发货成功标准是存在 affiliate_dash 任务;接单成功标准是存在 work_order。 + * manual/voucher 不需要执行载体,直接视为 ready。 + */ +export async function ensureCarrierForOrderItem(input: { + order: OrderRow + item: OrderItemRow + channel: FulfillmentChannel +}): Promise { + const { order, item, channel } = input + + if (channel === 'manual') { + 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 + LIMIT 1`, + [item.manual_sku_key || '', item.sku_name, item.sku_code], + ) + const row = config.rows[0] + if (row) { + await query( + `UPDATE order_items + SET fulfillment_config_id = $2, + canonical_product_id = COALESCE(canonical_product_id, $3), + updated_at = NOW() + WHERE id = $1`, + [item.id, row.id, row.canonical_product_id], + ) + return { ready: true, created: false, reason: 'manual_config_ready', message: '' } + } + return { + ready: true, + created: false, + reason: 'manual_config_missing', + message: '人工渠道已选定,但缺少人工履约配置', + } + } + + if (channel === 'third_party_voucher') { + return { ready: true, created: false, reason: 'carrier_not_required', message: '' } + } + + if (channel === 'work_order') { + if (await hasWorkOrder(Number(item.id))) { + return { ready: true, created: false, reason: 'work_order_exists', message: '' } + } + const snapshot = parseObject(item.item_snapshot_json) + const classification = parseObject(snapshot.classification) + const route = parseObject(snapshot.fulfillmentRoute) + const workOrderSnapshot = { + ...snapshot, + classification: { ...classification, channel: 'work_order' }, + fulfillmentRoute: { + ...route, + selectedExecutorKey: '', + reason: '内部 SKU 渠道指定接单平台', + }, + } + const workOrderItem = { + ...item, + item_snapshot_json: workOrderSnapshot, + } as OrderItemRow + const result = await syncWorkerOrdersForSourceOrder(order, [workOrderItem], { + source: 'carrier_creation', + autoOnly: true, + skipChannelAssign: true, + }) + const ready = (await hasWorkOrder(Number(item.id))) || result.created.length > 0 + if (ready) { + await query( + `UPDATE order_items SET item_snapshot_json = $2::jsonb, updated_at = NOW() WHERE id = $1`, + [item.id, JSON.stringify(workOrderSnapshot)], + ) + } + return { + ready, + created: result.created.length > 0, + reason: ready ? 'work_order_created' : result.skipped[0]?.reason || 'work_order_unmatched', + message: ready ? '' : '未匹配到可自动创建的接单模板', + } + } + + if (await hasAffiliateTask(Number(item.id))) { + return { ready: true, created: false, reason: 'affiliate_task_exists', message: '' } + } + + const resolved = await applyIntakeChannelDecision( + toResolvedItem(item), + { + provider: order.provider, + platform: order.platform, + shopId: order.shop_id, + }, + { preferredChannel: 'affiliate_dash' }, + ) + const snapshot = parseObject(resolved.snapshot) + const route = parseObject(snapshot.fulfillmentRoute) + if (route.selectedExecutorKey !== 'affiliate_dash') { + return { + ready: false, + created: false, + reason: 'affiliate_mapping_missing', + message: '自动发货映射缺失', + } + } + + await query( + `UPDATE order_items + SET sku_code = $2, spec_json = $3::jsonb, item_snapshot_json = $4::jsonb, updated_at = NOW() + WHERE id = $1`, + [ + item.id, + String(resolved.skuCode || item.sku_code), + JSON.stringify(resolved.spec || {}), + JSON.stringify(snapshot), + ], + ) + const before = await hasAffiliateTask(Number(item.id)) + if (!before) { + await syncDeliveryTasksForOrder(order, [ + { + ...item, + sku_code: String(resolved.skuCode || item.sku_code), + spec_json: resolved.spec || {}, + item_snapshot_json: snapshot, + } as OrderItemRow, + ]) + } + const ready = await hasAffiliateTask(Number(item.id)) + return { + ready, + created: ready && !before, + reason: ready ? 'affiliate_task_created' : 'affiliate_task_planning_failed', + message: ready ? '' : '自动发货任务规划失败', + } +} diff --git a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts index 794cb6cb..74f4f186 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -1,178 +1,323 @@ import { query } from '../../db/client.js' import { getOrderById } from '../../repositories/order-repo.js' -import { syncDeliveryTasksForOrder } from '../order/delivery-task-service.js' -import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js' -import { applyIntakeChannelDecision } from './intake-channel-decision-service.js' -import type { ResolvedFulfillmentItem } from './product-resolution-service.js' +import type { OrderItemRow } from '../../types/repository/rows.js' import { logWarn } from '../../utils/logger.js' +import { FULFILLMENT_CHANNELS, type FulfillmentChannel } from './channel-types.js' +import { ensureCarrierForOrderItem } from './carrier-creation-service.js' +import { + planInternalSkuChannels, + type InternalSkuChannelRow, +} from './internal-sku-channel-service.js' -type PendingCarrierItem = { +type CarrierCandidate = { id: number order_id: number - sku_code: string - sku_name: string - quantity: number - item_snapshot_json: unknown - spec_json: unknown - channel: 'affiliate_dash' | 'work_order' + internal_sku: string + channels: InternalSkuChannelRow[] } -function parseObject(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 {} - } +function normalizeChannels(value: unknown): InternalSkuChannelRow[] { + if (!Array.isArray(value)) return [] + return value + .filter((row): row is Record => Boolean(row) && typeof row === 'object') + .map((row) => ({ + internal_sku: String(row.internal_sku || '').trim(), + channel: String(row.channel || '') as FulfillmentChannel, + priority: Number(row.priority || 100), + is_fallback: row.is_fallback === true, + enabled: row.enabled !== false, + canonical_product_id: + row.canonical_product_id == null ? null : Number(row.canonical_product_id), + config_json: + row.config_json && typeof row.config_json === 'object' + ? (row.config_json as Record) + : {}, + source: String(row.source || ''), + })) + .filter((row) => FULFILLMENT_CHANNELS.includes(row.channel)) } -/** - * 为“内部 SKU 有渠道能力、但还没有载体”的 pending 单补载体。 - * - * - 接单平台:走现有工单匹配器,能匹配模板就建工单; - * - 自动发货:补 fulfillmentRoute/affiliateDash 后建任务; - * - 补不出来的继续保留 no_carrier,等运营补模板/映射。 - * - * 财务渠道判定不在本服务内做;调用方在补完载体后再跑一次 channel_assign。 - */ -export async function reconcilePendingCarriers(input: { limit?: number } = {}): Promise<{ - scanned: number - workOrdersCreated: number - affiliateTasksCreated: number - remaining: number -}> { - const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200))) - const rows = await query( +async function loadCandidateItems(input: { + limit: number + orderItemIds?: number[] + assignedMissingCarrier?: boolean +}): Promise { + const ids = (input.orderItemIds || []) + .map(Number) + .filter((value) => Number.isInteger(value) && value > 0) + const idFilter = ids.length > 0 ? 'AND oi.id = ANY($2::bigint[])' : '' + const statusFilter = input.assignedMissingCarrier + ? `oi.fulfillment_status = 'assigned' + AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') + AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + )` + : `oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE` + + const params: unknown[] = [input.limit] + if (ids.length > 0) params.push(ids) + const result = await query( ` - SELECT oi.id, oi.order_id, oi.sku_code, oi.sku_name, oi.quantity, - oi.item_snapshot_json, oi.spec_json, - cap.channel + SELECT + oi.id, + oi.order_id, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, + capability.channels FROM order_items oi JOIN LATERAL ( - SELECT c.channel - FROM internal_sku_channels c - WHERE c.enabled = TRUE - AND c.channel IN ('affiliate_dash', 'work_order') - AND btrim(c.internal_sku) = COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), btrim(oi.sku_name)) - ORDER BY - c.priority ASC, - CASE c.channel - WHEN 'affiliate_dash' THEN 1 - WHEN 'work_order' THEN 2 - ELSE 9 - END, - c.channel ASC - LIMIT 1 - ) cap ON TRUE - WHERE oi.fulfillment_status = 'pending' - AND oi.fulfillment_locked = FALSE - AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - AND NOT EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' - ) + SELECT COALESCE( + jsonb_agg( + jsonb_build_object( + 'internal_sku', channel.internal_sku, + 'channel', channel.channel, + 'priority', channel.priority, + 'is_fallback', channel.is_fallback, + 'enabled', channel.enabled, + 'canonical_product_id', channel.canonical_product_id, + 'config_json', channel.config_json, + 'source', channel.source + ) + ORDER BY channel.priority, channel.channel + ), + '[]'::jsonb + ) AS channels + FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND btrim(channel.internal_sku) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) + ) capability ON jsonb_array_length(capability.channels) > 0 + WHERE ${statusFilter} + ${idFilter} ORDER BY oi.id LIMIT $1 `, - [limit], + params, ) + return result.rows.map((row) => ({ ...row, channels: normalizeChannels(row.channels) })) +} + +async function markPendingWithoutCarrier(input: { + itemId: number + internalSku: string + attempts: Array<{ channel: FulfillmentChannel; reason: string; message: string }> +}): Promise { + await query( + ` + UPDATE order_items + SET fulfillment_pending_reason = 'no_carrier', + fulfillment_pending_detail = $2::jsonb, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status = 'pending' + `, + [ + input.itemId, + JSON.stringify({ + internalSku: input.internalSku, + attempts: input.attempts, + }), + ], + ) +} + +async function selectSafeFallback(input: { + orderItemId: number + channel: FulfillmentChannel + primaryChannel: FulfillmentChannel + reason: string + includeAssignedMissingCarrier: boolean +}): Promise { + const result = await query( + ` + UPDATE order_items + SET fulfillment_channel = $2, + fulfillment_status = 'assigned', + fulfillment_source = 'auto_mapping', + fulfillment_reason = $3, + channel_switch_reason = $3, + channel_switched_at = NOW(), + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = NOW() + WHERE id = $1 + AND ( + (fulfillment_status = 'pending' AND fulfillment_locked = FALSE) + OR ( + $4::boolean + AND fulfillment_status = 'assigned' + AND fulfillment_locked = FALSE + AND fulfillment_source NOT IN ('operator_assigned', 'manual_confirm') + ) + ) + `, + [ + input.orderItemId, + input.channel, + `主渠道 ${input.primaryChannel} 无法创建载体:${input.reason}`, + input.includeAssignedMissingCarrier, + ], + ) + return (result.rowCount || 0) > 0 +} + +/** + * 按 internal_sku_channels 的明确优先级补载体。 + * + * 主渠道是 affiliate/work_order 但载体暂时创建失败时,只有显式 is_fallback 的 + * manual/voucher 才能接管。所有结果都落 pending 明细,不再静默卡住。 + */ +export async function reconcilePendingCarriers( + input: { + limit?: number + orderItemIds?: number[] + includeAssignedMissingCarrier?: boolean + allowFallback?: boolean + } = {}, +): Promise<{ + scanned: number + workOrdersCreated: number + affiliateTasksCreated: number + fallbacksSelected: number + failed: number + remaining: number +}> { + const limit = Math.min(500, Math.max(1, Math.trunc(Number(input.limit) || 200))) + const rows = await loadCandidateItems({ + limit, + assignedMissingCarrier: input.includeAssignedMissingCarrier === true, + ...(input.orderItemIds ? { orderItemIds: input.orderItemIds } : {}), + }) let workOrdersCreated = 0 let affiliateTasksCreated = 0 - const byOrder = new Map() - for (const row of rows.rows) { + let fallbacksSelected = 0 + let failed = 0 + const byOrder = new Map() + for (const row of rows) { const list = byOrder.get(Number(row.order_id)) || [] list.push(row) byOrder.set(Number(row.order_id), list) } - for (const [orderId, items] of byOrder) { + for (const [orderId, candidates] of byOrder) { const order = await getOrderById(orderId) if (!order) continue - const itemRows = await query<{ - id: number - order_id: number - sku_code: string - sku_name: string - quantity: number - item_snapshot_json: unknown - spec_json: unknown - }>( - `SELECT id, order_id, sku_code, sku_name, quantity, item_snapshot_json, spec_json - FROM order_items WHERE order_id = $1`, - [orderId], - ) + const itemRows = await query(`SELECT * FROM order_items WHERE order_id = $1`, [ + orderId, + ]) const itemById = new Map(itemRows.rows.map((item) => [Number(item.id), item])) - for (const target of items) { - const item = itemById.get(Number(target.id)) + for (const candidate of candidates) { + const item = itemById.get(Number(candidate.id)) if (!item) continue - try { - if (target.channel === 'work_order') { - const result = await syncWorkerOrdersForSourceOrder(order, [item as never], { - source: 'carrier_reconcile', - autoOnly: true, - }) - workOrdersCreated += result.created.length - continue + const assignedChannel = String(item.fulfillment_channel || '') as FulfillmentChannel + const plan = planInternalSkuChannels(candidate.channels) + const exactOnly = + input.includeAssignedMissingCarrier === true && + (item.fulfillment_locked === true || + item.fulfillment_source === 'operator_assigned' || + item.fulfillment_source === 'manual_confirm') + const attempts = + input.includeAssignedMissingCarrier && exactOnly + ? FULFILLMENT_CHANNELS.includes(assignedChannel) + ? candidate.channels.filter((row) => row.channel === assignedChannel) + : [] + : plan.attempts + if (attempts.length === 0) { + failed += 1 + if (!input.includeAssignedMissingCarrier) { + await query( + `UPDATE order_items + SET fulfillment_pending_reason = 'routing_conflict', + fulfillment_pending_detail = $2::jsonb, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status = 'pending'`, + [ + item.id, + JSON.stringify({ + internalSku: candidate.internal_sku, + channels: plan.conflicts.map((channel) => channel.channel), + }), + ], + ) } + continue + } - const resolved = { - ...item, - itemId: String(item.id), - externalItemId: String(item.id), - externalSkuCode: item.sku_code, - externalSkuName: item.sku_name, - skuCode: item.sku_code, - skuName: item.sku_name, - spec: parseObject(item.spec_json), - snapshot: parseObject(item.item_snapshot_json), - isConfigured: false, - } as unknown as ResolvedFulfillmentItem - const enriched = await applyIntakeChannelDecision(resolved, { - provider: order.provider, - platform: order.platform, - shopId: order.shop_id, - }) - const snapshot = parseObject(enriched.snapshot) - const route = parseObject(snapshot.fulfillmentRoute) - if (route.selectedExecutorKey !== 'affiliate_dash') continue + const failures: Array<{ channel: FulfillmentChannel; reason: string; message: string }> = [] + let resolved = false + for (let index = 0; index < attempts.length; index += 1) { + const attempt = attempts[index]! + try { + const carrier = await ensureCarrierForOrderItem({ + order, + item, + channel: attempt.channel, + }) + if (!carrier.ready) { + failures.push({ + channel: attempt.channel, + reason: carrier.reason, + message: carrier.message, + }) + const canFallback = + input.allowFallback !== false && + !exactOnly && + index === 0 && + (attempt.channel === 'affiliate_dash' || attempt.channel === 'work_order') + if (canFallback) continue + break + } - await query( - `UPDATE order_items - SET sku_code = $2, - spec_json = $3, - item_snapshot_json = $4, - updated_at = NOW() - WHERE id = $1`, - [ - item.id, - String(enriched.skuCode || item.sku_code), - JSON.stringify(enriched.spec || {}), - JSON.stringify(snapshot), - ], - ) - const tasks = await syncDeliveryTasksForOrder(order, [ - { - ...item, - sku_code: String(enriched.skuCode || item.sku_code), - spec_json: enriched.spec || {}, - item_snapshot_json: snapshot, - } as never, - ]) - affiliateTasksCreated += tasks.length - } catch (error) { - logWarn('[carrier-reconciliation]', '补履约载体失败', { - orderItemId: target.id, - channel: target.channel, - error: error instanceof Error ? error.message : String(error), - }) + if (carrier.created && attempt.channel === 'work_order') workOrdersCreated += 1 + if (carrier.created && attempt.channel === 'affiliate_dash') affiliateTasksCreated += 1 + + const isFallbackAttempt = index > 0 + if ( + !exactOnly && + (attempt.channel === 'manual' || + attempt.channel === 'third_party_voucher' || + isFallbackAttempt) + ) { + const primaryChannel = plan.primary?.channel || attempts[0]!.channel + if ( + await selectSafeFallback({ + orderItemId: Number(item.id), + channel: attempt.channel, + primaryChannel, + reason: failures[0]?.reason || 'fallback', + includeAssignedMissingCarrier: input.includeAssignedMissingCarrier === true, + }) + ) { + fallbacksSelected += 1 + } + } + resolved = true + break + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + failures.push({ channel: attempt.channel, reason: 'exception', message }) + logWarn('[carrier-reconciliation]', '补履约载体失败', { + orderItemId: item.id, + channel: attempt.channel, + error: message, + }) + break + } + } + + if (!resolved) { + failed += 1 + if (!input.includeAssignedMissingCarrier) { + await markPendingWithoutCarrier({ + itemId: Number(item.id), + internalSku: candidate.internal_sku, + attempts: failures, + }) + } } } } @@ -181,15 +326,22 @@ export async function reconcilePendingCarriers(input: { limit?: number } = {}): ` SELECT count(*)::int AS count FROM order_items oi - WHERE oi.fulfillment_status = 'pending' - AND oi.fulfillment_pending_reason = 'no_carrier' + WHERE oi.fulfillment_status IN ('pending', 'assigned') + AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') + AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ) `, ) return { - scanned: rows.rows.length, + scanned: rows.length, workOrdersCreated, affiliateTasksCreated, + fallbacksSelected, + failed, remaining: Number(remainingResult.rows[0]?.count || 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 98de6e62..56f44eb7 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.test.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.test.ts @@ -6,6 +6,7 @@ import { type ChannelCandidateItem, type ChannelRule, } from './channel-assign-service.js' +import type { InternalSkuChannelRow } from './internal-sku-channel-service.js' function buildItem(patch: Partial = {}): ChannelCandidateItem { return { @@ -20,12 +21,26 @@ function buildItem(patch: Partial = {}): ChannelCandidateI has_work_order: false, has_affiliate_task: false, has_manual_task: false, - capability_channel: null, + channels: [], basis_date: '2026-10-31', ...patch, } } +function buildChannel(patch: Partial = {}): InternalSkuChannelRow { + return { + internal_sku: '套装-测试', + channel: 'manual', + priority: 10, + is_fallback: false, + enabled: true, + canonical_product_id: null, + config_json: {}, + source: 'test', + ...patch, + } +} + function buildRule(patch: Partial = {}): ChannelRule { return { id: 1, @@ -40,13 +55,13 @@ function buildRule(patch: Partial = {}): ChannelRule { } } -test('渠道判定:精确 SKU 规则优先于载体证据', () => { +test('渠道判定:真实载体优先于冲突的旧规则', () => { const decision = resolveChannelDecision(buildItem({ has_work_order: true }), [ buildRule({ channel: 'third_party_voucher', priority: 10 }), ]) - assert.equal(decision.channel, 'third_party_voucher') - assert.equal(decision.source, 'auto_rule') + assert.equal(decision.channel, 'work_order') + assert.equal(decision.source, 'carrier_evidence') assert.equal(decision.conflict, false) }) @@ -79,19 +94,19 @@ test('渠道判定:同优先级多渠道判为冲突,不静默选择', () => test('渠道判定:不同优先级时取优先级更小的', () => { const decision = resolveChannelDecision(buildItem(), [ - buildRule({ id: 1, channel: 'affiliate_dash', priority: 60 }), + buildRule({ id: 1, channel: 'third_party_voucher', priority: 60 }), buildRule({ id: 2, channel: 'manual', priority: 70 }), ]) - assert.equal(decision.channel, 'affiliate_dash') + 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: '' }) - assert.equal(resolveChannelDecision(buildItem(), [wide]).channel, 'affiliate_dash') + const wide = buildRule({ provider: '', shop_id: '', channel: 'third_party_voucher' }) + assert.equal(resolveChannelDecision(buildItem(), [wide]).channel, 'third_party_voucher') }) test('渠道判定:内部 SKU 与 SKU 名称两种键都能命中', () => { @@ -125,23 +140,50 @@ test('渠道判定:停用的规则不参与', () => { test('渠道判定:卡券/人工能力可兜底,自动发货/接单必须靠载体', () => { const voucher = resolveChannelDecision( - buildItem({ capability_channel: 'third_party_voucher' }), + buildItem({ channels: [buildChannel({ channel: 'third_party_voucher' })] }), [], ) assert.equal(voucher.channel, 'third_party_voucher') assert.equal(voucher.source, 'auto_mapping') - const manual = resolveChannelDecision(buildItem({ capability_channel: 'manual' }), []) + const manual = resolveChannelDecision(buildItem({ channels: [buildChannel()] }), []) assert.equal(manual.channel, 'manual') assert.equal(manual.source, 'auto_mapping') - const affiliate = resolveChannelDecision(buildItem({ capability_channel: 'affiliate_dash' }), []) + const affiliate = resolveChannelDecision( + buildItem({ channels: [buildChannel({ channel: 'affiliate_dash' })] }), + [], + ) assert.equal(affiliate.channel, null, '自动发货能力没有任务时不能只改财务口径') - const workOrder = resolveChannelDecision(buildItem({ capability_channel: 'work_order' }), []) + 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 同级多渠道判为冲突', () => { + const decision = resolveChannelDecision( + buildItem({ + channels: [buildChannel({ channel: 'work_order' }), buildChannel({ 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') diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 4fe3ee6c..d4c05b34 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -1,4 +1,6 @@ import { query } from '../../db/client.js' +import { getOrderById } from '../../repositories/order-repo.js' +import type { OrderItemRow } from '../../types/repository/rows.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' import { upsertAggregatedException } from '../cost-center/cost-exception-service.js' @@ -18,6 +20,14 @@ import { type MatchKeyType, type RuleSource, } from './channel-types.js' +import { + disableInternalSkuChannel, + planInternalSkuChannels, + setInternalSkuPrimaryChannel, + upsertInternalSkuChannel, + type InternalSkuChannelRow, +} from './internal-sku-channel-service.js' +import { ensureCarrierForOrderItem } from './carrier-creation-service.js' /** * 管道 A:渠道判定。 @@ -27,19 +37,14 @@ import { * * 判定顺序: * 1. 运营锁定 → 跳过 - * 2. 精确规则(sku_channel_rules,优先级升序);同优先级命中多渠道 → 冲突,保持 pending - * 3. 载体证据:已有工单 → work_order;已有自动发货任务 → affiliate_dash - * 4. 都没有 → 保持 pending(不再兜底成人工) + * 2. 已有且唯一的真实载体 → 直接按载体落渠道;多载体 → 冲突 + * 3. internal_sku_channels:一个主渠道 + 显式兜底;多主渠道 → 冲突 + * 4. 旧 sku_channel_rules 仅作兼容回退,且自动发货/接单仍必须有真实载体 + * 5. 都不满足 → 保留 pending 并写明确原因 */ -/** 内部 SKU:优先中文手动键,其次中文商品名,最后平台 SKU。 */ -const INTERNAL_SKU_SQL = ` - CASE - WHEN btrim(COALESCE(oi.manual_sku_key, '')) ~ '[一-龥]' THEN btrim(oi.manual_sku_key) - WHEN btrim(COALESCE(oi.sku_name, '')) ~ '[一-龥]' THEN btrim(oi.sku_name) - ELSE COALESCE(NULLIF(btrim(oi.manual_sku_key), ''), NULLIF(btrim(oi.sku_name), ''), oi.sku_code) - END -` +/** 数据库函数 `resolve_internal_sku` 是订单项内部 SKU 的唯一 SQL 口径。 */ +const INTERNAL_SKU_SQL = `resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)` export type ChannelRule = { id: number @@ -64,7 +69,7 @@ export type ChannelCandidateItem = { has_work_order: boolean has_affiliate_task: boolean has_manual_task: boolean - capability_channel: FulfillmentChannel | null + channels: InternalSkuChannelRow[] basis_date: string } @@ -74,6 +79,8 @@ export type ChannelDecision = { conflict: boolean conflictChannels: FulfillmentChannel[] matchedRuleIds: number[] + pendingChannel: FulfillmentChannel | null + pendingReason: 'no_internal_sku' | 'no_channel_capability' | 'no_carrier' | '' } function normalizeKey(value: unknown): string { @@ -99,7 +106,14 @@ function resolvePendingReason(item: ChannelCandidateItem): { if (!String(item.internal_sku || '').trim()) { return { reason: 'no_internal_sku', detail: { skuCode: item.sku_code } } } - if (!item.capability_channel) { + const plan = planInternalSkuChannels(item.channels || []) + if (plan.conflicts.length > 0) { + return { + reason: 'routing_conflict', + detail: { channels: plan.conflicts.map((channel) => channel.channel) }, + } + } + if (!plan.primary) { return { reason: 'no_channel_capability', detail: { internalSku: item.internal_sku }, @@ -107,15 +121,90 @@ function resolvePendingReason(item: ChannelCandidateItem): { } return { reason: 'no_carrier', - detail: { internalSku: item.internal_sku, channel: item.capability_channel }, + detail: { + internalSku: item.internal_sku, + channel: plan.primary.channel, + fallbacks: plan.fallbacks.map((channel) => channel.channel), + }, } } +function emptyDecision(pendingReason: ChannelDecision['pendingReason'] = '') { + return { + channel: null, + source: '', + conflict: false, + conflictChannels: [], + matchedRuleIds: [], + pendingChannel: null, + pendingReason, + } satisfies ChannelDecision +} + /** 纯函数:给定规则与订单项,得出渠道决策(便于单测与文档对照)。 */ export function resolveChannelDecision( item: ChannelCandidateItem, rules: ChannelRule[], ): ChannelDecision { + const carrierChannels: FulfillmentChannel[] = [ + ...(item.has_work_order ? ['work_order' as const] : []), + ...(item.has_affiliate_task ? ['affiliate_dash' as const] : []), + ...(item.has_manual_task ? ['manual' as const] : []), + ] + if (carrierChannels.length > 1) { + return { + channel: null, + source: '', + conflict: true, + conflictChannels: carrierChannels, + matchedRuleIds: [], + pendingChannel: carrierChannels[0] || null, + pendingReason: 'no_carrier', + } + } + + const plan = planInternalSkuChannels(item.channels || []) + if (plan.conflicts.length > 0) { + return { + channel: null, + source: '', + conflict: true, + conflictChannels: plan.conflicts.map((channel) => channel.channel), + matchedRuleIds: [], + pendingChannel: null, + pendingReason: 'no_channel_capability', + } + } + + // 真实载体是最终事实;配置只能决定准备创建哪个载体,不能覆盖已经存在的另一类载体。 + const evidence = carrierChannels[0] + if (evidence) { + return { + channel: evidence, + source: 'carrier_evidence', + conflict: false, + conflictChannels: [], + matchedRuleIds: [], + pendingChannel: evidence, + pendingReason: '', + } + } + + if (plan.primary) { + if (plan.primary.channel === 'manual' || plan.primary.channel === 'third_party_voucher') { + return { + channel: plan.primary.channel, + source: 'auto_mapping', + conflict: false, + conflictChannels: [], + matchedRuleIds: [], + pendingChannel: plan.primary.channel, + pendingReason: '', + } + } + return emptyDecision('no_carrier') + } + const hits = rules.filter( (rule) => rule.enabled && @@ -135,58 +224,25 @@ export function resolveChannelDecision( 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: channels[0]!, + channel, source: 'auto_rule', conflict: false, conflictChannels: [], matchedRuleIds: best.map((rule) => rule.id), + pendingChannel: channel, + pendingReason: '', } } - - if (item.has_work_order) { - return { - channel: 'work_order', - source: 'carrier_evidence', - conflict: false, - conflictChannels: [], - matchedRuleIds: [], - } - } - if (item.has_affiliate_task) { - return { - channel: 'affiliate_dash', - source: 'carrier_evidence', - conflict: false, - conflictChannels: [], - matchedRuleIds: [], - } - } - if (item.has_manual_task) { - return { - channel: 'manual', - source: 'carrier_evidence', - conflict: false, - conflictChannels: [], - matchedRuleIds: [], - } - } - - // 卡券/人工没有必须创建的履约任务,可直接用商品渠道能力兜底; - // 自动发货/接单平台仍要求真实载体,避免只改口径不派单。 - if (item.capability_channel === 'third_party_voucher' || item.capability_channel === 'manual') { - return { - channel: item.capability_channel, - source: 'auto_mapping', - conflict: false, - conflictChannels: [], - matchedRuleIds: [], - } - } - - return { channel: null, source: '', conflict: false, conflictChannels: [], matchedRuleIds: [] } + return emptyDecision('no_channel_capability') } export type AssignChannelsResult = { @@ -257,27 +313,9 @@ export async function assignPendingOrderItemChannels( EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' - ) AS has_manual_task, - cap.channel AS capability_channel + ) AS has_manual_task FROM order_items oi JOIN orders o ON o.id = oi.order_id - LEFT JOIN LATERAL ( - SELECT c.channel - FROM internal_sku_channels c - WHERE c.enabled = TRUE - AND btrim(c.internal_sku) = ${INTERNAL_SKU_SQL} - ORDER BY - c.priority ASC, - CASE c.channel - WHEN 'affiliate_dash' THEN 1 - WHEN 'work_order' THEN 2 - WHEN 'third_party_voucher' THEN 3 - WHEN 'manual' THEN 4 - ELSE 9 - END, - c.channel ASC - LIMIT 1 - ) cap ON TRUE WHERE oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE AND oi.id > $1 @@ -302,11 +340,33 @@ export async function assignPendingOrderItemChannels( source: FulfillmentSource }> = [] - for (const item of rows.rows) { + const channelsByInternalSku = await loadInternalSkuChannelsForKeys( + rows.rows.map((item) => item.internal_sku), + ) + + for (const row of rows.rows) { + const item: ChannelCandidateItem = { + ...row, + channels: channelsByInternalSku.get(normalizeKey(row.internal_sku)) || [], + } const decision = resolveChannelDecision(item, rules) if (decision.channel) affectedDates.add(item.basis_date) if (decision.conflict) { result.conflicted += 1 + await query( + `UPDATE order_items + SET fulfillment_pending_reason = 'routing_conflict', + fulfillment_pending_detail = $2::jsonb, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status = 'pending'`, + [ + item.id, + JSON.stringify({ + channels: decision.conflictChannels, + ruleIds: decision.matchedRuleIds, + }), + ], + ) await upsertAggregatedException({ reason: 'routing_conflict', scopeKey: `routing_conflict|${item.provider}|${item.shop_id}|${item.sku_code}|-`, @@ -367,13 +427,163 @@ export async function assignPendingOrderItemChannels( return result } -/** 运营指定渠道:写入并锁定,自动判定不再覆盖。 */ +async function assignOrderItemsAfterCarrierReady(input: { + orderItemIds: number[] + channel: FulfillmentChannel + source: FulfillmentSource + lock: boolean + note?: string | undefined +}): Promise<{ updated: number; carrierFailed: number }> { + const ids = [ + ...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)), + ] + if (ids.length === 0) return { updated: 0, carrierFailed: 0 } + + const items = ( + await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) + ).rows + const orderIds = [...new Set(items.map((item) => Number(item.order_id)))] + const orders = new Map( + (await Promise.all(orderIds.map((orderId) => getOrderById(orderId)))) + .filter((order): order is NonNullable => Boolean(order)) + .map((order) => [Number(order.id), order]), + ) + const assignedIds: number[] = [] + let carrierFailed = 0 + + for (const item of items) { + if (item.fulfillment_status === 'archived') continue + const order = orders.get(Number(item.order_id)) + if (!order) continue + + if ( + item.fulfillment_status === 'assigned' && + item.fulfillment_channel && + item.fulfillment_channel !== input.channel + ) { + // 旧载体尚未撤销时禁止直接换渠道,否则会同时留下两个履约执行方。 + carrierFailed += 1 + await query( + `UPDATE order_items + SET fulfillment_reason = $2, updated_at = NOW() + WHERE id = $1 AND fulfillment_status = 'assigned'`, + [item.id, '切换渠道前需先撤销现有履约载体'], + ) + continue + } + + let carrier + try { + carrier = await ensureCarrierForOrderItem({ order, item, channel: input.channel }) + } catch (error) { + carrier = { + ready: false, + created: false, + reason: 'exception', + message: error instanceof Error ? error.message : String(error), + } + } + + if (!carrier.ready) { + carrierFailed += 1 + if (item.fulfillment_status === 'assigned') { + // 运营切换渠道失败时恢复原渠道,宁可保留旧状态,也不制造渠道与载体不一致。 + await query( + `UPDATE order_items + SET fulfillment_channel = $2, + fulfillment_status = $3, + fulfillment_source = $4, + fulfillment_locked = $5, + fulfillment_reason = $6, + fulfillment_pending_reason = $7, + fulfillment_pending_detail = $8::jsonb, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status <> 'archived'`, + [ + item.id, + item.fulfillment_channel, + item.fulfillment_status, + item.fulfillment_source, + item.fulfillment_locked, + item.fulfillment_reason, + item.fulfillment_pending_reason, + JSON.stringify(item.fulfillment_pending_detail || null), + ], + ) + } else { + await query( + `UPDATE order_items + SET fulfillment_channel = NULL, + fulfillment_status = 'pending', + fulfillment_source = '', + fulfillment_locked = TRUE, + fulfillment_pending_reason = 'no_carrier', + fulfillment_pending_detail = $2::jsonb, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status <> 'archived'`, + [ + item.id, + JSON.stringify({ + channel: input.channel, + reason: carrier.reason, + message: carrier.message, + }), + ], + ) + } + continue + } + + const updated = await query( + `UPDATE order_items + SET fulfillment_channel = $2, + fulfillment_status = 'assigned', + fulfillment_source = $3, + fulfillment_locked = $4, + fulfillment_reason = COALESCE(NULLIF($5, ''), fulfillment_reason), + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = NOW() + WHERE id = $1 AND fulfillment_status <> 'archived'`, + [item.id, input.channel, input.source, input.lock, String(input.note || '').slice(0, 200)], + ) + if ((updated.rowCount || 0) > 0) assignedIds.push(Number(item.id)) + } + + if (assignedIds.length > 0) { + await normalizeProductsForItems(assignedIds).catch(() => undefined) + const cost = await syncCostSnapshotsForItems(assignedIds).catch(() => null) + const dates = [ + ...new Set([...(await orderItemBasisDates(assignedIds)), ...(cost?.affectedDates || [])]), + ] + await refreshFactsForDates(dates).catch(() => undefined) + } + const final = await query<{ count: number }>( + `WITH updated AS ( + UPDATE order_items + SET fulfillment_source = $3, + fulfillment_locked = $4, + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = NOW() + WHERE id = ANY($1::bigint[]) + AND fulfillment_status = 'assigned' + AND fulfillment_channel = $2 + RETURNING id + ) + SELECT count(*)::int AS count FROM updated`, + [ids, input.channel, input.source, input.lock], + ) + return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed } +} + +/** 运营指定渠道:先保证真实载体,成功后才写入并锁定。 */ export async function assignChannelToOrderItems(input: { orderItemIds: number[] channel: string adminUserId: number | null note?: string -}): Promise<{ updated: number }> { +}): Promise<{ updated: number; carrierFailed: number }> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } @@ -384,35 +594,20 @@ export async function assignChannelToOrderItems(input: { throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' }) } - const result = await query( - ` - UPDATE order_items - SET - fulfillment_channel = $2, - fulfillment_status = 'assigned', - fulfillment_source = 'operator_assigned', - fulfillment_locked = TRUE, - fulfillment_decided_by = $3, - fulfillment_decided_at = NOW(), - fulfillment_reason = COALESCE(NULLIF($4, ''), fulfillment_reason), - updated_at = NOW() - WHERE id = ANY($1::bigint[]) - AND fulfillment_status <> 'archived' - `, - [ids, input.channel, input.adminUserId, String(input.note || '').slice(0, 200)], + const result = await assignOrderItemsAfterCarrierReady({ + orderItemIds: ids, + channel: input.channel, + source: 'operator_assigned', + lock: true, + 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], ) - - const updated = result.rowCount || 0 - if (updated > 0) { - // 运营指定后先归一商品,再立即算成本并回填事实,避免旧单等冷通道。 - await normalizeProductsForItems(ids).catch(() => undefined) - const cost = await syncCostSnapshotsForItems(ids).catch(() => null) - const dates = [ - ...new Set([...(await orderItemBasisDates(ids)), ...(cost?.affectedDates || [])]), - ] - await refreshFactsForDates(dates).catch(() => undefined) - } - return { updated } + return result } /** 运营解锁:允许自动判定重新接管。 */ @@ -507,7 +702,7 @@ export async function assignChannelByInternalSku(input: { adminUserId: number | null note?: string createRule?: boolean -}): Promise<{ updated: number; ruleCreated: boolean }> { +}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } @@ -521,26 +716,35 @@ export async function assignChannelByInternalSku(input: { const idsResult = await query<{ id: number }>( ` - UPDATE order_items oi - SET - fulfillment_channel = $4, - fulfillment_status = 'assigned', - fulfillment_source = 'operator_assigned', - fulfillment_locked = TRUE, - fulfillment_decided_by = $5, - fulfillment_decided_at = NOW(), - updated_at = NOW() + SELECT oi.id 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 oi.fulfillment_status = 'pending' - RETURNING oi.id + AND ( + oi.fulfillment_status = 'pending' + OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $4) + ) `, - [input.provider, input.shopId, internalSku, input.channel, input.adminUserId], + [input.provider, input.shopId, 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], + ) + await setInternalSkuPrimaryChannel({ + internalSku, + channel: input.channel, + canonicalProductId: product.rows[0]?.id == null ? null : Number(product.rows[0].id), + source: 'operator', + configJson: { note: String(input.note || '').slice(0, 200) }, + }) + let ruleCreated = false if (input.createRule !== false) { const rule = await upsertChannelRule({ @@ -559,16 +763,21 @@ export async function assignChannelByInternalSku(input: { } const ids = idsResult.rows.map((row) => Number(row.id)) - if (ids.length > 0) { - await normalizeProductsForItems(ids).catch(() => undefined) - const cost = await syncCostSnapshotsForItems(ids).catch(() => null) - const dates = [ - ...new Set([...(await orderItemBasisDates(ids)), ...(cost?.affectedDates || [])]), - ] - await refreshFactsForDates(dates).catch(() => undefined) - } + const applied = await assignOrderItemsAfterCarrierReady({ + orderItemIds: ids, + channel: input.channel, + source: 'operator_assigned', + lock: true, + 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 { updated: ids.length, ruleCreated } + return { ...applied, ruleCreated } } /** 按 SKU 批量指定(待分类队列的主操作)。 */ @@ -580,31 +789,39 @@ export async function assignChannelBySku(input: { adminUserId: number | null note?: string createRule?: boolean -}): Promise<{ updated: number; ruleCreated: boolean }> { +}): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } - const result = await query( + const idsResult = await query<{ id: number; internal_sku: string }>( ` - UPDATE order_items oi - SET - fulfillment_channel = $4, - fulfillment_status = 'assigned', - fulfillment_source = 'operator_assigned', - fulfillment_locked = TRUE, - fulfillment_decided_by = $5, - fulfillment_decided_at = NOW(), - updated_at = NOW() + SELECT oi.id, ${INTERNAL_SKU_SQL} AS internal_sku 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 oi.sku_code = $3 - AND oi.fulfillment_status = 'pending' + AND ( + oi.fulfillment_status = 'pending' + OR (oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = $4) + ) `, - [input.provider, input.shopId, input.skuCode, input.channel, input.adminUserId], + [input.provider, input.shopId, input.skuCode, input.channel], ) + const internalSkus = [ + ...new Set(idsResult.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)), + ] + if (internalSkus.length === 1) { + await setInternalSkuPrimaryChannel({ + internalSku: internalSkus[0]!, + channel: input.channel, + source: 'operator', + configJson: { note: String(input.note || '').slice(0, 200) }, + }) + } + // 默认同时落一条渠道规则:否则同 SKU 的下一单还是会进待分类。 let ruleCreated = false if (input.createRule !== false) { @@ -623,7 +840,21 @@ export async function assignChannelBySku(input: { ruleCreated = rule.id > 0 } - return { updated: result.rowCount || 0, ruleCreated } + const ids = idsResult.rows.map((row) => Number(row.id)) + const applied = await assignOrderItemsAfterCarrierReady({ + orderItemIds: ids, + channel: input.channel, + source: 'operator_assigned', + lock: true, + 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 { ...applied, ruleCreated } } export async function listChannelRules( @@ -712,6 +943,18 @@ export async function upsertChannelRule(input: { ], ) + if (input.matchKeyType === 'internal_sku') { + await upsertInternalSkuChannel({ + internalSku: matchKey, + channel: input.channel, + priority: Math.trunc(Number(input.priority) || 100), + isFallback: Math.trunc(Number(input.priority) || 100) >= 90, + enabled: input.enabled !== false, + source: source === 'operator' ? 'operator' : `rule:${source}`, + replacePriority: true, + }) + } + return { id: result.rows[0]!.id } } @@ -720,8 +963,25 @@ export async function deleteChannelRule(ruleId: number): Promise<{ deleted: bool if (id <= 0) { throw createHttpError('规则 id 无效', { statusCode: 400, errorCode: 'invalid_rule_id' }) } - const result = await query(`DELETE FROM sku_channel_rules WHERE id = $1 RETURNING id`, [id]) - return { deleted: (result.rowCount || 0) > 0 } + const result = await query<{ + id: number + match_key_type: string + match_key: string + channel: string + }>( + `DELETE FROM sku_channel_rules + WHERE id = $1 + RETURNING id, match_key_type, match_key, channel`, + [id], + ) + const deleted = result.rows[0] + if (deleted?.match_key_type === 'internal_sku' && isFulfillmentChannel(deleted.channel)) { + await disableInternalSkuChannel({ + internalSku: deleted.match_key, + channel: deleted.channel, + }) + } + return { deleted: Boolean(deleted) } } async function loadEnabledRules(): Promise { @@ -735,6 +995,32 @@ async function loadEnabledRules(): Promise { return rows.rows } +async function loadInternalSkuChannelsForKeys( + keys: string[], +): Promise> { + const normalized = [...new Set(keys.map(normalizeKey).filter(Boolean))] + const result = new Map() + if (normalized.length === 0) return result + + const rows = await query( + ` + SELECT internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source + FROM internal_sku_channels + WHERE enabled = TRUE AND btrim(internal_sku) = ANY($1::text[]) + ORDER BY internal_sku, priority, channel + `, + [normalized], + ) + for (const row of rows.rows) { + const key = normalizeKey(row.internal_sku) + const list = result.get(key) || [] + list.push(row) + result.set(key, list) + } + return result +} + export const CHANNEL_RULE_KEY_TYPES = MATCH_KEY_TYPES export const CHANNEL_RULE_SOURCES = RULE_SOURCES export const CHANNEL_VALUES = FULFILLMENT_CHANNELS diff --git a/apps/backend/src/services/fulfillment/channel-types.ts b/apps/backend/src/services/fulfillment/channel-types.ts index ccaaaa74..26153db2 100644 --- a/apps/backend/src/services/fulfillment/channel-types.ts +++ b/apps/backend/src/services/fulfillment/channel-types.ts @@ -27,6 +27,7 @@ export const FULFILLMENT_SOURCES = [ 'manual_confirm', 'operator_assigned', 'cutover_archive', + 'affiliate_fallback', ] as const export type FulfillmentSource = (typeof FULFILLMENT_SOURCES)[number] | '' diff --git a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts new file mode 100644 index 00000000..3f33d956 --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -0,0 +1,136 @@ +/** + * 履约路由状态机集成测试(需要真实 PostgreSQL)。 + * + * 运行: + * FULFILLMENT_ROUTING_TEST_DATABASE_URL="$DATABASE_URL" \ + * npx tsx --test src/services/fulfillment/fulfillment-routing.integration.test.ts + * + * 未设置环境变量时跳过,普通 npm test 不依赖数据库。 + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String(process.env.FULFILLMENT_ROUTING_TEST_DATABASE_URL || '').trim() + +test( + '运营指定渠道:先保证载体,再写 assigned;缺载体保持 pending', + { skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_ROUTING_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { assignChannelByInternalSku } = await import('./channel-assign-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 orderIds: number[] = [] + const orderItemIds: number[] = [] + const internalSkus = [manualSku, missingAffiliateSku] + + async function createPendingOrderItem(sku: string) { + 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() + ) RETURNING id`, + [shopId, `IT-ROUTING-${suffix}-${orderItemIds.length + 1}`], + ) + const orderId = Number(order.rows[0]!.id) + orderIds.push(orderId) + const item = await query<{ id: number }>( + `INSERT INTO order_items ( + order_id, sku_code, sku_name, quantity, spec_json, + revenue_amount_fen, revenue_basis, created_at, updated_at + ) VALUES ($1, $2, $2, 1, $3::jsonb, 1000, 'order_total', NOW(), NOW()) + RETURNING id`, + [orderId, sku, JSON.stringify({ skuNick: sku })], + ) + const orderItemId = Number(item.rows[0]!.id) + orderItemIds.push(orderItemId) + return orderItemId + } + + try { + const manualItemId = await createPendingOrderItem(manualSku) + await query( + `INSERT INTO manual_fulfillment_configs ( + sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at + ) VALUES ($1, 'nanhang', 0, 'variable', TRUE, '集成测试', NOW(), NOW())`, + [manualSku], + ) + + const manualAssigned = await assignChannelByInternalSku({ + provider: '91kaquan', + shopId, + internalSku: manualSku, + channel: 'manual', + adminUserId: null, + note: '集成测试人工', + }) + assert.equal(manualAssigned.updated, 1) + assert.equal(manualAssigned.carrierFailed, 0) + const manualItem = await query<{ + fulfillment_status: string + fulfillment_channel: string + fulfillment_config_id: number | null + }>( + `SELECT fulfillment_status, fulfillment_channel, fulfillment_config_id + FROM order_items WHERE id = $1`, + [manualItemId], + ) + assert.equal(manualItem.rows[0]?.fulfillment_status, 'assigned') + assert.equal(manualItem.rows[0]?.fulfillment_channel, 'manual') + assert.ok(Number(manualItem.rows[0]?.fulfillment_config_id) > 0) + + const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku) + const affiliateFailed = await assignChannelByInternalSku({ + provider: '91kaquan', + shopId, + internalSku: missingAffiliateSku, + channel: 'affiliate_dash', + adminUserId: null, + note: '集成测试缺映射', + }) + assert.equal(affiliateFailed.updated, 0) + assert.equal(affiliateFailed.carrierFailed, 1) + const pendingItem = await query<{ + fulfillment_status: string + fulfillment_channel: string | null + fulfillment_pending_reason: string | null + }>( + `SELECT fulfillment_status, fulfillment_channel, fulfillment_pending_reason + FROM order_items WHERE id = $1`, + [missingAffiliateItemId], + ) + assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending') + assert.equal(pendingItem.rows[0]?.fulfillment_channel, null) + assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier') + } finally { + if (orderItemIds.length > 0) { + await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [ + orderItemIds, + ]) + await query(`DELETE FROM cost_snapshots WHERE order_item_id = ANY($1::bigint[])`, [ + orderItemIds, + ]) + await query(`DELETE FROM order_items WHERE id = ANY($1::bigint[])`, [orderItemIds]) + } + if (orderIds.length > 0) { + 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 internal_sku_channels WHERE internal_sku = ANY($1::text[])`, [ + internalSkus, + ]) + await query( + `DELETE FROM sku_channel_rules + WHERE provider = '91kaquan' AND shop_id = $1 AND match_key = ANY($2::text[])`, + [shopId, internalSkus], + ) + await closeDb() + } + }, +) 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 0210b7c2..635a8d32 100644 --- a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts @@ -1,10 +1,9 @@ -import { - listInternalSkuChannels, - type InternalSkuChannelRow, -} from './internal-sku-channel-service.js' +import { listInternalSkuChannels, planInternalSkuChannels } 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' +import type { FulfillmentChannel } from './channel-types.js' +import { resolveInternalSkuKey } from './internal-sku-resolver.js' type IntakeChannelDecisionInput = { provider?: string @@ -18,14 +17,6 @@ function parseObject(value: unknown): Record { : {} } -function resolveInternalSku(item: ResolvedFulfillmentItem): string { - const snapshot = parseObject(item.snapshot) - const spec = parseObject(item.spec) - return String( - snapshot.internalSku || spec.skuNick || spec.outSkuSn || item.skuCode || item.skuName || '', - ).trim() -} - /** * 下单入口读取内部 SKU 渠道决策。 * @@ -36,13 +27,14 @@ function resolveInternalSku(item: ResolvedFulfillmentItem): string { export async function applyIntakeChannelDecision( item: ResolvedFulfillmentItem, _input: IntakeChannelDecisionInput, + options: { preferredChannel?: FulfillmentChannel } = {}, ): Promise { - const internalSku = resolveInternalSku(item) + const internalSku = resolveInternalSkuKey(item) if (!internalSku) return item - let channels: InternalSkuChannelRow[] = [] + let plan try { - channels = await listInternalSkuChannels(internalSku) + plan = planInternalSkuChannels(await listInternalSkuChannels(internalSku)) } catch (error) { // 单测/旧库没有 118 迁移时不影响下单主流程,生产由迁移保证。 logWarn('[intake-channel-decision]', '读取内部 SKU 渠道失败', { @@ -51,14 +43,32 @@ export async function applyIntakeChannelDecision( }) return item } - if (channels.length === 0) return item + if (plan.conflicts.length > 0) { + return { + ...item, + snapshot: { + ...parseObject(item.snapshot), + internalSku, + internalSkuChannelReason: 'routing_conflict', + internalSkuConflictChannels: plan.conflicts.map((channel) => channel.channel), + }, + } + } + + 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 primary = channels[0]! const nextSnapshot: Record = { ...snapshot, internalSku: String(snapshot.internalSku || internalSku), @@ -69,7 +79,8 @@ export async function applyIntakeChannelDecision( const canOverrideDefaultManual = !selected || isDefaultManualFallback const existingAffiliateSku = String(parseObject(snapshot.affiliateDash).sku || '').trim() - if (primary.channel === 'affiliate_dash' || selected === 'affiliate_dash') { + const desiredChannel = options.preferredChannel || primary.channel + if (desiredChannel === 'affiliate_dash' || selected === 'affiliate_dash') { const dashSku = existingAffiliateSku || matchAffiliateDashSkuByInternalSku(internalSku) if (dashSku) { if (canOverrideDefaultManual || selected === 'affiliate_dash') { @@ -109,17 +120,27 @@ export async function applyIntakeChannelDecision( // 内部能力是接单/卡券,但路由只是默认人工兜底:清掉人工任务,交给工单或财务能力兜底。 if ( - (primary.channel === 'work_order' || primary.channel === 'third_party_voucher') && + (desiredChannel === 'work_order' || desiredChannel === 'third_party_voucher') && isDefaultManualFallback ) { nextSnapshot.fulfillmentRoute = { ...route, selectedExecutorKey: '', - reason: `内部 SKU 渠道能力为 ${primary.channel},不使用默认人工兜底`, + 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 } } 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 new file mode 100644 index 00000000..0bd3df58 --- /dev/null +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + planInternalSkuChannels, + type InternalSkuChannelRow, +} from './internal-sku-channel-service.js' + +function channel(patch: Partial): InternalSkuChannelRow { + return { + internal_sku: '内部SKU', + channel: 'manual', + priority: 10, + is_fallback: false, + enabled: true, + canonical_product_id: null, + config_json: {}, + source: 'test', + ...patch, + } +} + +test('渠道计划:主渠道失败后按优先级使用显式兜底', () => { + const plan = planInternalSkuChannels([ + channel({ channel: 'manual', priority: 90, is_fallback: true }), + channel({ channel: 'affiliate_dash', priority: 10 }), + ]) + assert.equal(plan.primary?.channel, 'affiliate_dash') + assert.deepEqual( + plan.attempts.map((item) => item.channel), + ['affiliate_dash', 'manual'], + ) +}) + +test('渠道计划:同级多主渠道是冲突,不能静默按字母选一个', () => { + const plan = planInternalSkuChannels([ + channel({ channel: 'manual', priority: 10 }), + channel({ channel: 'work_order', priority: 10 }), + ]) + assert.equal(plan.primary, null) + assert.deepEqual(plan.conflicts.map((item) => item.channel).sort(), ['manual', 'work_order']) +}) + +test('渠道计划:非兜底渠道即使优先级数值更大也压过兜底渠道', () => { + const plan = planInternalSkuChannels([ + channel({ channel: 'manual', priority: 1, is_fallback: true }), + channel({ channel: 'work_order', priority: 50 }), + ]) + assert.equal(plan.primary?.channel, 'work_order') + assert.equal(plan.fallbacks[0]?.channel, 'manual') +}) + +test('渠道计划:仅有一个兜底渠道时仍可作为实际主渠道', () => { + const plan = planInternalSkuChannels([ + channel({ channel: 'manual', priority: 90, is_fallback: true }), + ]) + assert.equal(plan.primary?.channel, 'manual') + assert.deepEqual( + plan.attempts.map((item) => item.channel), + ['manual'], + ) +}) 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 e55de975..3dfdb563 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -1,5 +1,6 @@ -import { query } from '../../db/client.js' +import { query, withTransaction } from '../../db/client.js' import type { FulfillmentChannel } from './channel-types.js' +import { compareFulfillmentChannel } from './internal-sku-resolver.js' export type InternalSkuChannelRow = { internal_sku: string @@ -8,9 +9,17 @@ export type InternalSkuChannelRow = { is_fallback: boolean enabled: boolean canonical_product_id: number | null + config_json: Record source: string } +export type InternalSkuChannelPlan = { + primary: InternalSkuChannelRow | null + fallbacks: InternalSkuChannelRow[] + attempts: InternalSkuChannelRow[] + conflicts: InternalSkuChannelRow[] +} + export async function listInternalSkuChannels( internalSku: string, ): Promise { @@ -18,18 +27,12 @@ export async function listInternalSkuChannels( if (!key) return [] const result = await query( ` - SELECT internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, source + SELECT internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, + config_json, source FROM internal_sku_channels WHERE btrim(internal_sku) = $1 AND enabled = TRUE ORDER BY priority ASC, - CASE channel - WHEN 'affiliate_dash' THEN 1 - WHEN 'work_order' THEN 2 - WHEN 'third_party_voucher' THEN 3 - WHEN 'manual' THEN 4 - ELSE 9 - END, channel ASC `, [key], @@ -37,15 +40,44 @@ export async function listInternalSkuChannels( return result.rows } -/** 财务侧可直接兜底的渠道:卡券/人工不要求新建履约任务。 */ -export async function resolveInternalSkuFallbackChannel( - internalSku: string, -): Promise { - const channels = await listInternalSkuChannels(internalSku) - const safe = channels.find( - (row) => row.channel === 'third_party_voucher' || row.channel === 'manual', +/** + * 渠道计划:同一时间只允许一个主渠道;同级多渠道视为配置冲突。 + * is_fallback 渠道只在主渠道不可用或失败后按 priority 顺序尝试。 + */ +export function planInternalSkuChannels(input: InternalSkuChannelRow[]): InternalSkuChannelPlan { + const channels = input + .filter((row) => row.enabled) + .slice() + .sort( + (a, b) => + a.priority - b.priority || + Number(a.is_fallback) - Number(b.is_fallback) || + compareFulfillmentChannel(a.channel, b.channel), + ) + + const explicitPrimary = channels.filter((row) => !row.is_fallback) + const primaryPool = explicitPrimary.length > 0 ? explicitPrimary : channels + if (primaryPool.length === 0) { + return { primary: null, fallbacks: [], attempts: [], conflicts: [] } + } + + const topPriority = Math.min(...primaryPool.map((row) => row.priority)) + const top = primaryPool.filter((row) => row.priority === topPriority) + const distinctTop = top.filter( + (row, index) => top.findIndex((candidate) => candidate.channel === row.channel) === index, ) - return safe?.channel || null + if (distinctTop.length > 1) { + return { primary: null, fallbacks: [], attempts: [], conflicts: distinctTop } + } + + const primary = distinctTop[0] || null + const fallbacks = channels.filter((row) => row.channel !== primary?.channel && row.is_fallback) + return { + primary, + fallbacks, + attempts: primary ? [primary, ...fallbacks] : [], + conflicts: [], + } } export async function upsertInternalSkuChannel(input: { @@ -53,31 +85,110 @@ export async function upsertInternalSkuChannel(input: { channel: FulfillmentChannel priority?: number isFallback?: boolean + enabled?: boolean canonicalProductId?: number | null + configJson?: Record | undefined source?: string + replacePriority?: boolean }): Promise { const internalSku = String(input.internalSku || '').trim() if (!internalSku) return - await query( - ` + await withTransaction(async (client) => { + if (input.enabled !== false && input.isFallback !== true) { + await client.query( + `UPDATE internal_sku_channels + SET is_fallback = TRUE, + priority = GREATEST(priority, 90), + updated_at = NOW() + WHERE btrim(internal_sku) = $1 + AND channel <> $2 + AND enabled = TRUE + AND is_fallback = FALSE`, + [internalSku, input.channel], + ) + } + await client.query( + ` INSERT INTO internal_sku_channels ( internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, source, created_at, updated_at - ) VALUES ($1, $2, $3, $4, TRUE, $5, $6, NOW(), NOW()) + canonical_product_id, config_json, source, created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, NOW(), NOW()) ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), - is_fallback = internal_sku_channels.is_fallback AND EXCLUDED.is_fallback, + priority = CASE + WHEN $9::boolean THEN EXCLUDED.priority + ELSE LEAST(internal_sku_channels.priority, EXCLUDED.priority) + END, + is_fallback = EXCLUDED.is_fallback, + enabled = EXCLUDED.enabled, canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), - enabled = TRUE, + config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + source = EXCLUDED.source, updated_at = NOW() + `, + [ + internalSku, + input.channel, + Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100, + input.isFallback === true, + input.enabled !== false, + input.canonicalProductId ?? null, + JSON.stringify(input.configJson || {}), + String(input.source || 'auto').trim() || 'auto', + input.replacePriority === true, + ], + ) + }) +} + +/** + * 运营指定一个主渠道。其他渠道保留为显式兜底,避免下一次又被同级优先级抢占。 + */ +export async function setInternalSkuPrimaryChannel(input: { + internalSku: string + channel: FulfillmentChannel + canonicalProductId?: number | null + source?: string + configJson?: Record | undefined +}): Promise { + const internalSku = String(input.internalSku || '').trim() + if (!internalSku) return + + await query( + ` + UPDATE internal_sku_channels + SET priority = GREATEST(priority, 90), + is_fallback = TRUE, + updated_at = NOW() + WHERE btrim(internal_sku) = $1 + AND channel <> $2 + AND enabled = TRUE `, - [ - internalSku, - input.channel, - Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100, - input.isFallback === true, - input.canonicalProductId ?? null, - String(input.source || 'auto').trim() || 'auto', - ], + [internalSku, input.channel], ) + await upsertInternalSkuChannel({ + internalSku, + channel: input.channel, + priority: 10, + isFallback: false, + enabled: true, + canonicalProductId: input.canonicalProductId ?? null, + configJson: input.configJson, + source: input.source || 'operator', + replacePriority: true, + }) +} + +export async function disableInternalSkuChannel(input: { + internalSku: string + channel: FulfillmentChannel +}): Promise { + const result = await query( + ` + UPDATE internal_sku_channels + SET enabled = FALSE, updated_at = NOW() + WHERE btrim(internal_sku) = $1 AND channel = $2 + `, + [String(input.internalSku || '').trim(), 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 fb53621f..19b7245b 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-mapping-service.ts @@ -1,4 +1,5 @@ import { query } from '../../db/client.js' +import { containsCjk } from './internal-sku-resolver.js' export type InternalSkuMappingMatchType = | 'sku_code' @@ -114,10 +115,6 @@ export async function upsertInternalSkuMapping(input: { return Number(result.rows[0]?.id || 0) } -function containsCjk(value: string): boolean { - return /[\u4e00-\u9fa5]/.test(value) -} - /** * 订单项级内部 SKU 补全: * - 现有 skuNick/outSkuSn/skuCode/skuName 已有中文 → 不查库; diff --git a/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts new file mode 100644 index 00000000..a77b0a26 --- /dev/null +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.test.ts @@ -0,0 +1,50 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { resolveInternalSkuKey } from './internal-sku-resolver.js' + +test('内部 SKU:入口显式值最优先', () => { + assert.equal( + resolveInternalSkuKey({ + snapshot: { internalSku: '映射后的内部SKU' }, + manualSkuKey: '平台码123', + skuName: '商品名', + }), + '映射后的内部SKU', + ) +}) + +test('内部 SKU:纯数字平台码不会压过中文商品名', () => { + assert.equal( + resolveInternalSkuKey({ + manualSkuKey: '5003', + skuCode: '5003', + skuName: '幸运币2个', + spec: { outSkuSn: '5003' }, + }), + '幸运币2个', + ) +}) + +test('内部 SKU:中文 skuNick 优先于 outSkuSn 和平台码', () => { + assert.equal( + resolveInternalSkuKey({ + skuCode: 'english-platform-code', + skuName: '展示标题', + spec: { skuNick: '内部中文SKU', outSkuSn: '规格码' }, + }), + '内部中文SKU', + ) +}) + +test('内部 SKU:没有中文时按历史生成列顺序回退', () => { + assert.equal( + resolveInternalSkuKey({ + manualSkuKey: 'manual-key', + skuCode: 'sku-code', + skuName: 'sku-name', + spec: { skuNick: 'nick', outSkuSn: 'out' }, + }), + 'manual-key', + ) +}) diff --git a/apps/backend/src/services/fulfillment/internal-sku-resolver.ts b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts new file mode 100644 index 00000000..56705824 --- /dev/null +++ b/apps/backend/src/services/fulfillment/internal-sku-resolver.ts @@ -0,0 +1,61 @@ +import type { FulfillmentChannel } from './channel-types.js' + +type JsonObject = Record + +export type InternalSkuResolverInput = { + manualSkuKey?: unknown + skuCode?: unknown + skuName?: unknown + spec?: unknown + snapshot?: unknown +} + +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() +} + +export function containsCjk(value: string): boolean { + return /[\u4e00-\u9fa5]/.test(value) +} + +/** + * 内部 SKU 的唯一解析口径。 + * + * 1. 入口显式写入的 snapshot.internalSku 最权威; + * 2. 旧数据优先使用中文键,避免纯数字平台码压过中文 skuNick/sku_name; + * 3. 没有中文时按生成列的历史顺序回退。 + */ +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 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) || + '' + ) +} + +/** 渠道选择里使用的稳定顺序,仅用于同优先级时生成诊断,不用于静默改渠道。 */ +export const INTERNAL_SKU_CHANNEL_ORDER: readonly FulfillmentChannel[] = [ + 'affiliate_dash', + 'work_order', + 'third_party_voucher', + 'manual', +] + +export function compareFulfillmentChannel(a: FulfillmentChannel, b: FulfillmentChannel): number { + return INTERNAL_SKU_CHANNEL_ORDER.indexOf(a) - INTERNAL_SKU_CHANNEL_ORDER.indexOf(b) +} diff --git a/apps/backend/src/services/fulfillment/product-resolution-service.ts b/apps/backend/src/services/fulfillment/product-resolution-service.ts index b87ec2cb..0db636c6 100644 --- a/apps/backend/src/services/fulfillment/product-resolution-service.ts +++ b/apps/backend/src/services/fulfillment/product-resolution-service.ts @@ -11,6 +11,7 @@ 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' export type FulfillmentItem = { @@ -129,9 +130,19 @@ export async function resolveOrderItemForFulfillment({ skuCode: externalSkuCode, skuName: externalSkuName, }) - const nextSpec = internalSkuMapping + const mappedSpec = internalSkuMapping ? { ...(isPlainObject(item.spec) ? item.spec : {}), skuNick: internalSkuMapping.internalSku } : item.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 const snapshot = { ...(isPlainObject(item.snapshot) ? item.snapshot : {}), @@ -154,9 +165,9 @@ export async function resolveOrderItemForFulfillment({ } : null, isConfigured: candidate.isConfigured, + ...(resolvedInternalSku ? { internalSku: resolvedInternalSku } : {}), ...(internalSkuMapping ? { - internalSku: internalSkuMapping.internalSku, internalSkuSource: internalSkuMapping.source, internalSkuMappingId: internalSkuMapping.mappingId, } diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index bafc610d..0a889df3 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -86,9 +86,11 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { const config = jobConfig(job) const limit = clampInt(config.limit, 200, 1, 2000) const reconciled = await reconcilePendingCarriers({ limit: Math.min(limit, 200) }) - if (reconciled.affiliateTasksCreated > 0) { - await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }).catch(() => undefined) - } + const repairedAssigned = await reconcilePendingCarriers({ + limit: Math.min(limit, 200), + includeAssignedMissingCarrier: true, + }) + await assignPendingOrderItemChannels({ batchSize: 500, maxBatches: 1 }).catch(() => undefined) const cursor = config.afterId != null ? Math.max(0, Math.trunc(Number(config.afterId))) @@ -101,13 +103,17 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { const manual = await query<{ id: number; sku_key: string; config_id: number }>( ` - SELECT oi.id, oi.manual_sku_key AS sku_key, cfg.id AS config_id + SELECT oi.id, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key, + cfg.id AS config_id FROM order_items oi - JOIN manual_fulfillment_configs cfg ON cfg.sku_key = oi.manual_sku_key AND cfg.enabled + JOIN manual_fulfillment_configs cfg + ON 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' AND oi.fulfillment_config_id IS NULL - AND oi.manual_sku_key = ANY($3::text[]) + AND resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[]) AND oi.id > $2 ORDER BY oi.id LIMIT $1 @@ -131,12 +137,13 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { // 人工渠道但没有配置:进「人工 SKU 未配置」队列。 const missingManual = await query<{ id: number; sku_key: string }>( ` - SELECT oi.id, oi.manual_sku_key AS sku_key + SELECT oi.id, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key FROM order_items oi WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL - AND NOT (oi.manual_sku_key = ANY($3::text[])) + AND NOT (resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) = ANY($3::text[])) AND NOT EXISTS ( SELECT 1 FROM cost_exception_items i @@ -165,7 +172,8 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { // 已配置人工 SKU、但还没确认履约的单:进入「人工成本未记录」队列。 const pendingManualCost = await query<{ id: number; sku_key: string }>( ` - SELECT oi.id, oi.manual_sku_key AS sku_key + SELECT oi.id, + resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS sku_key FROM order_items oi WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel = 'manual' @@ -212,7 +220,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { AND NOT EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id - AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher') + AND ft.executor_key = 'affiliate_dash' ) AND oi.id > $2 ORDER BY oi.id @@ -245,6 +253,39 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { detail: { channel: row.channel, skuCode: row.sku_code }, }) } + + // 已分配订单出现两个真实执行方时不能继续算正常渠道,必须进冲突队列人工处置。 + const conflictingCarriers = await query<{ id: number; channel: string; sku_code: string }>( + ` + SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code + FROM order_items oi + WHERE oi.fulfillment_status = 'assigned' + AND ( + EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + )::int + ( + EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + ) + )::int + ( + EXISTS ( + SELECT 1 FROM fulfillment_tasks ft + WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + ) + )::int > 1 + ORDER BY oi.id + LIMIT $1 + `, + [limit], + ) + for (const row of conflictingCarriers.rows) { + exceptionEntries.push({ + reason: 'routing_conflict', + scopeKey: `routing_conflict|${row.channel}|${row.sku_code}|carrier`, + orderItemId: Number(row.id), + detail: { channel: row.channel, skuCode: row.sku_code, issue: 'multiple_carriers' }, + }) + } if (exceptionEntries.length > 0) { await upsertAggregatedExceptionsBatch(exceptionEntries) } @@ -254,6 +295,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ...missingManual.rows, ...pendingManualCost.rows, ...missingCarrier.rows, + ...conflictingCarriers.rows, ]) { maxId = Math.max(maxId, Number(row.id)) } @@ -269,13 +311,17 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { status: 'ok', message: `人工载体挂载 ${attached},缺配置 ${missingManual.rows.length},` + - `缺履约载体 ${missingCarrier.rows.length},异常收口 ${resolved},` + - `自动转人工 ${fallback.switched}`, + `缺履约载体 ${missingCarrier.rows.length},多载体冲突 ${conflictingCarriers.rows.length},异常收口 ${resolved},` + + `自动转人工 ${fallback.switched},链路补载体 ${reconciled.workOrdersCreated + reconciled.affiliateTasksCreated + repairedAssigned.workOrdersCreated + repairedAssigned.affiliateTasksCreated},` + + `兜底 ${reconciled.fallbacksSelected + repairedAssigned.fallbacksSelected}`, attached, missingManual: missingManual.rows.length, missingCarrier: missingCarrier.rows.length, + conflictingCarriers: conflictingCarriers.rows.length, resolved, affiliateFallback: fallback.switched, + carrierReconciliation: reconciled, + assignedCarrierRepair: repairedAssigned, } } @@ -289,7 +335,7 @@ 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 = oi.manual_sku_key + WHERE 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' @@ -315,12 +361,13 @@ async function resolveCarrierDispatchExceptions(): Promise { WHERE ce.reason = 'carrier_missing' AND ce.status <> 'resolved' AND ( - EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) - OR EXISTS ( + (oi.fulfillment_channel = 'work_order' + AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)) + OR (oi.fulfillment_channel = 'affiliate_dash' AND EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id - AND ft.executor_key IN ('affiliate_dash', 'third_party_voucher') - ) + AND ft.executor_key = 'affiliate_dash' + )) ) LIMIT 500 `, @@ -442,8 +489,9 @@ export async function runLedgerPostJob(job: JsonObject = {}) { /** 顺序执行全部管道,供管理端「一键重算」使用。 */ export async function runFinancePipelineOnce(input: { days?: number } = {}) { - const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } }) + // 先保证载体,再让财务镜像真实渠道;避免规则先把订单写成 assigned 却永远没有派单。 const carrier = await runCarrierDispatchJob({ config: { limit: 500 } }) + const channel = await runChannelAssignJob({ config: { batchSize: 1000, maxBatches: 50 } }) const product = await runProductNormalizeJob({ config: { batchSize: 1000, maxBatches: 50 } }) const cost = await runCostSnapshotJob({ config: { batchSize: 1000, maxBatches: 50 } }) const facts = await runSalesFactsJob({ config: { days: input.days ?? 3 } }) 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 c51353fc..2eec3d7b 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 @@ -50,7 +50,12 @@ import { assignPendingOrderItemChannels } from '../fulfillment/channel-assign-se export async function syncWorkerOrdersForSourceOrder( order: OrderRow, orderItems: OrderItemRow[], - options: { source?: string; autoOnly?: boolean; sourceMetadata?: JsonObject } = {}, + options: { + source?: string + autoOnly?: boolean + sourceMetadata?: JsonObject + skipChannelAssign?: boolean + } = {}, ) { const [rules, mappings] = await Promise.all([ listWorkProductRules({ enabled: true }), @@ -309,15 +314,17 @@ export async function syncWorkerOrdersForSourceOrder( } // 订单处理完成后立即跑一次渠道判定;失败不阻断下单,由定时任务兜底。 - try { - await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }) - } catch (error) { - logWarn('[worker-order-consumer]', '履约方式归集失败,等待定时任务兜底', { - orderId: order.id, - platformOrderId: order.platform_order_id, - orderItemCount: orderItems.length, - error: error instanceof Error ? error.message : String(error), - }) + if (!options.skipChannelAssign) { + try { + await assignPendingOrderItemChannels({ batchSize: 200, maxBatches: 1 }) + } catch (error) { + logWarn('[worker-order-consumer]', '履约方式归集失败,等待定时任务兜底', { + orderId: order.id, + platformOrderId: order.platform_order_id, + orderItemCount: orderItems.length, + error: error instanceof Error ? error.message : String(error), + }) + } } const summary = { diff --git a/apps/backend/src/types/repository/rows.ts b/apps/backend/src/types/repository/rows.ts index afdefdca..6eef53cd 100644 --- a/apps/backend/src/types/repository/rows.ts +++ b/apps/backend/src/types/repository/rows.ts @@ -33,6 +33,7 @@ export type OrderItemRow = { quantity: number revenue_amount_fen?: number | string | null canonical_product_id?: number | string | null + manual_sku_key?: string | null spec_json: string | Record item_snapshot_json?: string | Record fulfillment_channel?: string | null @@ -41,6 +42,9 @@ export type OrderItemRow = { fulfillment_locked?: boolean | null fulfillment_decided_at?: string | null fulfillment_reason?: string | null + fulfillment_config_id?: number | null + fulfillment_pending_reason?: string | null + fulfillment_pending_detail?: string | Record | null created_at?: string updated_at?: string } diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index 430eea91..a4942e86 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -100,7 +100,13 @@ function PendingPanel() { channel: selections[rowKey(row)] || '', }), onSuccess: (result) => { - message.success(`已指定 ${result.data.updated} 个订单项`) + if (result.data.carrierFailed > 0) { + message.warning( + `已指定 ${result.data.updated} 个订单项,${result.data.carrierFailed} 个因载体创建失败仍待处理`, + ) + } else { + message.success(`已指定 ${result.data.updated} 个订单项`) + } void queryClient.invalidateQueries({ queryKey: ['fulfillment-pending'] }) }, onError: () => message.error('指定渠道失败'), @@ -135,6 +141,7 @@ function PendingPanel() { no_internal_sku: '无内部SKU', no_channel_capability: '无渠道能力', no_carrier: '无履约载体', + routing_conflict: '渠道冲突', } return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-' }, diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index 3ae7be53..579e6f2a 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -100,7 +100,10 @@ export function assignChannelBySku(payload: { channel: string note?: string }) { - return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload) + return apiPost<{ updated: number; carrierFailed: number; ruleCreated: boolean }>( + '/api/v1/admin/fulfillment/assign', + payload, + ) } export function assignChannelToItems(payload: { @@ -108,7 +111,10 @@ export function assignChannelToItems(payload: { channel: string note?: string }) { - return apiPost<{ updated: number }>('/api/v1/admin/fulfillment/assign', payload) + return apiPost<{ updated: number; carrierFailed: number }>( + '/api/v1/admin/fulfillment/assign', + payload, + ) } export function fetchChannelRules(params: { channel?: string } = {}) { diff --git a/docs/履约与财务分流统一方案-2026-11-02.md b/docs/履约与财务分流统一方案-2026-11-02.md index ba8fd651..b91d9e61 100644 --- a/docs/履约与财务分流统一方案-2026-11-02.md +++ b/docs/履约与财务分流统一方案-2026-11-02.md @@ -1,7 +1,7 @@ # 履约与财务分流统一方案 日期:2026-11-02 -状态:设计稿,待实施 +状态:已落地(2026-11-02);代码基线:迁移 121 + backend/frontend 当前 HEAD 目标:以内部中文 SKU 为唯一主键,统一「下单路由 → 履约载体 → 财务渠道 → 成本」链路。 ## 1. 现状结论 @@ -220,9 +220,19 @@ pending 只保留: - 后端/前端测试、构建、SQL 护栏通过; - 生产迁移/部署验证。 -## 8. 待确认 +## 8. 最终落地口径(2026-11-02) -1. PDD 纯数字内部 SKU 的中文映射由谁维护(商品名/规格/运营表)? -2. 双渠道商品(自动发货 + 人工)的兜底触发条件:任务失败、超时、还是人工手动切换? -3. 接单平台是否所有内部 SKU 都必须自动建工单,还是保留“运营指定”模式? -4. 同名 man/tpl 商品是否合并,还是保留多载体配置、只统一 internal_sku_channels? +1. **内部 SKU 只有一个解析函数**:`resolve_internal_sku(manual_sku_key, sku_name, sku_code)`;入口 TS 侧使用同一顺序,不再由各脚本自行拼 SQL。 +2. **渠道配置只有一个运行时入口**:`internal_sku_channels`。一个内部 SKU 最多一个主渠道,其余渠道必须 `is_fallback = true`;数据库唯一索引阻止并列主渠道。 +3. **载体先行**:自动发货/接单先创建 `fulfillment_tasks` / `work_orders`,成功后才写 `fulfillment_status = assigned`。创建失败保持 `pending / no_carrier`。 +4. **兜底触发**:主渠道为自动发货/接单且载体无法创建时,按 priority 尝试显式 fallback;人工配置、卡券为无需外部载体的安全渠道。已分配后自动任务失败仍由 `affiliate_fallback` 接管。 +5. **财务只镜像事实**:已有唯一真实载体优先;否则读取统一渠道计划;旧 `sku_channel_rules` 仅保留兼容回退,新增/修改会同步到统一渠道表。 +6. **人工配置即时入表**:保存/停用人工 SKU 配置会同步 `internal_sku_channels`,历史配置由迁移 120 回填。 +7. **旧规则自动迁移**:可确定的 `sku_code` 自动发货/卡券规则在迁移中转为内部 SKU 渠道并停用旧规则;无法唯一解析的规则由严格预检阻断。 +8. **同名 man/tpl 不强制合并**:保留不同载体能力,但同一内部 SKU 只能配置一个主渠道,其余显式兜底。 + +### 数据与回填约定 + +- PDD 纯数字码优先由 `internal_sku_mappings` 维护;未命中时使用中文 `sku_name`,仍无中文则保留原码并进入待分类。 +- 所有历史人工配置进入 `manual` 兜底能力;存在自动/接单/卡券能力时,人工 priority 固定不低于 90。 +- `npm run products:merge-duplicates`、`cleanup-legacy-sku-code-rules`、`reclassify-post-cutover` 保持 dry-run 默认;执行前后都运行严格预检。 diff --git a/docs/生产部署核对清单-2026-11-02.md b/docs/生产部署核对清单-2026-11-02.md index 350dc0f7..9d97dd28 100644 --- a/docs/生产部署核对清单-2026-11-02.md +++ b/docs/生产部署核对清单-2026-11-02.md @@ -1,7 +1,7 @@ # 生产部署核对清单(履约与财务分流统一) 日期:2026-11-02 -适用:生产当前在迁移 115,本次新增 116–119 + backend/frontend。 +适用:生产当前在迁移 115,本次新增 116–121 + backend/frontend。 ## 1. 部署前 @@ -24,6 +24,8 @@ 117_internal_sku_mappings 118_internal_sku_channels 119_fulfillment_pending_reason +120_fulfillment_routing_invariants +121_internal_sku_single_primary ``` 重点检查: @@ -32,10 +34,12 @@ - 118:按商品能力/规则回填 `internal_sku_channels`; - SQL 已做聚合,避免同名商品同渠道 `ON CONFLICT` 二次更新报错; - 119:只加列和索引,风险低。 +- 120:建立统一内部 SKU SQL 函数、回填人工配置、迁移可确定的旧 sku_code 规则。 +- 121:建立唯一主渠道索引;若仍有并列主渠道会主动阻断迁移。 ## 3. 部署顺序 -1. 执行迁移 116–119; +1. 执行迁移 116–121; 2. 部署 backend; 3. 部署 frontend; 4. 启动/恢复定时任务。 @@ -56,6 +60,7 @@ GROUP BY 1; - `no_internal_sku` - `no_channel_capability` - `no_carrier` +- `routing_conflict`(如仍有配置冲突,必须处理) ### 11月改名卡 @@ -110,6 +115,25 @@ WHERE enabled GROUP BY 1; ``` +### 严格预检 + +```bash +# 本地演示数据默认排除 sim-*;生产应直接执行 +npx tsx scripts/preflight-fulfillment-routing.ts --strict + +# 如确实要把演示数据也纳入检查 +npx tsx scripts/preflight-fulfillment-routing.ts --strict --include-demo +``` + +严格预检必须为 0 失败,重点包括: + +- 有内部 SKU 但无渠道能力的 pending; +- pending 未写入明确原因(说明管道尚未跑完); +- 已分配自动发货/接单但没有真实载体; +- 已分配订单同时存在多个真实履约载体; +- 多个主渠道; +- 仍启用的 `sku_code` 兼容规则。 + ## 5. 数据清理(确认后执行) ```bash @@ -136,6 +160,6 @@ npx tsx scripts/reclassify-post-cutover.ts --apply - 代码:回退到部署前镜像/commit; - 数据库: - - 116–119 均为新增表/列/数据,不删旧表,可保留; + - 116–121 均为新增表/列/函数/数据,不删旧表,可保留; - 若必须回滚,恢复到部署前备份; -- 迁移 118/119 不改旧字段语义,回滚代码后旧逻辑仍可运行。 +- 迁移 118–121 不改旧字段语义,回滚代码后旧逻辑仍可运行;121 的唯一主渠道索引会阻止旧代码写入并列主渠道。 From 6bf28489e009188f96ec20c2a0f231b5200ebb23 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 15:29:13 +0800 Subject: [PATCH 02/12] =?UTF-8?q?fix(finance):=20=E9=87=8D=E5=86=99?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E8=B7=AF=E7=94=B1=E8=BF=81=E7=A7=BB=E4=B8=8E?= =?UTF-8?q?=E7=BB=9F=E4=B8=80=E6=B8=A0=E9=81=93=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 重写 120/121 迁移,统一内部 SKU、渠道 id、config_json 和旧规则转换。 后台渠道配置改读统一渠道表,卡券与人工配置不再写旧规则。 增加安全渠道切换,并排除已关闭、失败或过期的历史任务载体。 按生产 119 基线重跑迁移、完整履约财务链路和严格预检。 --- README.md | 2 +- .../audit-fulfillment-finance-alignment.ts | 27 +- apps/backend/scripts/check-sql-guard.ts | 11 +- .../scripts/cleanup-legacy-sku-code-rules.ts | 11 - .../scripts/merge-duplicate-products.ts | 13 - .../scripts/preflight-fulfillment-routing.ts | 13 +- .../scripts/reclassify-post-cutover.ts | 3 +- .../scripts/seed-local-finance-demo.ts | 66 ++- .../120_fulfillment_routing_invariants.sql | 392 +++++++++----- .../121_internal_sku_single_primary.sql | 6 + .../src/routes/admin/cost-center-catalog.ts | 4 +- .../src/routes/admin/fulfillment-channel.ts | 36 +- .../cost-center/affiliate-product-service.ts | 3 + .../manual-fulfillment-config-service.ts | 30 +- .../cost-center/product-normalize-service.ts | 15 +- .../fulfillment/affiliate-fallback-service.ts | 5 +- .../fulfillment/carrier-creation-service.ts | 28 +- .../carrier-reconciliation-service.ts | 27 +- .../channel-assign-service.test.ts | 3 + .../fulfillment/channel-assign-service.ts | 477 ++++++++++++------ .../src/services/fulfillment/channel-types.ts | 10 + .../fulfillment-routing.integration.test.ts | 69 ++- .../intake-channel-decision-service.ts | 6 +- .../internal-sku-channel-service.test.ts | 3 + .../internal-sku-channel-service.ts | 53 +- .../scheduler/finance-pipeline-jobs.ts | 10 +- .../sync-worker-orders-service.ts | 34 +- .../admin/AdminFulfillmentChannelPage.tsx | 31 +- .../src/services/admin/fulfillment-channel.ts | 18 +- docs/履约与财务分流统一方案-2026-11-02.md | 15 +- docs/生产部署核对清单-2026-11-02.md | 1 + 31 files changed, 958 insertions(+), 464 deletions(-) diff --git a/README.md b/README.md index 525ed77a..a93b6fd9 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ SKIP_CHECKS=1 git push | 文件 | 含义 | | --- | --- | | `001_init.sql` | 空库基线,建立完整业务结构 | -| `002_xxx.sql` ~ `121_xxx.sql` | 后续结构与数据增量(接单平台、寄卖、财务、履约路由迁移) | +| `002_xxx.sql` ~ `121_xxx.sql` | 后续结构与数据增量;当前生产发布基线为 119,120–121 为本次履约路由升级 | 约定: diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts index e005c2b9..b054b1db 100644 --- a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts +++ b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts @@ -16,11 +16,11 @@ async function main() { ` SELECT count(*)::int AS total, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS with_work_order, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS with_affiliate_task, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS with_manual_task, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS with_work_order, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS with_affiliate_task, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS with_manual_task, count(*) FILTER ( - WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + WHERE NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled') AND NOT EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) )::int AS no_carrier FROM order_items oi @@ -41,9 +41,9 @@ async function main() { SELECT COALESCE(NULLIF(oi.fulfillment_channel, ''), '(pending)') AS channel, count(*)::int AS total, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS work_orders, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS work_orders, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS affiliate_tasks, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS manual_tasks, count(*) FILTER ( WHERE oi.fulfillment_channel = 'manual' AND oi.fulfillment_config_id IS NULL )::int AS config_missing, @@ -53,10 +53,11 @@ async function main() { AND NOT EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ) ) OR ( oi.fulfillment_channel = 'work_order' - AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled') ) )::int AS no_carrier FROM order_items oi @@ -137,25 +138,29 @@ async function main() { carrier_count: number }>( `SELECT oi.id AS order_item_id, oi.fulfillment_channel AS channel, - (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int + (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int + (EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ))::int + (EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ))::int AS carrier_count FROM order_items oi WHERE oi.fulfillment_status = 'assigned' - AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int + AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int + (EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ))::int + (EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ))::int > 1 ORDER BY oi.id LIMIT 200`, @@ -172,7 +177,7 @@ async function main() { count(*)::int AS total, count(*) FILTER (WHERE oi.fulfillment_status = 'pending')::int AS pending, count(*) FILTER ( - WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled') OR EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id) )::int AS with_carrier FROM order_items oi diff --git a/apps/backend/scripts/check-sql-guard.ts b/apps/backend/scripts/check-sql-guard.ts index d8a64158..0df4576f 100644 --- a/apps/backend/scripts/check-sql-guard.ts +++ b/apps/backend/scripts/check-sql-guard.ts @@ -92,6 +92,11 @@ const failures: string[] = [] // 用「已发布基线」而不是 HEAD 做比较,是因为以 HEAD 为基线时,本次提交新增的 // 迁移也会被算成历史迁移,合并提交后必然自相矛盾。 const MIGRATION_BASELINE_REF = resolveMigrationBaselineRef() +// 生产当前已发布到 119;120+ 仍允许在部署前重写。部署 120 后应将此值提升到 120。 +const PUBLISHED_MIGRATION_MAX_VERSION = Math.max( + 0, + Math.trunc(Number(process.env.PUBLISHED_MIGRATION_MAX_VERSION) || 119), +) function resolveMigrationBaselineRef(): string | null { const candidates = [process.env.MIGRATION_BASELINE_REF, 'main', 'origin/main'].filter( @@ -132,7 +137,11 @@ if (!MIGRATION_BASELINE_REF) { const changedHistoricalMigrations = diffOutput .split('\n') .map((line) => line.trim().split(/\s+/)) - .filter(([status, file]) => status && file?.endsWith('.sql') && status !== 'A') + .filter(([status, file]) => { + if (!status || !file?.endsWith('.sql') || status === 'A') return false + const version = Number(path.basename(file).slice(0, 3)) + return Number.isFinite(version) && version <= PUBLISHED_MIGRATION_MAX_VERSION + }) .map(([status, file]) => `${status} ${file}`) if (changedHistoricalMigrations.length > 0) { failures.push( diff --git a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts index fc8ebda0..e3c3b6d7 100644 --- a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts +++ b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts @@ -77,17 +77,6 @@ async function main() { converted.push({ id: rule.id, matchKey: rule.match_key, internalSku, channel: rule.channel }) if (!apply) continue - await query( - ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, enabled, note, created_at, updated_at - ) VALUES ('', '', 'internal_sku', $1, $2, $3, $4, TRUE, - '由旧 sku_code 规则迁移', NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING - `, - [internalSku, rule.channel, rule.priority, rule.source || 'operator'], - ) await query( `UPDATE internal_sku_channels SET is_fallback = TRUE, diff --git a/apps/backend/scripts/merge-duplicate-products.ts b/apps/backend/scripts/merge-duplicate-products.ts index 891cb5b6..92abfbe1 100644 --- a/apps/backend/scripts/merge-duplicate-products.ts +++ b/apps/backend/scripts/merge-duplicate-products.ts @@ -176,19 +176,6 @@ async function mergeGroup( [group.canonical_name, group.keeper_id], ) - await query( - ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, enabled, note, created_at, updated_at - ) - VALUES ('', '', 'internal_sku', $1, 'affiliate_dash', 10, 'affiliate', TRUE, - '合并重复商品后按内部SKU生成', NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING - `, - [group.canonical_name], - ) - // 只删除已经没有引用的重复商品,仍被引用就归档。 for (const duplicateId of group.duplicate_ids) { const refs = await query<{ total: number }>( diff --git a/apps/backend/scripts/preflight-fulfillment-routing.ts b/apps/backend/scripts/preflight-fulfillment-routing.ts index 963ec15e..1e8d5bfa 100644 --- a/apps/backend/scripts/preflight-fulfillment-routing.ts +++ b/apps/backend/scripts/preflight-fulfillment-routing.ts @@ -75,10 +75,11 @@ async function main() { WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') - AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled') AND NOT EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') )`, [includeDemo], ) @@ -91,14 +92,16 @@ async function main() { JOIN orders o ON o.id = oi.order_id WHERE oi.fulfillment_status = 'assigned' AND ($1::boolean OR o.shop_id NOT LIKE 'sim-%') - AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int + AND (EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int + (EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ))::int + (EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ))::int > 1`, [includeDemo], ) @@ -155,9 +158,9 @@ async function main() { }>( `SELECT COALESCE(fulfillment_channel, '(pending)') AS channel, count(*)::int AS total, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id))::int AS work_orders, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash'))::int AS affiliate_tasks, - count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch'))::int AS manual_tasks + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled'))::int AS work_orders, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS affiliate_tasks, + count(*) FILTER (WHERE EXISTS (SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled')))::int AS manual_tasks FROM order_items oi WHERE oi.fulfillment_status = 'assigned' GROUP BY 1 ORDER BY 2 DESC`, diff --git a/apps/backend/scripts/reclassify-post-cutover.ts b/apps/backend/scripts/reclassify-post-cutover.ts index c79323d5..dcb42ac7 100644 --- a/apps/backend/scripts/reclassify-post-cutover.ts +++ b/apps/backend/scripts/reclassify-post-cutover.ts @@ -60,6 +60,7 @@ async function loadCandidates(limit: number): Promise { OR EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') ) ) AND NOT EXISTS ( @@ -70,7 +71,7 @@ async function loadCandidates(limit: number): Promise { ) AND NOT EXISTS ( SELECT 1 FROM work_orders wo - WHERE wo.order_item_id = oi.id + WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled' ) AND NOT EXISTS ( SELECT 1 FROM cost_snapshots cs diff --git a/apps/backend/scripts/seed-local-finance-demo.ts b/apps/backend/scripts/seed-local-finance-demo.ts index a683cc17..070d3d1f 100644 --- a/apps/backend/scripts/seed-local-finance-demo.ts +++ b/apps/backend/scripts/seed-local-finance-demo.ts @@ -32,13 +32,19 @@ async function cleanup() { '模拟自动发货商品', '模拟三方卡券', '模拟人工商品')`, ) await query( - `DELETE FROM product_channel_capabilities WHERE product_id IN (SELECT id FROM product_catalog WHERE canonical_sku = 'aff:SIM_AFF_1')`, + `DELETE FROM product_channel_capabilities + WHERE product_id IN ( + SELECT id FROM product_catalog + WHERE canonical_sku IN ('aff:SIM_AFF_1', 'man:模拟人工商品') + )`, ) await query( `DELETE FROM channel_product_mappings WHERE canonical_product_id IN ( SELECT id FROM product_catalog - WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B') + WHERE canonical_sku IN ( + 'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B' + ) )`, ) await query( @@ -48,10 +54,15 @@ async function cleanup() { WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B') )`, ) - await query(`DELETE FROM manual_fulfillment_configs WHERE sku_key = '模拟人工商品'`) + await query( + `DELETE FROM manual_fulfillment_configs + WHERE sku_key IN ('模拟人工商品', '模拟自动发货商品')`, + ) await query( `DELETE FROM product_catalog - WHERE canonical_sku IN ('aff:SIM_AFF_1', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B')`, + WHERE canonical_sku IN ( + 'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B' + )`, ) } @@ -113,19 +124,56 @@ async function main() { VALUES ($1,'affiliate_dash',10,FALSE,TRUE,'demo'), ($1,'manual',20,TRUE,TRUE,'demo')`, [affProductId], ) + const manualProduct = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ( + 'man:模拟人工商品', '模拟人工商品', '人工', '件', 'active', + '{}'::jsonb, NOW(), NOW() + ) RETURNING id`, + ) + const manualProductId = Number(manualProduct.rows[0]!.id) + await query( + `INSERT INTO product_channel_capabilities ( + product_id, channel, priority, is_fallback, enabled, source + ) VALUES ($1, 'manual', 10, FALSE, TRUE, 'demo')`, + [manualProductId], + ) + const manualCostItem = await query<{ id: number }>( + `SELECT id FROM cost_items WHERE code = 'sys_manual_service'`, + ) + await query( + `INSERT INTO cost_versions ( + cost_item_id, scope_type, canonical_product_id, amount_fen, rate_bps, + effective_from, status, change_reason, created_at, updated_at + ) VALUES + ($1, 'product', $2, 100, 0, '2026-10-01T00:00:00+08:00', + 'active', '自动发货失败转人工成本', NOW(), NOW()), + ($1, 'product', $3, 100, 0, '2026-10-01T00:00:00+08:00', + 'active', '本地演示人工成本', NOW(), NOW())`, + [manualCostItem.rows[0]!.id, affProductId, manualProductId], + ) await query( `INSERT INTO internal_sku_channels ( internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, source, created_at, updated_at + canonical_product_id, config_json, source, created_at, updated_at ) VALUES - ('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1, 'demo', NOW(), NOW()), - ('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1, 'demo', NOW(), NOW())`, + ('模拟自动发货商品', 'affiliate_dash', 10, FALSE, TRUE, $1, + '{"affiliateSku":"SIM_AFF_1"}'::jsonb, 'demo', NOW(), NOW()), + ('模拟自动发货商品', 'manual', 90, TRUE, TRUE, $1, + '{}'::jsonb, 'demo', NOW(), NOW())`, [affProductId], ) // 人工演示配置 await query( - `INSERT INTO manual_fulfillment_configs (sku_key, platform_key, credit_quantity, cost_mode, enabled, note, created_at, updated_at) - VALUES ('模拟人工商品','nanhang',100,'fixed',TRUE,'本地演示',NOW(),NOW())`, + `INSERT INTO manual_fulfillment_configs ( + sku_key, canonical_product_id, platform_key, credit_quantity, + cost_mode, enabled, note, created_at, updated_at + ) VALUES + ('模拟自动发货商品', $1, 'nanhang', 100, 'fixed', TRUE, '自动发货兜底', NOW(), NOW()), + ('模拟人工商品', $2, 'nanhang', 100, 'fixed', TRUE, '本地演示', NOW(), NOW())`, + [affProductId, manualProductId], ) await query( `INSERT INTO internal_sku_channels ( diff --git a/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql b/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql index a9d24a01..dcb4e86e 100644 --- a/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql +++ b/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql @@ -1,10 +1,22 @@ -- 120_fulfillment_routing_invariants.sql -- --- 履约路由唯一口径与数据清理: --- 1. 订单项内部 SKU 由一个不可变 SQL 函数统一解析; --- 2. 人工配置自动成为 internal_sku_channels 的 manual 能力; --- 3. manual 与其他渠道并存时统一降为兜底,避免同优先级被静默抢占; --- 4. 可确定的旧 sku_code 规则迁移为内部 SKU 渠道并停用旧规则。 +-- 生产迁移 119 之后的履约路由统一迁移。 +-- +-- 目标: +-- 1. 订单项内部 SKU 只有一个 SQL 解析口径; +-- 2. internal_sku_channels 成为唯一渠道配置源; +-- 3. 人工配置、旧 sku_code 规则和渠道适配参数全部回填到统一表; +-- 4. 原有 sku_code 规则只做一次性迁移,迁移后可确定的规则全部停用。 + +-- 稳定 id 让后台渠道配置可以通过统一 API 增删改,而不必继续暴露旧规则表。 +ALTER TABLE internal_sku_channels + ADD COLUMN IF NOT EXISTS id BIGSERIAL, + ADD COLUMN IF NOT EXISTS note TEXT NOT NULL DEFAULT '', + ADD COLUMN IF NOT EXISTS created_by_admin_user_id BIGINT + REFERENCES admin_users(id) ON DELETE SET NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_internal_sku_channels_id + ON internal_sku_channels (id); CREATE OR REPLACE FUNCTION resolve_internal_sku( p_manual_sku_key TEXT, @@ -33,7 +45,7 @@ $$; COMMENT ON FUNCTION resolve_internal_sku(TEXT, TEXT, TEXT) IS '订单项内部 SKU 唯一 SQL 口径:中文 manual_sku_key/sku_name 优先,再按历史生成列顺序回退'; --- 人工配置本身就是一个 manual 渠道声明。存在其他渠道时默认作为 90 优先级兜底。 +-- 人工配置就是 manual 渠道声明。存在其他渠道时先按 90 优先级兜底。 INSERT INTO internal_sku_channels ( internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, config_json, source, created_at, updated_at @@ -42,15 +54,13 @@ SELECT BTRIM(cfg.sku_key), 'manual', CASE WHEN EXISTS ( - SELECT 1 - FROM internal_sku_channels c + SELECT 1 FROM internal_sku_channels c WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key) AND c.enabled = TRUE AND c.channel <> 'manual' ) THEN 90 ELSE 10 END, CASE WHEN EXISTS ( - SELECT 1 - FROM internal_sku_channels c + SELECT 1 FROM internal_sku_channels c WHERE BTRIM(c.internal_sku) = BTRIM(cfg.sku_key) AND c.enabled = TRUE AND c.channel <> 'manual' @@ -69,20 +79,241 @@ SELECT FROM manual_fulfillment_configs cfg WHERE BTRIM(COALESCE(cfg.sku_key, '')) <> '' ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = CASE - WHEN internal_sku_channels.source = 'operator' THEN internal_sku_channels.priority - ELSE EXCLUDED.priority - END, - is_fallback = CASE - WHEN internal_sku_channels.source = 'operator' THEN internal_sku_channels.is_fallback - ELSE EXCLUDED.is_fallback - END, + priority = CASE WHEN internal_sku_channels.source = 'operator' + THEN internal_sku_channels.priority ELSE EXCLUDED.priority END, + is_fallback = CASE WHEN internal_sku_channels.source = 'operator' + THEN internal_sku_channels.is_fallback ELSE EXCLUDED.is_fallback END, enabled = EXCLUDED.enabled, canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), config_json = internal_sku_channels.config_json || EXCLUDED.config_json, updated_at = NOW(); --- 只要同一内部 SKU 还有自动发货/接单/卡券能力,人工渠道就不能作为并列主渠道。 +-- 旧 sku_code 规则能稳定解析出的内部 SKU,统一补到入口映射表。 +WITH legacy_candidates AS ( + SELECT + rule.provider, + rule.shop_id, + rule.match_key AS sku_code, + BTRIM(product.canonical_name) AS internal_sku, + 10 AS priority + FROM sku_channel_rules rule + JOIN product_catalog product + ON product.canonical_sku = CONCAT('aff:', rule.match_key) + AND product.status = 'active' + WHERE rule.match_key_type = 'sku_code' + AND BTRIM(COALESCE(product.canonical_name, '')) <> '' + + UNION ALL + + SELECT + rule.provider, + rule.shop_id, + rule.match_key AS sku_code, + matched.internal_sku, + 20 AS priority + FROM sku_channel_rules rule + CROSS JOIN LATERAL ( + SELECT + MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, + COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count + FROM order_items oi + JOIN orders source_order ON source_order.id = oi.order_id + WHERE oi.sku_code = rule.match_key + AND (rule.provider = '' OR source_order.provider = rule.provider) + AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) + ) matched + WHERE rule.match_key_type = 'sku_code' + AND matched.internal_count = 1 + AND BTRIM(COALESCE(matched.internal_sku, '')) <> '' +), +ranked_legacy_candidates AS ( + SELECT *, + ROW_NUMBER() OVER ( + PARTITION BY provider, shop_id, sku_code + ORDER BY priority, internal_sku + ) AS candidate_rank + FROM legacy_candidates +) +INSERT INTO internal_sku_mappings ( + provider, shop_id, match_type, match_value, internal_sku, + priority, enabled, source, note, created_at, updated_at +) +SELECT + provider, shop_id, 'sku_code', sku_code, internal_sku, + priority, TRUE, 'legacy-rule', '由旧 sku_code 渠道规则迁移', NOW(), NOW() +FROM ranked_legacy_candidates +WHERE candidate_rank = 1 +ON CONFLICT (provider, shop_id, match_type, match_value) DO UPDATE SET + internal_sku = EXCLUDED.internal_sku, + priority = LEAST(internal_sku_mappings.priority, EXCLUDED.priority), + enabled = TRUE, + source = EXCLUDED.source, + note = EXCLUDED.note, + updated_at = NOW(); + +-- 可确定的旧 sku_code 渠道转换为 internal_sku_channels。 +WITH resolved_rules AS ( + SELECT + rule.id, + rule.provider, + rule.shop_id, + rule.match_key, + rule.channel, + rule.priority, + COALESCE( + ( + SELECT BTRIM(product.canonical_name) + FROM product_catalog product + WHERE product.canonical_sku = CONCAT('aff:', rule.match_key) + AND product.status = 'active' + ORDER BY product.id + LIMIT 1 + ), + matched.internal_sku + ) AS internal_sku, + matched.canonical_product_id + FROM sku_channel_rules rule + LEFT JOIN LATERAL ( + SELECT + MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, + COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count, + MIN(oi.canonical_product_id) AS canonical_product_id + FROM order_items oi + JOIN orders source_order ON source_order.id = oi.order_id + WHERE oi.sku_code = rule.match_key + AND (rule.provider = '' OR source_order.provider = rule.provider) + AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) + ) matched ON matched.internal_count = 1 + WHERE rule.match_key_type = 'sku_code' + AND rule.enabled = TRUE +) +INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at +) +SELECT + BTRIM(internal_sku), + channel, + MIN(priority), + FALSE, + TRUE, + MIN(canonical_product_id), + jsonb_build_object( + 'legacySkuCode', MIN(match_key), + 'legacyProvider', MIN(provider), + 'legacyShopId', MIN(shop_id) + ), + 'legacy-rule', + NOW(), + NOW() +FROM resolved_rules +WHERE BTRIM(COALESCE(internal_sku, '')) <> '' +GROUP BY BTRIM(internal_sku), channel +ON CONFLICT (internal_sku, channel) DO UPDATE SET + priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), + enabled = TRUE, + canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), + config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + updated_at = NOW(); + +-- 已转成内部 SKU 的旧规则停用;真正无法解析的规则保留 enabled,交给严格预检阻断。 +WITH resolved_rule_ids AS ( + SELECT rule.id + FROM sku_channel_rules rule + LEFT JOIN LATERAL ( + SELECT + MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, + COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count + FROM order_items oi + JOIN orders source_order ON source_order.id = oi.order_id + WHERE oi.sku_code = rule.match_key + AND (rule.provider = '' OR source_order.provider = rule.provider) + AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) + ) matched ON matched.internal_count = 1 + WHERE rule.match_key_type = 'sku_code' + AND rule.enabled = TRUE + AND ( + EXISTS ( + SELECT 1 FROM product_catalog product + WHERE product.canonical_sku = CONCAT('aff:', rule.match_key) + AND product.status = 'active' + ) + OR BTRIM(COALESCE(matched.internal_sku, '')) <> '' + ) +) +UPDATE sku_channel_rules rule +SET enabled = FALSE, + note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'), + updated_at = NOW() +FROM resolved_rule_ids +WHERE rule.id = resolved_rule_ids.id; + +-- 旧 internal_sku 规则已完整复制到统一表,不再参与运行时判定。 +UPDATE sku_channel_rules +SET enabled = FALSE, + note = CONCAT(COALESCE(note, ''), ' [internal_sku_channels 已成为真源]'), + updated_at = NOW() +WHERE match_key_type = 'internal_sku' AND enabled = TRUE; + +-- 回填适配参数,避免统一表只有“渠道名”而实际还要猜 affiliate SKU / 工单模板。 +WITH affiliate_config AS ( + SELECT + BTRIM(product.canonical_name) AS internal_sku, + MIN(SUBSTRING(product.canonical_sku FROM 5)) AS affiliate_sku, + MIN(product.id) AS canonical_product_id + FROM product_catalog product + WHERE product.status = 'active' AND product.canonical_sku LIKE 'aff:%' + GROUP BY BTRIM(product.canonical_name) +) +UPDATE internal_sku_channels channel +SET config_json = channel.config_json || jsonb_build_object( + 'affiliateSku', affiliate_config.affiliate_sku + ), + canonical_product_id = COALESCE(channel.canonical_product_id, affiliate_config.canonical_product_id), + updated_at = NOW() +FROM affiliate_config +WHERE channel.channel = 'affiliate_dash' + AND BTRIM(channel.internal_sku) = affiliate_config.internal_sku; + +WITH work_order_config AS ( + SELECT + BTRIM(product.canonical_name) AS internal_sku, + MIN(SUBSTRING(product.canonical_sku FROM 5)) AS work_rule_key, + MIN(product.id) AS canonical_product_id + FROM product_catalog product + WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpl:%' + GROUP BY BTRIM(product.canonical_name) +) +UPDATE internal_sku_channels channel +SET config_json = channel.config_json || jsonb_build_object( + 'workRuleKey', work_order_config.work_rule_key + ), + canonical_product_id = COALESCE(channel.canonical_product_id, work_order_config.canonical_product_id), + updated_at = NOW() +FROM work_order_config +WHERE channel.channel = 'work_order' + AND BTRIM(channel.internal_sku) = work_order_config.internal_sku; + +WITH voucher_config AS ( + SELECT + BTRIM(product.canonical_name) AS internal_sku, + MIN(SUBSTRING(product.canonical_sku FROM 5)) AS voucher_sku, + MIN(product.id) AS canonical_product_id + FROM product_catalog product + WHERE product.status = 'active' AND product.canonical_sku LIKE 'tpv:%' + GROUP BY BTRIM(product.canonical_name) +) +UPDATE internal_sku_channels channel +SET config_json = channel.config_json || jsonb_build_object( + 'voucherSku', voucher_config.voucher_sku + ), + canonical_product_id = COALESCE(channel.canonical_product_id, voucher_config.canonical_product_id), + updated_at = NOW() +FROM voucher_config +WHERE channel.channel = 'third_party_voucher' + AND BTRIM(channel.internal_sku) = voucher_config.internal_sku; + +-- 只要同一内部 SKU 还有非人工渠道,人工统一降为显式兜底。 UPDATE internal_sku_channels manual_channel SET priority = GREATEST(manual_channel.priority, 90), is_fallback = TRUE, @@ -98,128 +329,17 @@ WHERE manual_channel.channel = 'manual' AND other_channel.channel <> 'manual' ); --- 自动发货旧 sku_code 规则能通过 aff: 商品稳定定位内部 SKU,直接迁移。 -INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, created_at, updated_at -) -SELECT - BTRIM(product.canonical_name), - 'affiliate_dash', - MIN(rule.priority), - FALSE, - TRUE, - MIN(product.id), - jsonb_build_object('legacySkuCode', MIN(rule.match_key)), - 'legacy-rule', - NOW(), - NOW() -FROM sku_channel_rules rule -JOIN product_catalog product - ON product.canonical_sku = CONCAT('aff:', rule.match_key) - AND product.status = 'active' -WHERE rule.match_key_type = 'sku_code' - AND rule.channel = 'affiliate_dash' - AND rule.enabled = TRUE - AND BTRIM(COALESCE(product.canonical_name, '')) <> '' -GROUP BY BTRIM(product.canonical_name) -ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), - enabled = TRUE, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), - config_json = internal_sku_channels.config_json || EXCLUDED.config_json, - updated_at = NOW(); - -UPDATE sku_channel_rules rule -SET enabled = FALSE, - note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'), - updated_at = NOW() -WHERE rule.match_key_type = 'sku_code' - AND rule.channel = 'affiliate_dash' - AND rule.enabled = TRUE - AND EXISTS ( - SELECT 1 - FROM product_catalog product - WHERE product.canonical_sku = CONCAT('aff:', rule.match_key) - AND product.status = 'active' - AND BTRIM(COALESCE(product.canonical_name, '')) <> '' - ); - --- 卡券旧规则按历史订单中唯一可解析的内部 SKU 迁移;歧义数据保持原规则不动。 -WITH resolved_legacy AS ( - SELECT - rule.id, - matched.internal_sku, - MIN(rule.priority) AS priority - FROM sku_channel_rules rule - CROSS JOIN LATERAL ( - SELECT - MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, - COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count - FROM order_items oi - JOIN orders source_order ON source_order.id = oi.order_id - WHERE oi.sku_code = rule.match_key - AND (rule.provider = '' OR source_order.provider = rule.provider) - AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) - ) matched - WHERE rule.match_key_type = 'sku_code' - AND rule.channel = 'third_party_voucher' - AND rule.enabled = TRUE - AND matched.internal_count = 1 - AND BTRIM(COALESCE(matched.internal_sku, '')) <> '' - GROUP BY rule.id, matched.internal_sku -) -INSERT INTO internal_sku_channels ( - internal_sku, channel, priority, is_fallback, enabled, - config_json, source, created_at, updated_at -) -SELECT - internal_sku, - 'third_party_voucher', - priority, - FALSE, - TRUE, - '{}'::jsonb, - 'legacy-rule', - NOW(), - NOW() -FROM resolved_legacy -ON CONFLICT (internal_sku, channel) DO UPDATE SET - priority = LEAST(internal_sku_channels.priority, EXCLUDED.priority), - enabled = TRUE, - updated_at = NOW(); - -WITH resolved_legacy AS ( - SELECT rule.id - FROM sku_channel_rules rule - CROSS JOIN LATERAL ( - SELECT - MIN(resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_sku, - COUNT(DISTINCT resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)) AS internal_count - FROM order_items oi - JOIN orders source_order ON source_order.id = oi.order_id - WHERE oi.sku_code = rule.match_key - AND (rule.provider = '' OR source_order.provider = rule.provider) - AND (rule.shop_id = '' OR source_order.shop_id = rule.shop_id) - ) matched - WHERE rule.match_key_type = 'sku_code' - AND rule.channel = 'third_party_voucher' - AND rule.enabled = TRUE - AND matched.internal_count = 1 - AND BTRIM(COALESCE(matched.internal_sku, '')) <> '' -) -UPDATE sku_channel_rules rule -SET enabled = FALSE, - note = CONCAT(COALESCE(rule.note, ''), ' [已迁移 internal_sku]'), - updated_at = NOW() -FROM resolved_legacy -WHERE rule.id = resolved_legacy.id; - ALTER TABLE internal_sku_channels DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_priority_nonnegative; ALTER TABLE internal_sku_channels ADD CONSTRAINT chk_internal_sku_channels_priority_nonnegative CHECK (priority >= 0); +ALTER TABLE internal_sku_channels + DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_config_object; +ALTER TABLE internal_sku_channels + ADD CONSTRAINT chk_internal_sku_channels_config_object + CHECK (jsonb_typeof(config_json) = 'object'); + CREATE INDEX IF NOT EXISTS idx_internal_sku_channels_fallback ON internal_sku_channels (internal_sku, priority, channel) WHERE enabled = TRUE AND is_fallback = TRUE; diff --git a/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql b/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql index 4678b033..cb17f55f 100644 --- a/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql +++ b/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql @@ -23,3 +23,9 @@ END $$; CREATE UNIQUE INDEX IF NOT EXISTS uq_internal_sku_channels_single_primary ON internal_sku_channels (internal_sku) WHERE enabled = TRUE AND is_fallback = FALSE; + +ALTER TABLE internal_sku_channels + DROP CONSTRAINT IF EXISTS chk_internal_sku_channels_nonempty_trimmed; +ALTER TABLE internal_sku_channels + ADD CONSTRAINT chk_internal_sku_channels_nonempty_trimmed + CHECK (internal_sku <> '' AND internal_sku = BTRIM(internal_sku)); diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index e79e4671..b49e3631 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -374,8 +374,8 @@ router.post( errorMessage: '补齐卡券渠道规则失败', scope: '[admin/cost-center/voucher-products/ensure-rules]', audit: (_req, data) => ({ - action: 'voucher_channel_rules_ensured', - targetType: 'sku_channel_rules', + action: 'voucher_internal_channels_ensured', + targetType: 'internal_sku_channels', targetId: '', data: data as Record, }), diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 58125a9a..778803e3 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -14,6 +14,7 @@ import { deleteChannelRule, listChannelRules, listPendingChannelGroups, + switchChannelForOrderItems, unlockChannel, upsertChannelRule, } from '../../services/fulfillment/channel-assign-service.js' @@ -143,6 +144,35 @@ router.post( ), ) +router.post( + '/fulfillment/switch', + requireAdminPermission('finance.config'), + createJsonHandler( + (req) => { + const body = bodyOf(req) + return switchChannelForOrderItems({ + orderItemIds: Array.isArray(body.orderItemIds) + ? body.orderItemIds.map((value: unknown) => Number(value)) + : [Number(body.orderItemId)], + channel: String(body.channel || ''), + adminUserId: req.adminSession?.userId ?? null, + note: String(body.note || ''), + }) + }, + { + successMessage: '履约渠道已切换', + errorMessage: '切换履约渠道失败', + scope: '[admin/fulfillment/switch]', + audit: (req, data) => ({ + action: 'fulfillment.switch_channel', + targetType: 'order_item', + targetId: String(bodyOf(req).orderItemId || ''), + data: { payload: bodyOf(req), result: data as Record }, + }), + }, + ), +) + router.get( '/fulfillment/rules', requireAdminPermission('finance.view'), @@ -170,9 +200,7 @@ router.post( const body = bodyOf(req) return upsertChannelRule({ ...(body.id ? { id: Number(body.id) } : {}), - provider: String(body.provider || ''), - shopId: String(body.shopId || ''), - matchKeyType: String(body.matchKeyType || 'sku_code'), + matchKeyType: String(body.matchKeyType || 'internal_sku'), matchKey: String(body.matchKey || ''), channel: String(body.channel || ''), priority: Number(body.priority), @@ -188,7 +216,7 @@ router.post( scope: '[admin/fulfillment/rules]', audit: (req, data) => ({ action: 'fulfillment.upsert_rule', - targetType: 'sku_channel_rule', + targetType: 'internal_sku_channel', targetId: String((data as { id?: number })?.id || ''), data: { payload: bodyOf(req) }, }), diff --git a/apps/backend/src/services/cost-center/affiliate-product-service.ts b/apps/backend/src/services/cost-center/affiliate-product-service.ts index 3413b0a7..8ebe7d6f 100644 --- a/apps/backend/src/services/cost-center/affiliate-product-service.ts +++ b/apps/backend/src/services/cost-center/affiliate-product-service.ts @@ -178,6 +178,7 @@ export async function backfillAdminAffiliateDashSkus() { ) AS sku_code FROM fulfillment_tasks ft WHERE ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') AND ft.created_at >= NOW() - INTERVAL '30 days' AND btrim(COALESCE(ft.context_json->>'skuCode', '')) <> '' ORDER BY ft.order_item_id, ft.created_at DESC, ft.id DESC @@ -202,6 +203,7 @@ export async function backfillAdminAffiliateDashSkus() { SELECT COUNT(DISTINCT ft.order_item_id)::int FROM fulfillment_tasks ft WHERE ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') AND ft.created_at >= NOW() - INTERVAL '30 days' ) AS affiliate_item_count, (SELECT COUNT(*)::int FROM target) AS matched_count, @@ -245,6 +247,7 @@ export async function backfillAdminAffiliateDashSkus() { SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed','expired','failed','manual_review','retry_pending','cancelled') AND ft.created_at >= NOW() - INTERVAL '30 days' ) `, 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 a8807f18..f7a62b4f 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 @@ -408,7 +408,6 @@ export async function deleteManualFulfillmentConfig(skuKey: string) { `DELETE FROM manual_fulfillment_configs WHERE sku_key = $1 RETURNING id`, [key], ) - await query(`DELETE FROM sku_channel_rules WHERE source = 'manual' AND match_key = $1`, [key]) await upsertInternalSkuChannel({ internalSku: key, channel: 'manual', @@ -722,9 +721,7 @@ function requiredRow(row: T | undefined, message: string): T { } /** - * 人工配置 → 渠道规则(内部 SKU 精确匹配)。 - * - * 幂等:按唯一键 (provider, shop, match_key_type, match_key, channel) upsert。 + * 人工配置 → internal_sku_channels(内部 SKU 精确匹配)。 */ async function syncManualChannelRule(config: { id: number @@ -757,29 +754,4 @@ async function syncManualChannelRule(config: { configJson: { manualConfigId: config.id, note: String(config.note || '').slice(0, 200) }, replacePriority: true, }) - - await query( - ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, source_ref_id, enabled, note, created_at, updated_at - ) - VALUES ('', '', 'internal_sku', $1, 'manual', $5, 'manual', $2, $3, $4, NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) - DO UPDATE SET - enabled = EXCLUDED.enabled, - priority = EXCLUDED.priority, - source = 'manual', - source_ref_id = EXCLUDED.source_ref_id, - note = EXCLUDED.note, - updated_at = NOW() - `, - [ - skuKey, - config.id, - config.enabled !== false, - String(config.note || '').slice(0, 200), - hasOtherChannel ? 90 : 10, - ], - ) } 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 05523ece..693474e1 100644 --- a/apps/backend/src/services/cost-center/product-normalize-service.ts +++ b/apps/backend/src/services/cost-center/product-normalize-service.ts @@ -198,23 +198,10 @@ async function ensureVoucherProductForItem(item: ItemRow): Promise undefined) - // 同一内部中文 SKU 跨平台码统一分流到卡券渠道。 - await query( - ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, enabled, note, created_at, updated_at - ) - VALUES ('', '', 'internal_sku', $1, 'third_party_voucher', 10, 'voucher', TRUE, - '卡券商品自动建商品后生成', NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING - `, - [internalSku], - ) - return productId } diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index 6e109615..ad72624b 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -31,6 +31,7 @@ export async function runAffiliateFallback( internal_sku: string sku_name: string config_id: number | null + config_product_id: number | null basis_date: string }>( ` @@ -42,6 +43,7 @@ export async function runAffiliateFallback( resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code) AS internal_sku, oi.sku_name, cfg.id AS config_id, + cfg.canonical_product_id AS config_product_id, (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date FROM order_items oi JOIN orders o ON o.id = oi.order_id @@ -95,6 +97,7 @@ 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, updated_at = NOW() WHERE id = $1 AND fulfillment_status = 'assigned' @@ -102,7 +105,7 @@ export async function runAffiliateFallback( AND fulfillment_locked = FALSE RETURNING id `, - [row.id, row.config_id], + [row.id, row.config_id, row.config_product_id], ) if ((result.rowCount || 0) === 0) return false diff --git a/apps/backend/src/services/fulfillment/carrier-creation-service.ts b/apps/backend/src/services/fulfillment/carrier-creation-service.ts index a3977393..179ccdb7 100644 --- a/apps/backend/src/services/fulfillment/carrier-creation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -2,9 +2,10 @@ import { query } from '../../db/client.js' import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { syncDeliveryTasksForOrder } from '../order/delivery-task-service.js' import { syncWorkerOrdersForSourceOrder } from '../worker-platform/sync-worker-orders-service.js' -import type { FulfillmentChannel } from './channel-types.js' +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' function parseObject(value: unknown): Record { if (value && typeof value === 'object' && !Array.isArray(value)) { @@ -29,7 +30,9 @@ export type EnsureCarrierResult = { async function hasWorkOrder(orderItemId: number): Promise { const result = await query<{ exists: boolean }>( - `SELECT EXISTS (SELECT 1 FROM work_orders WHERE order_item_id = $1) AS exists`, + `SELECT EXISTS ( + SELECT 1 FROM work_orders WHERE order_item_id = $1 AND status <> 'cancelled' + ) AS exists`, [orderItemId], ) return result.rows[0]?.exists === true @@ -40,8 +43,9 @@ async function hasAffiliateTask(orderItemId: number): Promise { `SELECT EXISTS ( SELECT 1 FROM fulfillment_tasks WHERE order_item_id = $1 AND executor_key = 'affiliate_dash' + AND task_status <> ALL($2::text[]) ) AS exists`, - [orderItemId], + [orderItemId, INACTIVE_CARRIER_TASK_STATUSES], ) return result.rows[0]?.exists === true } @@ -110,6 +114,23 @@ 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 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 + LIMIT 1`, + [internalSku], + ) + const preferredWorkRuleKey = String( + channelConfig.rows[0]?.config_json?.workRuleKey || '', + ).trim() const snapshot = parseObject(item.item_snapshot_json) const classification = parseObject(snapshot.classification) const route = parseObject(snapshot.fulfillmentRoute) @@ -130,6 +151,7 @@ export async function ensureCarrierForOrderItem(input: { source: 'carrier_creation', autoOnly: true, skipChannelAssign: true, + ...(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 74f4f186..8b10d04b 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -2,7 +2,11 @@ import { query } from '../../db/client.js' import { getOrderById } from '../../repositories/order-repo.js' import type { OrderItemRow } from '../../types/repository/rows.js' import { logWarn } from '../../utils/logger.js' -import { FULFILLMENT_CHANNELS, type FulfillmentChannel } from './channel-types.js' +import { + FULFILLMENT_CHANNELS, + INACTIVE_CARRIER_TASK_STATUSES, + type FulfillmentChannel, +} from './channel-types.js' import { ensureCarrierForOrderItem } from './carrier-creation-service.js' import { planInternalSkuChannels, @@ -21,6 +25,7 @@ function normalizeChannels(value: unknown): InternalSkuChannelRow[] { return value .filter((row): row is Record => Boolean(row) && typeof row === 'object') .map((row) => ({ + id: Number(row.id || 0), internal_sku: String(row.internal_sku || '').trim(), channel: String(row.channel || '') as FulfillmentChannel, priority: Number(row.priority || 100), @@ -33,6 +38,9 @@ function normalizeChannels(value: unknown): InternalSkuChannelRow[] { ? (row.config_json as Record) : {}, source: String(row.source || ''), + note: String(row.note || ''), + created_by_admin_user_id: + row.created_by_admin_user_id == null ? null : Number(row.created_by_admin_user_id), })) .filter((row) => FULFILLMENT_CHANNELS.includes(row.channel)) } @@ -49,10 +57,14 @@ async function loadCandidateItems(input: { const statusFilter = input.assignedMissingCarrier ? `oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') - AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS ( + SELECT 1 FROM work_orders wo + WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled' + ) AND NOT EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')}) )` : `oi.fulfillment_status = 'pending' AND oi.fulfillment_locked = FALSE` @@ -70,6 +82,7 @@ async function loadCandidateItems(input: { SELECT COALESCE( jsonb_agg( jsonb_build_object( + 'id', channel.id, 'internal_sku', channel.internal_sku, 'channel', channel.channel, 'priority', channel.priority, @@ -78,6 +91,8 @@ async function loadCandidateItems(input: { 'canonical_product_id', channel.canonical_product_id, 'config_json', channel.config_json, 'source', channel.source + ,'note', channel.note + ,'created_by_admin_user_id', channel.created_by_admin_user_id ) ORDER BY channel.priority, channel.channel ), @@ -138,6 +153,8 @@ async function selectSafeFallback(input: { fulfillment_reason = $3, channel_switch_reason = $3, channel_switched_at = NOW(), + canonical_product_id = NULL, + fulfillment_config_id = NULL, fulfillment_pending_reason = NULL, fulfillment_pending_detail = NULL, updated_at = NOW() @@ -328,10 +345,14 @@ export async function reconcilePendingCarriers( FROM order_items oi WHERE oi.fulfillment_status IN ('pending', 'assigned') AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') - AND NOT EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + AND NOT EXISTS ( + SELECT 1 FROM work_orders wo + WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled' + ) AND NOT EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')}) ) `, ) 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 56f44eb7..8e9420ab 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.test.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.test.ts @@ -29,6 +29,7 @@ function buildItem(patch: Partial = {}): ChannelCandidateI function buildChannel(patch: Partial = {}): InternalSkuChannelRow { return { + id: 1, internal_sku: '套装-测试', channel: 'manual', priority: 10, @@ -37,6 +38,8 @@ function buildChannel(patch: Partial = {}): InternalSkuCh canonical_product_id: null, config_json: {}, source: 'test', + note: '', + created_by_admin_user_id: null, ...patch, } } diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index d4c05b34..b13ec83f 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -1,5 +1,8 @@ -import { query } from '../../db/client.js' +import { query, withTransaction } from '../../db/client.js' +import { closeClaimTokensByTaskId } from '../../repositories/claim-token-repo.js' import { getOrderById } from '../../repositories/order-repo.js' +import { createTaskEvent } from '../../repositories/task-event-repo.js' +import { createWorkOrderEvent } from '../../repositories/worker-platform/work-order-event-repo.js' import type { OrderItemRow } from '../../types/repository/rows.js' import { createHttpError } from '../../utils/http.js' import { nowIso } from '../../utils/time.js' @@ -10,10 +13,10 @@ import { normalizeProductsForItems } from '../cost-center/product-normalize-serv import { orderItemBasisDates, refreshFactsForDates } from '../finance/refresh-facts.js' import { FULFILLMENT_CHANNELS, + INACTIVE_CARRIER_TASK_STATUSES, MATCH_KEY_TYPES, RULE_SOURCES, isFulfillmentChannel, - isMatchKeyType, isRuleSource, type FulfillmentChannel, type FulfillmentSource, @@ -21,13 +24,14 @@ import { type RuleSource, } from './channel-types.js' import { - disableInternalSkuChannel, + deleteInternalSkuChannelById, planInternalSkuChannels, setInternalSkuPrimaryChannel, upsertInternalSkuChannel, type InternalSkuChannelRow, } from './internal-sku-channel-service.js' import { ensureCarrierForOrderItem } from './carrier-creation-service.js' +import { TASK_STATUS } from '../../domain/task-status.js' /** * 管道 A:渠道判定。 @@ -45,6 +49,7 @@ import { ensureCarrierForOrderItem } from './carrier-creation-service.js' /** 数据库函数 `resolve_internal_sku` 是订单项内部 SKU 的唯一 SQL 口径。 */ const INTERNAL_SKU_SQL = `resolve_internal_sku(oi.manual_sku_key, oi.sku_name, oi.sku_code)` +const ACTIVE_CARRIER_TASK_SQL = `task.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})` export type ChannelRule = { id: number @@ -304,15 +309,17 @@ export async function assignPendingOrderItemChannels( (COALESCE(o.paid_at, o.created_at) AT TIME ZONE 'Asia/Shanghai')::date::text AS basis_date, EXISTS ( - SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id + 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' + SELECT 1 FROM fulfillment_tasks task + WHERE task.order_item_id = oi.id AND task.executor_key = 'affiliate_dash' + AND ${ACTIVE_CARRIER_TASK_SQL} ) AS has_affiliate_task, EXISTS ( - SELECT 1 FROM fulfillment_tasks ft - WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + SELECT 1 FROM fulfillment_tasks task + WHERE task.order_item_id = oi.id AND task.executor_key = 'manual_dispatch' + AND ${ACTIVE_CARRIER_TASK_SQL} ) AS has_manual_task FROM order_items oi JOIN orders o ON o.id = oi.order_id @@ -577,6 +584,205 @@ async function assignOrderItemsAfterCarrierReady(input: { return { updated: Number(final.rows[0]?.count || assignedIds.length), carrierFailed } } +const WORK_ORDER_SWITCH_BLOCKED = new Set([ + 'open', + 'in_progress', + 'pending_acceptance', + 'problem', + 'accepted', +]) +const TASK_SWITCH_BLOCKED = new Set([ + TASK_STATUS.REDEEMING, + TASK_STATUS.DISPATCHED_PENDING_RETURN, + TASK_STATUS.COMPLETED, + TASK_STATUS.REDEEMED, +]) + +/** + * 显式切换渠道:先取消尚未实际履约的旧载体,再创建新载体。 + * 已经成功或进入实际交付阶段的订单不允许自动切换。 + */ +export async function switchChannelForOrderItems(input: { + orderItemIds: number[] + channel: string + adminUserId: number | null + note?: string +}): Promise<{ + switched: number + failed: number + errors: Array<{ orderItemId: number; reason: string }> +}> { + if (!isFulfillmentChannel(input.channel)) { + throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) + } + const ids = [ + ...new Set(input.orderItemIds.map(Number).filter((id) => Number.isInteger(id) && id > 0)), + ] + if (ids.length === 0) { + throw createHttpError('请选择订单项', { statusCode: 400, errorCode: 'order_item_required' }) + } + + const items = ( + await query(`SELECT * FROM order_items WHERE id = ANY($1::bigint[])`, [ids]) + ).rows + const orderIds = [...new Set(items.map((item) => Number(item.order_id)))] + const orders = new Map( + (await Promise.all(orderIds.map((orderId) => getOrderById(orderId)))) + .filter((order): order is NonNullable => Boolean(order)) + .map((order) => [Number(order.id), order]), + ) + const readyIds: number[] = [] + const errors: Array<{ orderItemId: number; reason: string }> = [] + + for (const item of items) { + const order = orders.get(Number(item.order_id)) + if (!order) { + errors.push({ orderItemId: Number(item.id), reason: 'order_not_found' }) + continue + } + if (item.fulfillment_status === 'archived') { + errors.push({ orderItemId: Number(item.id), reason: 'archived' }) + continue + } + if (item.fulfillment_channel === input.channel) { + readyIds.push(Number(item.id)) + continue + } + + try { + const workOrders = await query<{ id: number; status: string }>( + `SELECT id, status FROM work_orders + WHERE order_item_id = $1 AND status <> 'cancelled'`, + [item.id], + ) + const blockedWorkOrder = workOrders.rows.find((row) => + WORK_ORDER_SWITCH_BLOCKED.has(String(row.status)), + ) + if (blockedWorkOrder) { + throw createHttpError('旧接单工单已进入履约阶段,不能自动切换渠道', { + statusCode: 409, + errorCode: 'channel_switch_work_order_active', + }) + } + const tasks = await query<{ id: number; task_status: string }>( + `SELECT id, task_status FROM fulfillment_tasks WHERE order_item_id = $1`, + [item.id], + ) + const blockedTask = tasks.rows.find((row) => TASK_SWITCH_BLOCKED.has(String(row.task_status))) + if (blockedTask) { + throw createHttpError('旧履约任务已进入实际交付阶段,不能自动切换渠道', { + statusCode: 409, + errorCode: 'channel_switch_task_active', + }) + } + + const now = nowIso() + await withTransaction(async (client) => { + const locked = await client.query( + `SELECT * FROM order_items WHERE id = $1 FOR UPDATE`, + [item.id], + ) + if (!locked.rows[0] || locked.rows[0].fulfillment_status === 'archived') { + throw createHttpError('订单项状态已变化', { + statusCode: 409, + errorCode: 'channel_switch_order_item_changed', + }) + } + for (const workOrder of workOrders.rows) { + await client.query( + `UPDATE work_orders + SET status = 'cancelled', assigned_worker_id = NULL, assigned_at = NULL, + deadline_at = NULL, published_at = NULL, updated_at = $2 + WHERE id = $1 AND status <> 'cancelled'`, + [workOrder.id, now], + ) + } + for (const task of tasks.rows) { + await client.query( + `UPDATE fulfillment_tasks + SET task_status = $2, delivery_status = 'closed', result_code = 'closed_by_channel_switch', + result_message = $3, user_action_status = 'closed', claim_expires_at = $4, + updated_at = $4 + WHERE id = $1`, + [task.id, TASK_STATUS.CLOSED, '履约渠道已切换,原任务关闭', now], + ) + } + await client.query( + `UPDATE cost_snapshots + SET superseded_at = $2 + WHERE order_item_id = $1 AND superseded_at IS NULL AND reversed_at IS NULL`, + [item.id, now], + ) + await client.query( + `UPDATE order_items + SET original_channel = COALESCE(original_channel, fulfillment_channel), + fulfillment_channel = NULL, + fulfillment_status = 'pending', + fulfillment_source = 'operator_assigned', + fulfillment_locked = FALSE, + fulfillment_reason = $2, + channel_switch_reason = 'operator', + channel_switched_at = $3, + canonical_product_id = NULL, + fulfillment_config_id = NULL, + fulfillment_pending_reason = NULL, + fulfillment_pending_detail = NULL, + updated_at = $3 + WHERE id = $1`, + [item.id, String(input.note || '运营切换履约渠道').slice(0, 200), now], + ) + }) + + for (const workOrder of workOrders.rows) { + await createWorkOrderEvent({ + workOrderId: Number(workOrder.id), + actorType: 'admin', + actorId: String(input.adminUserId || ''), + eventType: 'cancelled_by_channel_switch', + fromStatus: workOrder.status, + toStatus: 'cancelled', + payloadJson: JSON.stringify({ channel: input.channel, note: input.note || '' }), + now, + }) + } + for (const task of tasks.rows) { + await closeClaimTokensByTaskId(Number(task.id), now) + await createTaskEvent( + Number(task.id), + 'task_closed_by_channel_switch', + { channel: input.channel, note: input.note || '', previousStatus: task.task_status }, + now, + ) + } + readyIds.push(Number(item.id)) + } catch (error) { + errors.push({ + orderItemId: Number(item.id), + reason: error instanceof Error ? error.message : String(error), + }) + } + } + + const assigned = await assignOrderItemsAfterCarrierReady({ + orderItemIds: readyIds, + channel: input.channel, + source: 'operator_assigned', + lock: true, + note: input.note, + }) + await query( + `UPDATE order_items + SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW() + WHERE id = ANY($1::bigint[])`, + [readyIds, input.adminUserId], + ) + return { + switched: assigned.updated, + failed: errors.length + assigned.carrierFailed, + errors, + } +} + /** 运营指定渠道:先保证真实载体,成功后才写入并锁定。 */ export async function assignChannelToOrderItems(input: { orderItemIds: number[] @@ -700,8 +906,8 @@ export async function assignChannelByInternalSku(input: { internalSku: string channel: string adminUserId: number | null - note?: string - createRule?: boolean + note?: string | undefined + createRule?: boolean | undefined }): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) @@ -713,6 +919,12 @@ export async function assignChannelByInternalSku(input: { errorCode: 'internal_sku_required', }) } + if (input.createRule === false) { + throw createHttpError('内部 SKU 渠道配置必须持久化,不能只改当前订单', { + statusCode: 400, + errorCode: 'persistent_channel_rule_required', + }) + } const idsResult = await query<{ id: number }>( ` @@ -743,24 +955,11 @@ export async function assignChannelByInternalSku(input: { canonicalProductId: product.rows[0]?.id == null ? null : Number(product.rows[0].id), source: 'operator', configJson: { note: String(input.note || '').slice(0, 200) }, + note: input.note, + adminUserId: input.adminUserId, }) - let ruleCreated = false - if (input.createRule !== false) { - const rule = await upsertChannelRule({ - provider: input.provider, - shopId: input.shopId, - matchKeyType: 'internal_sku', - matchKey: internalSku, - channel: input.channel, - priority: 10, - source: 'operator', - enabled: true, - note: `运营在待分类队列指定:${String(input.note || '').slice(0, 120)}`, - adminUserId: input.adminUserId, - }) - ruleCreated = rule.id > 0 - } + const ruleCreated = true const ids = idsResult.rows.map((row) => Number(row.id)) const applied = await assignOrderItemsAfterCarrierReady({ @@ -787,8 +986,8 @@ export async function assignChannelBySku(input: { skuCode: string channel: string adminUserId: number | null - note?: string - createRule?: boolean + note?: string | undefined + createRule?: boolean | undefined }): Promise<{ updated: number; ruleCreated: boolean; carrierFailed: number }> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) @@ -813,48 +1012,24 @@ export async function assignChannelBySku(input: { const internalSkus = [ ...new Set(idsResult.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)), ] - if (internalSkus.length === 1) { - await setInternalSkuPrimaryChannel({ - internalSku: internalSkus[0]!, - channel: input.channel, - source: 'operator', - configJson: { note: String(input.note || '').slice(0, 200) }, + if (internalSkus.length === 0) { + return { updated: 0, ruleCreated: false, carrierFailed: 0 } + } + if (internalSkus.length > 1) { + throw createHttpError('同一平台 SKU 对应多个内部 SKU,请按内部 SKU 指定渠道', { + statusCode: 409, + errorCode: 'sku_code_ambiguous_internal_sku', }) } - - // 默认同时落一条渠道规则:否则同 SKU 的下一单还是会进待分类。 - let ruleCreated = false - if (input.createRule !== false) { - const rule = await upsertChannelRule({ - provider: input.provider, - shopId: input.shopId, - matchKeyType: 'sku_code', - matchKey: input.skuCode, - channel: input.channel, - priority: 10, - source: 'operator', - enabled: true, - note: `运营在待分类队列指定:${String(input.note || '').slice(0, 120)}`, - adminUserId: input.adminUserId, - }) - ruleCreated = rule.id > 0 - } - - const ids = idsResult.rows.map((row) => Number(row.id)) - const applied = await assignOrderItemsAfterCarrierReady({ - orderItemIds: ids, + return assignChannelByInternalSku({ + provider: input.provider, + shopId: input.shopId, + internalSku: internalSkus[0]!, channel: input.channel, - source: 'operator_assigned', - lock: true, + adminUserId: input.adminUserId, note: input.note, + createRule: input.createRule, }) - await query( - `UPDATE order_items - SET fulfillment_decided_by = $2, fulfillment_decided_at = NOW() - WHERE id = ANY($1::bigint[])`, - [ids, input.adminUserId], - ) - return { ...applied, ruleCreated } } export async function listChannelRules( @@ -877,19 +1052,26 @@ export async function listChannelRules( const rows = await query( ` SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled - FROM sku_channel_rules + 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 + FROM internal_sku_channels + ) internal_rules ${where} ORDER BY priority, channel, match_key `, params, ) - return rows.rows + return rows.rows.map((row) => ({ + ...row, + id: Number(row.id), + priority: Number(row.priority), + })) } export async function upsertChannelRule(input: { id?: number - provider?: string - shopId?: string matchKeyType: string matchKey: string channel: string @@ -899,8 +1081,8 @@ export async function upsertChannelRule(input: { note?: string adminUserId: number | null }): Promise<{ id: number }> { - if (!isMatchKeyType(input.matchKeyType)) { - throw createHttpError('匹配键类型无效', { + if (input.matchKeyType !== 'internal_sku') { + throw createHttpError('渠道配置只接受内部 SKU;旧 sku_code 规则仅用于一次性迁移', { statusCode: 400, errorCode: 'invalid_match_key_type', }) @@ -914,48 +1096,19 @@ export async function upsertChannelRule(input: { throw createHttpError('匹配键不能为空', { statusCode: 400, errorCode: 'match_key_required' }) } - const result = await query<{ id: number }>( - ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, enabled, note, created_by_admin_user_id, created_at, updated_at - ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) - DO UPDATE SET - priority = EXCLUDED.priority, - enabled = EXCLUDED.enabled, - note = EXCLUDED.note, - updated_at = NOW() - RETURNING id - `, - [ - String(input.provider || '').trim(), - String(input.shopId || '').trim(), - input.matchKeyType, - matchKey, - input.channel, - Math.trunc(Number(input.priority) || 100), - source, - input.enabled !== false, - String(input.note || '').slice(0, 200), - input.adminUserId, - ], - ) - - if (input.matchKeyType === 'internal_sku') { - await upsertInternalSkuChannel({ - internalSku: matchKey, - channel: input.channel, - priority: Math.trunc(Number(input.priority) || 100), - isFallback: Math.trunc(Number(input.priority) || 100) >= 90, - enabled: input.enabled !== false, - source: source === 'operator' ? 'operator' : `rule:${source}`, - replacePriority: true, - }) - } - - return { id: result.rows[0]!.id } + const priority = Math.trunc(Number(input.priority) || 100) + const id = await upsertInternalSkuChannel({ + internalSku: matchKey, + channel: input.channel, + priority, + isFallback: priority >= 90, + enabled: input.enabled !== false, + source: source === 'operator' ? 'operator' : `rule:${source}`, + note: String(input.note || '').slice(0, 300), + adminUserId: input.adminUserId, + replacePriority: true, + }) + return { id } } export async function deleteChannelRule(ruleId: number): Promise<{ deleted: boolean }> { @@ -963,25 +1116,7 @@ export async function deleteChannelRule(ruleId: number): Promise<{ deleted: bool if (id <= 0) { throw createHttpError('规则 id 无效', { statusCode: 400, errorCode: 'invalid_rule_id' }) } - const result = await query<{ - id: number - match_key_type: string - match_key: string - channel: string - }>( - `DELETE FROM sku_channel_rules - WHERE id = $1 - RETURNING id, match_key_type, match_key, channel`, - [id], - ) - const deleted = result.rows[0] - if (deleted?.match_key_type === 'internal_sku' && isFulfillmentChannel(deleted.channel)) { - await disableInternalSkuChannel({ - internalSku: deleted.match_key, - channel: deleted.channel, - }) - } - return { deleted: Boolean(deleted) } + return { deleted: await deleteInternalSkuChannelById(id) } } async function loadEnabledRules(): Promise { @@ -992,7 +1127,11 @@ async function loadEnabledRules(): Promise { WHERE enabled = TRUE `, ) - return rows.rows + return rows.rows.map((row) => ({ + ...row, + id: Number(row.id), + priority: Number(row.priority), + })) } async function loadInternalSkuChannelsForKeys( @@ -1004,8 +1143,8 @@ async function loadInternalSkuChannelsForKeys( const rows = await query( ` - SELECT internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source + 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 @@ -1029,19 +1168,23 @@ export const CHANNEL_VALUES = FULFILLMENT_CHANNELS export async function ensureVoucherRuleForSku(skuCode: string): Promise { const key = String(skuCode || '').trim() if (!key) return false - const result = await query<{ id: number }>( - ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, enabled, note, created_at, updated_at - ) - VALUES ('', '', 'sku_code', $1, 'third_party_voucher', 70, 'voucher', TRUE, '卡券商品自动生成', NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING - RETURNING id - `, - [key], + const product = await query<{ id: number }>( + `SELECT id FROM product_catalog + WHERE canonical_sku = $1 AND status = 'active' + LIMIT 1`, + [`tpv:${key}`], ) - return (result.rowCount || 0) > 0 + await upsertInternalSkuChannel({ + internalSku: key, + channel: 'third_party_voucher', + priority: 10, + isFallback: false, + enabled: true, + canonicalProductId: Number(product.rows[0]?.id || 0) || null, + configJson: { voucherSku: key }, + source: 'voucher', + }) + return true } /** @@ -1054,28 +1197,38 @@ export async function ensureVoucherChannelRules(): Promise<{ created: number productCount: number }> { + await query( + `UPDATE internal_sku_channels channel + SET is_fallback = TRUE, + priority = GREATEST(priority, 90), + updated_at = NOW() + FROM product_catalog product + WHERE product.status = 'active' + AND product.canonical_sku LIKE 'tpv:%' + AND BTRIM(channel.internal_sku) = BTRIM(product.canonical_name) + AND channel.channel <> 'third_party_voucher' + AND channel.enabled = TRUE + AND channel.is_fallback = FALSE`, + ) const created = await query<{ id: number }>( ` - INSERT INTO sku_channel_rules ( - provider, shop_id, match_key_type, match_key, channel, priority, - source, enabled, note, created_at, updated_at + INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at ) SELECT - '', '', 'sku_code', substring(p.canonical_sku from 5), 'third_party_voucher', 70, - 'voucher', TRUE, '卡券商品自动生成', NOW(), NOW() + BTRIM(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 WHERE p.status = 'active' AND p.canonical_sku LIKE 'tpv:%' - AND btrim(substring(p.canonical_sku from 5)) <> '' - AND NOT EXISTS ( - SELECT 1 FROM sku_channel_rules r - WHERE r.provider = '' - AND r.shop_id = '' - AND r.match_key_type = 'sku_code' - AND r.match_key = substring(p.canonical_sku from 5) - AND r.channel = 'third_party_voucher' - ) - ON CONFLICT DO NOTHING + AND BTRIM(COALESCE(p.canonical_name, '')) <> '' + ON CONFLICT (internal_sku, channel) DO UPDATE SET + enabled = TRUE, + canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), + config_json = internal_sku_channels.config_json || EXCLUDED.config_json, + updated_at = NOW() RETURNING id `, ) diff --git a/apps/backend/src/services/fulfillment/channel-types.ts b/apps/backend/src/services/fulfillment/channel-types.ts index 26153db2..205dd6fd 100644 --- a/apps/backend/src/services/fulfillment/channel-types.ts +++ b/apps/backend/src/services/fulfillment/channel-types.ts @@ -20,6 +20,16 @@ export const FULFILLMENT_STATUSES = ['pending', 'assigned', 'archived'] as const export type FulfillmentStatus = (typeof FULFILLMENT_STATUSES)[number] +/** 这些任务状态只保留审计价值,不再代表当前履约载体。 */ +export const INACTIVE_CARRIER_TASK_STATUSES = [ + 'closed', + 'expired', + 'failed', + 'manual_review', + 'retry_pending', + 'cancelled', +] as const + export const FULFILLMENT_SOURCES = [ 'auto_rule', 'auto_mapping', 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 3f33d956..66db5f89 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -13,19 +13,27 @@ import test from 'node:test' const TEST_DATABASE_URL = String(process.env.FULFILLMENT_ROUTING_TEST_DATABASE_URL || '').trim() test( - '运营指定渠道:先保证载体,再写 assigned;缺载体保持 pending', + '运营指定渠道:先保证载体;支持安全切换;缺载体保持 pending', { skip: !TEST_DATABASE_URL ? '未设置 FULFILLMENT_ROUTING_TEST_DATABASE_URL' : false }, async () => { process.env.DATABASE_URL = TEST_DATABASE_URL const { closeDb, query } = await import('../../db/client.js') - const { assignChannelByInternalSku } = await import('./channel-assign-service.js') + const { + assignChannelByInternalSku, + deleteChannelRule, + ensureVoucherRuleForSku, + listChannelRules, + switchChannelForOrderItems, + upsertChannelRule, + } = await import('./channel-assign-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 orderIds: number[] = [] const orderItemIds: number[] = [] - const internalSkus = [manualSku, missingAffiliateSku] + const internalSkus = [manualSku, missingAffiliateSku, voucherSku] async function createPendingOrderItem(sku: string) { const order = await query<{ id: number }>( @@ -84,6 +92,33 @@ test( assert.equal(manualItem.rows[0]?.fulfillment_channel, 'manual') assert.ok(Number(manualItem.rows[0]?.fulfillment_config_id) > 0) + const switched = await switchChannelForOrderItems({ + orderItemIds: [manualItemId], + channel: 'third_party_voucher', + adminUserId: null, + note: '集成测试切换', + }) + assert.equal(switched.switched, 1) + assert.equal(switched.failed, 0) + const switchedItem = await query<{ + fulfillment_status: string + fulfillment_channel: string + original_channel: string | null + canonical_product_id: number | null + canonical_sku: string | null + }>( + `SELECT oi.fulfillment_status, oi.fulfillment_channel, oi.original_channel, + oi.canonical_product_id, product.canonical_sku + FROM order_items oi + LEFT JOIN product_catalog product ON product.id = oi.canonical_product_id + WHERE oi.id = $1`, + [manualItemId], + ) + assert.equal(switchedItem.rows[0]?.fulfillment_status, 'assigned') + 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:')) + const missingAffiliateItemId = await createPendingOrderItem(missingAffiliateSku) const affiliateFailed = await assignChannelByInternalSku({ provider: '91kaquan', @@ -107,6 +142,34 @@ test( assert.equal(pendingItem.rows[0]?.fulfillment_status, 'pending') assert.equal(pendingItem.rows[0]?.fulfillment_channel, null) assert.equal(pendingItem.rows[0]?.fulfillment_pending_reason, 'no_carrier') + + await ensureVoucherRuleForSku(voucherSku) + const voucherChannel = await query<{ channel: string; source: string }>( + `SELECT channel, source FROM internal_sku_channels + WHERE internal_sku = $1 ORDER BY channel`, + [voucherSku], + ) + assert.deepEqual(voucherChannel.rows, [{ channel: 'third_party_voucher', source: 'voucher' }]) + const legacyVoucherRules = await query<{ count: number }>( + `SELECT count(*)::int AS count FROM sku_channel_rules + WHERE match_key = $1`, + [voucherSku], + ) + assert.equal(legacyVoucherRules.rows[0]?.count, 0) + const savedRule = await upsertChannelRule({ + matchKeyType: 'internal_sku', + matchKey: voucherSku, + channel: 'third_party_voucher', + priority: 10, + enabled: true, + note: '集成测试统一渠道', + adminUserId: null, + }) + const listedRules = await listChannelRules({ channel: 'third_party_voucher', enabled: true }) + assert.ok( + listedRules.some((rule) => rule.id === savedRule.id && rule.match_key === voucherSku), + ) + assert.equal((await deleteChannelRule(savedRule.id)).deleted, true) } finally { if (orderItemIds.length > 0) { await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [ 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 635a8d32..908d5ae4 100644 --- a/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts +++ b/apps/backend/src/services/fulfillment/intake-channel-decision-service.ts @@ -78,10 +78,14 @@ export async function applyIntakeChannelDecision( 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 || matchAffiliateDashSkuByInternalSku(internalSku) + const dashSku = + existingAffiliateSku || + configuredAffiliateSku || + matchAffiliateDashSkuByInternalSku(internalSku) if (dashSku) { if (canOverrideDefaultManual || selected === 'affiliate_dash') { nextSnapshot.fulfillmentRoute = { 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 0bd3df58..2447ad19 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 @@ -8,6 +8,7 @@ import { function channel(patch: Partial): InternalSkuChannelRow { return { + id: 1, internal_sku: '内部SKU', channel: 'manual', priority: 10, @@ -16,6 +17,8 @@ function channel(patch: Partial): InternalSkuChannelRow { canonical_product_id: null, config_json: {}, source: 'test', + note: '', + created_by_admin_user_id: null, ...patch, } } 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 3dfdb563..a32f4b11 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -3,6 +3,7 @@ import type { FulfillmentChannel } from './channel-types.js' import { compareFulfillmentChannel } from './internal-sku-resolver.js' export type InternalSkuChannelRow = { + id: number internal_sku: string channel: FulfillmentChannel priority: number @@ -11,6 +12,8 @@ export type InternalSkuChannelRow = { canonical_product_id: number | null config_json: Record source: string + note: string + created_by_admin_user_id: number | null } export type InternalSkuChannelPlan = { @@ -27,8 +30,8 @@ export async function listInternalSkuChannels( if (!key) return [] const result = await query( ` - SELECT internal_sku, channel, priority, is_fallback, enabled, canonical_product_id, - config_json, source + 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 ORDER BY @@ -89,11 +92,13 @@ export async function upsertInternalSkuChannel(input: { canonicalProductId?: number | null configJson?: Record | undefined source?: string + note?: string | undefined + adminUserId?: number | null | undefined replacePriority?: boolean -}): Promise { +}): Promise { const internalSku = String(input.internalSku || '').trim() - if (!internalSku) return - await withTransaction(async (client) => { + if (!internalSku) return 0 + return withTransaction(async (client) => { if (input.enabled !== false && input.isFallback !== true) { await client.query( `UPDATE internal_sku_channels @@ -107,15 +112,16 @@ export async function upsertInternalSkuChannel(input: { [internalSku, input.channel], ) } - await client.query( + const result = await client.query<{ id: number }>( ` INSERT INTO internal_sku_channels ( internal_sku, channel, priority, is_fallback, enabled, - canonical_product_id, config_json, source, created_at, updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, NOW(), NOW()) + canonical_product_id, config_json, source, note, created_by_admin_user_id, + created_at, updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, NOW(), NOW()) ON CONFLICT (internal_sku, channel) DO UPDATE SET priority = CASE - WHEN $9::boolean THEN EXCLUDED.priority + WHEN $11::boolean THEN EXCLUDED.priority ELSE LEAST(internal_sku_channels.priority, EXCLUDED.priority) END, is_fallback = EXCLUDED.is_fallback, @@ -123,7 +129,10 @@ export async function upsertInternalSkuChannel(input: { canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), config_json = internal_sku_channels.config_json || EXCLUDED.config_json, source = EXCLUDED.source, + note = CASE WHEN EXCLUDED.note <> '' THEN EXCLUDED.note ELSE internal_sku_channels.note END, + created_by_admin_user_id = COALESCE(EXCLUDED.created_by_admin_user_id, internal_sku_channels.created_by_admin_user_id), updated_at = NOW() + RETURNING id `, [ internalSku, @@ -134,9 +143,12 @@ export async function upsertInternalSkuChannel(input: { input.canonicalProductId ?? null, JSON.stringify(input.configJson || {}), String(input.source || 'auto').trim() || 'auto', + String(input.note || '').slice(0, 300), + input.adminUserId ?? null, input.replacePriority === true, ], ) + return Number(result.rows[0]?.id || 0) }) } @@ -149,22 +161,12 @@ export async function setInternalSkuPrimaryChannel(input: { canonicalProductId?: number | null source?: string configJson?: Record | undefined + note?: string | undefined + adminUserId?: number | null }): Promise { const internalSku = String(input.internalSku || '').trim() if (!internalSku) return - await query( - ` - UPDATE internal_sku_channels - SET priority = GREATEST(priority, 90), - is_fallback = TRUE, - updated_at = NOW() - WHERE btrim(internal_sku) = $1 - AND channel <> $2 - AND enabled = TRUE - `, - [internalSku, input.channel], - ) await upsertInternalSkuChannel({ internalSku, channel: input.channel, @@ -174,6 +176,8 @@ export async function setInternalSkuPrimaryChannel(input: { canonicalProductId: input.canonicalProductId ?? null, configJson: input.configJson, source: input.source || 'operator', + note: input.note, + adminUserId: input.adminUserId, replacePriority: true, }) } @@ -192,3 +196,10 @@ export async function disableInternalSkuChannel(input: { ) return result.rowCount || 0 } + +export async function deleteInternalSkuChannelById(channelId: number): Promise { + const id = Math.trunc(Number(channelId) || 0) + if (id <= 0) return false + const result = await query(`DELETE FROM internal_sku_channels WHERE id = $1`, [id]) + return (result.rowCount || 0) > 0 +} diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index 0a889df3..475a06a0 100644 --- a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts +++ b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts @@ -213,7 +213,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ` SELECT oi.id, oi.fulfillment_channel AS channel, oi.sku_code FROM order_items oi - LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_orders wo ON wo.order_item_id = oi.id AND wo.status <> 'cancelled' WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ('affiliate_dash', 'work_order') AND wo.id IS NULL @@ -221,6 +221,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') ) AND oi.id > $2 ORDER BY oi.id @@ -261,16 +262,18 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { FROM order_items oi WHERE oi.fulfillment_status = 'assigned' AND ( - EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id) + EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled') )::int + ( EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') ) )::int + ( EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'manual_dispatch' + AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') ) )::int > 1 ORDER BY oi.id @@ -362,11 +365,12 @@ async function resolveCarrierDispatchExceptions(): Promise { AND ce.status <> 'resolved' AND ( (oi.fulfillment_channel = 'work_order' - AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id)) + AND EXISTS (SELECT 1 FROM work_orders wo WHERE wo.order_item_id = oi.id AND wo.status <> 'cancelled')) OR (oi.fulfillment_channel = 'affiliate_dash' AND EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') )) ) LIMIT 500 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 2eec3d7b..000716a0 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 @@ -55,6 +55,7 @@ export async function syncWorkerOrdersForSourceOrder( autoOnly?: boolean sourceMetadata?: JsonObject skipChannelAssign?: boolean + preferredWorkRuleKey?: string } = {}, ) { const [rules, mappings] = await Promise.all([ @@ -80,9 +81,36 @@ export async function syncWorkerOrdersForSourceOrder( continue } - const match = resolveMatchingProductRuleDecision(order, item, rules, mappings, { - legacyFallbackEnabled: productMatchConfig.legacyFallbackEnabled, - }) + const preferredRuleKey = String(options.preferredWorkRuleKey || '').trim() + 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 matchContext = resolveKuaishouWorkProductMatchContext(item) const snapshot = safeParseJson(item.item_snapshot_json) const sellerId = diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index a4942e86..df7de64a 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -58,11 +58,7 @@ const CHANNEL_COLOR: Record = { manual: 'orange', } -const MATCH_KEY_OPTIONS = [ - { value: 'sku_code', label: '平台 SKU' }, - { value: 'internal_sku', label: '内部 SKU' }, - { value: 'sku_name', label: 'SKU 名称' }, -] +const MATCH_KEY_OPTIONS = [{ value: 'internal_sku', label: '内部 SKU' }] export default function AdminFulfillmentChannelPage() { return ( @@ -214,7 +210,7 @@ function PendingPanel() { type="info" showIcon style={{ marginBottom: 12 }} - message="待分类订单的收入已经计入「未分类」桶,不会丢失;指定渠道后会自动进入商品归一与成本核算。" + message="指定渠道会写入该内部 SKU 的全局配置,并影响所有平台和店铺;系统会先确认载体创建成功,再进入商品归一与成本核算。" /> rowKey(row)} @@ -259,8 +255,7 @@ function RulesPanel() { }) const columns = [ - { title: '来源', dataIndex: 'provider', width: 120, render: (v: string) => v || '不限' }, - { title: '店铺', dataIndex: 'shop_id', width: 140, render: (v: string) => v || '不限' }, + { title: '作用范围', width: 110, render: () => 全局 }, { title: '匹配键', dataIndex: 'match_key_type', @@ -303,7 +298,7 @@ function RulesPanel() { return ( - ) : null - } + extra={canConfig ? 自动同步 : null} > (null) - const [syncingPrice, setSyncingPrice] = useState(false) const [exportingRules, setExportingRules] = useState(false) const [activeMappingId, setActiveMappingId] = useState(null) const [sourcePrefill, setSourcePrefill] = useState(null) @@ -296,9 +293,6 @@ export default function ProductRulesPanel() { itemTitleMatchType: rule.match.itemTitleMatchType, skuNickMatchType: rule.match.skuNickMatchType, categoryId: rule.categoryId || undefined, - pricingMode: rule.unitPriceFen > 0 ? 'unit' : 'fixed', - rewardAmount: rule.rewardAmount / 100, - unitPrice: rule.unitPriceFen / 100, sharingEnabled: rule.sharing?.enabled || false, sharingAutoFromOrder: rule.sharing?.autoFromOrder || false, sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined, @@ -342,9 +336,6 @@ export default function ProductRulesPanel() { itemTitleMatchType?: 'contains' | 'exact' skuNickMatchType?: 'contains' | 'exact' categoryId?: number - rewardAmount?: number - pricingMode?: string - unitPrice?: number sharingEnabled?: boolean sharingAutoFromOrder?: boolean sharingTotalQuantity?: number @@ -361,7 +352,6 @@ export default function ProductRulesPanel() { sortOrder?: number }) { try { - const unitMode = values.pricingMode === 'unit' const isFriendGift = values.acceptanceMode === 'friend_gift' if (isFriendGift && values.sharingEnabled === true) { message.error('好友赠送验收模式不支持拼单,请先关闭拼单') @@ -372,12 +362,11 @@ export default function ProductRulesPanel() { ? Number(values.sharingTotalQuantity || 0) > 0 || Number(values.sharingUnitReward || 0) > 0 : hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward) - const { pricingMode: _pricingMode, giftCooldownUnit, giftCooldownValue, ...payload } = values + const { giftCooldownUnit, giftCooldownValue, ...payload } = values const res = await saveAdminWorkProductRule({ ...payload, ruleId: editingRule?.ruleId, ruleKey: editingRule?.ruleKey || '', - unitPrice: unitMode ? Number(values.unitPrice || 0) : 0, acceptanceMode: isFriendGift ? 'friend_gift' : 'standard', giftCooldownHours: isFriendGift && giftCooldownUnit !== 'minutes' ? Number(giftCooldownValue || 0) : 0, @@ -385,10 +374,18 @@ export default function ProductRulesPanel() { isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0, sharingEnabled: isFriendGift ? false : values.sharingEnabled === true, sharingAutoFromOrder: - unitMode && values.sharingEnabled === true && !hasManualSharingOverride, + editingRule?.unitPriceFen !== undefined && + editingRule.unitPriceFen > 0 && + editingRule.rewardAmount === 0 && + values.sharingEnabled === true && + !hasManualSharingOverride, }) - message.success(editingRule ? '接单模板已更新' : '接单模板已创建') const savedRule = res.data?.rule + if (res.data?.costConfigurationRequired) { + message.warning('模板已保存为停用。请到【商品与成本中心】配置成本后再启用') + } else { + message.success(editingRule ? '接单模板已更新' : '接单模板已创建') + } if (savedRule) { editRule(savedRule) } else { @@ -401,42 +398,15 @@ export default function ProductRulesPanel() { queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-summary'], }), + queryClient.invalidateQueries({ + predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'), + }), ]) } catch (error) { message.error(error instanceof Error ? error.message : '保存失败') } } - function confirmSyncRulePrice() { - if (!editingRule) return - Modal.confirm({ - title: '同步模板价格', - content: - '将同步接单大厅中该模板的待抢单价格。部分拼单仅调整剩余份额价格,已加入份额报酬保持不变。', - okText: '确认同步', - cancelText: '取消', - onOk: async () => { - setSyncingPrice(true) - try { - const response = await syncAdminWorkProductRulePrice(editingRule.ruleId) - const result = response.data - message.success( - `已同步 ${result.updatedCount} 单:待抢单 ${result.updatedNormalCount} 单,部分拼单 ${result.updatedPartialSharingCount} 单`, - ) - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'] }), - queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-orders'] }), - ]) - } catch (error) { - message.error(error instanceof Error ? error.message : '同步价格失败') - throw error - } finally { - setSyncingPrice(false) - } - }, - }) - } - async function toggleRuleEnabled(rule: WorkProductRule, enabled: boolean) { try { const hasManualSharingOverride = hasManualSharingInput( @@ -450,8 +420,6 @@ export default function ProductRulesPanel() { platform: rule.platform, productName: rule.productName, categoryId: rule.categoryId || undefined, - rewardAmount: rule.rewardAmount / 100, - unitPrice: rule.unitPriceFen / 100, sharingEnabled: rule.sharing?.enabled || false, sharingAutoFromOrder: rule.unitPriceFen > 0 && rule.sharing?.enabled === true && !hasManualSharingOverride, @@ -473,6 +441,9 @@ export default function ProductRulesPanel() { await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-product-rules'], }) + await queryClient.invalidateQueries({ + predicate: (query) => String(query.queryKey[0] || '').startsWith('admin-cost-center'), + }) } catch (error) { message.error(error instanceof Error ? error.message : '修改状态失败') } @@ -787,14 +758,22 @@ export default function ProductRulesPanel() { width: '20%', render: (_, row) => (
- - {row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)} - - {row.unitPriceFen > 0 ? ( - - 按件: {formatMoney(row.unitPriceFen)} - - ) : null} + {row.rewardAmount > 0 || row.unitPriceFen > 0 ? ( + <> + + {row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)} + + {row.unitPriceFen > 0 ? ( + + 按件: {formatMoney(row.unitPriceFen)} + + ) : null} + + ) : ( + + 待成本配置 + + )}
), }, @@ -891,11 +870,6 @@ export default function ProductRulesPanel() { + 新建模板 ) : null} - {editingRule ? ( - - ) : null} @@ -913,9 +887,7 @@ export default function ProductRulesPanel() { matchType: 'contains', itemTitleMatchType: 'exact', skuNickMatchType: 'exact', - pricingMode: 'fixed', - rewardAmount: 25, - enabled: true, + enabled: false, autoCreate: true, sharingEnabled: false, acceptanceMode: 'standard', @@ -926,23 +898,21 @@ export default function ProductRulesPanel() { }} onFinish={saveRule} > - {editingRule ? ( - - 接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往 - - 【商品与成本中心】 - - 进行修改。 - - } - /> - ) : null} + + 固定金额、按件单价和价格同步统一由 + + 商品与成本中心 + + 管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。 + + } + /> {/* 基础信息 */}
@@ -965,55 +935,8 @@ export default function ProductRulesPanel() {
- {/* Row 2: 计价方式 + 单价/金额 + 任务时限 */} + {/* Row 2: 任务时限 */}
- - - - - - {({ getFieldValue }) => - getFieldValue('pricingMode') === 'unit' ? ( - - - - ) : ( - - - - ) - } - - @@ -1124,13 +1047,23 @@ export default function ProductRulesPanel() { {({ getFieldValue }) => { const sharingEnabled = getFieldValue('sharingEnabled') - const unitMode = getFieldValue('pricingMode') === 'unit' + const unitMode = + Number(editingRule?.unitPriceFen || 0) > 0 && + Number(editingRule?.rewardAmount || 0) === 0 const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift' + const hasConfiguredPrice = + Number(editingRule?.unitPriceFen || 0) > 0 || + Number(editingRule?.rewardAmount || 0) > 0 return (
- - + + @@ -1139,9 +1072,15 @@ export default function ProductRulesPanel() { label="启用拼单" name="sharingEnabled" valuePropName="checked" - tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined} + tooltip={ + friendGiftMode + ? '好友赠送模式不支持拼单' + : !hasConfiguredPrice + ? '请先在商品与成本中心配置成本' + : undefined + } > - + {sharingEnabled ? ( diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index ad92e8f4..3671398a 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -64,17 +64,6 @@ export function applyAdminTemplateProductBootstrap(payload?: { ) } -export function syncAdminWorkOrderTemplateCostVersions() { - return apiPost<{ - ruleCount: number - unitPriceRuleCount: number - activeVersionCount: number - createdCount: number - retiredCount: number - linkedSnapshotCount: number - }>('/api/v1/admin/cost-center/work-order-template-costs/sync') -} - export function backfillAdminWorkOrderProductSkus(payload?: { dateFrom?: string dateTo?: string @@ -493,6 +482,7 @@ export function setAdminProductPrice( productId: number, payload: { amountFen: number + pricingMode?: 'fixed' | 'unit' effectiveFrom?: string changeReason?: string syncOpenOrders?: boolean @@ -503,6 +493,7 @@ export function setAdminProductPrice( costVersionId: number amountFen: number effectiveFrom: string + pricingMode?: 'fixed' | 'unit' syncedOrders?: { candidateCount: number updatedCount: number diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index ab9cc411..9b6fca9f 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -290,8 +290,6 @@ export function saveAdminWorkProductRule(payload: { categoryId?: number enabled?: boolean autoCreate?: boolean - rewardAmount?: number - unitPrice?: number sharingEnabled?: boolean sharingAutoFromOrder?: boolean sharingTotalQuantity?: number @@ -305,7 +303,12 @@ export function saveAdminWorkProductRule(payload: { fieldsText?: string sortOrder?: number }) { - return apiPost<{ rule: WorkProductRule }>('/api/v1/admin/worker-platform/product-rules', payload) + return apiPost<{ + rule: WorkProductRule + productId?: number + productStatus?: 'active' | 'archived' + costConfigurationRequired?: boolean + }>('/api/v1/admin/worker-platform/product-rules', payload) } export function syncAdminWorkProductRulePrice(ruleId: number) { diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 8928ca1f..3f4c9542 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -51,6 +51,9 @@ export interface ProductCatalogItem { costVersionCount: number activeCostAmountFen?: number | null activeCostEffectiveFrom?: string | null + templatePricingMode?: 'fixed' | 'unit' | null + templateEnabled?: boolean | null + templateSharingEnabled?: boolean | null /** 自动发货履约任务数,实时取自 fulfillment_tasks。 */ affiliateTaskCount: number affiliateLastFulfilledAt: string | null @@ -231,9 +234,6 @@ export interface TemplateProductBootstrapResult { resetOrderItemCount: number mappingConflictCount: number mappingConflictItemCount: number - templateCostVersionCount: number - templateCostCreatedCount: number - templateCostRetiredCount: number } export interface CostLine { diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 233357dc..3bac7074 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -151,23 +151,30 @@ normalized_sku_series -- 剥离数量并归一同义字后的名称系列 **`normalized_quantity` 不预填。** 预填会把"5 个"类 SKU 永久锁死成 1,必须由回填脚本按 SKU 名称解析后写入。`quantity` 长期几乎恒为 1,禁止直接拿它当销量。 -### 2.4 接单模板批量初始化 +### 2.4 接单模板商品与成本自动投影 -接单模板不允许逐个手工建商品。后台「从接单模板全量同步商品与映射」按以下规则批量生成: +日常流程不再依赖「从接单模板初始化」: -1. **商品全量同步,不依赖订单**:所有启用模板都建/刷新标准商品,内部编码 `tpl:`;模板改名、改分类、改发单金额都会刷新到商品上。新模板还没有工单也能在「商品与成本中心」直接定价。停用模板默认不建商品,勾选后可一并建成「归档」商品。 -2. **渠道映射仍只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板只建商品、不建映射——没有订单就没有平台 SKU 可映射。 -3. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。 -4. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。 -5. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。 -6. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。 -7. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。 -8. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。 -9. 重跑初始化只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。 +1. **商品事件驱动**:接单模板保存时,在同一事务内自动创建/更新 `tpl:` 标准商品;模板改名、改分类和启停都会同步投影。新模板默认停用,但商品立即出现在成本中心等待配置价格。 +2. **成本唯一写入口**:固定金额、按件单价和待抢单价格同步只允许从「商品与成本中心 → 标准商品 → 配置成本」写入。接单模板 API 拒绝新增或修改价格。 +3. **事务一致性**:成本中心改价会在同一事务内新增/退休成本版本、更新 `work_product_rules` 运行价格和商品价格属性;事务成功后才同步未领取的大厅工单。 +4. **启用前校验**:模板必须有当前生效且金额大于 0 的规则级成本版本,才允许启用。历史已领取工单保持原价。 +5. **历史修复工具**:「修复历史商品/映射」只用于补历史渠道映射和修复异常商品,不再同步模板价格,也不是新模板或新成本的必经步骤。 + +历史渠道映射修复仍按以下规则执行: + +1. **映射只按工单证据建**:默认最近 30 天,可在页面日期筛选里调整;范围内没有工单的模板不会凭空生成平台 SKU 映射。 +2. **范围内的工单直接命中模板**,以 `work_orders.product_rule_id` 作为映射的第一手证据,不只看原始 `sku_code`。 +3. 同一 `provider + shop_id + sku_code` 只命中一个模板时,生成 `sku_code` 精确映射。 +4. 编码被多个模板复用时,继续用"编码 + 归一名称系列"和"编码 + 精确名称"消歧;只有唯一归属才生成映射。 +5. 同一编码 + 同一名称仍命中多个模板时,**不选最新、不按次数猜**;跳过并列入冲突清单。 +6. 来源 + 店铺内名称系列唯一时,额外生成 `sku_name_series` 兜底,永远排在编码类映射之后。 +7. 数量系列(1个/2个/5个/10个,秘钥/密钥等等价写法)归为同一标准商品;数量写入 `normalized_quantity`,**不能把"5个"再写成 `quantity_multiplier=5`**,否则会重复乘算。 +8. 重跑修复只清理**所选时间范围内**由 `source='import'`、备注为"接单模板 … 自动初始化"的映射带出的订单项 `canonical_product_id`,并删除范围内使用到的导入映射;范围外的人工映射、成本版本、快照和真实订单/工单、已建商品都不删除。 本地验证结果(2026-10-18):启用模板 662 个;分层映射 4,426 条(编码精确 2,302、编码 + 精确名称 440、编码 + 名称系列 160、名称系列兜底 1,524);可唯一归一 86,533 个有证据订单项,另有 25,918 个订单项属于 32 组真实冲突,保持未映射。 -全量同步改版后(2026-10-29,本地库预览 09-30 ~ 10-29):模板 710 个全部启用、范围内有工单证据 388 个;商品新建 704 个 / 复用 6 个,候选映射 3,198 条。之前被跳过的 322 个无单模板,现在也会有商品(可定价),只是暂时没有渠道映射。 +迁移 122 后(2026-11-02 本地核对):714 个接单模板全部有 `tpl:` 商品,无缺失;没有工单证据的模板也可以直接在成本中心定价,只是暂时没有渠道映射。 ### 2.5 自动发货商品 @@ -223,7 +230,7 @@ cost_versions | 编码 | 成本模型 | 说明 | | --- | --- | --- | -| `sys_work_order_reward` | `work_order_reward` | 662 个接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 | +| `sys_work_order_reward` | `work_order_reward` | 每个可启用的接单模板各一条规则级版本;全局 0 元版本只作"按工单实际结算"的占位 | | `sys_affiliate_purchase` | `affiliate_purchase` | 上游 SKU 固定成本版本,38 个商品级 | | `sys_manual_service` | `manual_actual` | 订单页确认履约写入 `actual` 快照;版本为 0 元占位 | diff --git a/docs/生产部署核对清单-2026-11-02.md b/docs/生产部署核对清单-2026-11-02.md index d4984963..16148048 100644 --- a/docs/生产部署核对清单-2026-11-02.md +++ b/docs/生产部署核对清单-2026-11-02.md @@ -26,6 +26,7 @@ 119_fulfillment_pending_reason 120_fulfillment_routing_invariants 121_internal_sku_single_primary +122_work_order_template_product_projection ``` 重点检查: @@ -36,11 +37,12 @@ - 119:只加列和索引,风险低。 - 120:建立统一内部 SKU SQL 函数、回填人工配置、迁移可确定的旧 sku_code 规则。 - 121:建立唯一主渠道索引;若仍有并列主渠道会主动阻断迁移。 +- 122:按接单模板补齐/刷新 `tpl:` 商品,只做 UPSERT,不删除商品、成本、订单或工单。 - SQL 护栏默认只保护到生产已发布的迁移 119;部署 120 后应把 `PUBLISHED_MIGRATION_MAX_VERSION` 提升为 120。 ## 3. 部署顺序 -1. 执行迁移 116–121; +1. 执行迁移 116–122; 2. 部署 backend; 3. 部署 frontend; 4. 启动/恢复定时任务。 @@ -161,6 +163,6 @@ npx tsx scripts/reclassify-post-cutover.ts --apply - 代码:回退到部署前镜像/commit; - 数据库: - - 116–121 均为新增表/列/函数/数据,不删旧表,可保留; + - 116–122 均为新增表/列/函数/投影数据,不删旧表,可保留; - 若必须回滚,恢复到部署前备份; - 迁移 118–121 不改旧字段语义,回滚代码后旧逻辑仍可运行;121 的唯一主渠道索引会阻止旧代码写入并列主渠道。 diff --git a/docs/部署启动与数据库重建.md b/docs/部署启动与数据库重建.md index da3fd8da..7964893b 100644 --- a/docs/部署启动与数据库重建.md +++ b/docs/部署启动与数据库重建.md @@ -244,10 +244,10 @@ bash deploy/post-deploy.sh --only=backfill # ① 收入回填 + ② 履约 1. 钱包管理 → 补建客服备用金账户 → 按需拨付; 2. 商品与成本 → 积分钱包 → 给客服发放三平台积分; -3. 商品与成本 → 标准商品 → 从接单模板初始化 → 按工单回填商品 → 归一人工商品; +3. 商品与成本 → 标准商品:迁移 122 会自动补齐接单模板商品;新模板保存时也会自动建商品。按需执行「按工单回填商品」和「归一人工商品」,不再需要从接单模板初始化; 4. 商品与成本 → 人工 SKU 配置 → 扫描并逐项配置(保存后自动回填订单项)。 -> 第 3 步的商品目录/工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个 +> 第 3 步的工单回填必须先于下一节 `--only=sync`。商品目录为空时同步会为每个 > 未归一订单项写一条 `product_mapping_missing` 异常(实测 90 天约 36 万条)。 ### 3. 财务同步 From 31b6d2d65ffc39b0abd6424512429b0a46c72ae4 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 17:07:29 +0800 Subject: [PATCH 07/12] =?UTF-8?q?fix(cost-center):=20=E5=BD=92=E6=A1=A3?= =?UTF-8?q?=E5=B7=B2=E8=A2=AB=E5=85=B6=E4=BB=96=E6=B8=A0=E9=81=93=E6=8E=A5?= =?UTF-8?q?=E7=AE=A1=E7=9A=84=E4=BA=BA=E5=B7=A5=E5=95=86=E5=93=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/cost-center-catalog.ts | 17 +++ .../cost-center/cost-center-mappers.ts | 20 ++- .../cost-center-overview-service.ts | 45 +++++-- .../services/cost-center/cost-center-types.ts | 1 + .../services/cost-center/cost-center.test.ts | 7 ++ ...manual-product-cleanup.integration.test.ts | 84 +++++++++++++ .../cost-center/product-catalog-service.ts | 117 ++++++++++++++++++ .../admin/cost-center/ProductCatalogPanel.tsx | 67 ++++++++-- .../src/services/admin/cost-center.ts | 17 +++ apps/frontend/src/types/admin/cost-center.ts | 1 + docs/成本中心与履约模型.md | 1 + 11 files changed, 361 insertions(+), 16 deletions(-) create mode 100644 apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index c884b599..213d4cc7 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -10,6 +10,7 @@ import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-as import { archiveHistoricalManualProducts, + archiveSupersededManualProducts, backfillAdminNormalizedQuantities, backfillAdminProductMappings, createAdminChannelMapping, @@ -178,6 +179,22 @@ router.post( }), ) +router.post( + '/cost-center/products/archive-superseded-manual', + requireAdminPermission('finance.config'), + createJsonHandler((req) => archiveSupersededManualProducts(bodyOf(req)), { + successMessage: '重复人工归一商品已归档', + errorMessage: '归档重复人工归一商品失败', + scope: '[admin/cost-center/products/archive-superseded-manual]', + audit: (_req, data) => ({ + action: 'cost_superseded_manual_products_archived', + targetType: 'product_catalog', + targetId: '', + data: data as Record, + }), + }), +) + router.patch( '/cost-center/products/:productId', requireAdminPermission('finance.config'), 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 210c1847..fac496db 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -34,12 +34,27 @@ export function productSourceOf(canonicalSku: string): string { } /** 商品来源筛选的 SQL 片段:按 canonical_sku 前缀过滤。 */ -export function productSourceFilterSql(paramIndex: number, column = 'p.canonical_sku'): string { +export function productSourceFilterSql( + paramIndex: number, + column = 'p.canonical_sku', + nameColumn = 'p.canonical_name', +): string { return `( ($${paramIndex} = 'affiliate_dash' AND ${column} LIKE 'aff:%') OR ($${paramIndex} = 'work_order' AND (${column} LIKE 'tpl:%' OR ${column} LIKE 'wo:%')) OR ($${paramIndex} = 'third_party_voucher' AND ${column} LIKE 'tpv:%') - OR ($${paramIndex} = 'manual' AND ${column} LIKE 'man:%') + OR ( + $${paramIndex} = 'manual' + AND ${column} LIKE 'man:%' + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels live_channel + WHERE live_channel.enabled = TRUE + AND live_channel.is_fallback = FALSE + AND live_channel.channel <> 'manual' + AND BTRIM(live_channel.internal_sku) = BTRIM(${nameColumn}) + ) + ) )` } @@ -121,6 +136,7 @@ export function mapProduct(row: ProductRow) { row.template_sharing_enabled === null || row.template_sharing_enabled === undefined ? null : row.template_sharing_enabled === true, + activePrimaryChannel: row.active_primary_channel || null, affiliateTaskCount: Number(row.affiliate_task_count || 0), affiliateLastFulfilledAt: row.affiliate_last_fulfilled_at || null, createdAt: row.created_at, 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 b7204500..3c8f58c4 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 @@ -42,7 +42,18 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} OR ($1 = 'affiliate_dash' AND p.canonical_sku LIKE 'aff:%') OR ($1 = 'work_order' AND (p.canonical_sku LIKE 'tpl:%' OR p.canonical_sku LIKE 'wo:%')) OR ($1 = 'third_party_voucher' AND p.canonical_sku LIKE 'tpv:%') - OR ($1 = 'manual' AND p.canonical_sku LIKE 'man:%') + OR ( + $1 = 'manual' + AND p.canonical_sku LIKE 'man:%' + AND NOT EXISTS ( + SELECT 1 + FROM internal_sku_channels live_channel + 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) + ) + ) ) ), scoped_cost_models AS ( @@ -99,11 +110,21 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ) const row = result.rows[0] const [productSourceResult, costModelResult] = await Promise.all([ - query<{ canonical_sku: string; total: number }>( - `SELECT canonical_sku, COUNT(*)::int AS total - FROM product_catalog - WHERE status = 'active' - GROUP BY canonical_sku`, + query<{ canonical_sku: string; active_primary_channel: string | null; total: number }>( + `SELECT p.canonical_sku, + ( + SELECT c.channel + FROM internal_sku_channels c + WHERE c.enabled = TRUE + AND c.is_fallback = FALSE + AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + ORDER BY c.priority, c.channel + LIMIT 1 + ) AS active_primary_channel, + COUNT(*)::int AS total + FROM product_catalog p + WHERE p.status = 'active' + GROUP BY p.canonical_sku, p.canonical_name`, ), query<{ cost_model: string; total: number }>( `SELECT cost_model, COUNT(*)::int AS total FROM cost_items WHERE status = 'active' GROUP BY cost_model`, @@ -137,7 +158,9 @@ function normalizeScope(raw: unknown): CostCenterScope | null { } /** 商品来源计数:按 canonical_sku 前缀归类,未分类单独成桶。 */ -function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: number }>) { +function productSourceCountsOf( + rows: Array<{ canonical_sku: string; active_primary_channel: string | null; total: number }>, +) { const counts: Record = { affiliate_dash: 0, work_order: 0, @@ -146,6 +169,14 @@ function productSourceCountsOf(rows: Array<{ canonical_sku: string; total: numbe } for (const row of rows) { const source = productSourceOf(row.canonical_sku) + // man: 只是旧来源;已有其他主渠道的商品不再计入当前人工客服。 + if ( + source === 'manual' && + row.active_primary_channel && + row.active_primary_channel !== 'manual' + ) { + continue + } const key = source || 'uncategorized' counts[key] = (counts[key] || 0) + toNumber(row.total) } diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts index 16bf35f7..99cc9c99 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -29,6 +29,7 @@ export type ProductRow = { template_pricing_mode?: string | null template_enabled?: boolean | null template_sharing_enabled?: boolean | null + active_primary_channel?: string | null affiliate_task_count?: number affiliate_last_fulfilled_at?: string | null } 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 a3f63456..56cdba0c 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -291,6 +291,13 @@ test('成本模型与商品来源必须覆盖第三方卡券渠道', () => { assert.ok(productSourceFilterSql(1).includes("'tpv:%'")) }) +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)')) +}) + test('tpv: 商品来源归为第三方卡券', async () => { const { productSourceOf } = await import('./cost-center-mappers.js') const assert = (await import('node:assert/strict')).default diff --git a/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts b/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts new file mode 100644 index 00000000..de9faff7 --- /dev/null +++ b/apps/backend/src/services/cost-center/legacy-manual-product-cleanup.integration.test.ts @@ -0,0 +1,84 @@ +/** 旧人工归一商品清理集成测试(真实 PostgreSQL)。 */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String( + process.env.LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL || '', +).trim() + +test( + '已被其他主渠道接管的历史 man 商品只归档并修正兜底指向', + { skip: !TEST_DATABASE_URL ? '未设置 LEGACY_MANUAL_PRODUCT_CLEANUP_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { archiveSupersededManualProducts } = await import('./product-catalog-service.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const internalSku = `清理测试人工商品-${suffix}` + const manSku = `man:${internalSku}` + const liveSku = `aff:cleanup-${suffix}` + let manProductId = 0 + let liveProductId = 0 + + try { + const inserted = await query<{ id: number; canonical_sku: string }>( + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES + ($1, $3, '人工客服', '件', 'active', '{"source":"manual_normalization"}'::jsonb, NOW(), NOW()), + ($2, $3, '自动发货', '件', 'active', '{}'::jsonb, NOW(), NOW()) + RETURNING id, canonical_sku + `, + [manSku, liveSku, internalSku], + ) + manProductId = Number(inserted.rows.find((row) => row.canonical_sku === manSku)?.id) + liveProductId = Number(inserted.rows.find((row) => row.canonical_sku === liveSku)?.id) + assert.ok(manProductId > 0) + assert.ok(liveProductId > 0) + + await query( + ` + INSERT INTO internal_sku_channels ( + internal_sku, channel, priority, is_fallback, enabled, + canonical_product_id, config_json, source, created_at, updated_at + ) VALUES + ($1, 'affiliate_dash', 10, FALSE, TRUE, $3, '{}'::jsonb, 'test', NOW(), NOW()), + ($1, 'manual', 90, TRUE, TRUE, $2, '{}'::jsonb, 'test', NOW(), NOW()) + `, + [internalSku, manProductId, liveProductId], + ) + + const preview = await archiveSupersededManualProducts() + assert.equal(preview.candidateCount, 1) + assert.ok(preview.sample.some((row) => Number(row.id) === manProductId)) + + const result = await archiveSupersededManualProducts({ execute: true }) + assert.equal(result.archivedCount, 1) + assert.equal(result.repointedChannelCount, 1) + + const products = await query<{ canonical_sku: string; status: string }>( + 'SELECT canonical_sku, status FROM product_catalog WHERE id = ANY($1::bigint[])', + [[manProductId, liveProductId]], + ) + assert.equal(products.rows.find((row) => row.canonical_sku === manSku)?.status, 'archived') + assert.equal(products.rows.find((row) => row.canonical_sku === liveSku)?.status, 'active') + const fallback = await query<{ canonical_product_id: number }>( + `SELECT canonical_product_id + FROM internal_sku_channels + WHERE internal_sku = $1 AND channel = 'manual'`, + [internalSku], + ) + assert.equal(Number(fallback.rows[0]?.canonical_product_id), liveProductId) + } finally { + if (manProductId > 0 || liveProductId > 0) { + await query('DELETE FROM internal_sku_channels WHERE internal_sku = $1', [internalSku]) + await query('DELETE FROM product_catalog WHERE id = ANY($1::bigint[])', [ + [manProductId, liveProductId], + ]) + } + await closeDb() + } + }, +) 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 ce900ae8..601e9d1b 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -160,6 +160,15 @@ export async function listAdminProducts(payload: JsonObject = {}) { END AS template_pricing_mode, tpl_rule.enabled AS template_enabled, tpl_rule.sharing_enabled AS template_sharing_enabled, + ( + SELECT c.channel + FROM internal_sku_channels c + WHERE c.enabled = TRUE + AND c.is_fallback = FALSE + AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + ORDER BY c.priority, c.channel + LIMIT 1 + ) AS active_primary_channel, CASE WHEN c.parent_id IS NOT NULL THEN c.id END AS sub_category_id, CASE WHEN c.parent_id IS NOT NULL THEN c.name ELSE '' END AS sub_category_name, root.id AS root_category_id, @@ -481,6 +490,114 @@ export async function archiveHistoricalManualProducts(payload: JsonObject = {}) } } +type SupersededManualProductRow = { + id: number + canonical_sku: string + canonical_name: string + primary_channel: string + target_product_id: number + target_product_sku: string +} + +/** + * 归档已被其他渠道主规则接管的旧人工归一商品。 + * + * `man:` 只表示旧人工来源,不代表当前主渠道。同名内部 SKU 已有自动发货、 + * 接单平台或卡券主渠道后,旧 man: 商品不再参与新路由,只需从当前目录归档。 + * 历史订单、成本版本和成本快照全部保留;仍指向旧商品的手动兜底改指当前主商品。 + */ +export async function archiveSupersededManualProducts(payload: JsonObject = {}) { + const execute = payload.execute === true || payload.execute === 'true' + return withTransaction(async (client) => { + const candidates = await client.query( + ` + SELECT + p.id, + p.canonical_sku, + p.canonical_name, + live.channel AS primary_channel, + target.id AS target_product_id, + target.canonical_sku AS target_product_sku + FROM product_catalog p + JOIN LATERAL ( + SELECT c.channel, c.canonical_product_id + FROM internal_sku_channels c + WHERE c.enabled = TRUE + AND c.is_fallback = FALSE + AND c.channel <> 'manual' + AND BTRIM(c.internal_sku) = BTRIM(p.canonical_name) + ORDER BY c.priority, c.channel + LIMIT 1 + ) live ON TRUE + JOIN product_catalog target + ON target.id = live.canonical_product_id + AND target.status = 'active' + WHERE p.status = 'active' + AND p.canonical_sku LIKE 'man:%' + AND p.attributes_json->>'source' = 'manual_normalization' + ORDER BY p.id + FOR UPDATE OF p + `, + ) + const rows = candidates.rows.map((row) => ({ + ...row, + id: Number(row.id), + target_product_id: Number(row.target_product_id), + })) + if (!execute || rows.length === 0) { + return { + dryRun: !execute, + candidateCount: rows.length, + archivedCount: 0, + repointedChannelCount: 0, + sample: rows.slice(0, 20), + } + } + + const repointed = await client.query( + ` + UPDATE internal_sku_channels c + SET canonical_product_id = input.target_product_id, + 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 + AND c.channel = 'manual' + AND c.canonical_product_id = input.source_product_id + `, + [ + rows.map((row) => row.canonical_name), + rows.map((row) => row.id), + rows.map((row) => row.target_product_id), + ], + ) + const archived = await client.query( + ` + UPDATE product_catalog + SET status = 'archived', + attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb, + updated_at = NOW() + WHERE id = ANY($2::bigint[]) AND status = 'active' + `, + [ + JSON.stringify({ + legacyManualArchived: true, + legacyManualArchiveReason: 'superseded_by_active_primary_channel', + legacyManualArchivedAt: nowIso(), + }), + rows.map((row) => row.id), + ], + ) + return { + dryRun: false, + candidateCount: rows.length, + archivedCount: archived.rowCount || 0, + repointedChannelCount: repointed.rowCount || 0, + sample: rows.slice(0, 20), + } + }) +} + type MappingProductGroupRow = { canonical_product_id: number canonical_sku: string diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 831e02db..48ec4e32 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -43,6 +43,7 @@ import dayjs from '@/lib/dayjs' import { applyAdminTemplateProductBootstrap, archiveAdminHistoricalManualProducts, + archiveAdminSupersededManualProducts, createAdminProductCatalog, deleteAdminProductCatalog, fetchAdminProductCatalog, @@ -56,6 +57,7 @@ import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admi import { useCostCenter } from './CostCenterContext' import { + FULFILLMENT_MODE_LABELS, PRODUCT_SOURCE_OPTIONS, PRODUCT_SOURCE_TAG_COLORS, labelOf, @@ -108,6 +110,7 @@ export function ProductCatalogPanel() { const [bootstrapPreviewing, setBootstrapPreviewing] = useState(false) const [bootstrapApplying, setBootstrapApplying] = useState(false) const [archivingHistoricalManuals, setArchivingHistoricalManuals] = useState(false) + const [archivingSupersededManuals, setArchivingSupersededManuals] = useState(false) const bootstrapSyncOptionsRef = useRef({ includeDisabledTemplates: false }) const [productPage, setProductPage] = useState(1) const [productPageSize, setProductPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) @@ -487,6 +490,41 @@ export function ProductCatalogPanel() { } } + async function archiveSupersededManuals() { + setArchivingSupersededManuals(true) + try { + const preview = (await archiveAdminSupersededManualProducts()).data + if (preview.candidateCount === 0) { + message.info('没有已被其他渠道接管的人工归一商品') + return + } + modal.confirm({ + title: '归档跨渠道重复的人工归一商品', + content: `发现 ${preview.candidateCount} 个 man: 商品已有自动发货、接单平台或卡券主渠道。将归档这些旧商品,并把仍指向它们的人工兜底改指当前主商品;不会删除商品行、订单、订单项、成本版本或成本快照。`, + okText: '确认归档', + cancelText: '取消', + onOk: async () => { + setArchivingSupersededManuals(true) + try { + const result = (await archiveAdminSupersededManualProducts({ execute: true })).data + message.success( + `已归档 ${result.archivedCount} 个重复商品,修正 ${result.repointedChannelCount} 条人工兜底`, + ) + refreshAll() + } catch (error) { + message.error(error instanceof Error ? error.message : '归档跨渠道重复商品失败') + } finally { + setArchivingSupersededManuals(false) + } + }, + }) + } catch (error) { + message.error(error instanceof Error ? error.message : '读取跨渠道重复商品失败') + } finally { + setArchivingSupersededManuals(false) + } + } + function deleteProduct(item: ProductCatalogItem) { const affiliateHint = item.source === 'affiliate_dash' @@ -554,6 +592,12 @@ export function ProductCatalogPanel() { {row.templatePricingMode === 'unit' ? '按件计价' : '固定金额'} ) : null} + {row.activePrimaryChannel && row.activePrimaryChannel !== row.source ? ( + + 当前主渠道: + {FULFILLMENT_MODE_LABELS[row.activePrimaryChannel] || row.activePrimaryChannel} + + ) : null}
), @@ -716,13 +760,22 @@ export function ProductCatalogPanel() { canConfig ? ( {productSourceFilter === 'manual' ? ( - + <> + + + ) : null} {productSourceFilter === 'work_order' ? ( <> diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 3671398a..61503fcf 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -105,6 +105,23 @@ export function archiveAdminHistoricalManualProducts(payload: { execute?: boolea }>('/api/v1/admin/cost-center/products/archive-historical-manual', payload) } +export function archiveAdminSupersededManualProducts(payload: { execute?: boolean } = {}) { + return apiPost<{ + dryRun: boolean + candidateCount: number + archivedCount: number + repointedChannelCount: number + sample: Array<{ + id: number + canonical_sku: string + canonical_name: string + primary_channel: string + target_product_id: number + target_product_sku: string + }> + }>('/api/v1/admin/cost-center/products/archive-superseded-manual', payload) +} + export function syncAdminAffiliateDashProducts() { return apiPost<{ createdCount: number; updatedCount: number; productCount: number }>( '/api/v1/admin/cost-center/affiliate-products/sync', diff --git a/apps/frontend/src/types/admin/cost-center.ts b/apps/frontend/src/types/admin/cost-center.ts index 3f4c9542..2f3c4cfd 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -54,6 +54,7 @@ export interface ProductCatalogItem { templatePricingMode?: 'fixed' | 'unit' | null templateEnabled?: boolean | null templateSharingEnabled?: boolean | null + activePrimaryChannel?: FulfillmentChannel | null /** 自动发货履约任务数,实时取自 fulfillment_tasks。 */ affiliateTaskCount: number affiliateLastFulfilledAt: string | null diff --git a/docs/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 3bac7074..8bc65685 100644 --- a/docs/成本中心与履约模型.md +++ b/docs/成本中心与履约模型.md @@ -395,6 +395,7 @@ fulfillment_decided_at / fulfillment_reason 4. **商品来源不能反推渠道**:`man:` 商品也可能由自动发货履约,判定只认规则与证据。 5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。 6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。 +7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。 --- From 735a791a2e899106fb1babf9ea42723eba63b353 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 17:17:46 +0800 Subject: [PATCH 08/12] =?UTF-8?q?perf(cost-center):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E6=80=BB=E8=A7=88=E5=88=B7=E6=96=B0=E6=9F=A5=E8=AF=A2=E4=B8=8E?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=8D=A0=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cost-center-overview-service.ts | 5 ++--- .../services/cost-center/cost-center.test.ts | 10 +++++++++ .../admin/cost-center/CostCenterShell.tsx | 22 ++++++++++++------- .../admin/cost-center/ProductCatalogPanel.tsx | 5 +++-- 4 files changed, 29 insertions(+), 13 deletions(-) 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 3c8f58c4..afc47bf1 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 @@ -89,12 +89,11 @@ export async function getAdminCostCenterOverview(params: { scope?: string } = {} ( SELECT COUNT(*)::int FROM cost_exceptions ce + JOIN order_items oi ON oi.id = ce.order_item_id WHERE ce.status = 'open' AND ( $1::text IS NULL - OR ce.order_item_id IN ( - SELECT oi.id FROM order_items oi WHERE COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1 - ) + OR COALESCE(oi.fulfillment_channel, CASE WHEN oi.fulfillment_status = 'archived' THEN 'archived' ELSE 'pending' END) = $1 ) ) AS open_exception_count, ( 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 56cdba0c..c65bbe97 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -298,6 +298,16 @@ test('人工客服筛选排除已有其他主渠道的旧 man 商品', () => { assert.ok(sql.includes('BTRIM(live_channel.internal_sku) = BTRIM(p.canonical_name)')) }) +test('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', async () => { + const { readFile } = await import('node:fs/promises') + const source = await readFile( + new URL('./cost-center-overview-service.ts', import.meta.url), + 'utf8', + ) + assert.ok(source.includes('JOIN order_items oi ON oi.id = ce.order_item_id')) + assert.ok(!source.includes('ce.order_item_id IN (')) +}) + test('tpv: 商品来源归为第三方卡券', async () => { const { productSourceOf } = await import('./cost-center-mappers.js') const assert = (await import('node:assert/strict')).default diff --git a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx index 42edb0df..9981a5f3 100644 --- a/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx +++ b/apps/frontend/src/pages/admin/cost-center/CostCenterShell.tsx @@ -22,7 +22,7 @@ import { Tag, Typography, } from 'antd' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useIsFetching, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect } from 'react' import FinanceSubNav from '@/components/admin/FinanceSubNav' @@ -97,6 +97,7 @@ export default function CostCenterShell() { const overviewQuery = useQuery({ queryKey: ['admin-cost-center-overview', scope], queryFn: () => fetchAdminCostCenterOverview({ scope }), + placeholderData: keepPreviousData, }) const optionsQuery = useQuery({ queryKey: ['admin-cost-center-options'], @@ -105,6 +106,9 @@ export default function CostCenterShell() { const overview = overviewQuery.data?.data const options = optionsQuery.data?.data + const costCenterFetching = useIsFetching({ + predicate: (query) => String(query.queryKey[0] ?? '').startsWith('admin-cost-center'), + }) function refreshAll() { // 按前缀失效:新增一个 admin-cost-center-* 查询不需要再回来改这里。 @@ -145,7 +149,7 @@ export default function CostCenterShell() { size="small" icon={} onClick={refreshAll} - loading={overviewQuery.isFetching} + loading={costCenterFetching > 0} > 刷新 @@ -162,7 +166,7 @@ export default function CostCenterShell() { ].map(([label, value]) => ( - + ))} @@ -179,11 +183,13 @@ export default function CostCenterShell() { {item.label} - {Number( - item.key === 'all' - ? (overview?.productTotalCount ?? overview?.productCount ?? 0) - : (overview?.productSourceCounts?.[item.key] ?? 0), - )} + {overview + ? Number( + item.key === 'all' + ? (overview.productTotalCount ?? overview.productCount ?? 0) + : (overview.productSourceCounts?.[item.key] ?? 0), + ) + : '—'} ), diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index 48ec4e32..af05be39 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -34,7 +34,7 @@ import { } from 'antd' import type { TableColumnsType } from 'antd' import PercentTable from '@/components/common/PercentTable' -import { useQuery, useQueryClient } from '@tanstack/react-query' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' import { useEffect, useRef, useState } from 'react' import type { Dayjs } from 'dayjs' @@ -152,6 +152,7 @@ export function ProductCatalogPanel() { page: productPage, pageSize: productPageSize, }), + placeholderData: keepPreviousData, }) const products = productsQuery.data?.data.items || [] @@ -901,7 +902,7 @@ export function ProductCatalogPanel() { size="small" columns={productColumns} dataSource={products} - loading={productsQuery.isLoading} + loading={productsQuery.isFetching} rowClassName={(row) => (row.mappingCount > 0 ? 'product-rule-row-mapped' : '')} scroll={{ x: 1180 }} pagination={buildAdminTablePagination({ From d871b93fde60fb5f5df5d554b57c62199a8b314e Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 17:31:55 +0800 Subject: [PATCH 09/12] =?UTF-8?q?feat(admin):=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=B1=A5=E7=BA=A6=E6=B8=A0=E9=81=93=E6=89=B9=E9=87=8F=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=B9=B6=E7=BB=9F=E4=B8=80=E5=88=86=E9=A1=B5=E9=85=8D?= =?UTF-8?q?=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/fulfillment-channel.ts | 17 ++++ .../fulfillment/channel-assign-service.ts | 16 +++ .../fulfillment-routing.integration.test.ts | 6 +- .../internal-sku-channel-service.ts | 18 ++++ .../pages/admin/AdminFinanceLedgerPage.tsx | 11 +-- .../admin/AdminFulfillmentChannelPage.tsx | 97 ++++++++++++++++--- .../admin/cost-center/VoucherSkuPanel.tsx | 20 ++-- .../pages/admin/fund/PlatformCreditPanel.tsx | 3 +- .../panels/ConsignmentInventoryPanel.tsx | 7 +- .../panels/ConsignmentSubmissionsPanel.tsx | 6 +- .../pages/admin/panels/ProductMatchPanel.tsx | 8 +- .../pages/admin/panels/RiskControlPanel.tsx | 11 +-- .../admin/platform/AdminPlatformShopsPage.tsx | 2 +- .../src/services/admin/fulfillment-channel.ts | 8 ++ 14 files changed, 184 insertions(+), 46 deletions(-) diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 778803e3..220a100e 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -12,6 +12,7 @@ import { assignChannelBySku, assignChannelToOrderItems, deleteChannelRule, + deleteChannelRules, listChannelRules, listPendingChannelGroups, switchChannelForOrderItems, @@ -234,6 +235,22 @@ router.delete( }), ) +router.post( + '/fulfillment/rules/batch-delete', + requireAdminPermission('finance.config'), + createJsonHandler((req) => deleteChannelRules(Array.isArray(req.body?.ids) ? req.body.ids : []), { + successMessage: '渠道规则已批量删除', + errorMessage: '批量删除渠道规则失败', + scope: '[admin/fulfillment/rules/batch-delete]', + audit: (_req, data) => ({ + action: 'fulfillment.batch_delete_rules', + targetType: 'internal_sku_channel', + targetId: '', + data: data as Record, + }), + }), +) + router.get( '/fulfillment/pipeline/status', requireAdminPermission('finance.view'), diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index b13ec83f..0df16aa4 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -25,6 +25,7 @@ import { } from './channel-types.js' import { deleteInternalSkuChannelById, + deleteInternalSkuChannelsByIds, planInternalSkuChannels, setInternalSkuPrimaryChannel, upsertInternalSkuChannel, @@ -1119,6 +1120,21 @@ export async function deleteChannelRule(ruleId: number): Promise<{ deleted: bool return { deleted: await deleteInternalSkuChannelById(id) } } +export async function deleteChannelRules(idsInput: number[]): Promise<{ + deletedCount: number + deletedIds: number[] + missingIds: number[] +}> { + if (!Array.isArray(idsInput) || idsInput.length === 0) { + throw createHttpError('请选择要删除的渠道规则', { + statusCode: 400, + errorCode: 'channel_rule_ids_required', + }) + } + const result = await deleteInternalSkuChannelsByIds(idsInput) + return { deletedCount: result.deletedIds.length, ...result } +} + async function loadEnabledRules(): Promise { const rows = await query( ` 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 189b3570..8db7760f 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -20,7 +20,7 @@ test( const { closeDb, query } = await import('../../db/client.js') const { assignChannelByInternalSku, - deleteChannelRule, + deleteChannelRules, ensureVoucherRuleForSku, listChannelRules, switchChannelForOrderItems, @@ -171,7 +171,9 @@ test( assert.ok( listedRules.some((rule) => rule.id === savedRule.id && rule.match_key === voucherSku), ) - assert.equal((await deleteChannelRule(savedRule.id)).deleted, true) + const deletedRules = await deleteChannelRules([savedRule.id]) + assert.equal(deletedRules.deletedCount, 1) + assert.deepEqual(deletedRules.deletedIds, [savedRule.id]) } finally { if (orderItemIds.length > 0) { await query(`DELETE FROM cost_exception_items WHERE order_item_id = ANY($1::bigint[])`, [ 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 a32f4b11..88cca624 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -203,3 +203,21 @@ export async function deleteInternalSkuChannelById(channelId: number): Promise 0 } + +export async function deleteInternalSkuChannelsByIds(idsInput: number[]): Promise<{ + deletedIds: number[] + missingIds: number[] +}> { + const ids = [...new Set(idsInput.map(Number).filter((id) => Number.isInteger(id) && id > 0))] + if (ids.length === 0) return { deletedIds: [], missingIds: [] } + const result = await query<{ id: number }>( + `DELETE FROM internal_sku_channels WHERE id = ANY($1::bigint[]) RETURNING id`, + [ids], + ) + const deletedIds = result.rows.map((row) => Number(row.id)) + const deleted = new Set(deletedIds) + return { + deletedIds, + missingIds: ids.filter((id) => !deleted.has(id)), + } +} diff --git a/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx b/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx index 21526b0e..0b44b6d0 100644 --- a/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFinanceLedgerPage.tsx @@ -27,6 +27,7 @@ import type { Dayjs } from 'dayjs' import { AdminRangePicker } from '@/components/admin/AdminDatePicker' import FinanceSubNav from '@/components/admin/FinanceSubNav' import PercentTable from '@/components/common/PercentTable' +import { buildAdminTablePagination } from '@/utils/admin-pagination' import dayjs from '@/lib/dayjs' import { closeFinancePeriod, @@ -509,16 +510,15 @@ export default function AdminFinanceLedgerPage() { dataSource={entriesQuery.data?.data.items || []} columns={entryColumns} onRow={(row) => ({ onClick: () => setEntryDetail(row) })} - pagination={{ + pagination={buildAdminTablePagination({ current: entryPage, pageSize: entryPageSize, total: entriesQuery.data?.data.pagination.total || 0, - showSizeChanger: true, onChange: (page, size) => { setEntryPage(page) setEntryPageSize(size) }, - }} + })} /> @@ -566,16 +566,15 @@ export default function AdminFinanceLedgerPage() { loading={reconciliationsQuery.isLoading} dataSource={reconciliationsQuery.data?.data.items || []} columns={reconColumns} - pagination={{ + pagination={buildAdminTablePagination({ current: reconPage, pageSize: reconPageSize, total: reconciliationsQuery.data?.data.pagination.total || 0, - showSizeChanger: true, onChange: (page, size) => { setReconPage(page) setReconPageSize(size) }, - }} + })} /> ), diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index df7de64a..eff0fda5 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -6,7 +6,12 @@ * · 没有命中规则的新单落在「待分类」,收入进未分类桶,不产生成本噪音; * · 第三方卡券是「只统计」渠道:不建任务、不发领取页。 */ -import { PlusOutlined, ReloadOutlined, ThunderboltOutlined } from '@ant-design/icons' +import { + DeleteOutlined, + PlusOutlined, + ReloadOutlined, + ThunderboltOutlined, +} from '@ant-design/icons' import { Alert, Button, @@ -30,7 +35,7 @@ import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' import { assignChannelBySku, - deleteChannelRule, + deleteChannelRules, fetchChannelExceptions, fetchChannelRules, fetchPendingChannelGroups, @@ -43,6 +48,11 @@ import { } from '@/services/admin' import { formatRedeemAmount } from '@/utils/redeem' import { formatDateTime } from '@/utils/date-time' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminLocalTablePagination, + buildAdminTablePagination, +} from '@/utils/admin-pagination' const CHANNEL_OPTIONS = [ { value: 'work_order', label: '接单平台' }, @@ -80,11 +90,13 @@ export default function AdminFulfillmentChannelPage() { function PendingPanel() { const queryClient = useQueryClient() const [keyword, setKeyword] = useState('') + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [selections, setSelections] = useState>({}) const query = useQuery({ - queryKey: ['fulfillment-pending', keyword], - queryFn: () => fetchPendingChannelGroups({ page: 1, pageSize: 100, keyword }), + queryKey: ['fulfillment-pending', keyword, page, pageSize], + queryFn: () => fetchPendingChannelGroups({ page, pageSize, keyword }), }) const assignMutation = useMutation({ @@ -191,7 +203,10 @@ function PendingPanel() { allowClear placeholder="SKU / 商品名" style={{ width: 220 }} - onSearch={(value) => setKeyword(value)} + onSearch={(value) => { + setKeyword(value) + setPage(1) + }} /> @@ -301,6 +342,15 @@ function RulesPanel() { title={`统一渠道配置(${query.data?.data?.length ?? 0})`} extra={ + @@ -320,7 +370,11 @@ function RulesPanel() { loading={query.isFetching} dataSource={query.data?.data || []} columns={columns} - pagination={{ pageSize: 20, showSizeChanger: false }} + rowSelection={{ + selectedRowKeys: selectedRuleIds, + onChange: (keys) => setSelectedRuleIds(keys.map(Number)), + }} + pagination={buildAdminLocalTablePagination()} /> fetchChannelExceptions({ reason, status: 'open', page: 1, pageSize: 100 }), + queryKey: ['fulfillment-exceptions', reason, page, pageSize], + queryFn: () => fetchChannelExceptions({ reason, status: 'open', page, pageSize }), }) const REASON_OPTIONS = [ @@ -408,7 +464,10 @@ function ExceptionsPanel() { style={{ width: 180 }} value={reason} options={REASON_OPTIONS} - onChange={setReason} + onChange={(value) => { + setReason(value) + setPage(1) + }} />
diff --git a/apps/frontend/src/pages/admin/panels/RiskControlPanel.tsx b/apps/frontend/src/pages/admin/panels/RiskControlPanel.tsx index 97b1ceaf..a0fbca98 100644 --- a/apps/frontend/src/pages/admin/panels/RiskControlPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/RiskControlPanel.tsx @@ -14,6 +14,7 @@ import { Tag, } from 'antd' import PercentTable from '@/components/common/PercentTable' +import { buildAdminTablePagination } from '@/utils/admin-pagination' import type { ColumnsType } from 'antd/es/table' import { useEffect, useState } from 'react' @@ -635,16 +636,15 @@ export default function RiskControlPanel() { dataSource={bans?.items || []} loading={bansQuery.isLoading} locale={{ emptyText: '暂无封禁记录' }} - pagination={{ + pagination={buildAdminTablePagination({ current: banPage, pageSize: banPageSize, total: bans?.total || 0, - showSizeChanger: true, onChange: (nextPage, nextPageSize) => { setBanPage(nextPage) setBanPageSize(nextPageSize) }, - }} + })} />
@@ -690,16 +690,15 @@ export default function RiskControlPanel() { columns={eventColumns} dataSource={events?.items || []} loading={eventsQuery.isLoading} - pagination={{ + pagination={buildAdminTablePagination({ current: page, pageSize, total: events?.total || 0, - showSizeChanger: true, onChange: (nextPage, nextPageSize) => { setPage(nextPage) setPageSize(nextPageSize) }, - }} + })} /> diff --git a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx index ce10c11d..e85948a1 100644 --- a/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx +++ b/apps/frontend/src/pages/admin/platform/AdminPlatformShopsPage.tsx @@ -1697,7 +1697,7 @@ function AffiliateDashPlatformPanel() { rowKey={(row) => row.productNo} size="small" - pagination={{ pageSize: 10, showSizeChanger: false }} + pagination={buildAdminLocalTablePagination()} dataSource={builtinSkuRows} columns={[ { title: '91 中文编码', dataIndex: 'productNo', key: 'productNo' }, diff --git a/apps/frontend/src/services/admin/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index f06029e1..6345be68 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -147,6 +147,14 @@ export function deleteChannelRule(ruleId: number) { return apiDelete<{ deleted: boolean }>(`/api/v1/admin/fulfillment/rules/${ruleId}`) } +export function deleteChannelRules(ids: number[]) { + return apiPost<{ + deletedCount: number + deletedIds: number[] + missingIds: number[] + }>('/api/v1/admin/fulfillment/rules/batch-delete', { ids }) +} + export function fetchPipelineStatus() { return apiGet('/api/v1/admin/fulfillment/pipeline/status') } From a7bac9c1d8e9475023ed82f59a326ff821d55390 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 17:57:49 +0800 Subject: [PATCH 10/12] =?UTF-8?q?fix(admin):=20=E6=81=A2=E5=A4=8D=E6=8E=A5?= =?UTF-8?q?=E5=8D=95=E6=A8=A1=E6=9D=BF=E8=AE=A1=E4=BB=B7=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=B9=B6=E7=BB=9F=E4=B8=80=E6=88=90=E6=9C=AC=E4=BB=B7=E6=A0=BC?= =?UTF-8?q?=E9=93=BE=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../123_work_product_rule_pricing_mode.sql | 54 ++++++++++++++++++ .../src/repositories/worker-platform/types.ts | 1 + .../worker-platform/work-product-rule-repo.ts | 7 ++- .../cost-center/cost-center-mappers.ts | 6 +- .../services/cost-center/cost-center-types.ts | 1 + .../cost-center/cost-definition-service.ts | 1 + .../cost-center/cost-snapshot-service.test.ts | 19 +++++++ .../cost-center/cost-snapshot-service.ts | 12 +++- .../cost-center/product-catalog-service.ts | 21 +++---- .../cost-center/template-bootstrap-service.ts | 3 + .../cost-center/work-order-product-service.ts | 8 +-- ...rk-order-template-cost.integration.test.ts | 53 ++++++++++++++++-- .../work-order-template-product-service.ts | 5 +- .../src/services/product-match/pricing.ts | 3 +- .../product-match/product-match.test.ts | 3 + .../admin-product-match-test-service.ts | 3 +- .../admin-worker-catalog-service.ts | 54 ++++++++++++++++-- .../mock-work-order-rule-match.test.ts | 1 + .../sync-worker-orders-service.ts | 1 + .../work-product-match-mappers.ts | 1 + .../worker-platform-service.test.ts | 34 +++++++++++- apps/frontend/src/layouts/AdminLayout.tsx | 4 ++ .../admin/cost-center/ProductCatalogPanel.tsx | 34 +++++------- .../pages/admin/panels/ProductRulesPanel.tsx | 55 ++++++++++++------- .../src/services/admin/cost-center.ts | 1 - .../src/services/admin/worker-platform.ts | 1 + apps/frontend/src/types/worker-platform.ts | 1 + 27 files changed, 304 insertions(+), 83 deletions(-) create mode 100644 apps/backend/src/db/migrations/123_work_product_rule_pricing_mode.sql diff --git a/apps/backend/src/db/migrations/123_work_product_rule_pricing_mode.sql b/apps/backend/src/db/migrations/123_work_product_rule_pricing_mode.sql new file mode 100644 index 00000000..18f930f2 --- /dev/null +++ b/apps/backend/src/db/migrations/123_work_product_rule_pricing_mode.sql @@ -0,0 +1,54 @@ +-- 接单模板显式保存计价方式,金额字段只由商品与成本中心写投影。 +-- 历史数据按旧运行口径回填:拼单自动按件优先识别,其次识别“单价>0 且固定金额=0”。 + +ALTER TABLE work_product_rules + ADD COLUMN IF NOT EXISTS pricing_mode TEXT; + +UPDATE work_product_rules +SET pricing_mode = CASE + WHEN sharing_enabled = TRUE + AND sharing_auto_from_order = TRUE + AND unit_price_fen > 0 THEN 'unit' + WHEN unit_price_fen > 0 AND reward_amount = 0 THEN 'unit' + ELSE 'fixed' +END +WHERE pricing_mode IS NULL + OR pricing_mode NOT IN ('fixed', 'unit'); + +ALTER TABLE work_product_rules + ALTER COLUMN pricing_mode SET DEFAULT 'fixed', + ALTER COLUMN pricing_mode SET NOT NULL; + +-- 自动拼单只能依赖每件金额;固定金额模板不能保留“随订单自动算拼单”的旧标志。 +UPDATE work_product_rules +SET sharing_auto_from_order = FALSE +WHERE pricing_mode = 'fixed' + AND sharing_auto_from_order = TRUE; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'chk_work_product_rules_pricing_mode' + AND conrelid = 'work_product_rules'::regclass + ) THEN + ALTER TABLE work_product_rules + ADD CONSTRAINT chk_work_product_rules_pricing_mode + CHECK (pricing_mode IN ('fixed', 'unit')); + END IF; +END $$; + +COMMENT ON COLUMN work_product_rules.pricing_mode IS + '接单计价方式:fixed 固定整单金额 / unit 按 SKU 数量乘每件金额'; + +-- 同步模板商品属性中的计价方式,避免继续从两个金额字段反推。 +UPDATE product_catalog p +SET attributes_json = COALESCE(p.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'pricingMode', wpr.pricing_mode, + 'rewardAmountFen', wpr.reward_amount, + 'unitPriceFen', wpr.unit_price_fen + ), + updated_at = NOW() +FROM work_product_rules wpr +WHERE p.canonical_sku = LEFT('tpl:' || BTRIM(wpr.rule_key), 64); diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 9511a1b0..0d12436b 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -288,6 +288,7 @@ export type WorkProductRuleRow = { auto_create: boolean reward_amount: number unit_price_fen: number + pricing_mode: 'fixed' | 'unit' required_deposit_amount: number deposit_threshold_amount: number sharing_enabled: boolean diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index 8bb1f58d..06318a2f 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -356,6 +356,7 @@ export async function upsertWorkProductRule(input: { autoCreate: boolean rewardAmount: number unitPriceFen?: number + pricingMode: 'fixed' | 'unit' requiredDepositAmount: number depositThresholdAmount: number sharingEnabled?: boolean @@ -385,7 +386,7 @@ export async function upsertWorkProductRule(input: { timeout_minutes, timeout_policy, acceptance_mode, gift_cooldown_hours, gift_cooldown_minutes, requirement_json, match_json, - sort_order, created_at, updated_at + sort_order, pricing_mode, created_at, updated_at ) VALUES ( $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, @@ -395,7 +396,7 @@ export async function upsertWorkProductRule(input: { $19, $20, $21, $22, $23, $24::jsonb, $25::jsonb, - $26, $27, $28 + $26, $27, $28, $29 ) ON CONFLICT (rule_key) DO UPDATE SET @@ -424,6 +425,7 @@ export async function upsertWorkProductRule(input: { requirement_json = EXCLUDED.requirement_json, match_json = EXCLUDED.match_json, sort_order = EXCLUDED.sort_order, + pricing_mode = EXCLUDED.pricing_mode, updated_at = EXCLUDED.updated_at RETURNING * `, @@ -454,6 +456,7 @@ export async function upsertWorkProductRule(input: { input.requirementJson, input.matchJson || '{}', input.sortOrder, + input.pricingMode, input.now, input.now, ], 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 fac496db..0e33fb50 100644 --- a/apps/backend/src/services/cost-center/cost-center-mappers.ts +++ b/apps/backend/src/services/cost-center/cost-center-mappers.ts @@ -188,13 +188,11 @@ export function mapCostVersion(row: CostVersionRow) { const revenueFen = Number(row.revenue_fen || 0) const rewardAvgFen = Number(row.reward_avg_fen || 0) const rewardFen = Number(row.reward_fen || 0) - const ruleUnitPriceFen = Number(row.rule_unit_price_fen || 0) const ruleRewardAmountFen = Number(row.rule_reward_amount || 0) const amountKind = row.cost_item_code === 'sys_work_order_reward' && row.scope_type === 'rule' && - ruleUnitPriceFen > 0 && - ruleRewardAmountFen === 0 + row.work_product_rule_pricing_mode === 'unit' ? 'unit_price' : 'fixed' return { @@ -214,7 +212,7 @@ export function mapCostVersion(row: CostVersionRow) { workProductRuleName: row.work_product_rule_name || '', workProductRuleKey: row.work_product_rule_key || '', ruleRewardAmountFen, - ruleUnitPriceFen, + ruleUnitPriceFen: Number(row.rule_unit_price_fen || 0), revenueItemCount, revenueFen, revenueAvgFen: revenueItemCount > 0 ? Math.round(revenueFen / revenueItemCount) : 0, diff --git a/apps/backend/src/services/cost-center/cost-center-types.ts b/apps/backend/src/services/cost-center/cost-center-types.ts index 99cc9c99..db0ab4b6 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -113,6 +113,7 @@ export type CostVersionRow = { work_product_rule_id: number | null work_product_rule_name: string | null work_product_rule_key: string | null + work_product_rule_pricing_mode?: 'fixed' | 'unit' | null rule_reward_amount: number | null rule_unit_price_fen: number | null revenue_item_count: number | null diff --git a/apps/backend/src/services/cost-center/cost-definition-service.ts b/apps/backend/src/services/cost-center/cost-definition-service.ts index 84700ef6..75f9bf59 100644 --- a/apps/backend/src/services/cost-center/cost-definition-service.ts +++ b/apps/backend/src/services/cost-center/cost-definition-service.ts @@ -324,6 +324,7 @@ export async function listAdminCostVersions(payload: JsonObject = {}) { p.canonical_name, wpr.product_name AS work_product_rule_name, wpr.rule_key AS work_product_rule_key, + wpr.pricing_mode AS work_product_rule_pricing_mode, wpr.reward_amount AS rule_reward_amount, wpr.unit_price_fen AS rule_unit_price_fen, rev.revenue_item_count, diff --git a/apps/backend/src/services/cost-center/cost-snapshot-service.test.ts b/apps/backend/src/services/cost-center/cost-snapshot-service.test.ts index 0dd3f99d..1e14a4ab 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-service.test.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-service.test.ts @@ -103,6 +103,25 @@ test('成本解析:卡券渠道按件计,取业务日期前的最新版本', assert.equal(resolution.unitAmountFen, 5000) }) +test('成本解析:接单模板按 SKU 计件时,预估成本按数量乘每件金额', () => { + const resolution = resolveCost( + item({ + fulfillment_channel: 'work_order', + canonical_product_id: null, + quantity: 2, + work_product_rule_id: 9, + work_product_rule_pricing_mode: 'unit', + }), + costItems, + versions, + pricingModes, + ) + assert.ok(resolution) + assert.equal(resolution.costVersionId, 101) + assert.equal(resolution.unitAmountFen, 840) + assert.equal(resolution.totalAmountFen, 1680) +}) + test('成本解析:生效日期晚于业务日期的版本不参与', () => { const version = selectCostVersion(versions, { costItemId: 4, diff --git a/apps/backend/src/services/cost-center/cost-snapshot-service.ts b/apps/backend/src/services/cost-center/cost-snapshot-service.ts index dcb65109..e25874e2 100644 --- a/apps/backend/src/services/cost-center/cost-snapshot-service.ts +++ b/apps/backend/src/services/cost-center/cost-snapshot-service.ts @@ -62,6 +62,7 @@ export type CostCandidateItem = { basis_at: string work_order_id: number | null work_product_rule_id: number | null + work_product_rule_pricing_mode?: 'fixed' | 'unit' | null work_order_status: string | null reward_amount: number | null revenue_fen: number @@ -157,7 +158,14 @@ export function resolveCost( }) if (!version) return null - const pricingMode = pricingModeByItemId.get(costItem.id) || costItem.pricing_mode + const pricingMode = + item.fulfillment_channel === 'work_order' && + costItem.code === 'sys_work_order_reward' && + item.work_product_rule_pricing_mode + ? item.work_product_rule_pricing_mode === 'unit' + ? 'per_item' + : 'per_order' + : pricingModeByItemId.get(costItem.id) || costItem.pricing_mode const amount = Number(version.amount_fen) || 0 const quantity = Math.max(1, Number(item.quantity) || 1) @@ -306,11 +314,13 @@ const CANDIDATE_SQL = ` ), 0)::bigint AS refund_fen, wo.id AS work_order_id, wo.product_rule_id AS work_product_rule_id, + wpr.pricing_mode AS work_product_rule_pricing_mode, wo.status AS work_order_status, wo.reward_amount FROM order_items oi JOIN orders o ON o.id = oi.order_id LEFT JOIN work_orders wo ON wo.order_item_id = oi.id + LEFT JOIN work_product_rules wpr ON wpr.id = wo.product_rule_id WHERE oi.fulfillment_status = 'assigned' AND oi.fulfillment_channel IN ( 'affiliate_dash', 'third_party_voucher', 'work_order', 'manual' 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 601e9d1b..f317601f 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -153,11 +153,7 @@ export async function listAdminProducts(payload: JsonObject = {}) { ` SELECT p.*, - CASE - WHEN tpl_rule.unit_price_fen > 0 AND tpl_rule.reward_amount = 0 THEN 'unit' - WHEN tpl_rule.id IS NOT NULL THEN 'fixed' - ELSE NULL - END AS template_pricing_mode, + tpl_rule.pricing_mode AS template_pricing_mode, tpl_rule.enabled AS template_enabled, tpl_rule.sharing_enabled AS template_sharing_enabled, ( @@ -1551,10 +1547,11 @@ export async function setAdminProductPrice( enabled: boolean reward_amount: number unit_price_fen: number + pricing_mode: 'fixed' | 'unit' sharing_enabled: boolean sharing_auto_from_order: boolean }>( - `SELECT id, rule_key, product_name, enabled, reward_amount, unit_price_fen, + `SELECT id, rule_key, product_name, enabled, reward_amount, unit_price_fen, pricing_mode, sharing_enabled, sharing_auto_from_order FROM work_product_rules WHERE rule_key = $1 @@ -1569,17 +1566,13 @@ export async function setAdminProductPrice( }) } const rawPricingMode = text(payload.pricingMode ?? payload.pricing_mode) - if (rawPricingMode && rawPricingMode !== 'fixed' && rawPricingMode !== 'unit') { - throw createHttpError('接单模板计价方式无效', { + if (rawPricingMode) { + throw createHttpError('接单模板计价方式只能在【接单模板】中设置', { statusCode: 400, - errorCode: 'cost_work_order_pricing_mode_invalid', + errorCode: 'cost_work_order_pricing_mode_managed_by_template', }) } - const pricingMode = - rawPricingMode || - (Number(rule.unit_price_fen || 0) > 0 && Number(rule.reward_amount || 0) === 0 - ? 'unit' - : 'fixed') + const pricingMode: 'fixed' | 'unit' = rule.pricing_mode === 'unit' ? 'unit' : 'fixed' if (pricingMode === 'fixed' && rule.sharing_enabled && rule.sharing_auto_from_order) { throw createHttpError( '拼单自动按件计价与固定金额冲突,请先在接单模板关闭拼单或改为手工拼单价', diff --git a/apps/backend/src/services/cost-center/template-bootstrap-service.ts b/apps/backend/src/services/cost-center/template-bootstrap-service.ts index 08554a08..a9c64b4f 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -128,6 +128,7 @@ export async function applyAdminTemplateProductBootstrap( skuNick: template.sku_nick, relSkuId: template.rel_sku_id, sellerIds: template.seller_ids_json, + pricingMode: template.pricing_mode === 'unit' ? 'unit' : 'fixed', unitPriceFen: Number(template.unit_price_fen || 0), rewardAmountFen: Number(template.reward_amount || 0), templateEnabled: template.enabled, @@ -282,6 +283,7 @@ type TemplateBootstrapTemplateRow = { category: string unit_price_fen: number reward_amount: number + pricing_mode: 'fixed' | 'unit' mapping_id: number | null mapping_type: string sku_nick: string @@ -696,6 +698,7 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat COALESCE(c.name, '') AS category, r.unit_price_fen, r.reward_amount, + r.pricing_mode, m.id AS mapping_id, COALESCE(m.mapping_type, '') AS mapping_type, COALESCE(m.sku_nick, '') AS sku_nick, diff --git a/apps/backend/src/services/cost-center/work-order-product-service.ts b/apps/backend/src/services/cost-center/work-order-product-service.ts index f0a9ed4a..4df12ebf 100644 --- a/apps/backend/src/services/cost-center/work-order-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-product-service.ts @@ -327,7 +327,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) { SELECT wpr.id AS rule_id, CASE - WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN wpr.unit_price_fen + WHEN wpr.pricing_mode = 'unit' THEN wpr.unit_price_fen ELSE wpr.reward_amount END AS amount_fen FROM work_product_rules wpr @@ -364,7 +364,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) { SELECT wpr.id AS rule_id, CASE - WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN wpr.unit_price_fen + WHEN wpr.pricing_mode = 'unit' THEN wpr.unit_price_fen ELSE wpr.reward_amount END AS amount_fen FROM work_product_rules wpr @@ -416,7 +416,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) { 'import', 'active', CASE - WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 + WHEN wpr.pricing_mode = 'unit' THEN '模板按件发单单价(数量取订单 SKU)' ELSE '模板固定发单金额' END, @@ -469,7 +469,7 @@ export async function syncAdminWorkOrderTemplateCostVersions(ruleId?: number) { SELECT COUNT(*)::int AS rule_count, COUNT(*) FILTER ( - WHERE wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 + WHERE wpr.pricing_mode = 'unit' )::int AS unit_price_rule_count, COUNT(v.id) FILTER ( WHERE v.scope_type = 'rule' AND v.status = 'active' diff --git a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts index ecec92e0..225c1531 100644 --- a/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts +++ b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts @@ -45,6 +45,7 @@ test( assert.ok(ruleId > 0) assert.ok(productId > 0) assert.equal(created.rule.enabled, false) + assert.equal(created.rule.pricingMode, 'fixed') assert.equal(created.costConfigurationRequired, true) const product = await query<{ canonical_sku: string; status: string }>( @@ -77,7 +78,6 @@ test( productId, { amountFen: 1234, - pricingMode: 'fixed', changeReason: '接单模板成本集成测试', syncOpenOrders: false, }, @@ -100,10 +100,11 @@ test( enabled: boolean reward_amount: number unit_price_fen: number + pricing_mode: string amount_fen: number }>( ` - SELECT wpr.enabled, wpr.reward_amount, wpr.unit_price_fen, v.amount_fen + SELECT wpr.enabled, wpr.reward_amount, wpr.unit_price_fen, wpr.pricing_mode, v.amount_fen FROM work_product_rules wpr JOIN cost_versions v ON v.work_product_rule_id = wpr.id AND v.status = 'active' WHERE wpr.id = $1 @@ -115,6 +116,7 @@ test( assert.equal(projection.rows[0]?.enabled, false) assert.equal(Number(projection.rows[0]?.reward_amount), 1234) assert.equal(Number(projection.rows[0]?.unit_price_fen), 0) + assert.equal(projection.rows[0]?.pricing_mode, 'fixed') assert.equal(Number(projection.rows[0]?.amount_fen), 1234) await assert.rejects( @@ -124,6 +126,30 @@ test( { amountFen: 2000, pricingMode: 'fixed', + syncOpenOrders: false, + }, + { + sessionId: `it-${suffix}`, + userId: Number(adminUser.id), + username: adminUser.username, + displayName: adminUser.display_name || adminUser.username, + role: 'admin', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + absoluteExpiresAt: new Date(Date.now() + 60_000).toISOString(), + sessionVersion: 1, + deviceId: 'integration-test', + permissions: [], + }, + ), + /计价方式只能在【接单模板】中设置/, + ) + + await assert.rejects( + () => + setAdminProductPrice( + productId, + { + amountFen: 2000, effectiveFrom: new Date(Date.now() + 60 * 60 * 1000).toISOString(), syncOpenOrders: false, }, @@ -143,11 +169,19 @@ test( /暂不支持未来生效/, ) + const unitModeRule = await saveAdminWorkProductRule({ + ruleId, + productName, + pricingMode: 'unit', + }) + assert.equal(unitModeRule.rule.pricingMode, 'unit') + assert.equal(unitModeRule.rule.unitPriceFen, 1234) + assert.equal(unitModeRule.rule.rewardAmount, 0) + await setAdminProductPrice( productId, { amountFen: 1300, - pricingMode: 'unit', changeReason: '接单模板按件成本集成测试', syncOpenOrders: false, }, @@ -167,10 +201,11 @@ test( const unitProjection = await query<{ reward_amount: number unit_price_fen: number + pricing_mode: string amount_fen: number }>( ` - SELECT wpr.reward_amount, wpr.unit_price_fen, v.amount_fen + SELECT wpr.reward_amount, wpr.unit_price_fen, wpr.pricing_mode, v.amount_fen FROM work_product_rules wpr JOIN cost_versions v ON v.work_product_rule_id = wpr.id AND v.status = 'active' WHERE wpr.id = $1 @@ -181,8 +216,18 @@ test( ) assert.equal(Number(unitProjection.rows[0]?.reward_amount), 0) assert.equal(Number(unitProjection.rows[0]?.unit_price_fen), 1300) + assert.equal(unitProjection.rows[0]?.pricing_mode, 'unit') assert.equal(Number(unitProjection.rows[0]?.amount_fen), 1300) + const fixedModeRule = await saveAdminWorkProductRule({ + ruleId, + productName, + pricingMode: 'fixed', + }) + assert.equal(fixedModeRule.rule.pricingMode, 'fixed') + assert.equal(fixedModeRule.rule.rewardAmount, 1300) + assert.equal(fixedModeRule.rule.unitPriceFen, 0) + const enabled = await saveAdminWorkProductRule({ ruleId, productName, diff --git a/apps/backend/src/services/cost-center/work-order-template-product-service.ts b/apps/backend/src/services/cost-center/work-order-template-product-service.ts index 0b4ebe67..acac9036 100644 --- a/apps/backend/src/services/cost-center/work-order-template-product-service.ts +++ b/apps/backend/src/services/cost-center/work-order-template-product-service.ts @@ -97,10 +97,7 @@ export async function syncWorkOrderTemplateProduct( ruleKey: rule.rule_key, categoryId: rule.category_id ? Number(rule.category_id) : null, templateEnabled: rule.enabled === true, - pricingMode: - Number(rule.unit_price_fen || 0) > 0 && Number(rule.reward_amount || 0) === 0 - ? 'unit' - : 'fixed', + pricingMode: rule.pricing_mode === 'unit' ? 'unit' : 'fixed', rewardAmountFen: Number(rule.reward_amount || 0), unitPriceFen: Number(rule.unit_price_fen || 0), sharingEnabled: rule.sharing_enabled === true, diff --git a/apps/backend/src/services/product-match/pricing.ts b/apps/backend/src/services/product-match/pricing.ts index 4f013ec2..cc269668 100644 --- a/apps/backend/src/services/product-match/pricing.ts +++ b/apps/backend/src/services/product-match/pricing.ts @@ -28,6 +28,7 @@ export function resolveWorkOrderRulePricing(input: { skuQuantity: number unitPriceFen: number fixedRewardAmount: number + pricingMode: 'fixed' | 'unit' | string sharingEnabled: boolean sharingAutoFromOrder: boolean sharingTotalQuantity: number @@ -43,7 +44,7 @@ export function resolveWorkOrderRulePricing(input: { : Math.max(0, Number(input.sharingUnitReward) || 0) const rewardAmount = input.sharingEnabled ? sharingTotalQuantity * sharingUnitReward - : unitPriceFen > 0 + : input.pricingMode === 'unit' ? unitPriceFen * skuQuantity : Math.max(0, Number(input.fixedRewardAmount) || 0) diff --git a/apps/backend/src/services/product-match/product-match.test.ts b/apps/backend/src/services/product-match/product-match.test.ts index cbe2aef6..9529b80b 100644 --- a/apps/backend/src/services/product-match/product-match.test.ts +++ b/apps/backend/src/services/product-match/product-match.test.ts @@ -68,6 +68,7 @@ function buildRule(overrides: Partial = {}): WorkProductRule auto_create: true, reward_amount: 0, unit_price_fen: 0, + pricing_mode: 'fixed', required_deposit_amount: 0, deposit_threshold_amount: 20000, sharing_enabled: false, @@ -141,6 +142,7 @@ const commanderRule = buildRule({ rule_key: 'bb', product_name: '指挥官密钥', unit_price_fen: 950, + pricing_mode: 'unit', sharing_enabled: true, sharing_auto_from_order: true, }) @@ -199,6 +201,7 @@ test('抖音:命中指挥官模板后按数量计价并开拼单(9.5 元/个 skuQuantity, unitPriceFen: commanderRule.unit_price_fen, fixedRewardAmount: commanderRule.reward_amount, + pricingMode: commanderRule.pricing_mode, sharingEnabled: commanderRule.sharing_enabled === true, sharingAutoFromOrder: commanderRule.sharing_auto_from_order === true, sharingTotalQuantity: commanderRule.sharing_total_quantity, diff --git a/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts b/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts index 8f778f51..12b6df4f 100644 --- a/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts +++ b/apps/backend/src/services/worker-platform/admin-product-match-test-service.ts @@ -108,6 +108,7 @@ function buildWorkOrderPlan( skuQuantity, unitPriceFen, fixedRewardAmount: Number(rule.reward_amount || 0), + pricingMode: rule.pricing_mode, sharingEnabled: rule.sharing_enabled === true, sharingAutoFromOrder: rule.sharing_auto_from_order === true, sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), @@ -120,7 +121,7 @@ function buildWorkOrderPlan( return { skuQuantity, unitPriceFen, - pricingMode: unitPriceFen > 0 ? 'unit' : 'fixed', + pricingMode: rule.pricing_mode === 'unit' ? 'unit' : 'fixed', rewardAmount: pricing.rewardAmount, sharingEnabled, sharingTotalQuantity: sharingEnabled ? pricing.sharingTotalQuantity : 1, diff --git a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts index 8947969d..31fd115a 100644 --- a/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts +++ b/apps/backend/src/services/worker-platform/admin-worker-catalog-service.ts @@ -27,7 +27,7 @@ import { upsertWorkProductRule, } from '../../repositories/worker-platform/work-product-rule-repo.js' import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js' -import { withTransaction } from '../../db/client.js' +import { query, withTransaction } from '../../db/client.js' import { archiveDeletedWorkOrderTemplateProduct, assertWorkOrderTemplateCanEnable, @@ -394,6 +394,34 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { const incomingUnitPriceFen = normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0) const existingRewardAmount = Number(existingRule?.reward_amount || 0) const existingUnitPriceFen = Number(existingRule?.unit_price_fen || 0) + const rawPricingMode = String(payload.pricingMode ?? payload.pricing_mode ?? '').trim() + if (rawPricingMode && rawPricingMode !== 'fixed' && rawPricingMode !== 'unit') { + throw createHttpError('接单模板计价方式无效', { + statusCode: 400, + errorCode: 'work_product_rule_pricing_mode_invalid', + }) + } + const existingPricingMode = existingRule?.pricing_mode === 'unit' ? 'unit' : 'fixed' + const pricingMode: 'fixed' | 'unit' = + rawPricingMode === 'unit' ? 'unit' : rawPricingMode === 'fixed' ? 'fixed' : existingPricingMode + let carriedPricingAmount = + existingPricingMode === 'unit' ? existingUnitPriceFen : existingRewardAmount + if (existingRule && pricingMode !== existingPricingMode) { + const activeCostResult = await query<{ amount_fen: number }>( + `SELECT amount_fen + FROM cost_versions + WHERE work_product_rule_id = $1 + AND scope_type = 'rule' + AND status = 'active' + AND effective_from <= NOW() + AND (effective_to IS NULL OR effective_to > NOW()) + ORDER BY effective_from DESC, id DESC + LIMIT 1`, + [existingRule.id], + ) + const activeCostAmount = Number(activeCostResult.rows[0]?.amount_fen || 0) + if (activeCostAmount > 0) carriedPricingAmount = activeCostAmount + } // 模板保存只维护业务配置。价格只能由商品与成本中心的统一命令写入。 if ( @@ -409,8 +437,18 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { }, ) } - const rewardAmount = existingRewardAmount - const unitPriceFen = existingUnitPriceFen + const rewardAmount = + pricingMode !== existingPricingMode || !existingRule + ? pricingMode === 'fixed' + ? carriedPricingAmount + : 0 + : existingRewardAmount + const unitPriceFen = + pricingMode !== existingPricingMode || !existingRule + ? pricingMode === 'unit' + ? carriedPricingAmount + : 0 + : existingUnitPriceFen const sharingEnabled = hasOwnPayload(payload, 'sharingEnabled') ? normalizeBoolean(payload.sharingEnabled, false) : hasOwnPayload(payload, 'sharing_enabled') @@ -431,12 +469,14 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { hasConfiguredSharingValue(payload.sharingTotalQuantity ?? payload.sharing_total_quantity) || hasConfiguredSharingValue(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan) || hasConfiguredSharingValue(payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan) - const sharingAutoFromOrder = hasOwnPayload(payload, 'sharingAutoFromOrder') + const requestedSharingAutoFromOrder = hasOwnPayload(payload, 'sharingAutoFromOrder') ? normalizeBoolean(payload.sharingAutoFromOrder, false) : hasOwnPayload(payload, 'sharing_auto_from_order') ? normalizeBoolean(payload.sharing_auto_from_order, false) : (existingRule?.sharing_auto_from_order === true && !hasManualSharingConfig) || - (!existingRule && sharingEnabled && unitPriceFen > 0 && !hasManualSharingConfig) + (!existingRule && sharingEnabled && pricingMode === 'unit' && !hasManualSharingConfig) + const sharingAutoFromOrder = + pricingMode === 'unit' && requestedSharingAutoFromOrder && !hasManualSharingConfig const hasManualSharingQuantity = hasConfiguredSharingValue( payload.sharingTotalQuantity ?? payload.sharing_total_quantity, ) @@ -445,7 +485,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { ) if ( sharingEnabled && - unitPriceFen > 0 && + pricingMode === 'unit' && !sharingAutoFromOrder && hasManualSharingQuantity !== hasManualSharingUnitReward ) { @@ -525,6 +565,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true), rewardAmount: finalRewardAmount, unitPriceFen, + pricingMode, // 商品规则不配置押金;工单创建后由运营按需手工填写。 requiredDepositAmount: 0, depositThresholdAmount: 0, @@ -585,6 +626,7 @@ export function resolveWorkOrderTemplateSyncPrice(input: { skuQuantity, unitPriceFen: Number(input.rule.unit_price_fen || 0), fixedRewardAmount: Number(input.rule.reward_amount || 0), + pricingMode: input.rule.pricing_mode, sharingEnabled: input.rule.sharing_enabled === true, sharingAutoFromOrder: input.rule.sharing_auto_from_order === true, sharingTotalQuantity: Number(input.rule.sharing_total_quantity || 1), diff --git a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts index 5a44b115..bbe8de0d 100644 --- a/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts +++ b/apps/backend/src/services/worker-platform/mock-work-order-rule-match.test.ts @@ -16,6 +16,7 @@ function rule( sort_order: input.sort_order ?? 100, unit_price_fen: input.unit_price_fen ?? 0, reward_amount: input.reward_amount ?? 0, + pricing_mode: input.pricing_mode ?? 'fixed', sharing_enabled: input.sharing_enabled ?? false, sharing_total_quantity: input.sharing_total_quantity ?? 1, sharing_unit_reward: input.sharing_unit_reward ?? 0, 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 000716a0..d420147d 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 @@ -231,6 +231,7 @@ export async function syncWorkerOrdersForSourceOrder( skuQuantity, unitPriceFen: Number(rule.unit_price_fen || 0), fixedRewardAmount: Number(rule.reward_amount || 0), + pricingMode: rule.pricing_mode, sharingEnabled: rule.sharing_enabled === true, sharingAutoFromOrder: rule.sharing_auto_from_order === true, sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), diff --git a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts index c852a02a..b1602481 100644 --- a/apps/backend/src/services/worker-platform/work-product-match-mappers.ts +++ b/apps/backend/src/services/worker-platform/work-product-match-mappers.ts @@ -64,6 +64,7 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) autoCreate: Boolean(rule.auto_create), rewardAmount: Number(rule.reward_amount || 0), unitPriceFen: Number(rule.unit_price_fen || 0), + pricingMode: rule.pricing_mode === 'unit' ? 'unit' : 'fixed', sharing: { enabled: rule.sharing_enabled === true, totalQuantity: Number(rule.sharing_total_quantity || 1), diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index a5ac8ad6..7ae97e9e 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -745,6 +745,7 @@ function buildProductRule(overrides: Partial = {}): WorkProd auto_create: true, reward_amount: 2500, unit_price_fen: 0, + pricing_mode: 'fixed', required_deposit_amount: 0, deposit_threshold_amount: 20000, sharing_enabled: false, @@ -1624,6 +1625,7 @@ test('按件拼单未手动覆盖时,使用匹配数量与模板单价', () => skuQuantity: 10, unitPriceFen: 20, fixedRewardAmount: 0, + pricingMode: 'unit', sharingEnabled: true, sharingAutoFromOrder: true, sharingTotalQuantity: 1, @@ -1642,6 +1644,7 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => { skuQuantity: 10, unitPriceFen: 20, fixedRewardAmount: 0, + pricingMode: 'unit', sharingEnabled: true, sharingAutoFromOrder: false, sharingTotalQuantity: 3, @@ -1657,7 +1660,7 @@ test('按件拼单手动覆盖数量和单价时,保留手工配置', () => { test('模板同步价格按工单来源规格重新计算普通按件单', () => { const price = resolveWorkOrderTemplateSyncPrice({ - rule: buildProductRule({ unit_price_fen: 200 }), + rule: buildProductRule({ unit_price_fen: 200, pricing_mode: 'unit' }), workOrder: buildWorkOrderRow({ product_name: '指挥官秘钥15个', material_json: { source: { skuQuantity: 8 } }, @@ -1667,10 +1670,39 @@ test('模板同步价格按工单来源规格重新计算普通按件单', () => assert.deepEqual(price, { rewardAmount: 1600 }) }) +test('模板同步价格以显式计价方式为准,不按两个金额字段反推', () => { + const workOrder = buildWorkOrderRow({ product_name: '指挥官秘钥15个' }) + + assert.deepEqual( + resolveWorkOrderTemplateSyncPrice({ + rule: buildProductRule({ + reward_amount: 2500, + unit_price_fen: 200, + pricing_mode: 'fixed', + }), + workOrder, + }), + { rewardAmount: 2500 }, + ) + + assert.deepEqual( + resolveWorkOrderTemplateSyncPrice({ + rule: buildProductRule({ + reward_amount: 2500, + unit_price_fen: 200, + pricing_mode: 'unit', + }), + workOrder, + }), + { rewardAmount: 3000 }, + ) +}) + test('模板同步价格为拼单返回新的份额单价', () => { const price = resolveWorkOrderTemplateSyncPrice({ rule: buildProductRule({ unit_price_fen: 200, + pricing_mode: 'unit', sharing_enabled: true, sharing_auto_from_order: true, }), diff --git a/apps/frontend/src/layouts/AdminLayout.tsx b/apps/frontend/src/layouts/AdminLayout.tsx index 701ff733..4026d75c 100644 --- a/apps/frontend/src/layouts/AdminLayout.tsx +++ b/apps/frontend/src/layouts/AdminLayout.tsx @@ -3,6 +3,7 @@ import { AccountBookOutlined, ApartmentOutlined, BellOutlined, + BranchesOutlined, BugOutlined, DashboardOutlined, FileSearchOutlined, @@ -182,6 +183,7 @@ export default function AdminLayout() { const isFinanceKey = [ '/admin/sales-profit', '/admin/cost-center', + '/admin/fulfillment-channel', '/admin/fund', '/admin/finance', '/admin/finance/ledger', @@ -198,6 +200,7 @@ export default function AdminLayout() { const financeSubItems: MenuProps['items'] = [ { key: '/admin/sales-profit', icon: , label: '销售毛利' }, { key: '/admin/cost-center', icon: , label: '商品与成本' }, + { key: '/admin/fulfillment-channel', icon: , label: '履约渠道' }, { key: '/admin/fund', icon: , label: '钱包管理' }, { key: '/admin/finance', icon: , label: '收支明细' }, { key: '/admin/finance/ledger', icon: , label: '总账与对账' }, @@ -846,6 +849,7 @@ function resolveSelectedKey(pathname: string) { if (pathname.startsWith('/admin/orders')) return '/admin/orders' if (pathname.startsWith('/admin/sales-profit')) return '/admin/sales-profit' if (pathname.startsWith('/admin/cost-center')) return '/admin/cost-center' + if (pathname.startsWith('/admin/fulfillment-channel')) return '/admin/fulfillment-channel' if (pathname.startsWith('/admin/fund')) return '/admin/fund' if (pathname.startsWith('/admin/finance/ledger')) return '/admin/finance/ledger' if (pathname.startsWith('/admin/finance')) return '/admin/finance' diff --git a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx index af05be39..a2fadcb4 100644 --- a/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/ProductCatalogPanel.tsx @@ -342,7 +342,6 @@ export function ProductCatalogPanel() { } function openSetProductPrice(item: ProductCatalogItem) { - const isTemplateProduct = item.canonicalSku.startsWith('tpl:') setPricingProduct(item) priceForm.resetFields() priceForm.setFieldsValue({ @@ -350,7 +349,6 @@ export function ProductCatalogPanel() { item.activeCostAmountFen !== null && item.activeCostAmountFen !== undefined ? (item.activeCostAmountFen / 100).toFixed(2) : undefined, - pricingMode: isTemplateProduct ? item.templatePricingMode || 'fixed' : undefined, effectiveScope: 'immediate', changeReason: '在商品与成本中心统一调整价格', syncSnapshots: true, @@ -360,7 +358,6 @@ export function ProductCatalogPanel() { async function submitProductPrice(values: { amountYuan: number | string - pricingMode?: 'fixed' | 'unit' effectiveScope: 'immediate' | 'custom' effectiveFromCustom?: string changeReason?: string @@ -381,7 +378,6 @@ export function ProductCatalogPanel() { try { const response = await setAdminProductPrice(pricingProduct.id, { amountFen, - pricingMode: values.pricingMode, effectiveFrom, changeReason: values.changeReason || '在商品与成本中心统一调整价格', syncOpenOrders: true, @@ -1022,17 +1018,15 @@ export function ProductCatalogPanel() { {pricingProduct.canonicalSku.startsWith('tpl:') ? ( - - - 固定金额 - 按件计价 - - + ) : null} diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index bfa4bc79..9d6c346a 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -293,6 +293,7 @@ export default function ProductRulesPanel() { itemTitleMatchType: rule.match.itemTitleMatchType, skuNickMatchType: rule.match.skuNickMatchType, categoryId: rule.categoryId || undefined, + pricingMode: rule.pricingMode || 'fixed', sharingEnabled: rule.sharing?.enabled || false, sharingAutoFromOrder: rule.sharing?.autoFromOrder || false, sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined, @@ -349,6 +350,7 @@ export default function ProductRulesPanel() { fieldsText?: string enabled?: boolean autoCreate?: boolean + pricingMode?: 'fixed' | 'unit' sortOrder?: number }) { try { @@ -374,9 +376,7 @@ export default function ProductRulesPanel() { isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0, sharingEnabled: isFriendGift ? false : values.sharingEnabled === true, sharingAutoFromOrder: - editingRule?.unitPriceFen !== undefined && - editingRule.unitPriceFen > 0 && - editingRule.rewardAmount === 0 && + values.pricingMode === 'unit' && values.sharingEnabled === true && !hasManualSharingOverride, }) @@ -420,9 +420,12 @@ export default function ProductRulesPanel() { platform: rule.platform, productName: rule.productName, categoryId: rule.categoryId || undefined, + pricingMode: rule.pricingMode, sharingEnabled: rule.sharing?.enabled || false, sharingAutoFromOrder: - rule.unitPriceFen > 0 && rule.sharing?.enabled === true && !hasManualSharingOverride, + rule.pricingMode === 'unit' && + rule.sharing?.enabled === true && + !hasManualSharingOverride, sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined, sharingUnitReward: hasManualSharingOverride ? (rule.sharing?.unitReward || 0) / 100 @@ -753,22 +756,17 @@ export default function ProductRulesPanel() { }, }, { - title: '接单金额', + title: '接单价格', key: 'reward', width: '20%', render: (_, row) => (
- {row.rewardAmount > 0 || row.unitPriceFen > 0 ? ( - <> - - {row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)} - - {row.unitPriceFen > 0 ? ( - - 按件: {formatMoney(row.unitPriceFen)} - - ) : null} - + {(row.pricingMode === 'unit' ? row.unitPriceFen : row.rewardAmount) > 0 ? ( + + {row.pricingMode === 'unit' + ? `${formatMoney(row.unitPriceFen)} / 件` + : formatMoney(row.rewardAmount)} + ) : ( 待成本配置 @@ -889,6 +887,7 @@ export default function ProductRulesPanel() { skuNickMatchType: 'exact', enabled: false, autoCreate: true, + pricingMode: 'fixed', sharingEnabled: false, acceptanceMode: 'standard', giftCooldownUnit: 'hours', @@ -902,10 +901,10 @@ export default function ProductRulesPanel() { type="info" showIcon style={{ marginBottom: 16 }} - message="接单模板只维护匹配和履约规则" + message="接单模板维护计价方式和履约规则" description={ - 固定金额、按件单价和价格同步统一由 + 在这里选择“固定金额”或“按 SKU 按件”;具体金额与价格同步统一由 商品与成本中心 @@ -940,6 +939,22 @@ export default function ProductRulesPanel() { + + +
{/* 验收模式:好友赠送模式与拼单互斥 */} @@ -1047,9 +1062,7 @@ export default function ProductRulesPanel() { {({ getFieldValue }) => { const sharingEnabled = getFieldValue('sharingEnabled') - const unitMode = - Number(editingRule?.unitPriceFen || 0) > 0 && - Number(editingRule?.rewardAmount || 0) === 0 + const unitMode = getFieldValue('pricingMode') === 'unit' const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift' const hasConfiguredPrice = Number(editingRule?.unitPriceFen || 0) > 0 || diff --git a/apps/frontend/src/services/admin/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 61503fcf..9e780390 100644 --- a/apps/frontend/src/services/admin/cost-center.ts +++ b/apps/frontend/src/services/admin/cost-center.ts @@ -499,7 +499,6 @@ export function setAdminProductPrice( productId: number, payload: { amountFen: number - pricingMode?: 'fixed' | 'unit' effectiveFrom?: string changeReason?: string syncOpenOrders?: boolean diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 9b6fca9f..ccae9b59 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -290,6 +290,7 @@ export function saveAdminWorkProductRule(payload: { categoryId?: number enabled?: boolean autoCreate?: boolean + pricingMode?: 'fixed' | 'unit' sharingEnabled?: boolean sharingAutoFromOrder?: boolean sharingTotalQuantity?: number diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 542fb12d..2f8c44da 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -438,6 +438,7 @@ export type WorkProductRule = { autoCreate: boolean rewardAmount: number unitPriceFen: number + pricingMode: 'fixed' | 'unit' sharing?: WorkOrderSharingConfig requirement: { fields: CollectField[] From 3989b0a649c9975b4dbc8acad43ecf101370a753 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Mon, 2 Nov 2026 19:46:08 +0800 Subject: [PATCH 11/12] =?UTF-8?q?fix(admin):=20=E4=BF=AE=E5=A4=8D=E5=B1=A5?= =?UTF-8?q?=E7=BA=A6=E6=B8=A0=E9=81=93=E5=88=86=E9=A1=B5=E5=B9=B6=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=88=97=E8=A1=A8=E5=88=86=E9=A1=B5=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/routes/admin/fulfillment-channel.ts | 6 +- .../fulfillment/channel-assign-service.ts | 96 ++++++++++++++----- .../fulfillment-routing.integration.test.ts | 11 +++ .../src/components/common/PercentTable.tsx | 56 ++++++++++- .../admin/AdminFulfillmentChannelPage.tsx | 34 ++++--- .../src/services/admin/fulfillment-channel.ts | 13 ++- 6 files changed, 177 insertions(+), 39 deletions(-) diff --git a/apps/backend/src/routes/admin/fulfillment-channel.ts b/apps/backend/src/routes/admin/fulfillment-channel.ts index 220a100e..e34dfbdf 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -13,7 +13,7 @@ import { assignChannelToOrderItems, deleteChannelRule, deleteChannelRules, - listChannelRules, + listChannelRulesPage, listPendingChannelGroups, switchChannelForOrderItems, unlockChannel, @@ -179,8 +179,10 @@ router.get( requireAdminPermission('finance.view'), createJsonHandler( (req) => - listChannelRules({ + listChannelRulesPage({ channel: String(req.query.channel || ''), + page: Number(req.query.page), + pageSize: Number(req.query.pageSize), ...(req.query.enabled === undefined ? {} : { enabled: String(req.query.enabled) !== 'false' }), diff --git a/apps/backend/src/services/fulfillment/channel-assign-service.ts b/apps/backend/src/services/fulfillment/channel-assign-service.ts index 0df16aa4..eae8b531 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -63,6 +63,13 @@ export type ChannelRule = { enabled: boolean } +export type ChannelRulePage = { + items: ChannelRule[] + total: number + page: number + pageSize: number +} + export type ChannelCandidateItem = { id: number provider: string @@ -1033,12 +1040,12 @@ export async function assignChannelBySku(input: { }) } -export async function listChannelRules( - input: { - channel?: string - enabled?: boolean - } = {}, -): Promise { +type ChannelRuleFilterInput = { + channel?: string + enabled?: boolean +} + +function buildChannelRuleFilter(input: ChannelRuleFilterInput) { const params: unknown[] = [] const clauses: string[] = [] if (input.channel) { @@ -1050,25 +1057,70 @@ export async function listChannelRules( clauses.push(`enabled = $${params.length}`) } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' - const rows = await query( - ` - SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled - 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 - FROM internal_sku_channels - ) internal_rules - ${where} - ORDER BY priority, channel, match_key - `, - params, - ) - return rows.rows.map((row) => ({ + return { where, params } +} + +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 + FROM internal_sku_channels + ) internal_rules +` + +function mapChannelRule(row: ChannelRule): ChannelRule { + return { ...row, id: Number(row.id), priority: Number(row.priority), - })) + } +} + +export async function listChannelRules(input: ChannelRuleFilterInput = {}): Promise { + const { where, params } = buildChannelRuleFilter(input) + const rows = await query( + ` + SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled + ${CHANNEL_RULE_FROM_SQL} + ${where} + ORDER BY priority, channel, match_key, id + `, + params, + ) + return rows.rows.map(mapChannelRule) +} + +export async function listChannelRulesPage( + input: ChannelRuleFilterInput & { page?: number; pageSize?: number } = {}, +): Promise { + const page = Math.max(1, Math.trunc(Number(input.page) || 1)) + const pageSize = Math.min(100, Math.max(1, Math.trunc(Number(input.pageSize) || 20))) + const offset = (page - 1) * pageSize + const { where, params } = buildChannelRuleFilter(input) + const pageParams = [...params, pageSize, offset] + const [countResult, rows] = await Promise.all([ + query<{ total: number }>( + `SELECT COUNT(*)::int AS total ${CHANNEL_RULE_FROM_SQL} ${where}`, + params, + ), + query( + ` + SELECT id, provider, shop_id, match_key_type, match_key, channel, priority, enabled + ${CHANNEL_RULE_FROM_SQL} + ${where} + ORDER BY priority, channel, match_key, id + LIMIT $${pageParams.length - 1} OFFSET $${pageParams.length} + `, + pageParams, + ), + ]) + return { + items: rows.rows.map(mapChannelRule), + total: Number(countResult.rows[0]?.total || 0), + page, + pageSize, + } } export async function upsertChannelRule(input: { 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 8db7760f..b32dae6b 100644 --- a/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -23,6 +23,7 @@ test( deleteChannelRules, ensureVoucherRuleForSku, listChannelRules, + listChannelRulesPage, switchChannelForOrderItems, upsertChannelRule, } = await import('./channel-assign-service.js') @@ -171,6 +172,16 @@ test( assert.ok( listedRules.some((rule) => rule.id === savedRule.id && rule.match_key === voucherSku), ) + const firstRulePage = await listChannelRulesPage({ + channel: 'third_party_voucher', + enabled: true, + page: 1, + pageSize: 2, + }) + assert.equal(firstRulePage.page, 1) + assert.equal(firstRulePage.pageSize, 2) + assert.ok(firstRulePage.items.length <= 2) + assert.equal(firstRulePage.total, listedRules.length) const deletedRules = await deleteChannelRules([savedRule.id]) assert.equal(deletedRules.deletedCount, 1) assert.deepEqual(deletedRules.deletedIds, [savedRule.id]) diff --git a/apps/frontend/src/components/common/PercentTable.tsx b/apps/frontend/src/components/common/PercentTable.tsx index 48c088c3..46c56fa9 100644 --- a/apps/frontend/src/components/common/PercentTable.tsx +++ b/apps/frontend/src/components/common/PercentTable.tsx @@ -1,5 +1,6 @@ import { Table as AntTable } from 'antd' -import type { TableProps } from 'antd' +import type { TablePaginationConfig, TableProps } from 'antd' +import { useEffect, useState } from 'react' import { normalizePercentColumns } from '@/utils/percent-table-layout' @@ -12,7 +13,56 @@ import { normalizePercentColumns } from '@/utils/percent-table-layout' export default function PercentTable>( props: TableProps, ) { - const { columns, scroll, tableLayout: _tableLayout, ...rest } = props + const { columns, scroll, tableLayout: _tableLayout, pagination, dataSource, ...rest } = props + const paginationConfig = pagination && typeof pagination === 'object' ? pagination : null + const paginationIsExternallyControlled = paginationConfig?.current !== undefined + // 本地分页也必须由组件持有 current;只交给 AntD 内部状态时,父级重渲染会丢失翻页。 + const [localPagination, setLocalPagination] = useState(() => ({ + current: 1, + pageSize: Number(paginationConfig?.defaultPageSize || paginationConfig?.pageSize || 10), + })) + + useEffect(() => { + if (paginationIsExternallyControlled || !paginationConfig?.pageSize) return + const nextPageSize = Number(paginationConfig.pageSize) + if (!Number.isFinite(nextPageSize) || nextPageSize <= 0) return + setLocalPagination((current) => + current.pageSize === nextPageSize ? current : { current: 1, pageSize: nextPageSize }, + ) + }, [paginationConfig?.pageSize, paginationIsExternallyControlled]) + + useEffect(() => { + if (paginationIsExternallyControlled || !paginationConfig) return + const total = Math.max(0, Number(paginationConfig.total ?? dataSource?.length ?? 0)) + const maxPage = Math.max(1, Math.ceil(total / Math.max(1, localPagination.pageSize))) + if (localPagination.current > maxPage) { + setLocalPagination((current) => ({ ...current, current: maxPage })) + } + }, [ + dataSource?.length, + localPagination.current, + localPagination.pageSize, + paginationConfig, + paginationIsExternallyControlled, + ]) + + const resolvedPagination: TablePaginationConfig | false | undefined = paginationConfig + ? paginationIsExternallyControlled + ? paginationConfig + : { + ...paginationConfig, + current: localPagination.current, + pageSize: localPagination.pageSize, + onChange: (nextPage, nextPageSize) => { + const normalizedPageSize = Math.max(1, Number(nextPageSize) || localPagination.pageSize) + setLocalPagination({ + current: normalizedPageSize === localPagination.pageSize ? nextPage : 1, + pageSize: normalizedPageSize, + }) + paginationConfig.onChange?.(nextPage, nextPageSize) + }, + } + : pagination const reservedPercent = (props.rowSelection ? 4 : 0) + (props.expandable && typeof props.expandable === 'object' ? 4 : 0) @@ -24,7 +74,9 @@ export default function PercentTable {...rest} + dataSource={dataSource} columns={normalizedColumns} + pagination={resolvedPagination} tableLayout="fixed" scroll={{ ...scrollConfig, x: '100%' }} /> diff --git a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx index eff0fda5..02ac192d 100644 --- a/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx +++ b/apps/frontend/src/pages/admin/AdminFulfillmentChannelPage.tsx @@ -29,7 +29,7 @@ import { message, } from 'antd' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useState } from 'react' import PercentTable from '@/components/common/PercentTable' import FinanceSubNav from '@/components/admin/FinanceSubNav' @@ -48,11 +48,7 @@ import { } from '@/services/admin' import { formatRedeemAmount } from '@/utils/redeem' import { formatDateTime } from '@/utils/date-time' -import { - ADMIN_DEFAULT_PAGE_SIZE, - buildAdminLocalTablePagination, - buildAdminTablePagination, -} from '@/utils/admin-pagination' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' const CHANNEL_OPTIONS = [ { value: 'work_order', label: '接单平台' }, @@ -251,12 +247,20 @@ function RulesPanel() { const queryClient = useQueryClient() const [open, setOpen] = useState(false) const [selectedRuleIds, setSelectedRuleIds] = useState([]) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [form] = Form.useForm() const query = useQuery({ - queryKey: ['fulfillment-rules'], - queryFn: () => fetchChannelRules(), + queryKey: ['fulfillment-rules', page, pageSize], + queryFn: () => fetchChannelRules({ page, pageSize }), }) + const ruleCount = query.data?.data?.total || 0 + + useEffect(() => { + const maxPage = Math.max(1, Math.ceil(ruleCount / pageSize)) + if (page > maxPage) setPage(maxPage) + }, [page, pageSize, ruleCount]) const saveMutation = useMutation({ mutationFn: (values: Record) => saveChannelRule(values as never), @@ -339,7 +343,7 @@ function RulesPanel() { return ( diff --git a/apps/frontend/src/utils/douyin-mashida.ts b/apps/frontend/src/utils/douyin-mashida.ts index 2b590572..332257c2 100644 --- a/apps/frontend/src/utils/douyin-mashida.ts +++ b/apps/frontend/src/utils/douyin-mashida.ts @@ -87,22 +87,52 @@ export function describeMissingConfigFields(config: MissingConfigCheck): string[ /** * 面板预览用的示例订单号(与真实 16 位平台订单号形态一致)。 */ -const CLAIM_LINK_PREVIEW_TOKEN = '2628301923197355' +const CLAIM_LINK_PREVIEW_ORDER_NO = '2628301923197355' /** - * 拼一条领取链接预览:`claimLinkBase` 去尾斜杠 + '/' + token。 - * 规则与后端拼装一致(后端用配置里的 `claimLinkBase`)。 - * 基址为空返回空串(后端会回退到通用兑换链接)。 + * 拼一条领取链接预览:`/<订单号>`。 + * 规则与后端一致,历史 `/#/claim`、`/claim` 基址也会归一化为 `/h5/订单号`。 + * 基址为空返回空串。 */ export function buildDouyinMashidaClaimLinkPreview( claimLinkBase: string, - token = CLAIM_LINK_PREVIEW_TOKEN, + orderNo = CLAIM_LINK_PREVIEW_ORDER_NO, ): string { - const base = String(claimLinkBase ?? '') - .trim() - .replace(/\/+$/, '') - if (!base) return '' - return `${base}/${String(token ?? '').trim()}` + const base = normalizeH5ClaimBase(claimLinkBase) + const normalizedOrderNo = String(orderNo ?? '').trim() + if (!base || !normalizedOrderNo) return '' + return `${base}/${encodeURIComponent(normalizedOrderNo)}` +} + +/** 将历史 `/#/claim`、`/claim` 基址归一化为同站点 `/h5`,与后端生成口径一致。 */ +function normalizeH5ClaimBase(claimLinkBase: string): string { + const raw = String(claimLinkBase ?? '').trim() + if (!raw) return '' + + let url: URL + try { + url = new URL(raw) + } catch { + return '' + } + + const pathname = url.pathname.replace(/\/+$/, '') + const h5Match = pathname.match(/\/h5(?:\/|$)/i) + const claimMatch = pathname.match(/\/claim(?:\/|$)/i) + + if (h5Match?.index !== undefined) { + url.pathname = `${pathname.slice(0, h5Match.index)}/h5` + } else if (claimMatch?.index !== undefined) { + url.pathname = `${pathname.slice(0, claimMatch.index)}/h5` + } else if (pathname && pathname !== '/') { + url.pathname = `${pathname}/h5` + } else { + url.pathname = '/h5' + } + + url.search = '' + url.hash = '' + return url.toString().replace(/\/+$/, '') } /** diff --git a/apps/frontend/tests/douyin-mashida.test.ts b/apps/frontend/tests/douyin-mashida.test.ts index 118ca90b..220435a6 100644 --- a/apps/frontend/tests/douyin-mashida.test.ts +++ b/apps/frontend/tests/douyin-mashida.test.ts @@ -89,22 +89,30 @@ test('describeMissingConfigFields:缺单项只返回对应中文名', () => { ) }) -test('领取链接预览 = 基址去尾斜杠 + token', () => { +test('领取链接预览统一为 /h5/订单号', () => { assert.equal( - buildDouyinMashidaClaimLinkPreview('https://x.ngrok-free.app/claim', 'tok123'), - 'https://x.ngrok-free.app/claim/tok123', + buildDouyinMashidaClaimLinkPreview('https://x.ngrok-free.app/claim', '6919156612984680391'), + 'https://x.ngrok-free.app/h5/6919156612984680391', ) // 尾斜杠与前后空格要容忍 assert.equal( - buildDouyinMashidaClaimLinkPreview(' https://x.example.com/claim/ ', 'tok123'), - 'https://x.example.com/claim/tok123', + buildDouyinMashidaClaimLinkPreview(' https://x.example.com/h5/ ', '6919156612984680391'), + 'https://x.example.com/h5/6919156612984680391', ) - // 裸域名也允许(后端统一补 /token) + // 裸域名和历史 hash 基址都归一化为 /h5 assert.equal( - buildDouyinMashidaClaimLinkPreview('https://x.example.com', 'tok123'), - 'https://x.example.com/tok123', + buildDouyinMashidaClaimLinkPreview('https://x.example.com', '6919156612984680391'), + 'https://x.example.com/h5/6919156612984680391', ) - // 留空 → 不生成链接(后端回退到通用兑换链接) + assert.equal( + buildDouyinMashidaClaimLinkPreview('https://x.example.com/#/claim', '6919156612984680391'), + 'https://x.example.com/h5/6919156612984680391', + ) + assert.equal( + buildDouyinMashidaClaimLinkPreview('https://x.example.com/claim/tok123', '6919156612984680391'), + 'https://x.example.com/h5/6919156612984680391', + ) + // 留空 → 不生成预览 assert.equal(buildDouyinMashidaClaimLinkPreview(''), '') assert.equal(buildDouyinMashidaClaimLinkPreview(' '), '') }) diff --git a/docs/91卡券/3.异步卡密下单.md b/docs/91卡券/3.异步卡密下单.md index 882876d1..c14942d1 100644 --- a/docs/91卡券/3.异步卡密下单.md +++ b/docs/91卡券/3.异步卡密下单.md @@ -97,7 +97,7 @@ your_secret_keybuyNum=1&callbackUrl=https://cb.example.com/notify/91/order&maxAm 4. 若 `productNo` 已映射到 affiliate-dash 履约商品,则创建履约任务; 5. 若 `productNo` 尚未配置,则订单进入后台“待补全”队列,由运营补齐履约配置后重试生成任务; 6. 若传入 `maxAmount`,则校验成本是否超限; -7. 履约任务生成后,生成当前项目领取链接,例如:`https://你的域名/#/claim/{token}`; +7. 履约任务生成后,生成当前项目领取链接,例如:`https://你的域名/h5/{订单号}`; 8. 首次响应返回处理中状态,由 `91卡券` 后续调用查询订单接口获取最终卡密内容。 ## 10. 响应参数 diff --git a/docs/91卡券/4.查询订单接口.md b/docs/91卡券/4.查询订单接口.md index 5e920343..3e15bf2d 100644 --- a/docs/91卡券/4.查询订单接口.md +++ b/docs/91卡券/4.查询订单接口.md @@ -96,7 +96,7 @@ your_secret_keyorderNo=P91KS202605040001×tamp=1777867260&version=1.0your_se | `failReason` | string | 失败时建议返回 | 失败原因。 | | `orderCost` | decimal(14,4) | 成功时建议返回 | 订单总成本,单位:元。 | | `cards` | string | `orderStatus = 20` 时必须 | 卡密数据加密串。当前项目通过该字段返回领取链接。 | -| `cards[0].cardNo` | string | 成功时必须 | 最终交付的领取链接,格式为 `https://你的域名/#/claim/{token}`。 | +| `cards[0].cardNo` | string | 成功时必须 | 最终交付的领取链接,格式为 `https://你的域名/h5/{订单号}`。 | | `cards[0].cardPwd` | string | 可选 | 当前场景固定为空字符串。 | | `cards[0].expireTime` | string | 可选 | 领取链接过期时间,支持 `yyyy-MM-dd HH:mm:ss` 或 10 位秒级 Unix 时间戳。 | | `cards[0].jumpLink` | string | 可选 | 与 `cardNo` 保持一致,用于兼容链接展示场景。 | @@ -115,15 +115,15 @@ your_secret_keyorderNo=P91KS202605040001×tamp=1777867260&version=1.0your_se ```json [ { - "cardNo": "https://你的域名/#/claim/abc123xyz", + "cardNo": "https://你的域名/h5/6917533224570258991", "cardPwd": "", "expireTime": "2026-05-05 12:00:00", - "jumpLink": "https://你的域名/#/claim/abc123xyz" + "jumpLink": "https://你的域名/h5/6917533224570258991" } ] ``` -若 `buyNum > 1`,则 `cards` 原始结构中返回多个卡项,每个卡项对应一个独立领取链接。 +若 `buyNum > 1`,则 `cards` 原始结构中返回多个卡项;由于对外链接按订单号生成,每个卡项会返回同一个 `/h5/{订单号}` 链接,领取页按任务顺序逐个推进。 ## 12. 响应示例 @@ -168,10 +168,10 @@ your_secret_keyorderNo=P91KS202605040001×tamp=1777867260&version=1.0your_se ```json [ { - "cardNo": "https://你的域名/#/claim/abc123xyz", + "cardNo": "https://你的域名/h5/6917533224570258991", "cardPwd": "", "expireTime": "2026-05-05 12:00:00", - "jumpLink": "https://你的域名/#/claim/abc123xyz" + "jumpLink": "https://你的域名/h5/6917533224570258991" } ] ``` diff --git a/docs/91卡券/5.当前项目对接配置.md b/docs/91卡券/5.当前项目对接配置.md index c42140b0..19651e3f 100644 --- a/docs/91卡券/5.当前项目对接配置.md +++ b/docs/91卡券/5.当前项目对接配置.md @@ -237,9 +237,9 @@ Content-Type: application/json;charset=utf-8 如果 `buyNum > 1`: -- 每个数量生成一个独立领取链接; +- 每个数量生成一个独立履约任务; - `cards` 中放多个卡项; -- 每个 `cardNo` 对应一个独立链接。 +- 每个 `cardNo` 统一返回同一个 `/h5/<订单号>` 链接,领取页按任务顺序推进。 ## 9. 查询成功响应示例 diff --git a/docs/91卡券/6.开发设计.md b/docs/91卡券/6.开发设计.md index e87b6a63..ddc82946 100644 --- a/docs/91卡券/6.开发设计.md +++ b/docs/91卡券/6.开发设计.md @@ -15,7 +15,7 @@ 2. 我方将订单作为独立接入平台订单写入当前项目; 3. 已配置商品自动走现有 affiliate-dash 履约链路; 4. 未配置商品进入后台待补全队列,可手动补齐履约信息后重试; -5. 我方生成领取链接 `https://你的域名/#/claim/{token}`; +5. 我方生成领取链接 `https://你的域名/h5/{订单号}`; 6. `91卡券` 调用查询订单接口; 7. 我方在 `cards` 中返回领取链接,由 `91卡券` 自动发货给买家。 @@ -246,7 +246,7 @@ Node 侧实现等价为: - 内部创建 `N` 个 task; - 查询成功时返回 `N` 个 card; -- 每个 card 对应一个独立领取链接。 +- 每个 card 对应一个独立履约任务;对外链接统一为同一个 `/h5/<订单号>`,领取页按任务顺序推进。 ## 10. 商品匹配设计 diff --git a/docs/履约配置/多发货平台与电子凭证整体链路.md b/docs/履约配置/多发货平台与电子凭证整体链路.md index 7351ffce..1686db0a 100644 --- a/docs/履约配置/多发货平台与电子凭证整体链路.md +++ b/docs/履约配置/多发货平台与电子凭证整体链路.md @@ -74,7 +74,7 @@ kuaishou-91 进单 这里的卡密内容继续保持为我方统一领取链接: ```text -claimUrl = https://你的域名/#/claim/{token} +claimUrl = https://你的域名/h5/{订单号} ``` 不要直接把 affiliate-dash 的绑定链接返回给 91。原因是领取页需要统一处理: diff --git a/docs/抖音-码速达/05-最小接入方案分析.md b/docs/抖音-码速达/05-最小接入方案分析.md index a5c09b00..6deaa605 100644 --- a/docs/抖音-码速达/05-最小接入方案分析.md +++ b/docs/抖音-码速达/05-最小接入方案分析.md @@ -301,7 +301,7 @@ function RedeemApplication() { **⚠️ `cert_link` 必须做成配置项**(`app_config` 里的 `certLink`),不要硬编码:链接一旦通过 `syncV2` 发给平台就**收不回来**(老券身上永远挂着老链接)。以后换域名时,**老域名必须继续可跳**(或用 301 兜住),否则老券全部点不开。 -- 备注:现有领取链接仍是 hash 式 `https://域名/#/claim`(`CLAIM_BASE_URL` 配置)—— 两套并存,本次**不动老链路**。 +- 备注:该方案落笔时领取链接仍是 hash 式 `https://域名/#/claim`;现已统一出站为 `https://域名/h5/<平台订单号>`,hash token 路由仅保留历史入站兼容。 --- diff --git a/docs/抖音-码速达/08-公网联调.md b/docs/抖音-码速达/08-公网联调.md index 352d7ed6..12b721e6 100644 --- a/docs/抖音-码速达/08-公网联调.md +++ b/docs/抖音-码速达/08-公网联调.md @@ -163,7 +163,7 @@ npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app --s 3. 填真实凭据 面板:uid = 抖店商户号,secret = 真实用户秘钥 (baseUrl 保持 https://dy-openplatform-api.wanmahui.com) 4. 皮肤领取页两项配置 面板: - claimLinkBase = https://<域名>/claim 皮肤订单领取页基址 + claimLinkBase = https://<域名>/h5 皮肤订单领取页基址 messageTemplate = 您的订单已发货,请点击领取:{link} ⚠️ claimLinkBase 必须是**路径式**(不要带 #): #/claim 是打手领取页,配错会把买家带到打手流程 @@ -175,7 +175,7 @@ npm run douyin-mashida:preflight -- --public-url https://xxxx.ngrok-free.app --s POST /api/v1/open/douyin-mashida/message 收到推送 [douyin-mashida/certificate] 券码已同步 syncV2 成功 fulfillment_tasks / claim_tokens 已建任务与领取 token -8. 手机打开买家领取链接 https://<域名>/claim/(首次要过 ngrok 警示页) +8. 手机打开买家领取链接 https://<域名>/h5/<平台订单号>(首次要过 ngrok 警示页) → 看到物品图片 → 填游戏账号 → 领取 → 本地看到 verifyV3 9. 对账 orders / order_items / redeem_certificates / fulfillment_tasks 四张表 ``` diff --git a/docs/抖音-码速达/09-皮肤领取流程.md b/docs/抖音-码速达/09-皮肤领取流程.md index 946f9b39..f69b20cb 100644 --- a/docs/抖音-码速达/09-皮肤领取流程.md +++ b/docs/抖音-码速达/09-皮肤领取流程.md @@ -38,13 +38,11 @@ ## 3. 链接必须是**路径式**,且只有一个 -- 领取页走**独立 BrowserRouter 分支**的 `/claim/`(照 `/redeem` 的做法,见 `apps/frontend/src/App.tsx`), - **不带 `#`**;主站里 hash 形式的 `#/claim/:token` 仍保留(pathname 为 `/`,两套互不影响)。 -- 链接由抖音配置的 `claimLinkBase` 拼:`claimLinkBase`(去尾斜杠)+ `/` + token。 - **绝不回退** `buildClaimUrl` / `orders.claimBaseUrl`(那是 hash 的打手领取页)。 -- 同一订单项若有多个履约单元(多个 token),固定取 task id 最小的那个并打 warn, - 保证「同一张券每次都解析出同一条链接」。 -- 老订单的券里已经写死了 `/redeem` 链接(发出去收不回来),旧页面**保留可用**。 +- 对外领取链接统一走**独立 BrowserRouter 分支**的 `/h5/<平台订单号>`(见 `apps/frontend/src/App.tsx`),**不带 `#`**。 +- 抖音配置了 `claimLinkBase` 时沿用其站点,未配置时使用全局 `orders.claimBaseUrl`;历史 `/#/claim`、`/claim` 基址会自动归一化到 `/h5`。 +- claim token 仍按履约单元生成,但只用于判断“该订单项是否已有自动发货单元”,不再拼进对外链接。 +- 同一订单有多个履约单元时共用一个订单号链接,领取页会按任务顺序推进。 +- 老订单的券里已经写死的 `/redeem` 或 token 链接(发出去收不回来),旧页面**保留兼容**。 ## 4. 商品图怎么来 diff --git a/docs/项目全流程与数据库设计梳理-2026-10-23.md b/docs/项目全流程与数据库设计梳理-2026-10-23.md index 62d2cedb..eee1b263 100644 --- a/docs/项目全流程与数据库设计梳理-2026-10-23.md +++ b/docs/项目全流程与数据库设计梳理-2026-10-23.md @@ -14,7 +14,7 @@ 2. 订单来源只有三个:`kuaishou-91`(91 回调 + 快手 send-code 两条通道,**同一个来源**)、`pdd`、`douyin-mashida`。代码层的来源键定义在 `apps/backend/src/services/product-match/types.ts`。 3. **核心不变式:一个订单 = 骨架(`orders` + `order_items`)+ 买家凭证(快手电子凭证 / 抖音券)+ 一个执行载体**。载体只有三种:`affiliate_dash` 自动发货、`work_orders` 工单、`manual` 人工(当前无显式表,靠「无任务且无工单」判定)。 4. 履约任务按「订单项 × 数量」拆单元:`fulfillment_tasks` 的 `UNIQUE(order_item_id, unit_index)` 是整套领取链接体系的基座。 -5. 领取链接 = `claimLinkBase + '/' + claim_tokens.token`,一任务一 token(`claim_tokens.task_id` 唯一);`CLAIM_TOKEN_TTL_HOURS=0` 表示永久有效。 +5. 对外领取链接统一为 `<领取站>/h5/<平台订单号>`;`claim_tokens` 仍按任务生成(`task_id` 唯一),但 token 只用于内部领取单元判定,不再拼进对外链接。 6. 打手侧是一条**独立业务线**:工单状态机 `pending_material → unassigned → open → in_progress → pending_acceptance → accepted`,另有 `problem`、`cancelled`;资金走「钱包 + 流水」模型,不是会计总账。 7. 寄卖是第三个业务面:提报 → 审核入库(`INV…`)→ 规格匹配(系统/区服/系列/钻级/璀璨)→ 履约(内部现货 or 指派打手)→ 结算与撤销押金策略。 8. 备用金台账(迁移 100)已落地:`fund_accounts`(主管池 / 客服账户)+ `fund_entries`(只追加流水,余额只能由流水变动)+ `manual_expenses` + `fund_reconciliations`。 @@ -240,8 +240,8 @@ flowchart TD - `manual_dispatch` → `profile_key='manual_review'`,初始状态 `manual_review`; - 未支付 → `pending_payment`。 3. 已支付订单立即 `preparePaidFulfillmentTask`(执行器注册表:`affiliate_dash` / `manual` / `legacy-retired` 只读桩)。 -4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`),`claimUrl = claimBaseUrl + '/' + token`。 -5. 抖音走独立基址 `claimLinkBase`(后台配置),**刻意不回退** `orders.claimBaseUrl`。 +4. 领取 token:`createTaskClaimToken` 写 `claim_tokens`(`task_id` 唯一、`token` 唯一、`max_use_count=1`);token 只用于内部领取单元判定,不再出现在对外链接中。 +5. 所有来源对外统一生成 `claimUrl = <领取站>/h5/<平台订单号>`;历史 `/#/claim/:token`、`/claim/:token` 仅保留入站兼容。 6. `orders.claim_status`(迁移 099)= `active / paused / closed`,用于暂停或关闭整单兑换,是后台处置链接失效问题的新开关。 ### 3.5 affiliate-dash 自动履约 @@ -262,8 +262,8 @@ flowchart TD | 入口 | 路由 | 用途 | | --- | --- | --- | -| 主站领取页 | `/#/claim/:token` | 快手渠道主路径,按 `executorKey` 渲染不同步骤 | -| H5 领取页 | `/h5/:orderNo`(BrowserRouter 分支) | 抖音等按订单号承接,`/api/v1/claim/h5/...` | +| 主站领取页(历史兼容) | `/#/claim/:token`、`/claim/:token` | 仅接收历史 token 链接,按 `executorKey` 渲染不同步骤 | +| H5 领取页(统一出站) | `/h5/:orderNo`(BrowserRouter 分支) | 所有来源按订单号承接,`/api/v1/claim/h5/...` | | 兑换页 | `/redeem` | 抖音 DYC 券兑换/核销 | | 补资料页 | `/collect` | 待补全订单资料收集(**仅凭订单号,无第二因子**) |