diff --git a/apps/backend/package.json b/apps/backend/package.json index c3232050..fd5fd577 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -33,6 +33,7 @@ "test:industry:gen": "tsx scripts/gen-test-cases.ts", "test:industry:curl": "tsx scripts/curl-send-callback.ts", "audit:fulfillment": "tsx scripts/audit-fulfillment-state.ts", + "audit:product-routing": "tsx scripts/audit-product-routing-alignment.ts", "typecheck": "tsc -p tsconfig.json --noEmit", "check": "npm run format:check && npm run check:sql && npm run lint:check && npm run typecheck && npm test && npm run build", "start": "node dist/index.js", diff --git a/apps/backend/scripts/audit-product-routing-alignment.ts b/apps/backend/scripts/audit-product-routing-alignment.ts new file mode 100644 index 00000000..f1a83ca5 --- /dev/null +++ b/apps/backend/scripts/audit-product-routing-alignment.ts @@ -0,0 +1,183 @@ +#!/usr/bin/env tsx +/** + * 商品来源、内部 SKU 与履约路由只读审计。 + * + * 用法: + * npm run audit:product-routing + * npm run audit:product-routing -- --strict + */ +import { closeDb, query } from '../src/db/client.js' + +const strict = process.argv.includes('--strict') + +async function main() { + const [ + sourceChannel, + routeCounts, + manualConfigRoutes, + orphanManualProducts, + multiplePrimaryRoutes, + missingInternalSku, + invalidSourceKinds, + ] = await Promise.all([ + query<{ source_kind: string; primary_channel: string | null; total: number }>( + ` + SELECT source_kind, active_primary_channel AS primary_channel, COUNT(*)::int AS total + FROM v_product_current + WHERE status = 'active' + GROUP BY 1, 2 + ORDER BY 1, 2 NULLS FIRST + `, + ), + query<{ channel: string; route_role: string; total: number }>( + ` + SELECT channel, route_role, COUNT(*)::int AS total + FROM sku_channel_routes + WHERE enabled = TRUE + GROUP BY 1, 2 + ORDER BY 1, 2 + `, + ), + query<{ + route_state: string + total: number + }>( + ` + SELECT + CASE + WHEN primary_route.channel = 'manual' THEN 'primary_manual' + WHEN manual_route.route_role = 'fallback' THEN 'manual_fallback' + ELSE 'missing_manual_route' + END AS route_state, + COUNT(*)::int AS total + FROM manual_fulfillment_configs config + LEFT JOIN LATERAL ( + SELECT route.channel + FROM sku_channel_routes route + WHERE route.internal_sku_id = config.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ORDER BY route.priority, route.channel + LIMIT 1 + ) primary_route ON TRUE + LEFT JOIN sku_channel_routes manual_route + ON manual_route.internal_sku_id = config.internal_sku_id + AND manual_route.channel = 'manual' + AND manual_route.enabled = TRUE + WHERE config.enabled = TRUE + GROUP BY 1 + ORDER BY 1 + `, + ), + query<{ + product_id: number + canonical_sku: string + canonical_name: string + }>( + ` + SELECT product.id AS product_id, + product.canonical_sku, + product.canonical_name + FROM product_catalog product + WHERE product.status = 'active' + AND product.source_kind = 'manual' + AND NOT EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ) + AND NOT EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR config.internal_sku_id = product.internal_sku_id + OR canonicalize_internal_sku(config.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)) + ) + ) + ORDER BY product.id + `, + ), + query<{ internal_sku_id: number; total: number }>( + ` + SELECT internal_sku_id, COUNT(*)::int AS total + FROM sku_channel_routes + WHERE enabled = TRUE AND route_role = 'primary' + GROUP BY 1 + HAVING COUNT(*) > 1 + ORDER BY 2 DESC, 1 + `, + ), + query<{ source_kind: string; total: number }>( + ` + SELECT source_kind, COUNT(*)::int AS total + FROM product_catalog + WHERE status = 'active' + AND source_kind IN ('manual', 'third_party_voucher') + AND internal_sku_id IS NULL + GROUP BY 1 + ORDER BY 1 + `, + ), + query<{ source_kind: string; total: number }>( + ` + SELECT source_kind, COUNT(*)::int AS total + FROM product_catalog + WHERE status = 'active' + AND source_kind NOT IN ( + 'affiliate_dash', + 'work_order', + 'third_party_voucher', + 'manual', + 'unclassified' + ) + GROUP BY 1 + ORDER BY 1 + `, + ), + ]) + + const critical = { + orphanManualProducts: orphanManualProducts.rows.length, + multiplePrimaryRoutes: multiplePrimaryRoutes.rows.length, + missingInternalSku: missingInternalSku.rows.reduce((sum, row) => sum + Number(row.total), 0), + invalidSourceKinds: invalidSourceKinds.rows.reduce((sum, row) => sum + Number(row.total), 0), + } + + console.info('[product-routing-audit] 商品来源 × 当前渠道') + console.table(sourceChannel.rows) + + console.info('[product-routing-audit] 路由分布') + console.table(routeCounts.rows) + + console.info('[product-routing-audit] 人工配置路由状态') + console.table(manualConfigRoutes.rows) + + console.info('[product-routing-audit] 无主路由且无人工配置的 man 商品') + console.table(orphanManualProducts.rows) + + console.info('[product-routing-audit] 多主路由内部 SKU') + console.table(multiplePrimaryRoutes.rows) + + console.info('[product-routing-audit] 缺少内部 SKU 的商品') + console.table(missingInternalSku.rows) + + console.info('[product-routing-audit] 非法商品来源') + console.table(invalidSourceKinds.rows) + + console.info('[product-routing-audit] 关键异常', critical) + if (strict && Object.values(critical).some((count) => count > 0)) { + process.exitCode = 1 + } +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(() => closeDb()) diff --git a/apps/backend/src/db/migrations/159_archive_orphan_manual_products.sql b/apps/backend/src/db/migrations/159_archive_orphan_manual_products.sql new file mode 100644 index 00000000..bac427f1 --- /dev/null +++ b/apps/backend/src/db/migrations/159_archive_orphan_manual_products.sql @@ -0,0 +1,49 @@ +-- 159_archive_orphan_manual_products.sql +-- +-- man: 只表示来源,不能证明当前可履约。 +-- 没有主路由、没有启用人工配置、也没有启用商品映射的 man 商品属于孤儿商品, +-- 只归档,不删除;历史订单、成本版本和成本快照继续保留。 + +WITH candidates AS ( + SELECT product.id + FROM product_catalog product + WHERE product.status = 'active' + AND product.source_kind = 'manual' + AND NOT EXISTS ( + SELECT 1 + FROM sku_channel_routes route + WHERE route.internal_sku_id = product.internal_sku_id + AND route.enabled = TRUE + AND route.route_role = 'primary' + ) + AND NOT EXISTS ( + SELECT 1 + FROM manual_fulfillment_configs config + WHERE config.enabled = TRUE + AND ( + config.canonical_product_id = product.id + OR config.internal_sku_id = product.internal_sku_id + OR canonicalize_internal_sku(config.sku_key) + = canonicalize_internal_sku(substring(product.canonical_sku from 5)) + ) + ) + AND NOT EXISTS ( + SELECT 1 + FROM channel_product_mappings mapping + WHERE mapping.canonical_product_id = product.id + AND mapping.enabled = TRUE + ) +) +UPDATE product_catalog product +SET status = 'archived', + attributes_json = COALESCE(product.attributes_json, '{}'::jsonb) || jsonb_build_object( + 'legacyManualArchived', TRUE, + 'legacyManualArchiveReason', 'no_primary_route_no_config_no_mapping', + 'legacyManualArchivedAt', NOW() + ), + updated_at = NOW() +FROM candidates +WHERE product.id = candidates.id; + +COMMENT ON COLUMN product_catalog.source_kind IS + '标准商品来源类型;来源不等于当前履约渠道,孤儿 man 商品只归档不删除'; diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index 0a2066f1..62b5f802 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -12,7 +12,7 @@ updated_at: 2026-11-08 > 状态:**唯一权威文档**。 > 最后校准:2026-11-08。 > 生产基线:迁移 `146`。 -> 开发基线:迁移 `158`(仅本地,尚未部署生产)。 +> 开发基线:迁移 `159`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -311,7 +311,7 @@ finance_journal_* -- 总账凭证,可重建 2026-11-08 生产只读核对结果: -- 生产迁移 `146`,本地开发基线迁移 `158`。 +- 生产迁移 `146`,本地开发基线迁移 `159`。 - 启用标准商品 `1127`:`aff:` 30、`tpl:` 903、`tpv:` 46、`man:` 148。 - `man:` 商品中只有 105 个当前主渠道为人工;41 个已走其他主渠道,2 个无主渠道。 - 人工页头部显示 `107`,商品列表显示 `148`,相差的 41 个就是跨渠道旧商品。 @@ -574,8 +574,18 @@ assigned 且 carrier_status=missing 必须进入异常队列 - 迁移 `156`:修复 `source_kind` 默认值导致的前缀回填失效。 - 迁移 `157`:保证只有兜底配置的 SKU 不会被误升级成主渠道。 - 迁移 `158`:禁止 `man:` 来源直接冒充人工主渠道;没有 primary route 时显示“未路由”。 +- 迁移 `159`:只归档无主路由、无人工配置、无启用映射的孤儿 `man:` 商品。 - 商品列表、总览、渠道映射和商品下拉统一读取商品当前渠道。 - 运行期代码停止写入 `product_channel_capabilities`,该表仅保留到兼容期结束。 +- 只读审计:`npm run audit:product-routing -- --strict`。 +- 本地严格审计关键异常归零:孤儿人工商品、多主路由、`man/tpv` 缺内部 SKU、非法来源。 +- 15 个自动/卡券/接单主渠道商品的人工配置均保留为显式 `fallback`,没有被误升为人工主渠道。 +- 1 个无主路由、无人工配置、无启用映射的 `man:` 商品已归档,未删除历史引用。 + +当前本地仍需业务处理的数据: + +- 1 个待分类订单项尚未命中内部 SKU 映射; +- 1 个已分配人工订单项没有启用的人工履约配置。 验收: