diff --git a/.env.mac-docker.example b/.env.mac-docker.example index a21954ec..86d11875 100644 --- a/.env.mac-docker.example +++ b/.env.mac-docker.example @@ -11,7 +11,7 @@ BACKEND_PORT=3000 HTTP_TRUST_PROXY=1 # Claim Links -CLAIM_BASE_URL=http://localhost/#/claim +CLAIM_BASE_URL=http://localhost/h5 # Database POSTGRES_DB=order_site diff --git a/.env.server.example b/.env.server.example index 3ee4594d..5470e05a 100644 --- a/.env.server.example +++ b/.env.server.example @@ -23,7 +23,7 @@ HTTP_TRUST_PROXY=1 # BASE_REGISTRY=docker.m.daocloud.io # Claim Links -CLAIM_BASE_URL=https://order.khhao.com/#/claim +CLAIM_BASE_URL=https://order.khhao.com/h5 CLAIM_TOKEN_TTL_HOURS=0 # Database diff --git a/.gitignore b/.gitignore index 4fd4740d..fbac5312 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ node_modules .env.local .env.back* .DS_Store - +.openidea @@ -40,4 +40,4 @@ send_code/ # 服务器上的"上次成功部署"记录(deploy/ubuntu-deploy.sh 按需重建用) /.deploy-state -artifacts/ \ No newline at end of file +artifacts/ diff --git a/README.md b/README.md index 334a6109..29184edc 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` | 后续结构与数据增量;当前生产发布基线为 119,120–121 为本次履约路由升级 | 约定: @@ -194,7 +194,7 @@ cp .env.server.example .env | --- | --- | | `APP_DOMAIN` / `CADDY_SITE_ADDR` | 后台与领取页域名、Caddy 主站监听地址 | | `WORKER_SITE_ADDR` | 打手端独立域名,如 `https://worker.khhao.com` | -| `CLAIM_BASE_URL` | 领取页完整前缀,如 `https://域名/#/claim` | +| `CLAIM_BASE_URL` | 买家领取页基址,如 `https://域名/h5`;对外链接统一为 `/h5/<订单号>` | | `CLAIM_TOKEN_TTL_HOURS` | 领取链接有效期(小时);`0` 表示长期有效,正整数表示限时 | | `DATABASE_URL` | PostgreSQL 连接串(容器内主机名用 `postgres`) | | `ADMIN_SESSION_SECRET` | 后台登录态签名密钥 | diff --git a/apps/backend/scripts/audit-fulfillment-finance-alignment.ts b/apps/backend/scripts/audit-fulfillment-finance-alignment.ts index 4d536538..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 @@ -34,18 +34,31 @@ async function main() { work_orders: number affiliate_tasks: number manual_tasks: number + config_missing: number no_carrier: number }>( ` 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 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' + 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 wo.status <> 'cancelled') + ) )::int AS no_carrier FROM order_items oi WHERE oi.fulfillment_status = 'assigned' @@ -63,9 +76,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 +123,49 @@ 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 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 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`, + ) + const byProvider = await query<{ provider: string total: number @@ -115,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 @@ -135,6 +197,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/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 918e645e..e3c3b6d7 100644 --- a/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts +++ b/apps/backend/scripts/cleanup-legacy-sku-code-rules.ts @@ -78,15 +78,15 @@ async function main() { 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'], + `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( ` @@ -95,6 +95,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..92abfbe1 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,25 +169,13 @@ 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() `, [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 10ed333a..1e8d5bfa 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,99 @@ 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 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], + ) + ).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 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], + ) + ).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 @@ -72,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`, @@ -84,8 +170,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..dcb42ac7 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 @@ -58,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 ( @@ -68,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 AND wo.status = 'accepted' + 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 288f45a5..070d3d1f 100644 --- a/apps/backend/scripts/seed-local-finance-demo.ts +++ b/apps/backend/scripts/seed-local-finance-demo.ts @@ -17,24 +17,52 @@ 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')`, + `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 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', 'man:模拟人工商品', '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 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 manual_fulfillment_configs + WHERE sku_key IN ('模拟人工商品', '模拟自动发货商品')`, + ) + await query( + `DELETE FROM product_catalog + WHERE canonical_sku IN ( + 'aff:SIM_AFF_1', 'man:模拟人工商品', 'tpv:模拟三方卡券', 'tpv:模拟三方卡券B' + )`, ) } @@ -96,15 +124,61 @@ 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 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 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, config_json, source, created_at, updated_at + ) VALUES + ('模拟自动发货商品', '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 ( + internal_sku, channel, priority, is_fallback, enabled, source, created_at, updated_at + ) VALUES ('模拟人工商品', 'manual', 10, FALSE, TRUE, 'demo', NOW(), NOW())`, ) // 1. 三方卡券:待分类,指定后自动建商品 diff --git a/apps/backend/src/config/defaults.ts b/apps/backend/src/config/defaults.ts index 36c4feea..437296df 100644 --- a/apps/backend/src/config/defaults.ts +++ b/apps/backend/src/config/defaults.ts @@ -62,7 +62,7 @@ export function createDefaultRuntimeConfig(projectRoot: string): RuntimeConfig { }, orders: { - claimBaseUrl: 'http://127.0.0.1:5173/#/claim', + claimBaseUrl: 'http://127.0.0.1:5173/h5', // 0 表示长期有效;后台手动关闭任务后领取链接仍会立即失效。 tokenTtlHours: 0, }, diff --git a/apps/backend/src/config/env-overrides.test.ts b/apps/backend/src/config/env-overrides.test.ts index 9b29bdfe..a53db9de 100644 --- a/apps/backend/src/config/env-overrides.test.ts +++ b/apps/backend/src/config/env-overrides.test.ts @@ -41,7 +41,7 @@ test('applyEnvOverrides derives claim base url from app base url when explicit c APP_BASE_URL: 'https://example.test///', }) - assert.equal(config.orders.claimBaseUrl, 'https://example.test/#/claim') + assert.equal(config.orders.claimBaseUrl, 'https://example.test/h5') }) test('CLAIM_TOKEN_TTL_HOURS=0 表示领取链接长期有效', () => { diff --git a/apps/backend/src/config/env-overrides.ts b/apps/backend/src/config/env-overrides.ts index 46fcf676..d3502261 100644 --- a/apps/backend/src/config/env-overrides.ts +++ b/apps/backend/src/config/env-overrides.ts @@ -158,7 +158,7 @@ function applyClaimBaseUrlOverride(config: RuntimeConfig, env: RuntimeEnv): void const appBaseUrl = parseString(env.APP_BASE_URL) if (appBaseUrl !== null) { - config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/#/claim` + config.orders.claimBaseUrl = `${appBaseUrl.replace(/\/+$/, '')}/h5` } } 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..dcb4e86e --- /dev/null +++ b/apps/backend/src/db/migrations/120_fulfillment_routing_invariants.sql @@ -0,0 +1,345 @@ +-- 120_fulfillment_routing_invariants.sql +-- +-- 生产迁移 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, + 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_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, + 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' + ); + +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 new file mode 100644 index 00000000..cb17f55f --- /dev/null +++ b/apps/backend/src/db/migrations/121_internal_sku_single_primary.sql @@ -0,0 +1,31 @@ +-- 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; + +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/db/migrations/122_work_order_template_product_projection.sql b/apps/backend/src/db/migrations/122_work_order_template_product_projection.sql new file mode 100644 index 00000000..e56af19d --- /dev/null +++ b/apps/backend/src/db/migrations/122_work_order_template_product_projection.sql @@ -0,0 +1,55 @@ +-- 接单模板商品改为事件驱动投影:新模板保存时自动建/更新商品。 +-- 本迁移补齐现有模板商品,并统一状态、名称、分类和价格属性。 + +WITH template_projection AS ( + SELECT + LEFT('tpl:' || BTRIM(wpr.rule_key), 64) AS canonical_sku, + wpr.product_name AS canonical_name, + COALESCE(wc.name, '') AS category, + CASE + WHEN wpr.product_name ~ '钻石|颗' THEN '颗' + WHEN wpr.product_name ~ '枪|把' THEN '把' + WHEN wpr.product_name ~ '套' THEN '套' + WHEN wpr.product_name ~ '密钥|秘钥' THEN '个' + ELSE '件' + END AS unit_name, + 'active' AS status, + jsonb_build_object( + 'source', 'work_product_rule', + 'ruleId', wpr.id, + 'ruleKey', wpr.rule_key, + 'categoryId', wpr.category_id, + 'templateEnabled', wpr.enabled, + 'pricingMode', + CASE + WHEN wpr.unit_price_fen > 0 AND wpr.reward_amount = 0 THEN 'unit' + ELSE 'fixed' + END, + 'rewardAmountFen', wpr.reward_amount, + 'unitPriceFen', wpr.unit_price_fen, + 'sharingEnabled', wpr.sharing_enabled, + 'sharingTotalQuantity', wpr.sharing_total_quantity, + 'sharingUnitReward', wpr.sharing_unit_reward, + 'sharingAutoFromOrder', wpr.sharing_auto_from_order + ) AS attributes_json + FROM work_product_rules wpr + LEFT JOIN work_categories wc ON wc.id = wpr.category_id +) +INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at +) +SELECT + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, NOW(), NOW() +FROM template_projection +ON CONFLICT (canonical_sku) DO UPDATE SET + canonical_name = EXCLUDED.canonical_name, + category = EXCLUDED.category, + unit_name = EXCLUDED.unit_name, + attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json, + updated_at = NOW() +WHERE product_catalog.canonical_sku LIKE 'tpl:%'; + +COMMENT ON TABLE product_catalog IS + '标准商品目录;tpl: 商品由接单模板事件自动投影,价格只来自成本中心'; 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 f7c4c419..f2c73c0e 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 d5e25077..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 @@ -1,3 +1,5 @@ +import type { PoolClient } from 'pg' + import { query, withTransaction } from '../../db/client.js' import { toPositiveInteger } from './shared.js' import { @@ -354,6 +356,7 @@ export async function upsertWorkProductRule(input: { autoCreate: boolean rewardAmount: number unitPriceFen?: number + pricingMode: 'fixed' | 'unit' requiredDepositAmount: number depositThresholdAmount: number sharingEnabled?: boolean @@ -369,8 +372,10 @@ export async function upsertWorkProductRule(input: { matchJson?: string sortOrder: number now: string + client?: Pick }): Promise { - const result = await query( + const db = input.client || { query } + const result = await db.query( ` INSERT INTO work_product_rules ( rule_key, provider, platform, shop_id, sku_code, product_name, @@ -381,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, @@ -391,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 @@ -420,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 * `, @@ -450,9 +456,15 @@ export async function upsertWorkProductRule(input: { input.requirementJson, input.matchJson || '{}', input.sortOrder, + input.pricingMode, input.now, input.now, ], ) - return getWorkProductRuleByKey(result.rows[0]?.rule_key || input.ruleKey) + const ruleKey = result.rows[0]?.rule_key || input.ruleKey + const selected = await db.query( + `${WORK_PRODUCT_RULE_SELECT} WHERE wpr.rule_key = $1 LIMIT 1`, + [ruleKey], + ) + return selected.rows[0] || null } diff --git a/apps/backend/src/routes/admin/cost-center-catalog.ts b/apps/backend/src/routes/admin/cost-center-catalog.ts index e79e4671..213d4cc7 100644 --- a/apps/backend/src/routes/admin/cost-center-catalog.ts +++ b/apps/backend/src/routes/admin/cost-center-catalog.ts @@ -9,6 +9,8 @@ import { Router } from 'express' import { ensureVoucherChannelRules } from '../../services/fulfillment/channel-assign-service.js' import { + archiveHistoricalManualProducts, + archiveSupersededManualProducts, backfillAdminNormalizedQuantities, backfillAdminProductMappings, createAdminChannelMapping, @@ -161,6 +163,38 @@ router.post( }), ) +router.post( + '/cost-center/products/archive-historical-manual', + requireAdminPermission('finance.config'), + createJsonHandler((req) => archiveHistoricalManualProducts(bodyOf(req)), { + successMessage: '历史人工商品已归档', + errorMessage: '归档历史人工商品失败', + scope: '[admin/cost-center/products/archive-historical-manual]', + audit: (_req, data) => ({ + action: 'cost_historical_manual_products_archived', + targetType: 'product_catalog', + targetId: '', + data: data as Record, + }), + }), +) + +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'), @@ -374,8 +408,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..e34dfbdf 100644 --- a/apps/backend/src/routes/admin/fulfillment-channel.ts +++ b/apps/backend/src/routes/admin/fulfillment-channel.ts @@ -12,8 +12,10 @@ import { assignChannelBySku, assignChannelToOrderItems, deleteChannelRule, - listChannelRules, + deleteChannelRules, + listChannelRulesPage, listPendingChannelGroups, + switchChannelForOrderItems, unlockChannel, upsertChannelRule, } from '../../services/fulfillment/channel-assign-service.js' @@ -143,13 +145,44 @@ 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'), 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' }), @@ -170,9 +203,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 +219,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) }, }), @@ -206,6 +237,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/routes/admin/worker-platform/config.ts b/apps/backend/src/routes/admin/worker-platform/config.ts index 0020091f..5776962a 100644 --- a/apps/backend/src/routes/admin/worker-platform/config.ts +++ b/apps/backend/src/routes/admin/worker-platform/config.ts @@ -1,6 +1,5 @@ import { Router } from 'express' import { setHttpCache } from '../../../middleware/http-cache.js' -import { syncAdminWorkOrderTemplateCostVersions } from '../../../services/cost-center/work-order-product-service.js' import { deleteAdminWorkCategory, @@ -151,25 +150,17 @@ router.get( router.post( '/worker-platform/product-rules', requireAdminRoles(['admin', 'operator']), - createJsonHandler( - async (req) => { - const result = await saveAdminWorkProductRule(req.body || {}) - const ruleId = Number((result as { rule?: { id?: number } })?.rule?.id || 0) - const templateCost = ruleId ? await syncAdminWorkOrderTemplateCostVersions(ruleId) : null - return { ...result, templateCost } - }, - { - successMessage: '接单物品规则已保存', - errorMessage: '保存接单物品规则失败', - scope: '[admin/worker-platform/product-rules]', - audit: (_req, data) => ({ - action: 'work_product_rule_saved', - targetType: 'work_product_rule', - targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''), - data: data && typeof data === 'object' ? (data as Record) : {}, - }), - }, - ), + createJsonHandler((req) => saveAdminWorkProductRule(req.body || {}), { + successMessage: '接单物品规则已保存', + errorMessage: '保存接单物品规则失败', + scope: '[admin/worker-platform/product-rules]', + audit: (_req, data) => ({ + action: 'work_product_rule_saved', + targetType: 'work_product_rule', + targetId: String((data as { rule?: { ruleKey?: string } })?.rule?.ruleKey || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }), ) router.post( diff --git a/apps/backend/src/routes/short-links.ts b/apps/backend/src/routes/short-links.ts index 9a42d0c0..c25265ad 100644 --- a/apps/backend/src/routes/short-links.ts +++ b/apps/backend/src/routes/short-links.ts @@ -1,6 +1,6 @@ /** * 历史短链只读跳转入口(/s/:code)。 - * 新 claim 交付请使用本站 /#/claim/{token},勿再为 feifei 生成 shortLink。 + * 新 claim 交付请使用本站 /h5/{订单号},勿再为 feifei 生成 shortLink。 */ import { Router } from 'express' diff --git a/apps/backend/src/services/admin/admin-read-service.ts b/apps/backend/src/services/admin/admin-read-service.ts index 8e51c719..40b9558d 100644 --- a/apps/backend/src/services/admin/admin-read-service.ts +++ b/apps/backend/src/services/admin/admin-read-service.ts @@ -1,4 +1,4 @@ -import { buildClaimUrl, buildConfiguredClaimUrl } from '../claim/claim-service.js' +import { buildOrderClaimUrl } from '../claim/claim-service.js' import { buildClaimIdentityAdminSummary } from '../claim/claim-identity.js' import { getClaimTokenById } from '../../repositories/claim-token-repo.js' import { @@ -385,9 +385,7 @@ export async function getAdminTaskDetail( const taskContext = parseTaskContext(task) const taskState = parseTaskState(task) const viewerContext = createAdminViewerContext(session) - // 后台回显的领取链接口径按**订单来源**决定(不再按 executor_key): - // 抖音码速达订单用后台配置的 `claimLinkBase`(买家领取页,走 affiliate_dash 领取流程); - // 其它来源沿用全局 buildClaimUrl(orders.claimBaseUrl)。 + // 后台回显的领取链接统一为 `/h5/<平台订单号>`;抖音码速达仍可使用自己的领取站基址。 const claimUrl = resolveAdminTaskClaimUrl(claimToken, order) const screenshotUrl = await resolveAdminTaskScreenshotUrl(task, viewerContext) const cloudSourceLabelMap = buildCloudSourceLabelMap() @@ -514,32 +512,24 @@ export async function getAdminTaskDetail( } /** - * 后台展示的领取链接。 + * 后台展示的买家领取链接,所有来源统一为 `/h5/<平台订单号>`。 * - * - 抖音码速达订单(`provider='douyin_mashida'`):`claimLinkBase` + token(买家领取页, - * 走 affiliate_dash 领取流程);未配 `claimLinkBase` 时退回全局 claim 链接,至少链接不会为空。 - * - 其它来源:沿用全局 `buildClaimUrl`(orders.claimBaseUrl),历史行为不变。 - * - * ⚠️ 这里按 **provider** 判断,不按 executor_key:抖音皮肤单已改走 `affiliate_dash` 通道, - * 按执行器判断会失效。拿不到 provider(order 为空)时按「其它来源」处理。 + * 抖音码速达可继续使用自己配置的领取站基址,其它来源使用全局 claimBaseUrl; + * 订单号缺失时不生成链接。链接不再依赖 claim token。 */ function resolveAdminTaskClaimUrl( claimToken: { token: string } | null | undefined, - order: Pick | null | undefined, + order: Pick | null | undefined, ): string { - const token = String(claimToken?.token || '').trim() - if (!token) { + if (!String(claimToken?.token || '').trim() || !order) { return '' } - if (isDouyinMashidaProvider(order?.provider)) { - const claimUrl = buildConfiguredClaimUrl(getDouyinMashidaSourceConfig().claimLinkBase, token) - if (claimUrl) { - return claimUrl - } - } + const douyinClaimBase = isDouyinMashidaProvider(order.provider) + ? getDouyinMashidaSourceConfig().claimLinkBase + : '' - return buildClaimUrl(token) + return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined) } function mapKuaishouIndustryVoucherContext(value: unknown): JsonRecord | null { diff --git a/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts b/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts index 941f47e1..891ccb60 100644 --- a/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts +++ b/apps/backend/src/services/admin/douyin-mashida-certificate-service.ts @@ -33,6 +33,7 @@ import { cancelCertificateVerify, ensureCertificateForOrderItem, formatGrantTime, + resolveDouyinMashidaCertLink, verifyCertificate, } from '../redeem/certificate-service.js' import type { @@ -154,7 +155,18 @@ export async function syncAdminDouyinMashidaCertificate( }) } - const certLink = String(certificate.cert_link || config.certLink || '').trim() + const context = await resolveCertificateOrderContext({ + ...(certificate.local_order_id !== null ? { localOrderId: certificate.local_order_id } : {}), + ...(certificate.order_item_id !== null ? { orderItemId: certificate.order_item_id } : {}), + }) + const regeneratedCertLink = await resolveDouyinMashidaCertLink({ + order: context.order, + orderItem: context.orderItem, + config, + }) + const certLink = String( + regeneratedCertLink || certificate.cert_link || config.certLink || '', + ).trim() if (!certLink) { throw createHttpError('未配置兑换链接(certLink),无法同步券码', { statusCode: 400, diff --git a/apps/backend/src/services/admin/write-helpers.ts b/apps/backend/src/services/admin/write-helpers.ts index 8136eaea..b23848ff 100644 --- a/apps/backend/src/services/admin/write-helpers.ts +++ b/apps/backend/src/services/admin/write-helpers.ts @@ -1,4 +1,4 @@ -import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' +import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' import { maskCode as maskCodeValue, maskPhone as maskPhoneValue } from '../../utils/masking.js' import type { TaskRow } from '../../types/repository/rows.js' @@ -52,11 +52,11 @@ export async function ensureTaskClaimLink(task: TaskRow): Promise return { token, expiredAt, - claimUrl: buildClaimUrl(token), + claimUrl: buildOrderClaimUrl(task.platform_order_id), } } - const claimToken = await createTaskClaimToken(task.id) + const claimToken = await createTaskClaimToken(task.id, task.platform_order_id) return { token: claimToken.token, expiredAt: claimToken.expired_at, diff --git a/apps/backend/src/services/admin/write/task-actions.ts b/apps/backend/src/services/admin/write/task-actions.ts index 8c5cd951..19424623 100644 --- a/apps/backend/src/services/admin/write/task-actions.ts +++ b/apps/backend/src/services/admin/write/task-actions.ts @@ -5,11 +5,7 @@ import { import { updateTask } from '../../../repositories/task-repo.js' import { createTaskEvent } from '../../../repositories/task-event-repo.js' import { getOrderById } from '../../../repositories/order-repo.js' -import { - createTaskClaimToken, - buildClaimUrl, - buildConfiguredClaimUrl, -} from '../../claim/claim-service.js' +import { buildOrderClaimUrl, createTaskClaimToken } from '../../claim/claim-service.js' import { getDouyinMashidaSourceConfig } from '../../platforms/douyin-mashida/source-config-service.js' import { isDouyinMashidaProvider } from '../../platforms/douyin-mashida/order-mapper.js' import { notifyTaskAutoManualReview } from '../../notification/domain-notifications.js' @@ -240,33 +236,25 @@ export async function retryAdminTask( return response } -/** - * 后台回显的领取链接口径(按**订单来源**判断,不按 executor_key): - * - 抖音码速达订单:后台配置的 `claimLinkBase` + token(买家领取页,走 affiliate_dash 领取流程); - * 未配 `claimLinkBase` 时退回通用领取页,至少链接不会为空; - * - 其它渠道:沿用 runtimeConfig 的通用领取页(行为完全不变)。 - */ +/** 后台回显的买家领取链接统一为 `/h5/<平台订单号>`,不再生成 token 链接。 */ async function resolveAdminTaskClaimUrl( task: Pick, token: string, ): Promise { - const normalizedToken = String(token || '').trim() - if (!normalizedToken) { + if (!String(token || '').trim()) { return '' } const order = await getOrderById(task.order_id) - if (isDouyinMashidaProvider(order?.provider)) { - const claimUrl = buildConfiguredClaimUrl( - getDouyinMashidaSourceConfig().claimLinkBase, - normalizedToken, - ) - if (claimUrl) { - return claimUrl - } + if (!order) { + return '' } - return buildClaimUrl(normalizedToken) + const douyinClaimBase = isDouyinMashidaProvider(order.provider) + ? getDouyinMashidaSourceConfig().claimLinkBase + : '' + + return buildOrderClaimUrl(order.platform_order_id, douyinClaimBase || undefined) } export async function regenerateAdminTaskClaimLink( @@ -306,7 +294,7 @@ export async function regenerateAdminTaskClaimLink( }) } - const claimToken = await createTaskClaimToken(task.id) + const claimToken = await createTaskClaimToken(task.id, task.platform_order_id) const nextStatus = task.task_status === TASK_STATUS.MANUAL_REVIEW || task.task_status === TASK_STATUS.RETRY_PENDING ? TASK_STATUS.LINK_GENERATED diff --git a/apps/backend/src/services/claim/claim-service.test.ts b/apps/backend/src/services/claim/claim-service.test.ts index a5758d9c..4a2649cb 100644 --- a/apps/backend/src/services/claim/claim-service.test.ts +++ b/apps/backend/src/services/claim/claim-service.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { - buildConfiguredClaimUrl, + buildOrderClaimUrl, pickOrderItemClaimTask, resolveClaimTokenExpiration, } from './claim-service.js' @@ -18,26 +18,40 @@ test('领取链接保留正整数小时的限时配置能力', () => { ) }) -test('配置化领取链接只拼传入基址(去尾斜杠),不读全局 claimBaseUrl', () => { +test('所有来源统一生成 /h5/<订单号> 领取链接', () => { + const orderNo = '6919156612984680391' + assert.equal( - buildConfiguredClaimUrl('https://x.ngrok-free.app/#/claim', 'AbC123'), - 'https://x.ngrok-free.app/#/claim/AbC123', + buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/#/claim'), + `https://x.ngrok-free.app/h5/${orderNo}`, ) assert.equal( - buildConfiguredClaimUrl('https://x.ngrok-free.app/claim/', 'AbC123'), - 'https://x.ngrok-free.app/claim/AbC123', + buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/'), + `https://x.ngrok-free.app/h5/${orderNo}`, ) assert.equal( - buildConfiguredClaimUrl('https://x.ngrok-free.app', 'AbC123'), - 'https://x.ngrok-free.app/AbC123', + buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/custom'), + `https://x.ngrok-free.app/custom/h5/${orderNo}`, + ) + assert.equal( + buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5///'), + `https://x.ngrok-free.app/h5/${orderNo}`, + ) + assert.equal( + buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/h5/old-token'), + `https://x.ngrok-free.app/h5/${orderNo}`, + ) + assert.equal( + buildOrderClaimUrl(orderNo, 'https://x.ngrok-free.app/claim/old-token'), + `https://x.ngrok-free.app/h5/${orderNo}`, ) }) -test('配置化领取链接基址/token 缺失返回空串(由调用方决定不发链接、不发消息)', () => { - assert.equal(buildConfiguredClaimUrl('', 'AbC123'), '') - assert.equal(buildConfiguredClaimUrl(undefined, 'AbC123'), '') - assert.equal(buildConfiguredClaimUrl('https://x/claim', ''), '') - assert.equal(buildConfiguredClaimUrl('https://x/claim', null), '') +test('订单号或领取基址缺失/无效时返回空串', () => { + assert.equal(buildOrderClaimUrl('', 'https://x/claim'), '') + assert.equal(buildOrderClaimUrl(null, 'https://x/claim'), '') + assert.equal(buildOrderClaimUrl('6919156612984680391', ''), '') + assert.equal(buildOrderClaimUrl('6919156612984680391', 'not-a-url'), '') }) test('pickOrderItemClaimTask 取该订单项 task id 最小且带 token 的单元', () => { diff --git a/apps/backend/src/services/claim/claim-service.ts b/apps/backend/src/services/claim/claim-service.ts index 4f7621d7..37bb2c70 100644 --- a/apps/backend/src/services/claim/claim-service.ts +++ b/apps/backend/src/services/claim/claim-service.ts @@ -1,6 +1,6 @@ import { runtimeConfig } from '../../config/runtime.js' import { createClaimToken } from '../../repositories/claim-token-repo.js' -import { listTasksByOrderId } from '../../repositories/task-repo.js' +import { getTaskById, listTasksByOrderId } from '../../repositories/task-repo.js' import { createHttpError } from '../../utils/http.js' import { logIntegration } from '../../utils/logger.js' import { addHours, nowIso } from '../../utils/time.js' @@ -11,7 +11,10 @@ type TaskClaimToken = NonNullable>> claimUrl: string } -export async function createTaskClaimToken(taskId: number | string): Promise { +export async function createTaskClaimToken( + taskId: number | string, + orderNo?: unknown, +): Promise { const createdAt = nowIso() const expiredAt = resolveClaimTokenExpiration(createdAt, runtimeConfig.orders.tokenTtlHours) const token = await createClaimToken({ @@ -33,9 +36,12 @@ export async function createTaskClaimToken(taskId: number | string): Promise/h5/<平台订单号>`。 * - * ⚠️ 默认基址是主站 HashRouter 里的买家领取页(`…/#/claim`)。**抖音码速达**的领取链接 - * 走 `buildConfiguredClaimUrl` + 后台配置的 `claimLinkBase`,两条口径刻意分开、互不回退。 + * `claimBaseUrl` 兼容历史配置形态(如 `https://x/#/claim`、`https://x/claim`), + * 这里会归一化到同一站点的 `/h5` 路径;订单号为空或基址无效时返回空串。 */ -export function buildClaimUrl(token: string): string { - const baseUrl = String(runtimeConfig.orders.claimBaseUrl || '').trim() - return baseUrl ? `${baseUrl.replace(/\/+$/, '')}/${token}` : token -} - -/** - * 按**调用方给定的基址**拼领取链接:`${基址去尾斜杠}/${token}`。 - * - * 抖音码速达的基址只能来自后台配置的 `claimLinkBase`(买家领取页), - * 绝不回退 `buildClaimUrl` 的 `orders.claimBaseUrl`(那是主站默认领取页,口径不同)。 - * 基址或 token 缺失时返回空串,由调用方决定「不生成链接 / 不发消息」,不猜、不回退。 - */ -export function buildConfiguredClaimUrl(baseUrl: unknown, token: unknown): string { - const base = String(baseUrl || '') - .trim() - .replace(/\/+$/, '') - const normalizedToken = String(token || '').trim() - if (!base || !normalizedToken) { +export function buildOrderClaimUrl( + orderNo: unknown, + claimBaseUrl: unknown = runtimeConfig.orders.claimBaseUrl, +): string { + const normalizedOrderNo = String(orderNo ?? '').trim() + if (!normalizedOrderNo) { return '' } - return `${base}/${normalizedToken}` + + const baseUrl = resolveH5ClaimBaseUrl(claimBaseUrl) + if (!baseUrl) { + return '' + } + + return `${baseUrl}/${encodeURIComponent(normalizedOrderNo)}` +} + +/** 把任意历史领取页基址归一化成站点下的 `/h5` 基址。 */ +export function resolveH5ClaimBaseUrl(claimBaseUrl: unknown): string { + const rawValue = String(claimBaseUrl ?? '').trim() + if (!rawValue) { + return '' + } + + let url: URL + try { + url = new URL(rawValue) + } 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(/\/+$/, '') } export type OrderItemClaimLink = { - /** 领取链接;未配置 claimLinkBase、或该订单项没有带 token 的履约任务时为空串 */ + /** 统一 H5 领取链接;无可用履约单元或订单号时为空串 */ claimUrl: string /** 命中的履约任务 id(多个时取最小);无可用任务时不下发该字段 */ taskId?: number @@ -97,7 +130,7 @@ export type OrderItemClaimTaskPick = { token: string /** 该订单项下的履约任务总数,不论有没有 token */ taskCount: number - /** 其中带 claim token 的单元数(>1 时会有多个领取链接) */ + /** 其中带 claim token 的单元数;对外仍共用一个订单号链接 */ tokenTaskCount: number } @@ -105,7 +138,7 @@ export type OrderItemClaimTaskPick = { * 纯函数:从订单的履约任务里挑出该订单项的领取单元。 * * 一个订单项按数量会拆成多个履约单元(`fulfillment_tasks`),每个单元有自己的 claim token; - * 这里固定取 **task id 最小** 的那个,保证同一张券每次解析出同一条链接。 + * 这里固定取 **task id 最小** 的那个,用于判断该订单项是否已经准备好自动履约。 */ export function pickOrderItemClaimTask( tasks: Array> | null | undefined, @@ -142,17 +175,17 @@ export function pickOrderItemClaimTask( } /** - * 通用领取链接解析(不再依赖任何具体执行器): - * 某订单项履约任务上的 claim token → `claimLinkBase` + `/` + token。 + * 通用领取单元解析(不再依赖任何具体执行器): + * 先按订单项找到带 claim token 的履约任务,再生成统一的 `/h5/<订单号>` 链接。 * - * 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道,履约任务上的 token 就是买家领取 token, - * 因此这里只按 `order_item_id` 找任务、**不看 executor_key**: - * 换执行器不会再让领取链接解析失效。 + * 抖音码速达的皮肤/礼包订单走 `affiliate_dash` 通道。这里只按 `order_item_id` 找任务、 + * **不看 executor_key**,换执行器不会影响链接和自动发货判断。 */ export async function resolveOrderItemClaimLink(input: { orderId: number | string + orderNo: unknown orderItemId: number | string - claimLinkBase: unknown + claimLinkBase?: unknown }): Promise { const orderId = Number(input.orderId) if (!Number.isFinite(orderId)) { @@ -180,7 +213,7 @@ export async function resolveOrderItemClaimLink(input: { } return { - claimUrl: buildConfiguredClaimUrl(input.claimLinkBase, picked.token), + claimUrl: buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined), taskId: picked.taskId, taskCount: picked.taskCount, } diff --git a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts index 0b6b7450..cec5c0b3 100644 --- a/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts +++ b/apps/backend/src/services/claim/kuaishou-cloud-claim-context.ts @@ -11,7 +11,7 @@ import { formatFenToAmount, normalizeFen } from '../../utils/money.js' import { parseTaskContext as parseTaskContextValue } from '../../utils/task-json.js' import { nowIso } from '../../utils/time.js' import { TASK_STATUS, isTaskFinalStatus } from '../../domain/task-status.js' -import { buildClaimUrl } from './claim-service.js' +import { buildOrderClaimUrl } from './claim-service.js' import { resolveProductImageUrl } from '../platforms/affiliate-dash/product-image.js' import { buildClaimIdentityPayload } from './claim-identity.js' import { normalizeAffiliateDashFlow } from '../fulfillment/affiliate-dash/index.js' @@ -301,7 +301,7 @@ function buildAffiliateDashClaimDetailPayload({ return { tokenStatus: claimToken.status, - claimUrl: buildClaimUrl(claimToken.token), + claimUrl: buildOrderClaimUrl(order.platform_order_id), flowType: 'affiliate_dash' as const, channelOffline: false, channelOfflineMessage: '', @@ -382,7 +382,7 @@ function buildRetiredClaimDetailPayload({ claimToken, task, order, orderItem }: return { tokenStatus: claimToken.status, - claimUrl: buildClaimUrl(claimToken.token), + claimUrl: buildOrderClaimUrl(order.platform_order_id), flowType: executorKey === 'kuaishou_feifei' ? ('kuaishou_feifei' as const) @@ -436,7 +436,7 @@ function buildGenericClaimDetailPayload({ claimToken, task, order, orderItem }: return { tokenStatus: claimToken.status, - claimUrl: buildClaimUrl(claimToken.token), + claimUrl: buildOrderClaimUrl(order.platform_order_id), flowType: String(task.executor_key || 'manual_dispatch').trim() || 'manual_dispatch', channelOffline: false, channelOfflineMessage: '', 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/cost-center-mappers.ts b/apps/backend/src/services/cost-center/cost-center-mappers.ts index 5fee88fa..0e33fb50 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}) + ) + ) )` } @@ -112,6 +127,16 @@ export function mapProduct(row: ProductRow) { activeCostEffectiveFrom: row.active_cost_effective_from ? String(row.active_cost_effective_from) : null, + templatePricingMode: row.template_pricing_mode || null, + templateEnabled: + row.template_enabled === null || row.template_enabled === undefined + ? null + : row.template_enabled === true, + templateSharingEnabled: + 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, @@ -163,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 { @@ -189,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-overview-service.ts b/apps/backend/src/services/cost-center/cost-center-overview-service.ts index b7204500..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 @@ -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 ( @@ -78,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, ( @@ -99,11 +109,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 +157,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 +168,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 bb29e52c..db0ab4b6 100644 --- a/apps/backend/src/services/cost-center/cost-center-types.ts +++ b/apps/backend/src/services/cost-center/cost-center-types.ts @@ -26,6 +26,10 @@ export type ProductRow = { cost_version_count?: number active_cost_amount_fen?: number | null active_cost_effective_from?: string | null + 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 } @@ -109,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-center.test.ts b/apps/backend/src/services/cost-center/cost-center.test.ts index cef18d66..c65bbe97 100644 --- a/apps/backend/src/services/cost-center/cost-center.test.ts +++ b/apps/backend/src/services/cost-center/cost-center.test.ts @@ -275,21 +275,13 @@ test('mapping lookup follows specificity and concrete-shop priority', () => { ) }) -/** - * 2026-10-29 生产:改「接单模板」商品价格 500。 - * - * PostgreSQL 报 `inconsistent types deduced for parameter $1`(integer versus bigint): - * 同一个 $1 在 `reward_amount = $1` 处按 bigint 推断,在 `CASE ... THEN $1 ELSE 0` - * 处又因为 `0` 是 integer 被推断成 integer。node-pg 不发送参数类型,必须显式转换。 - */ -test('接单模板改价 SQL 的参数必须显式指定 bigint', async () => { +test('接单模板改价必须在同一事务中同步成本版本与模板投影', async () => { const { readFile } = await import('node:fs/promises') const source = await readFile(new URL('./product-catalog-service.ts', import.meta.url), 'utf8') - assert.ok( - source.includes( - 'unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1::bigint ELSE 0::bigint END', - ), - ) + assert.ok(source.includes('const costVersionId = await withTransaction')) + assert.ok(source.includes('reward_amount = $1::bigint')) + assert.ok(source.includes('unit_price_fen = $2::bigint')) + assert.ok(source.includes('cost_work_order_future_price_not_supported')) }) test('成本模型与商品来源必须覆盖第三方卡券渠道', () => { @@ -299,6 +291,23 @@ 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('成本中心总览异常统计必须走异常表关联,不能全表扫描订单项子查询', 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/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/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/manual-fulfillment-config-service.ts b/apps/backend/src/services/cost-center/manual-fulfillment-config-service.ts index 989775fc..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 @@ -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 )`) } @@ -407,7 +408,15 @@ 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', + 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 +444,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) @@ -712,34 +721,37 @@ 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 sku_key: string + canonical_product_id: number | null enabled: boolean note: string }): Promise { const skuKey = String(config.sku_key || '').trim() if (!skuKey) return - await query( + const otherChannel = await query<{ exists: boolean }>( ` - 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()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) - DO UPDATE SET - enabled = EXCLUDED.enabled, - source = 'manual', - source_ref_id = EXCLUDED.source_ref_id, - note = EXCLUDED.note, - updated_at = NOW() + SELECT EXISTS ( + SELECT 1 FROM internal_sku_channels + WHERE btrim(internal_sku) = $1 AND enabled = TRUE AND channel <> 'manual' + ) AS exists `, - [skuKey, config.id, config.enabled !== false, String(config.note || '').slice(0, 200)], + [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, + }) } 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 b449accd..f317601f 100644 --- a/apps/backend/src/services/cost-center/product-catalog-service.ts +++ b/apps/backend/src/services/cost-center/product-catalog-service.ts @@ -6,7 +6,7 @@ * 2. 渠道映射唯一键包含匹配方式与名称键,91 同店铺复用 sku_code 才能消歧; * 3. 归一数量必须由回填脚本按 SKU 名称解析后写入,不预填 quantity。 */ -import { query } from '../../db/client.js' +import { query, withTransaction } from '../../db/client.js' import type { AdminSession } from '../admin/admin-auth-service.js' import type { JsonObject } from '../../types/json.js' import { createHttpError } from '../../utils/http.js' @@ -103,12 +103,11 @@ export async function listAdminProducts(payload: JsonObject = {}) { `(p.canonical_sku ILIKE $${baseParams.length} OR p.canonical_name ILIKE $${baseParams.length} OR p.category ILIKE $${baseParams.length} OR root.name ILIKE $${baseParams.length})`, ) } - const status = text(payload.status) - if (status) { - assertOneOf(status, PRODUCT_STATUSES, '商品状态无效') - baseParams.push(status) - baseClauses.push(`p.status = $${baseParams.length}`) - } + // 商品目录默认只看当前启用商品;需要历史商品时必须显式传 status=archived。 + const status = text(payload.status) || 'active' + assertOneOf(status, PRODUCT_STATUSES, '商品状态无效') + baseParams.push(status) + baseClauses.push(`p.status = $${baseParams.length}`) // carrierType 参数保留,语义改为「按 canonical_sku 前缀筛选商品来源」。 const source = text(payload.carrierType ?? payload.carrier_type ?? payload.source) if (source) { @@ -154,6 +153,18 @@ export async function listAdminProducts(payload: JsonObject = {}) { ` SELECT p.*, + tpl_rule.pricing_mode 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, @@ -192,20 +203,6 @@ export async function listAdminProducts(payload: JsonObject = {}) { AND wpr.rule_key = substring(p.canonical_sku from 5) ORDER BY v.effective_from DESC, v.id DESC LIMIT 1 - ), - -- 还没有成本版本的接单模板商品(例如刚建、还没跑「同步模板发单金额」), - -- 直接展示模板同步过来的发单金额,避免显示「未配置」让改价入口看起来不可用。 - ( - SELECT CASE - WHEN wpr2.unit_price_fen > 0 AND wpr2.reward_amount = 0 - THEN wpr2.unit_price_fen - ELSE wpr2.reward_amount - END - FROM work_product_rules wpr2 - WHERE p.canonical_sku LIKE 'tpl:%' - AND wpr2.rule_key = substring(p.canonical_sku from 5) - AND (wpr2.reward_amount > 0 OR wpr2.unit_price_fen > 0) - LIMIT 1 ) ) AS active_cost_amount_fen, COALESCE( @@ -232,6 +229,9 @@ export async function listAdminProducts(payload: JsonObject = {}) { ${affiliateColumnsSql} FROM product_catalog p ${PRODUCT_CATEGORY_JOINS} + LEFT JOIN work_product_rules tpl_rule + ON p.canonical_sku LIKE 'tpl:%' + AND tpl_rule.rule_key = substring(p.canonical_sku from 5) ${affiliateTaskJoinSql} ${where} ORDER BY @@ -438,6 +438,162 @@ export async function deleteAdminProduct(productId: number | string) { return { deleted: true, product: mapProduct(current) } } +/** + * 批量归档旧人工归一商品。 + * + * 只处理 attributes_json.source=manual_normalization、当前为 active, + * 且所有关联订单项都已 archived 的商品。只改 status,不删除任何数据。 + */ +export async function archiveHistoricalManualProducts(payload: JsonObject = {}) { + const execute = payload.execute === true || payload.execute === 'true' + const candidates = await query<{ id: number; canonical_sku: string; canonical_name: string }>( + ` + SELECT p.id, p.canonical_sku, p.canonical_name + FROM product_catalog p + WHERE p.status = 'active' + AND p.canonical_sku LIKE 'man:%' + AND p.attributes_json->>'source' = 'manual_normalization' + AND EXISTS (SELECT 1 FROM order_items oi WHERE oi.canonical_product_id = p.id) + AND NOT EXISTS ( + SELECT 1 FROM order_items oi + WHERE oi.canonical_product_id = p.id + AND oi.fulfillment_status <> 'archived' + ) + ORDER BY p.id + `, + ) + const ids = candidates.rows.map((row) => Number(row.id)) + if (!execute || ids.length === 0) { + return { + dryRun: !execute, + candidateCount: ids.length, + archivedCount: 0, + sample: candidates.rows.slice(0, 20), + } + } + + const updated = await query( + `UPDATE product_catalog + SET status = 'archived', updated_at = NOW() + WHERE id = ANY($1::bigint[]) AND status = 'active'`, + [ids], + ) + return { + dryRun: false, + candidateCount: ids.length, + archivedCount: updated.rowCount || 0, + sample: candidates.rows.slice(0, 20), + } +} + +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 @@ -1388,13 +1544,51 @@ export async function setAdminProductPrice( id: number rule_key: string product_name: string + 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, reward_amount, unit_price_fen FROM work_product_rules WHERE rule_key = $1 LIMIT 1', + `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 + LIMIT 1`, [ruleKey], ) const rule = ruleResult.rows[0] + if (!rule) { + throw createHttpError('接单模板商品没有关联模板,请先修复商品目录', { + statusCode: 409, + errorCode: 'cost_work_order_template_missing', + }) + } + const rawPricingMode = text(payload.pricingMode ?? payload.pricing_mode) + if (rawPricingMode) { + throw createHttpError('接单模板计价方式只能在【接单模板】中设置', { + statusCode: 400, + errorCode: 'cost_work_order_pricing_mode_managed_by_template', + }) + } + const pricingMode: 'fixed' | 'unit' = rule.pricing_mode === 'unit' ? 'unit' : 'fixed' + if (pricingMode === 'fixed' && rule.sharing_enabled && rule.sharing_auto_from_order) { + throw createHttpError( + '拼单自动按件计价与固定金额冲突,请先在接单模板关闭拼单或改为手工拼单价', + { + statusCode: 400, + errorCode: 'cost_work_order_sharing_pricing_conflict', + }, + ) + } + // 模板运行价格只有一个当前值;未来生效无法与成本版本同时切换,因此暂只允许立即生效。 + if (Date.parse(effectiveFrom) > Date.now() + 5_000) { + throw createHttpError('接单模板成本暂不支持未来生效,请选择立即生效', { + statusCode: 400, + errorCode: 'cost_work_order_future_price_not_supported', + }) + } const costItemResult = await query<{ id: number }>( "SELECT id FROM cost_items WHERE code = 'sys_work_order_reward' OR cost_model = 'work_order_reward' LIMIT 1", @@ -1403,123 +1597,138 @@ export async function setAdminProductPrice( if (!costItemId) { throw createHttpError('未找到工单结算成本项(sys_work_order_reward)', { statusCode: 500 }) } - - // 查找已有的 active 版本 (按 rule_id 或 canonical_product_id) - const existingResult = await query<{ id: number; amount_fen: number; effective_from: string }>( - `SELECT id, amount_fen, effective_from FROM cost_versions - WHERE cost_item_id = $1 AND (work_product_rule_id = $2 OR canonical_product_id = $3) AND status = 'active' - ORDER BY effective_from DESC, id DESC LIMIT 1`, - [costItemId, rule?.id ?? null, product.id], - ) - const existing = existingResult.rows[0] - const existingTime = existing ? Date.parse(existing.effective_from) : null - const targetTime = Date.parse(effectiveFrom) - - let costVersionId = existing?.id || 0 - - if (existing && existingTime !== null && targetTime < existingTime) { - // 追溯改价:退休旧版本并新增一个从 targetTime 起生效的版本,旧行保留可追溯。 - // 不能就地改金额,否则 cost_snapshots.cost_version_id 指向的版本内容会被篡改。 - await query("UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", [ - now, - existing.id, - ]) - const inserted = await query<{ id: number }>( - `INSERT INTO cost_versions ( - cost_item_id, scope_type, provider, shop_id, canonical_product_id, - work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, - source, status, change_reason, created_by_admin_user_id, - approved_by_admin_user_id, created_at, updated_at - ) VALUES ( - $1, 'rule', '', '', $2, - $3, $4, 0, $5, NULL, - 'manual', 'active', $6, $7, - $7, $8, $8 - ) RETURNING id`, - [ - costItemId, - product.id, - rule?.id ?? null, - amountFen, - effectiveFrom, - changeReason, - session.userId, - now, - ], + const projectedRewardAmount = pricingMode === 'unit' ? 0 : amountFen + const projectedUnitPrice = pricingMode === 'unit' ? amountFen : 0 + const costVersionId = await withTransaction(async (client) => { + const existingResult = await client.query<{ + id: number + amount_fen: number + effective_from: string + }>( + `SELECT id, amount_fen, effective_from + FROM cost_versions + WHERE cost_item_id = $1 + AND work_product_rule_id = $2 + AND status = 'active' + ORDER BY effective_from DESC, id DESC + LIMIT 1`, + [costItemId, rule.id], ) - costVersionId = inserted.rows[0]?.id || 0 - } else if (existing && existingTime !== null && targetTime === existingTime) { - // 相同生效时间的更正:没有可插入的时间区间,只能就地更正并保留变更原因。 - await query( - `UPDATE cost_versions - SET amount_fen = $1, - change_reason = $2, - created_by_admin_user_id = $3, - approved_by_admin_user_id = $3, - updated_at = $4 - WHERE id = $5`, - [amountFen, changeReason, session.userId, now, existing.id], - ) - } else { - if (existing) { - await query( - "UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3", - [effectiveFrom, now, existing.id], + const existing = existingResult.rows[0] + const existingTime = existing ? Date.parse(existing.effective_from) : null + const targetTime = Date.parse(effectiveFrom) + let nextCostVersionId = existing?.id || 0 + + if (existing && existingTime !== null && targetTime < existingTime) { + await client.query( + "UPDATE cost_versions SET status = 'retired', updated_at = $1 WHERE id = $2", + [now, existing.id], ) + const inserted = await client.query<{ id: number }>( + `INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'rule', '', '', $2, + $3, $4, 0, $5, NULL, + 'manual', 'active', $6, $7, + $7, $8, $8 + ) RETURNING id`, + [ + costItemId, + product.id, + rule.id, + amountFen, + effectiveFrom, + changeReason, + session.userId, + now, + ], + ) + nextCostVersionId = inserted.rows[0]?.id || 0 + } else if (existing && existingTime !== null && targetTime === existingTime) { + await client.query( + `UPDATE cost_versions + SET amount_fen = $1, + change_reason = $2, + created_by_admin_user_id = $3, + approved_by_admin_user_id = $3, + updated_at = $4 + WHERE id = $5`, + [amountFen, changeReason, session.userId, now, existing.id], + ) + } else { + if (existing) { + await client.query( + "UPDATE cost_versions SET status = 'retired', effective_to = $1, updated_at = $2 WHERE id = $3", + [effectiveFrom, now, existing.id], + ) + } + const inserted = await client.query<{ id: number }>( + `INSERT INTO cost_versions ( + cost_item_id, scope_type, provider, shop_id, canonical_product_id, + work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, + source, status, change_reason, created_by_admin_user_id, + approved_by_admin_user_id, created_at, updated_at + ) VALUES ( + $1, 'rule', '', '', $2, + $3, $4, 0, $5, NULL, + 'manual', 'active', $6, $7, + $7, $8, $8 + ) RETURNING id`, + [ + costItemId, + product.id, + rule.id, + amountFen, + effectiveFrom, + changeReason, + session.userId, + now, + ], + ) + nextCostVersionId = inserted.rows[0]?.id || 0 } - const inserted = await query<{ id: number }>( - `INSERT INTO cost_versions ( - cost_item_id, scope_type, provider, shop_id, canonical_product_id, - work_product_rule_id, amount_fen, rate_bps, effective_from, effective_to, - source, status, change_reason, created_by_admin_user_id, - approved_by_admin_user_id, created_at, updated_at - ) VALUES ( - $1, 'rule', '', '', $2, - $3, $4, 0, $5, NULL, - 'manual', 'active', $6, $7, - $7, $8, $8 - ) RETURNING id`, - [ - costItemId, - product.id, - rule?.id ?? null, - amountFen, - effectiveFrom, - changeReason, - session.userId, - now, - ], - ) - costVersionId = inserted.rows[0]?.id || 0 - } - // 同步更新 work_product_rules 保持数据一致 - if (rule) { - await query( + const updatedRule = await client.query( `UPDATE work_product_rules SET reward_amount = $1::bigint, - unit_price_fen = CASE WHEN unit_price_fen > 0 THEN $1::bigint ELSE 0::bigint END, - cost_item_id = $2, - updated_at = $3 - WHERE id = $4`, - [amountFen, costItemId, now, rule.id], + unit_price_fen = $2::bigint, + cost_item_id = $3, + updated_at = $4 + WHERE id = $5 + RETURNING id`, + [projectedRewardAmount, projectedUnitPrice, costItemId, now, rule.id], ) - } - - // 更新 product_catalog.attributes_json - const attr = - typeof product.attributes_json === 'object' && product.attributes_json - ? { ...(product.attributes_json as Record) } - : {} - attr.rewardAmountFen = amountFen - await query( - 'UPDATE product_catalog SET attributes_json = $1::jsonb, updated_at = $2 WHERE id = $3', - [JSON.stringify(attr), now, product.id], - ) + if (updatedRule.rowCount !== 1) { + throw createHttpError('接单模板价格投影失败', { statusCode: 500 }) + } + await client.query( + `UPDATE product_catalog + SET attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb, + updated_at = $2 + WHERE id = $3`, + [ + JSON.stringify({ + pricingMode, + rewardAmountFen: projectedRewardAmount, + unitPriceFen: projectedUnitPrice, + }), + now, + product.id, + ], + ) + if (!nextCostVersionId) { + throw createHttpError('接单模板成本版本保存失败', { statusCode: 500 }) + } + return nextCostVersionId + }) // 如果指定了同步工单价格(默认 true) let syncedOrders: unknown = null - if (payload.syncOpenOrders !== false && rule) { + if (payload.syncOpenOrders !== false) { syncedOrders = await syncAdminWorkProductRulePrice(rule.id, session.username) } @@ -1529,6 +1738,7 @@ export async function setAdminProductPrice( costVersionId, amountFen, effectiveFrom, + pricingMode, syncedOrders, message: '接单模板结算价格已更新', } 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..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,38 +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], - ) - - const skuCode = String(item.sku_code || '').trim() - if (skuCode) { - 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 ($1, $2, 'sku_code', $3, 'third_party_voucher', 10, 'voucher', TRUE, - '卡券订单自动建商品后生成', NOW(), NOW()) - ON CONFLICT (provider, shop_id, match_key_type, match_key, channel) DO NOTHING - `, - [item.provider, item.shop_id, skuCode], - ) - } return productId } 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 65781afa..a9c64b4f 100644 --- a/apps/backend/src/services/cost-center/template-bootstrap-service.ts +++ b/apps/backend/src/services/cost-center/template-bootstrap-service.ts @@ -18,7 +18,12 @@ import { backfillAdminNormalizedQuantities, backfillAdminProductMappings, } from './product-catalog-service.js' -import { syncAdminWorkOrderTemplateCostVersions } from './work-order-product-service.js' +import { + buildTemplateProductSku, + inferTemplateUnitName, +} from './work-order-template-product-service.js' + +export { buildTemplateProductSku, inferTemplateUnitName } export async function previewAdminTemplateProductBootstrap(payload: JsonObject = {}) { const range = resolveBootstrapDateRange(payload) @@ -123,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, @@ -241,8 +247,6 @@ export async function applyAdminTemplateProductBootstrap( dateTo: range.toDate, }) mappedOrderItemCount = mappingBackfill.updatedCount - const templateCosts = await syncAdminWorkOrderTemplateCostVersions() - return { dateFrom: range.fromDate, dateTo: range.toDate, @@ -260,9 +264,6 @@ export async function applyAdminTemplateProductBootstrap( resetOrderItemCount: resetResult.resetOrderItemCount, mappingConflictCount: plan.conflicts.length, mappingConflictItemCount: plan.unresolvedItemCount, - templateCostVersionCount: templateCosts.activeVersionCount, - templateCostCreatedCount: templateCosts.createdCount, - templateCostRetiredCount: templateCosts.retiredCount, } } @@ -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, @@ -806,19 +809,6 @@ async function loadTemplateProductBootstrapData(range: { fromDate: string; toDat } } -export function buildTemplateProductSku(ruleKey: string) { - return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64) -} - -export function inferTemplateUnitName(productName: string) { - const name = String(productName || '').trim() - if (/钻石|颗/.test(name)) return '颗' - if (/枪|把/.test(name)) return '把' - if (/套/.test(name)) return '套' - if (/密钥|秘钥/.test(name)) return '个' - return '件' -} - function resolveBootstrapDateRange(payload: JsonObject) { const range = resolveFinanceDateRange( { dateFrom: payload.dateFrom, dateTo: payload.dateTo }, 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 8c36dae7..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 @@ -311,7 +311,9 @@ type WorkOrderTemplateCostSyncRow = { } /** - * 把接单模板的发单金额同步成规则级成本版本。 + * 历史迁移/灾后修复:把既有接单模板发单金额补齐为规则级成本版本。 + * + * 日常成本真源是商品与成本中心;模板保存不再调用本函数,前端也不再提供按钮。 * * 固定金额模板取 `reward_amount`;按件模板取 `unit_price_fen`,数量由订单 SKU * 决定。全局 0 元版本只作为“按工单实际结算”的占位,不再代表模板价格。 @@ -325,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 @@ -362,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 @@ -414,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, @@ -467,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 new file mode 100644 index 00000000..225c1531 --- /dev/null +++ b/apps/backend/src/services/cost-center/work-order-template-cost.integration.test.ts @@ -0,0 +1,260 @@ +/** + * 接单模板成本单一写入口集成测试(需要真实 PostgreSQL)。 + * + * WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL=postgres://... \ + * npx tsx --test src/services/cost-center/work-order-template-cost.integration.test.ts + */ +import assert from 'node:assert/strict' +import test from 'node:test' + +const TEST_DATABASE_URL = String( + process.env.WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL || '', +).trim() + +test( + '模板自动生成商品,成本中心保存价格后才允许启用', + { skip: !TEST_DATABASE_URL ? '未设置 WORK_ORDER_TEMPLATE_COST_TEST_DATABASE_URL' : false }, + async () => { + process.env.DATABASE_URL = TEST_DATABASE_URL + const { closeDb, query } = await import('../../db/client.js') + const { saveAdminWorkProductRule } = + await import('../worker-platform/admin-worker-catalog-service.js') + const { setAdminProductPrice } = await import('./product-catalog-service.js') + const suffix = `${Date.now()}_${Math.floor(Math.random() * 1_000_000)}` + const productName = `集成测试接单模板-${suffix}` + let ruleId = 0 + let productId = 0 + + try { + const admin = await query<{ id: number; username: string; display_name: string }>( + 'SELECT id, username, display_name FROM admin_users ORDER BY id LIMIT 1', + ) + const adminUser = admin.rows[0] + assert.ok(adminUser, '集成测试库至少需要一个后台账号') + + const created = await saveAdminWorkProductRule({ + productName, + itemTitle: productName, + itemTitleMatchType: 'exact', + matchType: 'exact', + enabled: true, + autoCreate: true, + }) + ruleId = Number(created.rule.ruleId) + productId = Number(created.productId) + 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 }>( + 'SELECT canonical_sku, status FROM product_catalog WHERE id = $1', + [productId], + ) + assert.equal(product.rows[0]?.canonical_sku, `tpl:${created.rule.ruleKey}`) + assert.equal(product.rows[0]?.status, 'active') + + await assert.rejects( + () => + saveAdminWorkProductRule({ + ruleId, + productName, + enabled: true, + }), + /先在商品与成本中心配置接单模板成本/, + ) + await assert.rejects( + () => + saveAdminWorkProductRule({ + ruleId, + productName, + rewardAmount: 999, + }), + /价格只能由【商品与成本中心】维护/, + ) + + const price = await setAdminProductPrice( + productId, + { + amountFen: 1234, + changeReason: '接单模板成本集成测试', + 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: [], + }, + ) + assert.ok(price.costVersionId > 0) + + const projection = await query<{ + 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, 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 + ORDER BY v.effective_from DESC, v.id DESC + LIMIT 1 + `, + [ruleId], + ) + 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( + () => + setAdminProductPrice( + productId, + { + 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, + }, + { + 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: [], + }, + ), + /暂不支持未来生效/, + ) + + 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, + changeReason: '接单模板按件成本集成测试', + 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: [], + }, + ) + 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, 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 + ORDER BY v.effective_from DESC, v.id DESC + LIMIT 1 + `, + [ruleId], + ) + 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, + enabled: true, + }) + assert.equal(enabled.rule.enabled, true) + assert.equal(enabled.costConfigurationRequired, false) + } finally { + if (ruleId > 0 || productId > 0) { + await query('DELETE FROM cost_snapshots WHERE canonical_product_id = $1', [productId]) + await query( + `DELETE FROM cost_versions + WHERE work_product_rule_id = $1 OR canonical_product_id = $2`, + [ruleId, productId], + ) + await query('DELETE FROM channel_product_mappings WHERE canonical_product_id = $1', [ + productId, + ]) + await query('DELETE FROM product_channel_capabilities WHERE product_id = $1', [productId]) + await query( + 'UPDATE internal_sku_channels SET canonical_product_id = NULL WHERE canonical_product_id = $1', + [productId], + ) + await query('DELETE FROM product_catalog WHERE id = $1', [productId]) + await query('DELETE FROM work_product_rules WHERE id = $1', [ruleId]) + } + await closeDb() + } + }, +) 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 new file mode 100644 index 00000000..acac9036 --- /dev/null +++ b/apps/backend/src/services/cost-center/work-order-template-product-service.ts @@ -0,0 +1,163 @@ +import type { PoolClient, QueryResult, QueryResultRow } from 'pg' + +import { query } from '../../db/client.js' +import { createHttpError } from '../../utils/http.js' +import { nowIso } from '../../utils/time.js' +import type { WorkProductRuleRow } from '../../repositories/worker-platform/types.js' + +type DbClient = Pick + +type WorkOrderTemplateProjectionRow = WorkProductRuleRow & { + category_name: string | null +} + +type WorkOrderTemplateProductState = { + rule: WorkOrderTemplateProjectionRow + productId: number + hasActiveCost: boolean + status: 'active' | 'archived' +} + +function runQuery( + client: DbClient | undefined, + text: string, + params: unknown[] = [], +): Promise> { + return client ? client.query(text, params) : query(text, params) +} + +export function buildTemplateProductSku(ruleKey: string) { + return `tpl:${String(ruleKey || '').trim()}`.slice(0, 64) +} + +export function inferTemplateUnitName(productName: string) { + const name = String(productName || '').trim() + if (/钻石|颗/.test(name)) return '颗' + if (/枪|把/.test(name)) return '把' + if (/套/.test(name)) return '套' + if (/密钥|秘钥/.test(name)) return '个' + return '件' +} + +async function loadWorkOrderTemplateRule(ruleId: number, client?: DbClient) { + const result = await runQuery( + client, + ` + SELECT wpr.*, wc.name AS category_name + FROM work_product_rules wpr + LEFT JOIN work_categories wc ON wc.id = wpr.category_id + WHERE wpr.id = $1 + `, + [ruleId], + ) + return result.rows[0] || null +} + +async function hasActiveWorkOrderCost(ruleId: number, client?: DbClient) { + const result = await runQuery<{ active: boolean }>( + client, + ` + SELECT EXISTS ( + SELECT 1 + FROM cost_versions v + JOIN cost_items ci ON ci.id = v.cost_item_id + WHERE ci.code = 'sys_work_order_reward' + AND v.scope_type = 'rule' + AND v.work_product_rule_id = $1 + AND v.status = 'active' + AND v.amount_fen > 0 + AND v.effective_from <= NOW() + AND (v.effective_to IS NULL OR v.effective_to > NOW()) + ) AS active + `, + [ruleId], + ) + return result.rows[0]?.active === true +} + +/** + * 接单模板的商品投影。 + * + * 模板负责名称、分类和匹配配置;商品状态仍由运营在成本中心显式启停。 + * 这里只写商品身份和模板属性,不修改成本版本金额,避免模板入口成为价格真源。 + */ +export async function syncWorkOrderTemplateProduct( + ruleId: number, + client?: DbClient, +): Promise { + const rule = await loadWorkOrderTemplateRule(ruleId, client) + if (!rule) { + throw createHttpError(`接单模板不存在: ${ruleId}`, { statusCode: 404 }) + } + const hasActiveCost = await hasActiveWorkOrderCost(ruleId, client) + const now = nowIso() + const attributes = { + source: 'work_product_rule', + ruleId: Number(rule.id), + ruleKey: rule.rule_key, + categoryId: rule.category_id ? Number(rule.category_id) : null, + templateEnabled: rule.enabled === true, + 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, + sharingTotalQuantity: Number(rule.sharing_total_quantity || 1), + sharingUnitReward: Number(rule.sharing_unit_reward || 0), + sharingAutoFromOrder: rule.sharing_auto_from_order === true, + } + const result = await runQuery<{ id: number; status: string }>( + client, + ` + INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, status, + attributes_json, created_at, updated_at + ) VALUES ($1, $2, $3, $4, 'active', $5::jsonb, $6, $6) + ON CONFLICT (canonical_sku) DO UPDATE SET + canonical_name = EXCLUDED.canonical_name, + category = EXCLUDED.category, + unit_name = EXCLUDED.unit_name, + attributes_json = COALESCE(product_catalog.attributes_json, '{}'::jsonb) || EXCLUDED.attributes_json, + updated_at = EXCLUDED.updated_at + RETURNING id, status + `, + [ + buildTemplateProductSku(rule.rule_key), + rule.product_name, + rule.category_name || '', + inferTemplateUnitName(rule.product_name), + JSON.stringify(attributes), + now, + ], + ) + return { + rule, + productId: Number(result.rows[0]?.id || 0), + hasActiveCost, + status: result.rows[0]?.status === 'archived' ? 'archived' : 'active', + } +} + +export async function assertWorkOrderTemplateCanEnable(ruleId: number, client?: DbClient) { + if (!(await hasActiveWorkOrderCost(ruleId, client))) { + throw createHttpError('请先在商品与成本中心配置接单模板成本,再启用模板', { + statusCode: 400, + errorCode: 'work_order_template_cost_required', + }) + } +} + +export async function archiveDeletedWorkOrderTemplateProduct(ruleKey: string, client?: DbClient) { + await runQuery( + client, + `UPDATE product_catalog + SET status = 'archived', + attributes_json = COALESCE(attributes_json, '{}'::jsonb) || $1::jsonb, + updated_at = $2 + WHERE canonical_sku = $3 AND status = 'active'`, + [ + JSON.stringify({ templateEnabled: false, templateDeleted: true }), + nowIso(), + buildTemplateProductSku(ruleKey), + ], + ) +} diff --git a/apps/backend/src/services/dev-mock/dev-mock-service.ts b/apps/backend/src/services/dev-mock/dev-mock-service.ts index a27d7904..d3f08e21 100644 --- a/apps/backend/src/services/dev-mock/dev-mock-service.ts +++ b/apps/backend/src/services/dev-mock/dev-mock-service.ts @@ -12,7 +12,7 @@ import { getFulfillmentProfileByKey, upsertFulfillmentProfile, } from '../../repositories/fulfillment-profile-repo.js' -import { buildClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' +import { buildOrderClaimUrl, createTaskClaimToken } from '../claim/claim-service.js' import { assertValidClaimUid, normalizeClaimUid } from '../claim/claim-identity.js' import { OPEN_91_PLATFORM, OPEN_91_PROVIDER, assertOpen91Config } from '../open-91/config.js' import { @@ -258,7 +258,7 @@ export async function createAffiliateDashMockClaim( }) } - const claimToken = await createTaskClaimToken(task.id) + const claimToken = await createTaskClaimToken(task.id, orderNo) await updateTask(task.id, { claim_token: claimToken.token, claim_expires_at: claimToken.expired_at, @@ -274,7 +274,7 @@ export async function createAffiliateDashMockClaim( taskId: task.id, taskNo: task.task_no, claimToken: claimToken.token, - claimUrl: buildClaimUrl(claimToken.token), + claimUrl: buildOrderClaimUrl(orderNo), frontendBaseUrl: input.frontendBaseUrl, expectedUid: fallbackUid, step, @@ -633,7 +633,7 @@ function buildCreateResult(input: { taskNo: input.taskNo, claimToken: input.claimToken, claimUrl: input.claimUrl, - frontendClaimUrl: buildFrontendClaimUrl(input.claimToken, input.frontendBaseUrl), + frontendClaimUrl: buildOrderClaimUrl(input.orderNo, input.frontendBaseUrl || undefined), expectedUid: input.expectedUid, step: input.step, productName: input.productName, @@ -642,14 +642,6 @@ function buildCreateResult(input: { } } -function buildFrontendClaimUrl(token: string, frontendBaseUrl?: unknown) { - const base = String(frontendBaseUrl || '').trim() - if (base) { - return `${base.replace(/\/+$/, '')}/#/claim/${token}` - } - return `http://127.0.0.1/#/claim/${token}` -} - function buildAffiliateDashTips(step: AffiliateDashMockStep, uid: string) { if (step === 'uid') { return [`打开领取链接,Step1 填 UID:${uid}(或自定义)`, '提交后进入绑定步,mock 会给出二维码'] diff --git a/apps/backend/src/services/finance/finance-controls.test.ts b/apps/backend/src/services/finance/finance-controls.test.ts index 384df527..5a0a2156 100644 --- a/apps/backend/src/services/finance/finance-controls.test.ts +++ b/apps/backend/src/services/finance/finance-controls.test.ts @@ -110,12 +110,13 @@ test('P1:资金/积分写命令支持 requestId 幂等', async () => { assert.ok(migration.includes('uq_platform_credit_entries_account_request')) }) -test('P1:成本版本追溯改价退休旧版本而不是覆盖金额', async () => { +test('P1:成本改价退休旧版本且模板运行价格由成本中心同步', async () => { const [product, affiliate] = await Promise.all([ read(new URL('../cost-center/product-catalog-service.ts', import.meta.url)), read(new URL('../cost-center/affiliate-product-service.ts', import.meta.url)), ]) - assert.ok(product.includes('新增一个从 targetTime 起生效的版本')) + assert.ok(product.includes('const costVersionId = await withTransaction')) + assert.ok(product.includes('UPDATE work_product_rules')) assert.ok(product.includes("status = 'retired'")) assert.ok(affiliate.includes('新增追溯版本')) assert.ok(affiliate.includes("status = 'retired'")) diff --git a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts index 78d2df25..ad72624b 100644 --- a/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts +++ b/apps/backend/src/services/fulfillment/affiliate-fallback-service.ts @@ -28,9 +28,10 @@ export async function runAffiliateFallback( order_item_id: number provider: string shop_id: string - manual_sku_key: string | null + internal_sku: string sku_name: string config_id: number | null + config_product_id: number | null basis_date: string }>( ` @@ -39,23 +40,35 @@ export async function runAffiliateFallback( oi.id AS order_item_id, o.provider, o.shop_id, - oi.manual_sku_key, + 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 LEFT JOIN manual_fulfillment_configs cfg - ON cfg.sku_key = oi.manual_sku_key AND cfg.enabled + 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 = 'affiliate_dash' AND oi.fulfillment_locked = FALSE 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 = ANY($1::text[]) + SELECT 1 FROM internal_sku_channels channel + WHERE channel.enabled = TRUE + AND channel.channel = 'manual' + AND channel.is_fallback = TRUE + AND btrim(channel.internal_sku) = resolve_internal_sku( + oi.manual_sku_key, oi.sku_name, oi.sku_code + ) ) + AND ( + SELECT task.task_status + FROM fulfillment_tasks task + WHERE task.order_item_id = oi.id AND task.executor_key = 'affiliate_dash' + ORDER BY task.unit_index DESC, task.updated_at DESC, task.id DESC + LIMIT 1 + ) = ANY($1::text[]) AND NOT EXISTS ( SELECT 1 FROM fulfillment_tasks ft WHERE ft.order_item_id = oi.id @@ -84,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' @@ -91,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 new file mode 100644 index 00000000..179ccdb7 --- /dev/null +++ b/apps/backend/src/services/fulfillment/carrier-creation-service.ts @@ -0,0 +1,224 @@ +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 { 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)) { + 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 AND status <> 'cancelled' + ) 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' + AND task_status <> ALL($2::text[]) + ) AS exists`, + [orderItemId, INACTIVE_CARRIER_TASK_STATUSES], + ) + 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 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) + 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, + ...(preferredWorkRuleKey ? { preferredWorkRuleKey } : {}), + }) + 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..8b10d04b 100644 --- a/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts +++ b/apps/backend/src/services/fulfillment/carrier-reconciliation-service.ts @@ -1,178 +1,340 @@ 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, + INACTIVE_CARRIER_TASK_STATUSES, + 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) => ({ + id: Number(row.id || 0), + 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 || ''), + 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)) } -/** - * 为“内部 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 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` + + 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( + 'id', channel.id, + '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 + ,'note', channel.note + ,'created_by_admin_user_id', channel.created_by_admin_user_id + ) + 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(), + canonical_product_id = NULL, + fulfillment_config_id = NULL, + 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 +343,26 @@ 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 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(', ')}) + ) `, ) 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..8e9420ab 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,29 @@ 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 { + id: 1, + internal_sku: '套装-测试', + channel: 'manual', + priority: 10, + is_fallback: false, + enabled: true, + canonical_product_id: null, + config_json: {}, + source: 'test', + note: '', + created_by_admin_user_id: null, + ...patch, + } +} + function buildRule(patch: Partial = {}): ChannelRule { return { id: 1, @@ -40,13 +58,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 +97,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 +143,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..eae8b531 100644 --- a/apps/backend/src/services/fulfillment/channel-assign-service.ts +++ b/apps/backend/src/services/fulfillment/channel-assign-service.ts @@ -1,4 +1,9 @@ -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' import { upsertAggregatedException } from '../cost-center/cost-exception-service.js' @@ -8,16 +13,26 @@ 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, type MatchKeyType, type RuleSource, } from './channel-types.js' +import { + deleteInternalSkuChannelById, + deleteInternalSkuChannelsByIds, + 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:渠道判定。 @@ -27,19 +42,15 @@ 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)` +const ACTIVE_CARRIER_TASK_SQL = `task.task_status NOT IN (${INACTIVE_CARRIER_TASK_STATUSES.map((status) => `'${status}'`).join(', ')})` export type ChannelRule = { id: number @@ -52,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 @@ -64,7 +82,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 +92,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 +119,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 +134,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 +237,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 = { @@ -248,36 +317,20 @@ 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' - ) AS has_manual_task, - cap.channel AS capability_channel + 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 - 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 +355,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 +442,184 @@ export async function assignPendingOrderItemChannels( return result } -/** 运营指定渠道:写入并锁定,自动判定不再覆盖。 */ -export async function assignChannelToOrderItems(input: { +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 } +} + +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<{ updated: number }> { +}): Promise<{ + switched: number + failed: number + errors: Array<{ orderItemId: number; reason: string }> +}> { if (!isFulfillmentChannel(input.channel)) { throw createHttpError('渠道无效', { statusCode: 400, errorCode: 'invalid_channel' }) } @@ -384,35 +630,198 @@ 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 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 }> = [] - 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) + 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), + }) + } } - return { updated } + + 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[] + channel: string + adminUserId: number | null + note?: string +}): Promise<{ updated: number; carrierFailed: number }> { + 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 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], + ) + return result } /** 运营解锁:允许自动判定重新接管。 */ @@ -505,9 +914,9 @@ export async function assignChannelByInternalSku(input: { internalSku: string channel: string adminUserId: number | null - note?: string - createRule?: boolean -}): Promise<{ updated: number; ruleCreated: 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' }) } @@ -518,57 +927,64 @@ 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 }>( ` - 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], ) - 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 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) }, + note: input.note, + adminUserId: input.adminUserId, + }) + + const ruleCreated = true 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 批量指定(待分类队列的主操作)。 */ @@ -578,60 +994,58 @@ export async function assignChannelBySku(input: { skuCode: string channel: string adminUserId: number | null - note?: string - createRule?: boolean -}): Promise<{ updated: number; ruleCreated: 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' }) } - 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], ) - // 默认同时落一条渠道规则:否则同 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 internalSkus = [ + ...new Set(idsResult.rows.map((row) => String(row.internal_sku || '').trim()).filter(Boolean)), + ] + if (internalSkus.length === 0) { + return { updated: 0, ruleCreated: false, carrierFailed: 0 } } - - return { updated: result.rowCount || 0, ruleCreated } + if (internalSkus.length > 1) { + throw createHttpError('同一平台 SKU 对应多个内部 SKU,请按内部 SKU 指定渠道', { + statusCode: 409, + errorCode: 'sku_code_ambiguous_internal_sku', + }) + } + return assignChannelByInternalSku({ + provider: input.provider, + shopId: input.shopId, + internalSku: internalSkus[0]!, + channel: input.channel, + adminUserId: input.adminUserId, + note: input.note, + createRule: input.createRule, + }) } -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) { @@ -643,22 +1057,74 @@ export async function listChannelRules( clauses.push(`enabled = $${params.length}`) } const where = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '' + 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 - FROM sku_channel_rules + ${CHANNEL_RULE_FROM_SQL} ${where} - ORDER BY priority, channel, match_key + ORDER BY priority, channel, match_key, id `, params, ) - return rows.rows + 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: { id?: number - provider?: string - shopId?: string matchKeyType: string matchKey: string channel: string @@ -668,8 +1134,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', }) @@ -683,36 +1149,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, - ], - ) - - 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 }> { @@ -720,8 +1169,22 @@ 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 } + 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 { @@ -732,7 +1195,37 @@ 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( + 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 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 + `, + [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 @@ -743,19 +1236,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 } /** @@ -768,28 +1265,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 ccaaaa74..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', @@ -27,6 +37,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/delivery-link-service.test.ts b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts index 5d9f3668..cb44b244 100644 --- a/apps/backend/src/services/fulfillment/delivery-link-service.test.ts +++ b/apps/backend/src/services/fulfillment/delivery-link-service.test.ts @@ -1,7 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { buildClaimUrl } from '../claim/claim-service.js' +import { buildOrderClaimUrl } from '../claim/claim-service.js' import { resolveTaskDeliveryLink } from './delivery-link-service.js' import type { TaskRow } from '../../types/repository/rows.js' @@ -15,7 +15,7 @@ test('resolveTaskDeliveryLink 为 affiliate_dash 任务返回本站领取链接' ) assert.deepEqual(result, { - claimUrl: buildClaimUrl('affiliate-token'), + claimUrl: buildOrderClaimUrl('2614900602069169'), expireTime: '2026-07-09T00:00:00.000Z', }) }) @@ -30,7 +30,7 @@ test('resolveTaskDeliveryLink 兼容 claim_token 回落字段', async () => { ) assert.deepEqual(result, { - claimUrl: buildClaimUrl('legacy-token'), + claimUrl: buildOrderClaimUrl('2614900602069169'), expireTime: '2026-07-09T08:00:00.000Z', }) }) diff --git a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts index 7459a1ed..617dea4a 100644 --- a/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts +++ b/apps/backend/src/services/fulfillment/executors/affiliate-dash-executor.ts @@ -1,4 +1,4 @@ -import { buildClaimUrl } from '../../claim/claim-service.js' +import { buildOrderClaimUrl } from '../../claim/claim-service.js' import { TASK_STATUS } from '../../../domain/task-status.js' import { prepareAffiliateDashTask } from '../affiliate-dash/index.js' import { @@ -22,7 +22,7 @@ async function preparePaidTask( try { let workingTask = task if (!task.primary_claim_token_id && !String(task.claim_token || '').trim()) { - const claimToken = await deps.createTaskClaimToken(task.id) + const claimToken = await deps.createTaskClaimToken(task.id, task.platform_order_id) workingTask = (await deps.updateTask(task.id, { claim_token: claimToken.token, @@ -64,7 +64,7 @@ async function resolveDeliveryLink(task: TaskRow): Promise Promise - createTaskClaimToken: (taskId: number | string) => Promise<{ + createTaskClaimToken: ( + taskId: number | string, + orderNo?: unknown, + ) => Promise<{ token: string expired_at: string | null [key: string]: unknown 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..b32dae6b --- /dev/null +++ b/apps/backend/src/services/fulfillment/fulfillment-routing.integration.test.ts @@ -0,0 +1,232 @@ +/** + * 履约路由状态机集成测试(需要真实 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( + '运营指定渠道:先保证载体;支持安全切换;缺载体保持 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, + deleteChannelRules, + ensureVoucherRuleForSku, + listChannelRules, + listChannelRulesPage, + 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, voucherSku] + let generatedVoucherProductId: number | null = null + + 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 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:')) + generatedVoucherProductId = Number(switchedItem.rows[0]?.canonical_product_id || 0) || null + + 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') + + 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), + ) + 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]) + } 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], + ) + if (generatedVoucherProductId) { + await query(`DELETE FROM sales_daily_facts WHERE canonical_product_id = $1`, [ + generatedVoucherProductId, + ]) + await query(`DELETE FROM cost_snapshots WHERE canonical_product_id = $1`, [ + generatedVoucherProductId, + ]) + await query(`DELETE FROM channel_product_mappings WHERE canonical_product_id = $1`, [ + generatedVoucherProductId, + ]) + await query(`DELETE FROM cost_versions WHERE canonical_product_id = $1`, [ + generatedVoucherProductId, + ]) + await query(`DELETE FROM product_catalog WHERE id = $1 AND canonical_sku = $2`, [ + generatedVoucherProductId, + `tpv:${manualSku}`, + ]) + } + 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..908d5ae4 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), @@ -68,9 +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() - if (primary.channel === 'affiliate_dash' || selected === 'affiliate_dash') { - const dashSku = existingAffiliateSku || matchAffiliateDashSkuByInternalSku(internalSku) + const desiredChannel = options.preferredChannel || primary.channel + if (desiredChannel === 'affiliate_dash' || selected === 'affiliate_dash') { + const dashSku = + existingAffiliateSku || + configuredAffiliateSku || + matchAffiliateDashSkuByInternalSku(internalSku) if (dashSku) { if (canOverrideDefaultManual || selected === 'affiliate_dash') { nextSnapshot.fulfillmentRoute = { @@ -109,17 +124,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..2447ad19 --- /dev/null +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.test.ts @@ -0,0 +1,65 @@ +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 { + id: 1, + internal_sku: '内部SKU', + channel: 'manual', + priority: 10, + is_fallback: false, + enabled: true, + canonical_product_id: null, + config_json: {}, + source: 'test', + note: '', + created_by_admin_user_id: null, + ...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..88cca624 100644 --- a/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts +++ b/apps/backend/src/services/fulfillment/internal-sku-channel-service.ts @@ -1,14 +1,26 @@ -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 = { + id: number internal_sku: string channel: FulfillmentChannel priority: number is_fallback: boolean enabled: boolean canonical_product_id: number | null + config_json: Record source: string + note: string + created_by_admin_user_id: number | null +} + +export type InternalSkuChannelPlan = { + primary: InternalSkuChannelRow | null + fallbacks: InternalSkuChannelRow[] + attempts: InternalSkuChannelRow[] + conflicts: InternalSkuChannelRow[] } export async function listInternalSkuChannels( @@ -18,18 +30,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 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 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 +43,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 +88,136 @@ export async function upsertInternalSkuChannel(input: { channel: FulfillmentChannel priority?: number isFallback?: boolean + enabled?: boolean + canonicalProductId?: number | null + configJson?: Record | undefined + source?: string + note?: string | undefined + adminUserId?: number | null | undefined + replacePriority?: boolean +}): Promise { + const internalSku = String(input.internalSku || '').trim() + if (!internalSku) return 0 + return 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], + ) + } + 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, 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 $11::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), + 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, + 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', + String(input.note || '').slice(0, 300), + input.adminUserId ?? null, + input.replacePriority === true, + ], + ) + return Number(result.rows[0]?.id || 0) + }) +} + +/** + * 运营指定一个主渠道。其他渠道保留为显式兜底,避免下一次又被同级优先级抢占。 + */ +export async function setInternalSkuPrimaryChannel(input: { + internalSku: string + channel: FulfillmentChannel 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( + + await upsertInternalSkuChannel({ + internalSku, + channel: input.channel, + priority: 10, + isFallback: false, + enabled: true, + canonicalProductId: input.canonicalProductId ?? null, + configJson: input.configJson, + source: input.source || 'operator', + note: input.note, + adminUserId: input.adminUserId, + replacePriority: true, + }) +} + +export async function disableInternalSkuChannel(input: { + internalSku: string + channel: FulfillmentChannel +}): Promise { + const result = await 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()) - 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, - canonical_product_id = COALESCE(EXCLUDED.canonical_product_id, internal_sku_channels.canonical_product_id), - enabled = TRUE, - updated_at = NOW() + UPDATE internal_sku_channels + SET enabled = FALSE, updated_at = NOW() + WHERE btrim(internal_sku) = $1 AND channel = $2 `, - [ - internalSku, - input.channel, - Number.isFinite(Number(input.priority)) ? Number(input.priority) : 100, - input.isFallback === true, - input.canonicalProductId ?? null, - String(input.source || 'auto').trim() || 'auto', - ], + [String(input.internalSku || '').trim(), input.channel], ) + 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 +} + +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/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/open-91/protocol.test.ts b/apps/backend/src/services/open-91/protocol.test.ts index 8e44d467..c6394434 100644 --- a/apps/backend/src/services/open-91/protocol.test.ts +++ b/apps/backend/src/services/open-91/protocol.test.ts @@ -117,14 +117,14 @@ test('resolveOpen91QueryState returns success only when every task is ready', () test('buildOpen91CardItem mirrors claim link into jumpLink and leaves password empty', () => { assert.deepEqual( buildOpen91CardItem({ - claimUrl: 'https://example.com/#/claim/abc123xyz', + claimUrl: 'https://example.com/h5/6919156612984680391', expireTime: '2026-05-05 12:00:00', }), { - cardNo: 'https://example.com/#/claim/abc123xyz', + cardNo: 'https://example.com/h5/6919156612984680391', cardPwd: '', expireTime: '2026-05-05 12:00:00', - jumpLink: 'https://example.com/#/claim/abc123xyz', + jumpLink: 'https://example.com/h5/6919156612984680391', }, ) }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts index 905ff851..baa62e25 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.test.ts @@ -1,12 +1,11 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { buildOrderClaimUrl } from '../../claim/claim-service.js' import { buildDouyinMashidaBuyerClaimUrl, buildDouyinMashidaClaimMessage, - isDouyinMashidaH5ClaimBase, renderDouyinMashidaMessageTemplate, - resolveDouyinMashidaClaimUrl, } from './claim-link.js' import { createDefaultDouyinMashidaSourceConfig, @@ -45,22 +44,6 @@ test('抖音配置归一化:claimLinkBase 去尾斜杠,开关可关,模板 assert.equal(config.claimMessageEnabled, false) }) -test('买家领取链接 = claimLinkBase(去尾斜杠)+ / + token,绝不回退全局领取页', () => { - assert.equal( - resolveDouyinMashidaClaimUrl( - 'AbC123', - buildConfig({ claimLinkBase: 'https://x.ngrok-free.app/claim/' }), - ), - 'https://x.ngrok-free.app/claim/AbC123', - ) - - assert.equal(resolveDouyinMashidaClaimUrl('AbC123', buildConfig()), '') - assert.equal( - resolveDouyinMashidaClaimUrl('', buildConfig({ claimLinkBase: 'https://x/claim' })), - '', - ) -}) - test('模板占位符替换:支持 link/sku/orderNo/productTitle,空值保留占位符', () => { assert.equal( renderDouyinMashidaMessageTemplate( @@ -105,16 +88,6 @@ test('发消息正文:无领取链接则不发(返回空串),默认模 ) }) -test('领取链接基址形态:/h5 与 /h5/xxx 判为 H5 订单号形态,其它不是', () => { - assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5'), true) - assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/'), true) - assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5/anything'), true) - assert.equal(isDouyinMashidaH5ClaimBase(' https://ks.khhao.com/claim '), false) - assert.equal(isDouyinMashidaH5ClaimBase('https://ks.khhao.com/h5x'), false) - assert.equal(isDouyinMashidaH5ClaimBase(''), false) - assert.equal(isDouyinMashidaH5ClaimBase(undefined), false) -}) - test('买家领取消息链接:没有自动发货领取单元(人工单)一律返回空串 = 不发消息', () => { // 线上实测的缺陷场景:未命中映射的「套装-牛仔情诗」单回落人工,却收到了「已发货」领取链接 assert.equal( @@ -126,19 +99,18 @@ test('买家领取消息链接:没有自动发货领取单元(人工单) '', ) - // token 形态基址同样不发:人工单本来就没有 token,不能拿订单号去凑 + // 历史 /claim 基址同样不发:人工单本来就没有自动发货单元 assert.equal( buildDouyinMashidaBuyerClaimUrl({ claimLinkBase: 'https://ks.khhao.com/claim', orderNo: '6918490107300556291', hasAutoClaimUnit: false, - tokenClaimUrl: '', }), '', ) }) -test('买家领取消息链接:有自动发货领取单元时按基址形态拼接', () => { +test('买家领取消息链接:有自动发货领取单元时统一生成 /h5/订单号', () => { // /h5 形态:拼订单号(结尾斜杠、结尾多斜杠都容忍) assert.equal( buildDouyinMashidaBuyerClaimUrl({ @@ -157,15 +129,14 @@ test('买家领取消息链接:有自动发货领取单元时按基址形态 'https://ks.khhao.com/h5/6918490107300556291', ) - // token 形态:只用 resolveOrderItemClaimLink 解析出的链接 + // 历史 /claim 基址也归一化到 /h5,不再回退 token 链接 assert.equal( buildDouyinMashidaBuyerClaimUrl({ claimLinkBase: 'https://ks.khhao.com/claim', orderNo: '6918490107300556291', hasAutoClaimUnit: true, - tokenClaimUrl: 'https://ks.khhao.com/claim/AbC123', }), - 'https://ks.khhao.com/claim/AbC123', + 'https://ks.khhao.com/h5/6918490107300556291', ) }) @@ -176,7 +147,7 @@ test('买家领取消息链接:基址或订单号缺失时返回空串(不 orderNo: '6918490107300556291', hasAutoClaimUnit: true, }), - '', + buildOrderClaimUrl('6918490107300556291'), ) assert.equal( buildDouyinMashidaBuyerClaimUrl({ diff --git a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts index a95804b8..35964d7e 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/claim-link.ts @@ -1,4 +1,4 @@ -import { buildConfiguredClaimUrl } from '../../claim/claim-service.js' +import { buildOrderClaimUrl } from '../../claim/claim-service.js' import { DEFAULT_CLAIM_MESSAGE_TEMPLATE, getDouyinMashidaSourceConfig, @@ -8,9 +8,7 @@ import { /** * 抖音码速达的买家领取链接与发货消息拼装(纯函数,便于单测)。 * - * 领取链接 = `config.claimLinkBase`(去尾斜杠) + `/` + token。 - * ⚠️ 这里**只认** `claimLinkBase`,绝不回退 `runtimeConfig.orders.claimBaseUrl` - * (那条口径的默认基址属于主站默认领取页,两者不能互相回退)。 + * 买家链接统一为 `/h5/<平台订单号>`;配置了更具体的领取站基址时沿用其站点。 */ export type DouyinMashidaClaimMessageVars = { @@ -20,25 +18,6 @@ export type DouyinMashidaClaimMessageVars = { productTitle?: string | undefined } -/** 买家领取链接;`claimLinkBase` 或 token 缺失时返回空串(调用方据此跳过发链接/发消息)。 */ -export function resolveDouyinMashidaClaimUrl( - token: unknown, - config: DouyinMashidaSourceConfig = getDouyinMashidaSourceConfig(), -): string { - return buildConfiguredClaimUrl(config.claimLinkBase, token) -} - -/** - * 领取链接基址是否是**路径式 H5 订单号**形态(`https://<域名>/h5`、`.../h5/xxx`)。 - * - * 这种基址拼的是**订单号**(`/h5/<订单号>`),不是 claim token;两种形态的拼接规则不同, - * 判断只此一处,避免各调用方各写一份。 - */ -export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean { - const base = normalizeClaimLinkBase(claimLinkBase) - return Boolean(base) && (base.endsWith('/h5') || base.includes('/h5/')) -} - /** * 买家消息里真正要发出去的领取链接(**空串 = 这条消息不许发**)。 * @@ -52,40 +31,21 @@ export function isDouyinMashidaH5ClaimBase(claimLinkBase: unknown): boolean { * 历史缺陷:`/h5` 基址原先走了一条捷径——只拼订单号、跳过履约任务校验, * 于是人工单也照样收到领取消息(线上实测:未命中映射的「套装-牛仔情诗」单被发了链接)。 * - * 拼接规则按基址形态分流: - * - `/h5` 形态 → `claimLinkBase` + `/` + 订单号; - * - 其它形态 → `resolveOrderItemClaimLink` 解析出的 token 链接。 + * 拼接规则:有自动发货领取单元时,所有来源统一发 `/h5/<平台订单号>`。 + * 历史配置的 `/#/claim`、`/claim` 等基址也会由公共 helper 归一化到 `/h5`。 */ export function buildDouyinMashidaBuyerClaimUrl(input: { claimLinkBase: unknown - /** 平台订单号(`/h5` 形态用) */ + /** 平台订单号 */ orderNo: unknown /** 该订单项是否有自动发货领取单元(`resolveOrderItemClaimLink().taskId` 非空即为有) */ hasAutoClaimUnit: boolean - /** token 形态链接(`resolveOrderItemClaimLink().claimUrl`) */ - tokenClaimUrl?: unknown }): string { if (!input.hasAutoClaimUnit) { return '' } - const base = normalizeClaimLinkBase(input.claimLinkBase) - if (!base) { - return '' - } - - if (isDouyinMashidaH5ClaimBase(base)) { - return buildConfiguredClaimUrl(base, String(input.orderNo ?? '').trim()) - } - - return String(input.tokenClaimUrl ?? '').trim() -} - -/** 基址归一化:去首尾空白与结尾斜杠。 */ -function normalizeClaimLinkBase(claimLinkBase: unknown): string { - return String(claimLinkBase ?? '') - .trim() - .replace(/\/+$/, '') + return buildOrderClaimUrl(input.orderNo, input.claimLinkBase || undefined) } /** diff --git a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts index 2b6316bc..10eb2258 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/notify-service.ts @@ -254,31 +254,28 @@ async function notifyBuyerClaimLink(input: { const claimLinkBase = String(input.config.claimLinkBase || '') .trim() .replace(/\/+$/, '') - if (!claimLinkBase) { - return - } - - const orderNo = resolveReceiptOrderId(input.order, input.orderItem) + const receiptOrderNo = resolveReceiptOrderId(input.order, input.orderItem) + const claimOrderNo = String(input.order.platform_order_id || receiptOrderNo).trim() // 先核对履约任务:`resolveOrderItemClaimLink` 只有在该订单项存在带 token 的领取单元时才给 taskId。 const claimLink = await resolveOrderItemClaimLink({ orderId: input.order.id, + orderNo: claimOrderNo, orderItemId: input.orderItem.id, claimLinkBase, }) const claimUrl = buildDouyinMashidaBuyerClaimUrl({ claimLinkBase, - orderNo, + orderNo: claimOrderNo, hasAutoClaimUnit: Boolean(claimLink.taskId), - tokenClaimUrl: claimLink.claimUrl, }) if (!claimUrl) { logIntegration('[douyin-mashida/notify]', '该订单项没有自动发货领取单元,跳过领取链接消息', { orderId: input.order.id, orderItemId: input.orderItem.id, - orderNo, + orderNo: claimOrderNo, claimLinkBase, taskCount: claimLink.taskCount, hasClaimUnit: Boolean(claimLink.taskId), @@ -290,19 +287,24 @@ async function notifyBuyerClaimLink(input: { config: input.config, claimUrl, sku: input.orderItem.sku_code, - orderNo, + orderNo: claimOrderNo, productTitle: input.orderItem.sku_name, }) if (!content) { return } - await sendDouyinMashidaOrderMessage({ orderId: orderNo, content, config: input.config }) + await sendDouyinMashidaOrderMessage({ + orderId: receiptOrderNo, + content, + config: input.config, + }) logIntegration('[douyin-mashida/notify]', '已把领取链接发给买家(order.message)', { orderId: input.order.id, orderItemId: input.orderItem.id, - orderNo, + orderNo: claimOrderNo, + receiptOrderNo, taskId: claimLink.taskId, claimUrl, }) diff --git a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts index 4ee7fc5d..e2cc541a 100644 --- a/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts +++ b/apps/backend/src/services/platforms/douyin-mashida/source-config-service.ts @@ -18,13 +18,11 @@ import { * - `uid` 是抖店商户号;`secret` 是用户秘钥(两者都由平台方分配)。 * - `certLink` 是我们发给平台的固定兑换链接(`coupons.syncV2` 的 `cert_link`), * **必须做成配置项**:链接一旦同步给平台就收不回来,换域名时只影响新券。 - * 命中 affiliate-dash 映射的订单会优先用「买家领取链接」(`claimLinkBase` + token), - * 此时 `certLink` 退化为兜底。 - * - `claimLinkBase` 是**买家领取页**基址,必须渲染 affiliate-dash 领取流程 - * (输 UID → 二维码 → 扫码绑定 → 比对「绑定 UID == 填的 UID」完成发货)。 - * 本仓库前端该页面是 `pages/claim/ClaimPage`,路径式 `https://<域名>/claim` 与 - * hash 式 `https://<域名>/#/claim` 都会渲染它;它**不是** `runtimeConfig.orders.claimBaseUrl` - * (两条口径不能互相回退)。 + * 买家领取链接统一为 `/h5/<订单号>`;未配 `claimLinkBase` 时使用全局领取站, + * `certLink` 仅在订单号或领取站不可用时兜底。 + * - `claimLinkBase` 是**买家领取页**基址;出站链接会在其站点下统一生成 + * `/h5/<平台订单号>`,历史 `/#/claim`、`/claim` 基址也会被归一化。 + * 本仓库前端该页面是 `pages/claim/ClaimPage`,走 affiliate-dash 领取流程。 * - `handledTopics` 是本地处理白名单:白名单外的 topic 只落库留痕、不产生副作用。 */ 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/redeem/certificate-service.ts b/apps/backend/src/services/redeem/certificate-service.ts index 4b7451b3..26d8b200 100644 --- a/apps/backend/src/services/redeem/certificate-service.ts +++ b/apps/backend/src/services/redeem/certificate-service.ts @@ -19,7 +19,6 @@ import { import type { OrderItemRow, OrderRow } from '../../types/repository/rows.js' import { isPlainObject, type JsonObject } from '../../types/json.js' -import { resolveOrderItemClaimLink } from '../claim/claim-service.js' import { createHttpError } from '../../utils/http.js' import { logIntegration } from '../../utils/logger.js' import { nowIso } from '../../utils/time.js' @@ -30,10 +29,7 @@ import { syncDouyinMashidaCoupons, verifyDouyinMashidaCoupon, } from '../platforms/douyin-mashida/gateway-client.js' -import { - buildDouyinMashidaBuyerClaimUrl, - isDouyinMashidaH5ClaimBase, -} from '../platforms/douyin-mashida/claim-link.js' +import { buildDouyinMashidaBuyerClaimUrl } from '../platforms/douyin-mashida/claim-link.js' import { DOUYIN_MASHIDA_PROVIDER, extractDouyinMashidaSubOrderId, @@ -295,18 +291,9 @@ async function attachDouyinMashidaCertificateToWorkOrder(input: { /** * 解析本次同步要交给平台的 `cert_link`。 * - * 判据:券对**所有订单**都要发(业务口径),链接按基址形态决定: - * - `/h5` 形态基址 → `claimLinkBase/<订单号>`(买家落到自己订单的状态页,自动发货/人工处理中都合理); - * - 其它形态基址 → 只有该订单项确实有自动发货领取单元(claim token)时才给 token 领取链接; - * - 拿不到可用链接 → 回退固定 `config.certLink`。 - * - * 历史缺陷:`claimLinkBase` 以 `/h5` 结尾时走了一条捷径——**无条件**下拼订单号, - * 但那只是「拼法」问题;真正的缺陷在私信侧(无 token 也发「已发货」,见 notify-service, - * 已用同一个纯函数修好)。这里保留 `/h5` 按订单号拼的行为,但**不再让链接依赖 token**。 - * - * 链接解析走通用 helper(只看订单项的履约任务,不看执行器 key): - * 抖音皮肤/礼包单已改走 affiliate_dash 通道,换执行器不会再让 cert_link 失效。 - * 同一个订单项按数量拆成多个履约单元时会有多个 token,helper 取最小 task id 并打 warn。 + * 券对**所有订单**都要发,买家链接统一使用 `/h5/<平台订单号>`; + * 抖音配置了 `claimLinkBase` 时使用其站点,否则使用全局领取站; + * 订单号缺失或领取站不可用时回退固定 `config.certLink`。 * * ⚠️ 只对新单生效:已同步成功的券不会被改写。 */ @@ -315,70 +302,24 @@ export async function resolveDouyinMashidaCertLink(input: { orderItem: OrderItemRow config: DouyinMashidaSourceConfig }): Promise { - const fallback = String(input.config.certLink || '').trim() - const claimLinkBase = String(input.config.claimLinkBase || '') - .trim() - .replace(/\/+$/, '') - if (!claimLinkBase) { - return fallback - } - - const claimLink = await resolveOrderItemClaimLink({ - orderId: input.order.id, - orderItemId: input.orderItem.id, - claimLinkBase: input.config.claimLinkBase, - }) - - // 有履约任务却拿不到 token = 任务没被正确准备(配置/执行器问题),值得留痕; - // 连任务都还没有属于建单顺序上的正常瞬时状态,静默回退即可。 - if (!claimLink.taskId && claimLink.taskCount > 0) { - logIntegration( - '[douyin-mashida/certificate]', - '该订单项有履约任务但尚无 claim token,cert_link 回退固定兑换链接', - { - orderId: input.order.id, - orderItemId: input.orderItem.id, - taskCount: claimLink.taskCount, - }, - { level: 'warn' }, - ) - } - return resolveDouyinMashidaCertLinkFromClaim({ - certLink: fallback, - claimLinkBase, - orderNo: resolveReceiptOrderId(input.order, input.orderItem), - tokenClaimUrl: claimLink.claimUrl, + certLink: input.config.certLink, + claimLinkBase: input.config.claimLinkBase, + orderNo: input.order.platform_order_id, }) } -/** - * `cert_link` 的**纯函数**判定(便于单测)。 - * - * 业务口径:**券对所有订单都要发**(与是否命中自动发货无关),因此链接不能退化成裸基址: - * - `/h5` 形态基址 → 一律按**订单号**拼(买家直接落到自己订单的状态页); - * - 其它形态基址 → 有 token 才给领取链接,否则回退固定 `certLink`。 - */ +/** `cert_link` 的纯函数判定,便于单测。 */ export function resolveDouyinMashidaCertLinkFromClaim(input: { certLink: unknown claimLinkBase: unknown orderNo: unknown - tokenClaimUrl?: unknown }): string { const fallback = String(input.certLink ?? '').trim() - const base = String(input.claimLinkBase ?? '') - .trim() - .replace(/\/+$/, '') - if (!base) { - return fallback - } - - const orderNoLink = isDouyinMashidaH5ClaimBase(base) const claimUrl = buildDouyinMashidaBuyerClaimUrl({ - claimLinkBase: base, + claimLinkBase: input.claimLinkBase, orderNo: input.orderNo, - hasAutoClaimUnit: orderNoLink || Boolean(String(input.tokenClaimUrl ?? '').trim()), - tokenClaimUrl: input.tokenClaimUrl, + hasAutoClaimUnit: true, }) return claimUrl || fallback diff --git a/apps/backend/src/services/redeem/redeem-service.test.ts b/apps/backend/src/services/redeem/redeem-service.test.ts index e5f7f3de..af1a2902 100644 --- a/apps/backend/src/services/redeem/redeem-service.test.ts +++ b/apps/backend/src/services/redeem/redeem-service.test.ts @@ -6,6 +6,7 @@ import { generateRedeemCertificateCode, resolveRedeemVerifyCertNo, } from '../../repositories/redeem-certificate-repo.js' +import { buildOrderClaimUrl } from '../claim/claim-service.js' import { createDefaultDouyinMashidaSourceConfig } from '../platforms/douyin-mashida/source-config-service.js' import { buildAfterVerifyMessage, @@ -129,65 +130,60 @@ test('平台侧是否已核销:status=3 或 verify_num>0', () => { assert.equal(isPlatformCouponVerified(null), false) }) -// ---- 抖音 cert_link:券对所有订单都发,链接按基址形态决定 ---- +// ---- 抖音 cert_link:券对所有订单都发,链接统一使用 /h5/订单号 ---- -test('抖音 cert_link:/h5 形态基址 → 一律按订单号拼(与是否命中自动发货无关)', () => { +test('抖音 cert_link:/h5 基址 → 一律按订单号拼(与是否命中自动发货无关)', () => { assert.equal( resolveDouyinMashidaCertLinkFromClaim({ certLink: 'https://ks.khhao.com/redeem', claimLinkBase: 'https://ks.khhao.com/h5', orderNo: '6930318258716179555', - tokenClaimUrl: '', }), 'https://ks.khhao.com/h5/6930318258716179555', ) }) -test('抖音 cert_link:/h5 基址 + 有 token → 仍按订单号拼(H5 页认订单号)', () => { +test('抖音 cert_link:/h5 基址带尾斜杠也只按订单号拼', () => { assert.equal( resolveDouyinMashidaCertLinkFromClaim({ certLink: 'https://ks.khhao.com/redeem', claimLinkBase: 'https://ks.khhao.com/h5/', orderNo: '6930318258716179555', - tokenClaimUrl: 'https://ks.khhao.com/h5/token-abc', }), 'https://ks.khhao.com/h5/6930318258716179555', ) }) -test('抖音 cert_link:token 形态基址 + 有 token → 用 token 领取链接', () => { +test('抖音 cert_link:历史 /claim 基址也归一化为 /h5/订单号', () => { assert.equal( resolveDouyinMashidaCertLinkFromClaim({ certLink: 'https://ks.khhao.com/redeem', claimLinkBase: 'https://ks.khhao.com/claim', orderNo: '6930318258716179555', - tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc', }), - 'https://ks.khhao.com/claim/token-abc', + 'https://ks.khhao.com/h5/6930318258716179555', ) }) -test('抖音 cert_link:token 形态基址 + 无 token(人工单 / 工单单)→ 回退固定兑换链接', () => { +test('抖音 cert_link:订单号缺失时回退固定兑换链接', () => { assert.equal( resolveDouyinMashidaCertLinkFromClaim({ certLink: 'https://ks.khhao.com/redeem', - claimLinkBase: 'https://ks.khhao.com/claim', - orderNo: '6930318258716179555', - tokenClaimUrl: '', + claimLinkBase: 'https://ks.khhao.com/h5', + orderNo: '', }), 'https://ks.khhao.com/redeem', ) }) -test('抖音 cert_link:未配 claimLinkBase 时回退固定链接(不报错)', () => { +test('抖音 cert_link:未配 claimLinkBase 时回退全局 /h5 链接', () => { assert.equal( resolveDouyinMashidaCertLinkFromClaim({ certLink: 'https://ks.khhao.com/redeem', claimLinkBase: '', orderNo: '6930318258716179555', - tokenClaimUrl: 'https://ks.khhao.com/claim/token-abc', }), - 'https://ks.khhao.com/redeem', + buildOrderClaimUrl('6930318258716179555'), ) }) diff --git a/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts b/apps/backend/src/services/scheduler/finance-pipeline-jobs.ts index bafc610d..475a06a0 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' @@ -205,14 +213,15 @@ 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 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 ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') ) AND oi.id > $2 ORDER BY oi.id @@ -245,6 +254,41 @@ 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 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 $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 +298,7 @@ export async function runCarrierDispatchJob(job: JsonObject = {}) { ...missingManual.rows, ...pendingManualCost.rows, ...missingCarrier.rows, + ...conflictingCarriers.rows, ]) { maxId = Math.max(maxId, Number(row.id)) } @@ -269,13 +314,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 +338,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 +364,14 @@ 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 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 IN ('affiliate_dash', 'third_party_voucher') - ) + AND ft.executor_key = 'affiliate_dash' + AND ft.task_status NOT IN ('closed', 'expired', 'failed', 'manual_review', 'retry_pending', 'cancelled') + )) ) LIMIT 500 `, @@ -442,8 +493,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/short-links/short-link-service.ts b/apps/backend/src/services/short-links/short-link-service.ts index 5f86fdbf..5272d1b1 100644 --- a/apps/backend/src/services/short-links/short-link-service.ts +++ b/apps/backend/src/services/short-links/short-link-service.ts @@ -2,7 +2,7 @@ * 内部短链服务。 * * 注意(2026-07 起): - * - 领取主路径已统一为本站 `/#/claim/{token}`,feifei 不再为 claim 生成 shortLink。 + * - 领取链接已统一为本站 `/h5/{订单号}`,feifei 不再为 claim 生成 shortLink。 * - 本模块仅保留:历史 `/s/{code}` 解析、其它潜在内部用途。 * - 新业务不要再依赖 shortLink 作为对外交付链接。 */ @@ -46,7 +46,7 @@ export async function ensureShortLinkForTarget( const source = String(options.source || '').trim() if (source === SHORT_LINK_SOURCE_KUAISHOU_FEIFEI_CLAIM) { console.warn( - '[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /#/claim/{token} 并在 H5 拼 uid', + '[short-link] feifei claim 已不再使用 shortLink 作为对外交付;请使用本站 /h5/{订单号} 领取页', ) } const targetUrlHash = hashTargetUrl(normalizedTargetUrl) 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 e019df3f..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,12 @@ import { upsertWorkProductRule, } from '../../repositories/worker-platform/work-product-rule-repo.js' import type { WorkOrderRow, WorkProductRuleRow } from '../../repositories/worker-platform/types.js' -import { syncAdminWorkOrderTemplateCostVersions } from '../cost-center/work-order-product-service.js' +import { query, withTransaction } from '../../db/client.js' +import { + archiveDeletedWorkOrderTemplateProduct, + assertWorkOrderTemplateCanEnable, + syncWorkOrderTemplateProduct, +} from '../cost-center/work-order-template-product-service.js' import { normalizeWorkOrderAcceptanceMode, normalizeWorkOrderGiftCooldownHours, @@ -379,35 +384,76 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { }) } - const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0) - const unitPriceFen = - payload.unitPrice === undefined && payload.unitPriceYuan === undefined - ? 0 - : normalizeAmountFen(payload.unitPrice ?? payload.unitPriceYuan, 0) - - // 全系统只有【商品与成本中心】是标准价格,接单模板价格禁止在其他地方修改 - if (existingRule && !payload.internalAllowPriceChange) { - const existingRewardAmount = Number(existingRule.reward_amount || 0) - const existingUnitPrice = Number(existingRule.unit_price_fen || 0) - const hasPricePayload = - payload.rewardAmount !== undefined || - payload.rewardAmountYuan !== undefined || - payload.unitPrice !== undefined || - payload.unitPriceYuan !== undefined - if ( - hasPricePayload && - (rewardAmount !== existingRewardAmount || unitPriceFen !== existingUnitPrice) - ) { - throw createHttpError( - '接单模板价格已由【商品与成本中心】统一定价,价格更新只能走商品与成本中心。请前往成本中心修改标准价格', - { - statusCode: 400, - errorCode: 'price_update_restricted_to_cost_center', - }, - ) - } + const hasRewardPayload = + payload.rewardAmount !== undefined || payload.rewardAmountYuan !== undefined + const hasUnitPricePayload = payload.unitPrice !== undefined || payload.unitPriceYuan !== undefined + const incomingRewardAmount = normalizeAmountFen( + payload.rewardAmount ?? payload.rewardAmountYuan, + 0, + ) + 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 sharingEnabled = normalizeBoolean(payload.sharingEnabled ?? payload.sharing_enabled, false) + 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 ( + (hasRewardPayload && incomingRewardAmount !== existingRewardAmount) || + (hasUnitPricePayload && incomingUnitPriceFen !== existingUnitPriceFen) || + (!existingRule && (incomingRewardAmount > 0 || incomingUnitPriceFen > 0)) + ) { + throw createHttpError( + '接单模板价格只能由【商品与成本中心】维护。请先保存模板,再到成本中心配置价格', + { + statusCode: 400, + errorCode: 'price_update_restricted_to_cost_center', + }, + ) + } + 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') + ? normalizeBoolean(payload.sharing_enabled, false) + : existingRule?.sharing_enabled === true const acceptanceMode = normalizeWorkOrderAcceptanceMode( hasOwnPayload(payload, 'acceptanceMode') || hasOwnPayload(payload, 'acceptance_mode') ? payload.acceptanceMode || payload.acceptance_mode @@ -423,11 +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 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 && pricingMode === 'unit' && !hasManualSharingConfig) const sharingAutoFromOrder = - sharingEnabled && - unitPriceFen > 0 && - (normalizeBoolean(payload.sharingAutoFromOrder ?? payload.sharing_auto_from_order, false) || - !hasManualSharingConfig) + pricingMode === 'unit' && requestedSharingAutoFromOrder && !hasManualSharingConfig const hasManualSharingQuantity = hasConfiguredSharingValue( payload.sharingTotalQuantity ?? payload.sharing_total_quantity, ) @@ -436,7 +485,7 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { ) if ( sharingEnabled && - unitPriceFen > 0 && + pricingMode === 'unit' && !sharingAutoFromOrder && hasManualSharingQuantity !== hasManualSharingUnitReward ) { @@ -445,17 +494,19 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { errorCode: 'work_product_rule_sharing_override_incomplete', }) } - const sharingUnitReward = normalizeAmountFen( - payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, - 0, - ) + const sharingUnitReward = + payload.sharingUnitReward !== undefined || payload.sharingUnitRewardYuan !== undefined + ? normalizeAmountFen(payload.sharingUnitReward ?? payload.sharingUnitRewardYuan, 0) + : Number(existingRule?.sharing_unit_reward || 0) const sharingTotalAmount = normalizeAmountFen( payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan, 0, ) const sharingTotalQuantity = resolveSharingQuantity( sharingTotalAmount, - normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1), + payload.sharingTotalQuantity !== undefined || payload.sharing_total_quantity !== undefined + ? normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1) + : normalizePositiveInteger(existingRule?.sharing_total_quantity, 1), sharingUnitReward, ) const resolvedSharingTotalAmount = @@ -473,77 +524,90 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) { ? 0 : resolvedSharingTotalAmount : rewardAmount + const requestedEnabled = hasOwnPayload(payload, 'enabled') + ? normalizeBoolean(payload.enabled, false) + : existingRule?.enabled === true + const enabled = existingRule ? requestedEnabled : false + if (existingRule && enabled) { + await assertWorkOrderTemplateCanEnable(existingRule.id) + } const fields = normalizeRequirementFieldsFromPayload(payload) const ruleKey = existingRule?.rule_key || randomId('rule-') - const rule = await upsertWorkProductRule({ - ruleKey, - provider: hasOwnPayload(payload, 'provider') - ? String(payload.provider || '').trim() - : String(existingRule?.provider || '').trim(), - platform: hasOwnPayload(payload, 'platform') - ? String(payload.platform || '').trim() - : String(existingRule?.platform || '').trim(), - shopId: - hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id') - ? String(payload.shopId || payload.shop_id || '').trim() - : String(existingRule?.shop_id || '').trim(), - skuCode: - hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code') - ? skuCode - : String(existingRule?.sku_code || '').trim(), - productName: productName || matchedProductName, - matchType: - hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type') - ? normalizeMatchType(payload.matchType || payload.match_type) - : normalizeMatchType(existingRule?.match_type), - categoryId: - normalizeOptionalId(payload.categoryId || payload.category_id) || - defaults.category?.id || - null, - enabled: normalizeBoolean(payload.enabled, true), - autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true), - rewardAmount: finalRewardAmount, - unitPriceFen, - // 商品规则不配置押金;工单创建后由运营按需手工填写。 - requiredDepositAmount: 0, - depositThresholdAmount: 0, - sharingEnabled, - sharingTotalQuantity, - sharingUnitReward, - sharingAutoFromOrder, - timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), - timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), - acceptanceMode, - giftCooldownHours: - hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours') - ? normalizeWorkOrderGiftCooldownHours( - payload.giftCooldownHours ?? payload.gift_cooldown_hours, - ) - : normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours), - giftCooldownMinutes: - hasOwnPayload(payload, 'giftCooldownMinutes') || - hasOwnPayload(payload, 'gift_cooldown_minutes') - ? normalizeWorkOrderGiftCooldownMinutes( - payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes, - ) - : normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes), - requirementJson: JSON.stringify({ fields }), - matchJson: JSON.stringify(match), - sortOrder: normalizeInteger(payload.sortOrder, 100), - now: nowIso(), + const hasCategoryPayload = + hasOwnPayload(payload, 'categoryId') || hasOwnPayload(payload, 'category_id') + const categoryId = hasCategoryPayload + ? normalizeOptionalId(payload.categoryId ?? payload.category_id) + : existingRule?.category_id || defaults.category?.id || null + const projection = await withTransaction(async (client) => { + const savedRule = await upsertWorkProductRule({ + ruleKey, + provider: hasOwnPayload(payload, 'provider') + ? String(payload.provider || '').trim() + : String(existingRule?.provider || '').trim(), + platform: hasOwnPayload(payload, 'platform') + ? String(payload.platform || '').trim() + : String(existingRule?.platform || '').trim(), + shopId: + hasOwnPayload(payload, 'shopId') || hasOwnPayload(payload, 'shop_id') + ? String(payload.shopId || payload.shop_id || '').trim() + : String(existingRule?.shop_id || '').trim(), + skuCode: + hasOwnPayload(payload, 'skuCode') || hasOwnPayload(payload, 'sku_code') + ? skuCode + : String(existingRule?.sku_code || '').trim(), + productName: productName || matchedProductName, + matchType: + hasOwnPayload(payload, 'matchType') || hasOwnPayload(payload, 'match_type') + ? normalizeMatchType(payload.matchType || payload.match_type) + : normalizeMatchType(existingRule?.match_type), + categoryId, + enabled, + autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, true), + rewardAmount: finalRewardAmount, + unitPriceFen, + pricingMode, + // 商品规则不配置押金;工单创建后由运营按需手工填写。 + requiredDepositAmount: 0, + depositThresholdAmount: 0, + sharingEnabled, + sharingTotalQuantity, + sharingUnitReward, + sharingAutoFromOrder, + timeoutMinutes: Math.max(0, normalizeInteger(payload.timeoutMinutes, 0)), + timeoutPolicy: normalizeWorkOrderTimeoutPolicy(payload.timeoutPolicy), + acceptanceMode, + giftCooldownHours: + hasOwnPayload(payload, 'giftCooldownHours') || hasOwnPayload(payload, 'gift_cooldown_hours') + ? normalizeWorkOrderGiftCooldownHours( + payload.giftCooldownHours ?? payload.gift_cooldown_hours, + ) + : normalizeWorkOrderGiftCooldownHours(existingRule?.gift_cooldown_hours), + giftCooldownMinutes: + hasOwnPayload(payload, 'giftCooldownMinutes') || + hasOwnPayload(payload, 'gift_cooldown_minutes') + ? normalizeWorkOrderGiftCooldownMinutes( + payload.giftCooldownMinutes ?? payload.gift_cooldown_minutes, + ) + : normalizeWorkOrderGiftCooldownMinutes(existingRule?.gift_cooldown_minutes), + requirementJson: JSON.stringify({ fields }), + matchJson: JSON.stringify(match), + sortOrder: normalizeInteger(payload.sortOrder, 100), + now: nowIso(), + client, + }) + if (!savedRule) { + throw createHttpError('接单模板保存失败', { statusCode: 500 }) + } + const productProjection = await syncWorkOrderTemplateProduct(savedRule.id, client) + return { rule: savedRule, productProjection } }) - // 模板价格变更即同步成本版本(事件驱动);失败不阻塞模板保存, - // 夜间巡检与后台「同步发单金额」按钮仍可修复。 - if (rule) { - try { - await syncAdminWorkOrderTemplateCostVersions(rule.id) - } catch { - // 成本版本同步失败不允许影响模板本身;由巡检补齐。 - } + return { + rule: mapWorkProductRule(projection.rule), + productId: projection.productProjection.productId, + productStatus: projection.productProjection.status, + costConfigurationRequired: !projection.productProjection.hasActiveCost, } - - return { rule: mapWorkProductRule(rule) } } /** 根据工单创建时的规格,计算当前模板可同步的价格。 */ @@ -562,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), @@ -669,5 +734,6 @@ export async function deleteAdminWorkProductRule(ruleId: number | string) { errorCode: 'work_product_rule_not_found', }) } + await archiveDeletedWorkOrderTemplateProduct(rule.rule_key) return { deleted, retiredCostVersionCount: detached.updated } } 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 c51353fc..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 @@ -50,7 +50,13 @@ 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 + preferredWorkRuleKey?: string + } = {}, ) { const [rules, mappings] = await Promise.all([ listWorkProductRules({ enabled: true }), @@ -75,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 = @@ -198,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), @@ -309,15 +343,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/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/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/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/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/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 430eea91..02ac192d 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, @@ -24,13 +29,13 @@ 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' import { assignChannelBySku, - deleteChannelRule, + deleteChannelRules, fetchChannelExceptions, fetchChannelRules, fetchPendingChannelGroups, @@ -43,6 +48,7 @@ import { } from '@/services/admin' import { formatRedeemAmount } from '@/utils/redeem' import { formatDateTime } from '@/utils/date-time' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' const CHANNEL_OPTIONS = [ { value: 'work_order', label: '接单平台' }, @@ -58,11 +64,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 ( @@ -84,11 +86,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({ @@ -100,7 +104,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 +145,7 @@ function PendingPanel() { no_internal_sku: '无内部SKU', no_channel_capability: '无渠道能力', no_carrier: '无履约载体', + routing_conflict: '渠道冲突', } return value?.length ? value.map((reason) => labels[reason] || reason).join(' / ') : '-' }, @@ -188,7 +199,10 @@ function PendingPanel() { allowClear placeholder="SKU / 商品名" style={{ width: 220 }} - onSearch={(value) => setKeyword(value)} + onSearch={(value) => { + setKeyword(value) + setPage(1) + }} /> @@ -296,9 +343,18 @@ function RulesPanel() { return ( + @@ -309,20 +365,32 @@ function RulesPanel() { } > - 接单平台由接单模板匹配决定,不在此表维护;这里负责自动发货、人工、第三方卡券三个渠道与运营强制覆盖。 - 同优先级命中多个渠道时订单保持待分类并进「配置冲突」。 + 配置按内部 SKU 全局生效,会同时影响所有平台和店铺。每条内部 SKU 只能有一个主渠道; + 其他渠道请设置 90 以上优先级作为兜底。接单平台的具体模板由统一配置中的 workRuleKey 控制。 setSelectedRuleIds(keys.map(Number)), + }} + pagination={buildAdminTablePagination({ + current: query.data?.data?.page || page, + pageSize: query.data?.data?.pageSize || pageSize, + total: ruleCount, + onChange: (nextPage, nextPageSize) => { + setPage(nextPageSize !== pageSize ? 1 : nextPage) + setPageSize(nextPageSize) + }, + })} /> setOpen(false)} onOk={() => form.submit()} @@ -331,15 +399,13 @@ function RulesPanel() {
saveMutation.mutate(values)} > - - - - - - - {pricingProduct.canonicalSku.startsWith('tpl:') ? ( - - - 立即同步至接单大厅待抢单工单(推荐) -
- 接单大厅中所有未领取的该模板工单将立即同步为新价格;已领取的工单保持原价不变。 -
-
-
- ) : null} - {pricingProduct.source === 'affiliate_dash' ? ( diff --git a/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx b/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx index d8ab6c86..4d57465a 100644 --- a/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx +++ b/apps/frontend/src/pages/admin/cost-center/VoucherSkuPanel.tsx @@ -27,25 +27,25 @@ import { setAdminProductPrice, } from '@/services/admin' import type { ProductCatalogItem } from '@/types/admin' +import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' import { useCostCenter } from './CostCenterContext' -const PAGE_SIZE = 100 - export default function VoucherSkuPanel() { const queryClient = useQueryClient() const { canConfig, refreshAll } = useCostCenter() - const [page] = useState(1) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE) const [selected, setSelected] = useState(null) const [ensuringRules, setEnsuringRules] = useState(false) const [form] = Form.useForm<{ amountFen: number; changeReason?: string }>() const query = useQuery({ - queryKey: ['cost-center-voucher-products', page], + queryKey: ['cost-center-voucher-products', page, pageSize], queryFn: () => fetchAdminProductCatalog({ carrierType: 'third_party_voucher', page, - pageSize: PAGE_SIZE, + pageSize, }), }) @@ -154,7 +154,15 @@ export default function VoucherSkuPanel() { loading={query.isFetching} dataSource={items} columns={columns} - pagination={{ pageSize: 20, showSizeChanger: false }} + pagination={buildAdminTablePagination({ + current: page, + pageSize, + total, + onChange: (nextPage, nextPageSize) => { + setPage(nextPageSize !== pageSize ? 1 : nextPage) + setPageSize(nextPageSize) + }, + })} /> = [ { title: '接单规则 / 模板', @@ -176,24 +158,14 @@ export function WorkOrderPricePanel() { {total} 条 } - extra={ - canConfig ? ( - - ) : null - } + extra={canConfig ? 自动同步 : null} > = { nanhang: 'blue', @@ -406,7 +407,7 @@ export function PlatformCreditPanel() { dataSource={visibleAccounts} loading={accountsQuery.isLoading} scroll={{ x: 1080 }} - pagination={{ pageSize: 20, showSizeChanger: true }} + pagination={buildAdminLocalTablePagination()} locale={{ emptyText: '还没有客服积分账户' }} /> diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx index 42cf0567..25630c9d 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentInventoryPanel.tsx @@ -29,6 +29,7 @@ import { Typography, } from 'antd' import PercentTable from '@/components/common/PercentTable' +import { buildAdminTablePagination } from '@/utils/admin-pagination' import type { TableColumnsType } from 'antd' import { useState } from 'react' @@ -880,18 +881,16 @@ export default function ConsignmentInventoryPanel() { dataSource={items} loading={inventoriesQuery.isLoading} scroll={{ x: 1590 }} - pagination={{ + pagination={buildAdminTablePagination({ current: page, pageSize, total, - showSizeChanger: true, - pageSizeOptions: ['10', '20', '50', '100'], showTotal: (t) => `共 ${t} 件物品`, onChange: (p, ps) => { setPage(p) setPageSize(ps) }, - }} + })} /> diff --git a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx index 922b1a1f..14f5f4bf 100644 --- a/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ConsignmentSubmissionsPanel.tsx @@ -26,6 +26,7 @@ import { App, } from 'antd' import PercentTable from '@/components/common/PercentTable' +import { buildAdminTablePagination } from '@/utils/admin-pagination' import type { TableColumnsType } from 'antd' import { useState } from 'react' @@ -474,17 +475,16 @@ export default function ConsignmentSubmissionsPanel() { dataSource={submissions} loading={submissionsQuery.isLoading} scroll={{ x: 1490 }} - pagination={{ + pagination={buildAdminTablePagination({ current: submissionPage, pageSize: submissionPageSize, total: submissionTotal, - showSizeChanger: true, showTotal: (t) => `共 ${t} 条提报记录`, onChange: (p, ps) => { setSubmissionPage(p) setSubmissionPageSize(ps) }, - }} + })} /> diff --git a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx index 29ea4fd3..8028a5f7 100644 --- a/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductMatchPanel.tsx @@ -44,7 +44,11 @@ import { import type { KuaishouMatchSource, WorkProductMatchLog } from '@/types/worker-platform' import type { AdminKuaishouIndustryShopOption } from '@/types/admin' import { formatAdminDateTime } from '@/utils/admin-time' -import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination' +import { + ADMIN_DEFAULT_PAGE_SIZE, + buildAdminLocalTablePagination, + buildAdminTablePagination, +} from '@/utils/admin-pagination' import { formatMoney } from './shared' export default function ProductMatchPanel() { @@ -366,7 +370,7 @@ export default function ProductMatchPanel() { loading={sourcesQuery.isLoading} columns={sourceColumns} dataSource={sources} - pagination={{ pageSize: 10, showSizeChanger: false }} + pagination={buildAdminLocalTablePagination()} size="small" /> diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index c3444218..9d6c346a 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -18,7 +18,6 @@ import { Form, Input, InputNumber, - Modal, Popconfirm, Radio, Select, @@ -42,7 +41,6 @@ import { fetchAdminWorkProductRules, reprocessAdminKuaishouWorkOrderMatches, saveAdminWorkProductRule, - syncAdminWorkProductRulePrice, } from '@/services/admin' import type { CollectField, @@ -73,7 +71,6 @@ export default function ProductRulesPanel() { const hasInitializedRuleSelection = useRef(false) const [editingRule, setEditingRule] = useState(null) - const [syncingPrice, setSyncingPrice] = useState(false) const [exportingRules, setExportingRules] = useState(false) const [activeMappingId, setActiveMappingId] = useState(null) const [sourcePrefill, setSourcePrefill] = useState(null) @@ -296,9 +293,7 @@ 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, + pricingMode: rule.pricingMode || 'fixed', sharingEnabled: rule.sharing?.enabled || false, sharingAutoFromOrder: rule.sharing?.autoFromOrder || false, sharingTotalQuantity: hasManualSharingOverride ? rule.sharing?.totalQuantity : undefined, @@ -342,9 +337,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 @@ -358,10 +350,10 @@ export default function ProductRulesPanel() { fieldsText?: string enabled?: boolean autoCreate?: boolean + pricingMode?: 'fixed' | 'unit' sortOrder?: number }) { try { - const unitMode = values.pricingMode === 'unit' const isFriendGift = values.acceptanceMode === 'friend_gift' if (isFriendGift && values.sharingEnabled === true) { message.error('好友赠送验收模式不支持拼单,请先关闭拼单') @@ -372,12 +364,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 +376,16 @@ export default function ProductRulesPanel() { isFriendGift && giftCooldownUnit === 'minutes' ? Number(giftCooldownValue || 0) : 0, sharingEnabled: isFriendGift ? false : values.sharingEnabled === true, sharingAutoFromOrder: - unitMode && values.sharingEnabled === true && !hasManualSharingOverride, + values.pricingMode === 'unit' && + 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,11 +420,12 @@ export default function ProductRulesPanel() { platform: rule.platform, productName: rule.productName, categoryId: rule.categoryId || undefined, - rewardAmount: rule.rewardAmount / 100, - unitPrice: rule.unitPriceFen / 100, + 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 @@ -473,6 +444,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 : '修改状态失败') } @@ -782,19 +756,22 @@ export default function ProductRulesPanel() { }, }, { - title: '接单金额', + title: '接单价格', key: 'reward', width: '20%', render: (_, row) => (
- - {row.sharing?.autoFromOrder ? '随订单计算' : formatMoney(row.rewardAmount)} - - {row.unitPriceFen > 0 ? ( - - 按件: {formatMoney(row.unitPriceFen)} + {(row.pricingMode === 'unit' ? row.unitPriceFen : row.rewardAmount) > 0 ? ( + + {row.pricingMode === 'unit' + ? `${formatMoney(row.unitPriceFen)} / 件` + : formatMoney(row.rewardAmount)} - ) : null} + ) : ( + + 待成本配置 + + )}
), }, @@ -891,11 +868,6 @@ export default function ProductRulesPanel() { + 新建模板 ) : null} - {editingRule ? ( - - ) : null} @@ -913,10 +885,9 @@ export default function ProductRulesPanel() { matchType: 'contains', itemTitleMatchType: 'exact', skuNickMatchType: 'exact', - pricingMode: 'fixed', - rewardAmount: 25, - enabled: true, + enabled: false, autoCreate: true, + pricingMode: 'fixed', sharingEnabled: false, acceptanceMode: 'standard', giftCooldownUnit: 'hours', @@ -926,23 +897,21 @@ export default function ProductRulesPanel() { }} onFinish={saveRule} > - {editingRule ? ( - - 接单单价由商品与成本中心维护并提供同步发单功能。在此处价格为只读展示;如需改价或同步工单价格,请前往 - - 【商品与成本中心】 - - 进行修改。 - - } - /> - ) : null} + + 在这里选择“固定金额”或“按 SKU 按件”;具体金额与价格同步统一由 + + 商品与成本中心 + + 管理。新模板默认停用,配置成本并启用模板后自动出现在商品目录。 + + } + /> {/* 基础信息 */}
@@ -965,58 +934,27 @@ export default function ProductRulesPanel() {
- {/* Row 2: 计价方式 + 单价/金额 + 任务时限 */} + {/* Row 2: 任务时限 */}
- + + + + - - - {({ getFieldValue }) => - getFieldValue('pricingMode') === 'unit' ? ( - - - - ) : ( - - - - ) - } - - - - -
{/* 验收模式:好友赠送模式与拼单互斥 */} @@ -1126,11 +1064,19 @@ export default function ProductRulesPanel() { const sharingEnabled = getFieldValue('sharingEnabled') const unitMode = getFieldValue('pricingMode') === 'unit' const friendGiftMode = getFieldValue('acceptanceMode') === 'friend_gift' + const hasConfiguredPrice = + Number(editingRule?.unitPriceFen || 0) > 0 || + Number(editingRule?.rewardAmount || 0) > 0 return (
- - + + @@ -1139,9 +1085,15 @@ export default function ProductRulesPanel() { label="启用拼单" name="sharingEnabled" valuePropName="checked" - tooltip={friendGiftMode ? '好友赠送模式不支持拼单' : undefined} + tooltip={ + friendGiftMode + ? '好友赠送模式不支持拼单' + : !hasConfiguredPrice + ? '请先在商品与成本中心配置成本' + : undefined + } > - + {sharingEnabled ? ( 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/cost-center.ts b/apps/frontend/src/services/admin/cost-center.ts index 4c4149b8..9e780390 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 @@ -107,6 +96,32 @@ export function createAdminProductCatalog(payload: Record) { return apiPost<{ product: ProductCatalogItem }>('/api/v1/admin/cost-center/products', payload) } +export function archiveAdminHistoricalManualProducts(payload: { execute?: boolean } = {}) { + return apiPost<{ + dryRun: boolean + candidateCount: number + archivedCount: number + sample: Array<{ id: number; canonical_sku: string; canonical_name: string }> + }>('/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', @@ -494,6 +509,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/fulfillment-channel.ts b/apps/frontend/src/services/admin/fulfillment-channel.ts index 3ae7be53..a55095af 100644 --- a/apps/frontend/src/services/admin/fulfillment-channel.ts +++ b/apps/frontend/src/services/admin/fulfillment-channel.ts @@ -24,15 +24,20 @@ export type PendingChannelGroupListResult = { /** 渠道规则(L1)。 */ export type ChannelRule = { id: number - provider: string - shop_id: string - match_key_type: 'sku_code' | 'internal_sku' | 'sku_name' + match_key_type: 'internal_sku' match_key: string channel: 'work_order' | 'affiliate_dash' | 'third_party_voucher' | 'manual' priority: number enabled: boolean } +export type ChannelRuleListResult = { + items: ChannelRule[] + total: number + page: number + pageSize: number +} + export type ChannelException = { id: number reason: string @@ -100,7 +105,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,17 +116,32 @@ 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 } = {}) { - return apiGet('/api/v1/admin/fulfillment/rules', params) +export function switchChannelForItems(payload: { + orderItemIds: number[] + channel: string + note?: string +}) { + return apiPost<{ + switched: number + failed: number + errors: Array<{ orderItemId: number; reason: string }> + }>('/api/v1/admin/fulfillment/switch', payload) +} + +export function fetchChannelRules( + params: { channel?: string; page?: number; pageSize?: number } = {}, +) { + return apiGet('/api/v1/admin/fulfillment/rules', params) } export function saveChannelRule(payload: { id?: number - provider?: string - shopId?: string matchKeyType: string matchKey: string channel: string @@ -133,6 +156,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') } diff --git a/apps/frontend/src/services/admin/worker-platform.ts b/apps/frontend/src/services/admin/worker-platform.ts index 66656210..865edd89 100644 --- a/apps/frontend/src/services/admin/worker-platform.ts +++ b/apps/frontend/src/services/admin/worker-platform.ts @@ -291,8 +291,7 @@ export function saveAdminWorkProductRule(payload: { categoryId?: number enabled?: boolean autoCreate?: boolean - rewardAmount?: number - unitPrice?: number + pricingMode?: 'fixed' | 'unit' sharingEnabled?: boolean sharingAutoFromOrder?: boolean sharingTotalQuantity?: number @@ -306,7 +305,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..2f3c4cfd 100644 --- a/apps/frontend/src/types/admin/cost-center.ts +++ b/apps/frontend/src/types/admin/cost-center.ts @@ -51,6 +51,10 @@ export interface ProductCatalogItem { costVersionCount: number activeCostAmountFen?: number | null activeCostEffectiveFrom?: string | null + templatePricingMode?: 'fixed' | 'unit' | null + templateEnabled?: boolean | null + templateSharingEnabled?: boolean | null + activePrimaryChannel?: FulfillmentChannel | null /** 自动发货履约任务数,实时取自 fulfillment_tasks。 */ affiliateTaskCount: number affiliateLastFulfilledAt: string | null @@ -231,9 +235,6 @@ export interface TemplateProductBootstrapResult { resetOrderItemCount: number mappingConflictCount: number mappingConflictItemCount: number - templateCostVersionCount: number - templateCostCreatedCount: number - templateCostRetiredCount: number } export interface CostLine { diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index 585cac1a..4180cf9f 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[] 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/履约与财务分流统一方案-2026-11-02.md b/docs/履约与财务分流统一方案-2026-11-02.md index ba8fd651..be60cc23 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,28 @@ 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` 只作为 119 升级迁移的输入,迁移后不再新增或修改。 +6. **人工配置即时入表**:保存/停用人工 SKU 配置会同步 `internal_sku_channels`,历史配置由迁移 120 回填。 +7. **旧规则自动迁移**:可确定的 `sku_code` 规则会同时补 `internal_sku_mappings`、渠道配置和 `config_json`,随后停用旧规则;无法唯一解析的规则由严格预检阻断。 +8. **同名 man/tpl 不强制合并**:保留不同载体能力,但同一内部 SKU 只能配置一个主渠道,其余显式兜底。 +9. **渠道切换有明确状态**:未进入实际履约的旧任务/工单会关闭、领取链接失效、旧成本快照作废,再创建新载体;已进入实际交付或已成功的订单拒绝自动切换。 + +### `config_json` 约定 + +- `affiliate_dash`:`affiliateSku`; +- `work_order`:`workRuleKey`; +- `manual`:`manualConfigId`、`platformKey` 等人工配置引用; +- `third_party_voucher`:`voucherSku`; +- 其他审计字段只追加,不覆盖已有对象键。 + +### 数据与回填约定 + +- 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/履约配置/多发货平台与电子凭证整体链路.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/成本中心与履约模型.md b/docs/成本中心与履约模型.md index 233357dc..8bc65685 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 元占位 | @@ -388,6 +395,7 @@ fulfillment_decided_at / fulfillment_reason 4. **商品来源不能反推渠道**:`man:` 商品也可能由自动发货履约,判定只认规则与证据。 5. `voucher_state` 只做展示与核销流程,不参与任何成本/履约判断。 6. 多渠道证据同时命中必须先告警人工复核,不能静默取其一。 +7. **旧人工来源清理只归档**:同名内部 SKU 已有其他非兜底主渠道时,旧 `man:` 商品从当前目录归档;历史订单、成本版本、成本快照和事实数据保持不动,人工兜底改指当前主商品。 --- 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-11-02.md b/docs/生产部署核对清单-2026-11-02.md index 350dc0f7..16148048 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,9 @@ 117_internal_sku_mappings 118_internal_sku_channels 119_fulfillment_pending_reason +120_fulfillment_routing_invariants +121_internal_sku_single_primary +122_work_order_template_product_projection ``` 重点检查: @@ -32,10 +35,14 @@ - 118:按商品能力/规则回填 `internal_sku_channels`; - SQL 已做聚合,避免同名商品同渠道 `ON CONFLICT` 二次更新报错; - 119:只加列和索引,风险低。 +- 120:建立统一内部 SKU SQL 函数、回填人工配置、迁移可确定的旧 sku_code 规则。 +- 121:建立唯一主渠道索引;若仍有并列主渠道会主动阻断迁移。 +- 122:按接单模板补齐/刷新 `tpl:` 商品,只做 UPSERT,不删除商品、成本、订单或工单。 +- SQL 护栏默认只保护到生产已发布的迁移 119;部署 120 后应把 `PUBLISHED_MIGRATION_MAX_VERSION` 提升为 120。 ## 3. 部署顺序 -1. 执行迁移 116–119; +1. 执行迁移 116–122; 2. 部署 backend; 3. 部署 frontend; 4. 启动/恢复定时任务。 @@ -56,6 +63,7 @@ GROUP BY 1; - `no_internal_sku` - `no_channel_capability` - `no_carrier` +- `routing_conflict`(如仍有配置冲突,必须处理) ### 11月改名卡 @@ -110,6 +118,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 +163,6 @@ npx tsx scripts/reclassify-post-cutover.ts --apply - 代码:回退到部署前镜像/commit; - 数据库: - - 116–119 均为新增表/列/数据,不删旧表,可保留; + - 116–122 均为新增表/列/函数/投影数据,不删旧表,可保留; - 若必须回滚,恢复到部署前备份; -- 迁移 118/119 不改旧字段语义,回滚代码后旧逻辑仍可运行。 +- 迁移 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. 财务同步 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` | 待补全订单资料收集(**仅凭订单号,无第二因子**) |