维护:增加商品路由审计并归档孤儿人工商品
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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())
|
||||
@@ -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 商品只归档不删除';
|
||||
+12
-2
@@ -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 个已分配人工订单项没有启用的人工履约配置。
|
||||
|
||||
验收:
|
||||
|
||||
|
||||
Reference in New Issue
Block a user