From e77f96ee3c31ff653aab34d02a9a4ed618d2019c Mon Sep 17 00:00:00 2001 From: yml Date: Sun, 15 Nov 2026 00:06:09 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B8=85=E7=90=86=E5=B1=A5=E7=BA=A6=E5=81=87?= =?UTF-8?q?=E8=8D=89=E7=A8=BF=E5=95=86=E5=93=81=E6=AE=8B=E7=95=99=E5=85=B3?= =?UTF-8?q?=E8=81=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...fillment_invalid_draft_product_cleanup.sql | 68 +++++++++++++++++++ ...-order-dynamic-routing.integration.test.ts | 24 +++++++ docs/统一订单履约与财务模型-权威版.md | 17 +++-- 3 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 apps/backend/src/db/migrations/167_fulfillment_invalid_draft_product_cleanup.sql diff --git a/apps/backend/src/db/migrations/167_fulfillment_invalid_draft_product_cleanup.sql b/apps/backend/src/db/migrations/167_fulfillment_invalid_draft_product_cleanup.sql new file mode 100644 index 00000000..ab13d563 --- /dev/null +++ b/apps/backend/src/db/migrations/167_fulfillment_invalid_draft_product_cleanup.sql @@ -0,0 +1,68 @@ +-- 167_fulfillment_invalid_draft_product_cleanup.sql +-- +-- 迁移 166 已停止生成并归档接单平台的 tpl:<内部SKU> 假草稿商品。 +-- 待判定履约记录仍可能保留这些商品的派生关联,需要在重试前清空, +-- 避免页面继续展示旧商品或后续成本归集到错误商品。 + +WITH invalid_products AS ( + SELECT product.id + FROM product_catalog product + WHERE product.source_kind = 'work_order' + AND product.canonical_sku LIKE 'tpl:%' + AND COALESCE(product.attributes_json ->> 'draft', 'false') = 'true' + AND NOT EXISTS ( + SELECT 1 + FROM work_product_rules rule + WHERE rule.canonical_product_id = product.id + ) +) +UPDATE fulfillments fulfillment +SET canonical_product_id = NULL, + updated_at = NOW() +FROM invalid_products invalid +WHERE fulfillment.canonical_product_id = invalid.id; + +CREATE OR REPLACE FUNCTION assert_fulfillment_product_not_invalid_draft() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.canonical_product_id IS NULL THEN + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM product_catalog product + WHERE product.id = NEW.canonical_product_id + AND product.source_kind = 'work_order' + AND product.canonical_sku LIKE 'tpl:%' + AND COALESCE(product.attributes_json ->> 'draft', 'false') = 'true' + AND NOT EXISTS ( + SELECT 1 + FROM work_product_rules rule + WHERE rule.canonical_product_id = product.id + ) + ) THEN + RAISE EXCEPTION + 'fulfillment % cannot reference invalid work-order draft product %', + NEW.order_item_id, + NEW.canonical_product_id + USING ERRCODE = '23503', + HINT = 'Attach the canonical product from the matched work_product_rule instead.'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_fulfillments_invalid_draft_product + ON fulfillments; +CREATE TRIGGER trg_fulfillments_invalid_draft_product +BEFORE INSERT OR UPDATE OF canonical_product_id +ON fulfillments +FOR EACH ROW +EXECUTE FUNCTION assert_fulfillment_product_not_invalid_draft(); + +COMMENT ON FUNCTION assert_fulfillment_product_not_invalid_draft() IS + '履约商品不能指向接单平台历史伪造的 tpl 草稿商品;必须使用真实接单模板商品'; diff --git a/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts b/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts index 88b33c41..9c67a285 100644 --- a/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts +++ b/apps/backend/src/services/fulfillment/work-order-dynamic-routing.integration.test.ts @@ -281,6 +281,30 @@ test( finalFulfillments.rows.every((row) => row.issue_code === '' && row.pending_reason === null), ) + const invalidDraftProduct = await query<{ id: number }>( + `INSERT INTO product_catalog ( + canonical_sku, canonical_name, category, unit_name, source_kind, status, + attributes_json, created_at, updated_at + ) VALUES ( + $1, '历史假草稿商品', '接单测试', '件', 'work_order', 'archived', + '{"draft":true}'::jsonb, NOW(), NOW() + ) RETURNING id`, + [`tpl:invalid-${suffix}`], + ) + const invalidDraftProductId = Number(invalidDraftProduct.rows[0]!.id) + productIds.push(invalidDraftProductId) + await assert.rejects( + () => + query( + `UPDATE fulfillments + SET canonical_product_id = $2, + updated_at = NOW() + WHERE order_item_id = $1`, + [orderItemIds[0], invalidDraftProductId], + ), + /invalid work-order draft product/, + ) + await assert.rejects( () => query( diff --git a/docs/统一订单履约与财务模型-权威版.md b/docs/统一订单履约与财务模型-权威版.md index b22a63aa..f9b5a406 100644 --- a/docs/统一订单履约与财务模型-权威版.md +++ b/docs/统一订单履约与财务模型-权威版.md @@ -3,16 +3,16 @@ type: 权威架构与实施计划 description: 订单、内部 SKU、履约路由、履约执行、商品、成本与财务的唯一权威口径及重构计划。 tags: [订单, 内部SKU, 履约, 商品, 成本, 财务, 迁移] status: authoritative -version: 2.22 -updated_at: 2026-11-14 +version: 2.23 +updated_at: 2026-11-15 --- # 统一订单、履约与财务模型 > 状态:**唯一权威文档**。 -> 最后校准:2026-11-14。 -> 生产基线:迁移 `165`。 -> 开发基线:迁移 `166`(仅本地,尚未部署生产)。 +> 最后校准:2026-11-15。 +> 生产基线:迁移 `166`。 +> 开发基线:迁移 `167`(仅本地,尚未部署生产)。 > > 从本文件生效起,订单、内部 SKU、履约渠道、履约载体、标准商品、成本和财务相关实现, > 只能以本文件为业务口径。其他旧分析只作为历史资料,不再具备设计或验收效力。 @@ -409,6 +409,8 @@ manual fulfillment - 统一渠道配置不得为接单平台创建 `tpl:<内部 SKU>` 假草稿商品。真实成本商品来自命中的模板, 工单创建后写入 `fulfillments.canonical_product_id`。 - 错误 key、历史草稿商品和错误履约商品关联由迁移 `166` 清理;历史订单、模板和成本事实不删除。 +- 迁移 `167` 清空仍指向历史假草稿商品的 `fulfillments.canonical_product_id`,并拒绝后续写入 + 未被真实接单模板引用的 `tpl:` 草稿商品;订单、工单、成本和资金事实不变。 ### 3.5 商品与财务域 @@ -1055,7 +1057,7 @@ docs/archive/ ## 十三、当前下一步 -截至 2026-11-14,生产基线为 `165`,集成分支开发基线为 `166`。迁移 `152` 保护订单来源快照, +截至 2026-11-15,生产基线为 `166`,集成分支开发基线为 `167`。迁移 `152` 保护订单来源快照, 迁移 `158–160` 收口寄卖登记人同步、寄卖平台与自动匹配设置; 迁移 `161` 一次建立人工渠道半自动任务、内置 Connector 默认值、账号级 Cookie JSON 凭证、 任务凭证引用和最终约束;迁移 `162` 建立履约待办四桶只读投影、补载体专用索引和新单优先扫描口径。 @@ -1067,12 +1069,13 @@ docs/archive/ 工单在发布时物化最新价格,避免每次改价扫描数万行。 迁移 `166` 让接单平台路由支持动态模板匹配,清理历史伪造 key 和草稿商品,并拒绝对不存在模板 的非空 `workRuleKey` 写入。 +迁移 `167` 清理待判定履约记录对历史假草稿商品的残留引用,并增加履约商品关联闸门。 迁移中保留的 `semi_auto.credential.export` 权限仅作兼容记录, 不再作为 CK 导出门禁。 1. 生产升级直接按 `deploy/` 脚本、实际迁移和停写流程执行,不再维护独立升级手册。 2. 升级前备份必须完成隔离恢复验证;恢复验证未通过时禁止迁移。 -3. `165 → 166` 迁移后刷新统计并完成商品、履约、 +3. `166 → 167` 迁移后刷新统计并完成商品、履约、 财务和大厅价格审计;无法确定的数据进入未路由或待分类,不做猜测。 4. 执行 `finance:rebuild-derived` 的 dry-run,核对 A 级数据守恒后, 再按窗口执行 `--execute --confirm=REBUILD_DERIVED`,并验证第二次重建幂等。