Bläddra i källkod

feat(mdp): complete S8 stage-3 procurement identity bridge

YY968XX 6 dagar sedan
förälder
incheckning
c473ee7351

+ 12 - 3
server/Admin.NET.Web.Entry/Admin.NET.Web.Entry.csproj

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.538</AssemblyVersion>
-    <FileVersion>1.0.538</FileVersion>
-    <Version>1.0.538</Version>
+    <AssemblyVersion>1.0.536</AssemblyVersion>
+    <FileVersion>1.0.536</FileVersion>
+    <Version>1.0.536</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -847,6 +847,15 @@
     <None Update="UpdateScripts\1.0.538.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.539.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.539.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.539.warnings.ops.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 179 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.539.sql

@@ -0,0 +1,179 @@
+-- =====================================================================================
+-- 1.0.539 · S8 Stage-3 采购订单身份桥(Order Identity Propagation → MDP)
+--
+-- ── 背景 ────────────────────────────────────────────────────────────────────────────
+-- 采购的订单归因链在【源侧】已实证成立,但走的不是 PurOrdDetail.WorkOrd,而是 Req:
+--   PurOrdDetail.Req → srm_pr_main.pr_billno → srm_pr_main.pr_mono
+--     → WorkOrdMaster.WorkOrd → WorkOrdMaster.BusinessID (= crm_seorderentry.Id)
+--       → crm_seorderentry.seorder_id → crm_seorder.Id
+--   实测:PurOrdDetail.Req 207/207 命中 pr_billno;89 条 PO 行归属 13 张真实销售订单。
+--
+-- 但标准层缺两段投影,导致 S8 无法在「只读中台」前提下完成同样归因:
+--   · mdp_std_purchase_request 没有 pr_mono / 订单行 Id
+--   · mdp_std_purchase_order   没有 Req
+--
+-- 本脚本补这两段,使下面这条链可以【全程只读 mdp_*】跑通:
+--   mdp_std_purchase_receipt(ord_nbr, ord_line)
+--     → mdp_std_purchase_order(po_no, po_line).purchase_request_no
+--       → mdp_std_purchase_request(pr_no).sales_order_entry_id
+--         → mdp_std_so(order_entry_id) → order_id
+--
+-- ── 为什么不回填源表 srm_pr_main.sentry_id ──────────────────────────────────────────
+-- 该字段 schema 存在但源侧 1012/1012 全为 NULL(贴源层亦 0 命中),说明生产代码从未写它。
+-- 本批改为在标准层用 pr_mono → mdp_std_work_order_schedule.sales_order_entry_id 派生,
+-- 因此【不需要写任何业务源表】。源侧补写 sentry_id 属独立的数据治理批次。
+--
+-- ── 本脚本不做 ──────────────────────────────────────────────────────────────────────
+-- 不建 dwd_procurement_kpi;不加 S3_PROCUREMENT_COMPLETE;不写采购完成时间;
+-- 不修 239 个 BusinessID=0 的历史/UAT 导入工单(登记为 DATA_QUALITY_KNOWN)。
+-- =====================================================================================
+
+-- ---------------------------------------------------------------------------
+-- ① mdp_std_purchase_request 补订单身份
+--    work_order            = srm_pr_main.pr_mono(PR 所属工单号)
+--    sales_order_entry_id  = 经 mdp_std_work_order_schedule 派生的订单行 Id
+--    ⚠️ 归因 Grain 必须保持 PR 行级:同一物料可跨多工单共享采购(实测 152 个物料跨工单),
+--       但一条 PR 恒属单一工单(实测 pr_multi_wo=0),因此行级归因不会把共享采购
+--       错并给某一张订单。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+       AND `COLUMN_NAME` = 'work_order'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_request` ADD COLUMN `work_order` VARCHAR(64) NULL COMMENT ''PR 所属工单号(源 srm_pr_main.pr_mono);字面量 null 已归一为 NULL'' AFTER `pr_line`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+       AND `COLUMN_NAME` = 'sales_order_entry_id'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_request` ADD COLUMN `sales_order_entry_id` BIGINT NULL COMMENT ''销售订单明细行Id = crm_seorderentry.Id;经 work_order 从 mdp_std_work_order_schedule 派生,未知时为 NULL'' AFTER `work_order`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+       AND `INDEX_NAME` = 'idx_std_pr_entry'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_request` ADD INDEX `idx_std_pr_entry` (`tenant_id`, `sales_order_entry_id`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+       AND `INDEX_NAME` = 'idx_std_pr_wo'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_request` ADD INDEX `idx_std_pr_wo` (`tenant_id`, `work_order`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ② mdp_std_purchase_order 补 PR 指针
+--    purchase_request_no = PurOrdDetail.Req(PO 行 → PR 的 1:1 Authority)
+--    ⚠️ 这才是 Stage-3 的主归因列。既有 work_order 列【不可】作主归因:
+--       它只在历史/UAT 批量导入的工单上有值,且 451 行中 208 行是字面量 'null'。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_order'
+       AND `COLUMN_NAME` = 'purchase_request_no'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_order` ADD COLUMN `purchase_request_no` VARCHAR(64) NULL COMMENT ''采购申请单号(源 PurOrdDetail.Req)= mdp_std_purchase_request.pr_no;Stage-3 订单归因主键路径'' AFTER `po_line`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_order'
+       AND `INDEX_NAME` = 'idx_std_po_req'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_order` ADD INDEX `idx_std_po_req` (`tenant_id`, `purchase_request_no`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ③ mdp_std_purchase_receipt 补桥索引
+--    收货 → PO 的键已存在(ord_nbr / ord_line,源 PurOrdRctDetail.OrdNbr/OrdLine,
+--    源侧实测 245/245 有值、240 命中 PurOrdDetail(PurOrd,Line)),只缺索引。
+--    收货侧 raw_data 无 Req,故收货必须经 PO 才能到 PR,不可直连。
+-- ---------------------------------------------------------------------------
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`STATISTICS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_receipt'
+       AND `INDEX_NAME` = 'idx_std_rct_po'),
+  'SELECT 1',
+  'ALTER TABLE `mdp_std_purchase_receipt` ADD INDEX `idx_std_rct_po` (`tenant_id`, `ord_nbr`, `ord_line`)'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;
+
+-- ---------------------------------------------------------------------------
+-- ④ 清掉标准层的 tenant_id=0 遗留行(租户隔离缺陷)
+--    实测:mdp_std_purchase_request 224 行、mdp_std_purchase_order 2 行 tenant_id=0,
+--    全部来自单一历史批次 S3_MDP_FULL_20260810023343(2026-08-10,批次号无租户段,
+--    属租户化改造前的旧格式)。贴源层 mdp_stg_supply_demand 侧 tenant_id=0 为 0 行,
+--    故本次清理后正常跑批不会重新产生。
+--    新的 Stage-3 身份桥绝不能建立在 tenant=0 的数据上 —— 无法确定租户的行一律删除,
+--    不猜测、不补默认租户。同类先例:1.0.355 / 1.0.356 对 mdp_std_so 的处理。
+-- ---------------------------------------------------------------------------
+DELETE FROM `mdp_std_purchase_request` WHERE `tenant_id` IS NULL OR `tenant_id` <= 0;
+DELETE FROM `mdp_std_purchase_order`   WHERE `tenant_id` IS NULL OR `tenant_id` <= 0;
+DELETE FROM `mdp_std_purchase_receipt` WHERE `tenant_id` IS NULL OR `tenant_id` <= 0;
+
+-- ---------------------------------------------------------------------------
+-- ⑤ 归一既有 work_order 的字面量 'null'
+--    实测 451 行里 208 行存的是 4 字符字符串 "null"(JSON_UNQUOTE 对 JSON null
+--    的既有行为)。留着它,任何按 work_order 的 JOIN 都会去匹配一个叫 "null"
+--    的工单号并互相串起来。本列改造后只作参考、不作归因,但仍必须归一。
+-- ---------------------------------------------------------------------------
+UPDATE `mdp_std_purchase_order` SET `work_order` = NULL WHERE `work_order` IN ('null', '');
+
+-- ---------------------------------------------------------------------------
+-- ⑥ 一次性身份回填(全程只读 mdp_stg_* / mdp_std_*,不读任何源业务表)
+--
+--    为什么要在迁移里回填,而不是等下一次跑批:
+--      · PR 侧转换不按批次收窄,下次跑批会自然覆盖全量;
+--      · 但 PO 侧转换按 `sync_batch_id=@BatchId` 收窄 + 每轮淘汰,
+--        新列要等该租户下一次真实拉取才会被填上。
+--      两者节奏不一致会让身份链在「已建列但半空」的状态里停留不确定的时间。
+--      这里用贴源层已经落地的数据把三列一次性补齐,使身份链在迁移完成那一刻
+--      即可被消费,且与转换逻辑同口径(同样的 JSON 路径、同样的 'null' 归一)。
+--
+--    回填只写 mdp 标准层的三个身份列,不写任何业务字段、不造任何业务事实,
+--    不涉及采购完成时间 / Stage-3 事件 / ActualHours。
+--
+--    去重取 MAX:贴源层同时存在两代 source_biz_key(旧 `Domain:PurOrd:Line` 与
+--    新的裸 RecID),同一逻辑行可能有多条贴源记录。按 (租户, source_biz_key)
+--    聚合可保证结果确定,不依赖 MySQL 任取一行。
+-- ---------------------------------------------------------------------------
+
+-- ⑥-1 PR → 工单号(源 srm_pr_main.pr_mono)
+UPDATE `mdp_std_purchase_request` p
+  JOIN ( SELECT g.`tenant_id`, g.`source_biz_key`,
+                MAX(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.pr_mono')), 'null'), '')) AS `wo`
+           FROM `mdp_stg_supply_demand` g
+          WHERE g.`source_table` = 'srm_pr_main' AND g.`tenant_id` > 0
+          GROUP BY g.`tenant_id`, g.`source_biz_key` ) s
+    ON s.`tenant_id` = p.`tenant_id` AND s.`source_biz_key` = p.`source_biz_key`
+   SET p.`work_order` = s.`wo`
+ WHERE p.`tenant_id` > 0;
+
+-- ⑥-2 PR → 销售订单明细行 Id(经 1.0.532 建立的 S2 工单桥派生)
+--      同租户同工单号在 mdp_std_work_order_schedule 内唯一(实测重复组 = 0),
+--      故这里不会扇出、不需要再取一行。
+--      未被 S2 跑批覆盖的工单保持 NULL —— 宁可为空,不猜订单。
+UPDATE `mdp_std_purchase_request` p
+  JOIN `mdp_std_work_order_schedule` w
+    ON w.`tenant_id` = p.`tenant_id` AND w.`work_order` = p.`work_order`
+   SET p.`sales_order_entry_id` = w.`sales_order_entry_id`
+ WHERE p.`tenant_id` > 0
+   AND p.`work_order` IS NOT NULL
+   AND w.`sales_order_entry_id` IS NOT NULL;
+
+-- ⑥-3 PO → 采购申请单号(源 PurOrdDetail.Req)
+UPDATE `mdp_std_purchase_order` o
+  JOIN ( SELECT g.`tenant_id`, g.`source_biz_key`,
+                MAX(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.Req')), 'null'), '')) AS `req`
+           FROM `mdp_stg_purchase_order` g
+          WHERE g.`source_table` = 'PurOrdDetail' AND g.`tenant_id` > 0
+          GROUP BY g.`tenant_id`, g.`source_biz_key` ) s
+    ON s.`tenant_id` = o.`tenant_id` AND s.`source_biz_key` = o.`source_biz_key`
+   SET o.`purchase_request_no` = s.`req`
+ WHERE o.`tenant_id` > 0;

+ 198 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.539.verify.sql

@@ -0,0 +1,198 @@
+-- 1.0.539.verify.sql — HARD ASSERTIONS,每条 SELECT 都必须返回 1
+--
+-- ⚠️ AutoVersionUpdate 按分号切分本文件,并把【每一个切片】都当断言、要求返回 1。因此:
+--      ① 不得放"仅供排查"的 informational 查询(它返回的是结果集,不是 1)
+--      ② 文件必须以最后一条断言的分号结尾,末尾不能留纯注释块
+--      ③ 注释里不得出现任何字面分号,否则会被切在错误的位置
+--    正因为 runner 只认「返回 1」,它没有"告警"这一档 —— 只阻断或不阻断。
+--    故全局卫生类观测放在同目录的 1.0.539.warnings.ops.sql(runner 不执行,人工按需跑)。
+--
+-- ── 断言口径(重要,改本文件前先读)────────────────────────────────────────────────
+-- 只断言【本批 writer 实际控制的产出】,不断言全库状态。判据是「列的归属」:
+--
+--   本批新建、且只由 S3 标准层 INSERT 写入 → BATCH INVARIANT,必须阻断
+--     · mdp_std_purchase_request.work_order
+--     · mdp_std_purchase_request.sales_order_entry_id
+--     · mdp_std_purchase_order.purchase_request_no
+--
+--   本批之前就存在、且已确认存在本批边界外的写入者 → GLOBAL HYGIENE,只观测不阻断
+--     · mdp_std_purchase_order.work_order
+--       (本批仅做过一次性归一,稳态不归本批管。实测租户 838 的 S3_MDP_FULL 批次
+--          在贴源侧同批次同 source_biz_key 归一结果为 NULL 的前提下,标准层仍出现
+--          26 行字面量 —— 该值不可能由当前标准层 INSERT 在该批次产生,写入者未定位)
+--     · mdp_std_purchase_receipt.tenant_id
+--       (写入者已定位:MaterialWarehouse/PurchaseReceiptMdpSyncService.cs,
+--          批次前缀 S5_PUR_RCT_STD_,属 S5 模块边界,见 CLAUDE.md 第九节模块边界表)
+--
+-- 这两项失败都不能证明本批身份桥失败 —— 身份桥完全不经过它们:
+--   PO.purchase_request_no → PR.pr_no → PR.sales_order_entry_id → mdp_std_so.order_entry_id
+--
+-- 注意 mdp_std_so 在 (tenant_id, order_entry_id) 上并不唯一(实测 25 组重复),
+-- 所以凡是经它取订单的断言一律用 DISTINCT,不用行数。
+
+-- ── ① PR 身份列已就位 ──────────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+           AND `COLUMN_NAME` IN ('work_order','sales_order_entry_id')) = 2;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+           AND `COLUMN_NAME` = 'sales_order_entry_id'
+           AND `DATA_TYPE` = 'bigint' AND `IS_NULLABLE` = 'YES') = 1;
+
+SELECT (SELECT COUNT(DISTINCT `INDEX_NAME`) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_request'
+           AND `INDEX_NAME` IN ('idx_std_pr_entry','idx_std_pr_wo')) = 2;
+
+-- ── ② PO → PR 指针已就位 ───────────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_order'
+           AND `COLUMN_NAME` = 'purchase_request_no' AND `IS_NULLABLE` = 'YES') = 1;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_order'
+           AND `INDEX_NAME` = 'idx_std_po_req') > 0;
+
+-- ── ③ 收货 → PO 身份键与索引已就位 ─────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_receipt'
+           AND `COLUMN_NAME` IN ('ord_nbr','ord_line')) = 2;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_receipt'
+           AND `INDEX_NAME` = 'idx_std_rct_po') > 0;
+
+-- ── ④ S3 标准层自身不得有非法租户 ──────────────────────────────────────────────────
+--     只覆盖 S3 转换独占写入的两张表。收货表有 S5 writer,见 warnings.ops.sql
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request`
+         WHERE `tenant_id` IS NULL OR `tenant_id` <= 0) = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order`
+         WHERE `tenant_id` IS NULL OR `tenant_id` <= 0) = 0;
+
+-- ── ⑤ sales_order_entry_id 有值时必须能在同租户的 mdp_std_so 里命中 ────────────────
+--     0 也算非法:无法确定订单行就必须留 NULL,不得落 0
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request` p
+         WHERE p.`sales_order_entry_id` IS NOT NULL
+           AND (p.`sales_order_entry_id` <= 0
+             OR NOT EXISTS (SELECT 1 FROM `mdp_std_so` s
+                             WHERE s.`tenant_id` = p.`tenant_id`
+                               AND s.`order_entry_id` = p.`sales_order_entry_id`))) = 0;
+
+-- ── ⑥ PO → PR 桥可跑通(全程只读 mdp_std_*)────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order` o
+          JOIN `mdp_std_purchase_request` p
+            ON p.`tenant_id` = o.`tenant_id` AND p.`pr_no` = o.`purchase_request_no`
+         WHERE o.`purchase_request_no` IS NOT NULL) > 0;
+
+-- ── ⑦ PO → 销售订单 桥可跑通(全程只读 mdp_std_*,不触碰任何源业务表)──────────────
+SELECT (SELECT COUNT(DISTINCT s.`order_id`) FROM `mdp_std_purchase_order` o
+          JOIN `mdp_std_purchase_request` p
+            ON p.`tenant_id` = o.`tenant_id` AND p.`pr_no` = o.`purchase_request_no`
+          JOIN `mdp_std_so` s
+            ON s.`tenant_id` = p.`tenant_id` AND s.`order_entry_id` = p.`sales_order_entry_id`
+         WHERE p.`sales_order_entry_id` IS NOT NULL) > 0;
+
+-- ── ⑧ 收货 → PO 桥可跑通(收货侧贴源无 Req,必须经 PO 才能到 PR)───────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_receipt` r
+          JOIN `mdp_std_purchase_order` o
+            ON o.`tenant_id` = r.`tenant_id` AND o.`po_no` = r.`ord_nbr` AND o.`po_line` = r.`ord_line`) > 0;
+
+-- ── ⑨ 无跨租户命中 ─────────────────────────────────────────────────────────────────
+--     ⑤ 已保证"同租户能命中",这里补的是"绝不能靠别的租户命中"
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request` p
+          JOIN `mdp_std_so` s ON s.`order_entry_id` = p.`sales_order_entry_id`
+         WHERE p.`sales_order_entry_id` IS NOT NULL
+           AND s.`tenant_id` <> p.`tenant_id`) = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order` o
+          JOIN `mdp_std_purchase_request` p ON p.`pr_no` = o.`purchase_request_no`
+         WHERE o.`purchase_request_no` IS NOT NULL
+           AND p.`tenant_id` <> o.`tenant_id`
+           AND NOT EXISTS (SELECT 1 FROM `mdp_std_purchase_request` q
+                            WHERE q.`tenant_id` = o.`tenant_id`
+                              AND q.`pr_no` = o.`purchase_request_no`)) = 0;
+
+-- ── ⑩ 本批 writer 独占的身份列不得出现字面量 'null' 或空串 ─────────────────────────
+--     这三列全部由 1.0.539 新建,只有 S3 标准层 INSERT 写它们 → 属 BATCH INVARIANT
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request`
+         WHERE `work_order` IN ('null', '')) = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order`
+         WHERE `purchase_request_no` IN ('null', '')) = 0;
+
+-- ── ⑪ 归因 Grain 必须是 PO 行:一条 PO 行不得被归给多张销售订单 ─────────────────────
+SELECT (SELECT COUNT(*) FROM (
+          SELECT o.`tenant_id`, o.`po_no`, o.`po_line`
+            FROM `mdp_std_purchase_order` o
+            JOIN `mdp_std_purchase_request` p
+              ON p.`tenant_id` = o.`tenant_id` AND p.`pr_no` = o.`purchase_request_no`
+            JOIN `mdp_std_so` s
+              ON s.`tenant_id` = p.`tenant_id` AND s.`order_entry_id` = p.`sales_order_entry_id`
+           WHERE p.`sales_order_entry_id` IS NOT NULL
+           GROUP BY o.`tenant_id`, o.`po_no`, o.`po_line`
+          HAVING COUNT(DISTINCT s.`order_id`) > 1) t) = 0;
+
+-- ── ⑫ 投影 / 派生正确性:本批三列必须逐行等于其定义式 ──────────────────────────────
+--     这才是本批能担保的东西。刻意【不】断言"PO 的指针一定能找到 PR"——
+--     PR 行能否进标准层取决于转换既有的工厂谓词
+--     COALESCE(NULLIF(g.factory_id,0),1)=@FactoryId,与本批投影无关:
+--     实测 PR20260903080128786 贴源 factory_id=838257186320453,被 @FactoryId=1 挡下,
+--     而其 PO 行 factory_id=1 得以入库,于是指针悬空。那是既有作用域行为,
+--     不是本批的投影缺陷,断言它等于替边界外逻辑背书。
+
+-- (a) PO.purchase_request_no 必须逐行等于贴源 $.Req 的归一值
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order` o
+          JOIN `mdp_stg_purchase_order` g
+            ON g.`tenant_id` = o.`tenant_id` AND g.`source_biz_key` = o.`source_biz_key`
+           AND g.`source_table` = 'PurOrdDetail'
+         WHERE IFNULL(o.`purchase_request_no`, '#')
+            <> IFNULL(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.Req')), 'null'), ''), '#')) = 0;
+
+-- (b) PR.work_order 必须逐行等于贴源 $.pr_mono 的归一值
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request` p
+          JOIN `mdp_stg_supply_demand` g
+            ON g.`tenant_id` = p.`tenant_id` AND g.`source_biz_key` = p.`source_biz_key`
+           AND g.`source_table` = 'srm_pr_main'
+         WHERE IFNULL(p.`work_order`, '#')
+            <> IFNULL(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.`raw_data`, '$.pr_mono')), 'null'), ''), '#')) = 0;
+
+-- (c) PR.sales_order_entry_id 必须逐行等于同租户工单桥的派生值,不得自行发明
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request` p
+          LEFT JOIN `mdp_std_work_order_schedule` w
+            ON w.`tenant_id` = p.`tenant_id` AND w.`work_order` = p.`work_order`
+         WHERE p.`work_order` IS NOT NULL
+           AND IFNULL(p.`sales_order_entry_id`, -1) <> IFNULL(w.`sales_order_entry_id`, -1)) = 0;
+
+-- (d) 没有工单号就不可能有订单行 Id —— 杜绝绕过工单桥的野路子赋值
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request`
+         WHERE `work_order` IS NULL AND `sales_order_entry_id` IS NOT NULL) = 0;
+
+-- ── ⑬ 本批不得建 Stage-3 KPI 事实表 ────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` IN ('dwd_procurement_kpi','dwd_purchase_kpi')) = 0;
+
+-- ── ⑭ 本批不得引入 Stage-3 完成事件 ────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `aidop_action_run_log`
+         WHERE `action_code` = 'S3_PROCUREMENT_COMPLETE') = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_std_action_event`
+         WHERE `action_code` = 'S3_PROCUREMENT_COMPLETE') = 0;
+
+-- ── ⑮ Stage 1 / Stage 2 未被本批触碰 ───────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` IN ('dwd_order_review_kpi','dwd_product_design_kpi')) = 2;
+
+SELECT (SELECT COUNT(*) FROM `dwd_order_review_kpi` k
+         WHERE k.`tenant_id` IS NULL OR k.`tenant_id` <= 0
+            OR NOT EXISTS (SELECT 1 FROM `mdp_std_so` s
+                            WHERE s.`tenant_id` = k.`tenant_id`
+                              AND s.`order_id` = k.`sales_order_id`)) = 0;
+
+SELECT (SELECT COUNT(*) FROM `dwd_product_design_kpi` p
+         WHERE p.`tenant_id` IS NULL OR p.`tenant_id` <= 0
+            OR NOT EXISTS (SELECT 1 FROM `mdp_std_so` s
+                            WHERE s.`tenant_id` = p.`tenant_id`
+                              AND s.`order_id` = p.`sales_order_id`)) = 0;

+ 43 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.539.warnings.ops.sql

@@ -0,0 +1,43 @@
+-- 1.0.539.warnings.ops.sql — GLOBAL HYGIENE WARNINGS(只观测,不阻断)
+--
+-- ⚠️ 本文件【不是】verify。AutoVersionUpdate 不执行 .ops.sql,只执行 <version>.sql
+--    与 <version>.verify.sql。这里放的是「本批 writer 管不到、但值得盯」的全库卫生项。
+--    人工执行:mysql <conn> < 1.0.539.warnings.ops.sql
+--
+-- 为什么不放进 verify:runner 对 verify 的每个切片都要求返回 1,没有"告警"这一档。
+-- 把这两项写成 verify 断言,等于让本批为边界外 writer 的行为背书,一旦对方回归就
+-- 阻断本批迁移、拖垮后端启动 —— 那是错误的责任划分,不是更严格。
+--
+-- 两项都【不影响】Stage-3 身份桥,身份桥完全不经过它们:
+--   PO.purchase_request_no → PR.pr_no → PR.sales_order_entry_id → mdp_std_so.order_entry_id
+
+-- ── WARN_GLOBAL_RECEIPT_INVALID_TENANT ─────────────────────────────────────────────
+-- 写入者已定位:Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PurchaseReceiptMdpSyncService.cs
+--   批次前缀 S5_PUR_RCT_STD_,属 S5 模块边界(CLAUDE.md 第九节模块边界表)
+-- 本批只对该表加了 idx_std_rct_po,未接管其写入
+SELECT 'WARN_GLOBAL_RECEIPT_INVALID_TENANT' AS `WARNING_CODE`,
+       'mdp_std_purchase_receipt'          AS `TABLE_NAME`,
+       `tenant_id`                         AS `TENANT`,
+       COUNT(*)                            AS `CNT`,
+       MAX(`sync_batch_id`)                AS `LAST_WRITER_BATCH`,
+       MAX(`update_time`)                  AS `LAST_WRITE_AT`
+  FROM `mdp_std_purchase_receipt`
+ WHERE `tenant_id` IS NULL OR `tenant_id` <= 0
+ GROUP BY `tenant_id`;
+
+-- ── WARN_GLOBAL_PO_LITERAL_NULL ────────────────────────────────────────────────────
+-- mdp_std_purchase_order.work_order 是本批之前就存在的列,本批只做过一次性归一。
+-- 实测:租户 838 的 S3_MDP_FULL 批次里,贴源层同批次、同 source_biz_key 的
+--   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.WorkOrd')),'null'),'')
+-- 求值结果为 NULL,标准层却仍出现字面量 —— 该值不可能由当前标准层 INSERT 在该批次
+-- 产生,写入者尚未定位。登记为 KNOWN ISSUE,不在本批处理。
+-- 该列【不作 Stage-3 归因】,归因走 purchase_request_no。
+SELECT 'WARN_GLOBAL_PO_LITERAL_NULL' AS `WARNING_CODE`,
+       'mdp_std_purchase_order'      AS `TABLE_NAME`,
+       `tenant_id`                   AS `TENANT`,
+       COUNT(*)                      AS `CNT`,
+       MAX(`sync_batch_id`)          AS `LAST_WRITER_BATCH`,
+       MAX(`update_time`)            AS `LAST_WRITE_AT`
+  FROM `mdp_std_purchase_order`
+ WHERE `work_order` IN ('null', '')
+ GROUP BY `tenant_id`;

+ 47 - 20
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/S3MdpSyncTransformService.cs

@@ -717,25 +717,44 @@ public class S3MdpSyncTransformService : ITransient
         yield return Cmd(
             """
             INSERT INTO mdp_std_purchase_request
-            (tenant_id, factory_id, company_id, source_system, pr_no, pr_line, item_code, item_name, supplier_code, request_qty, request_date, send_date, arrive_date, status, source_biz_key, sync_batch_id, sync_time)
-            SELECT tenant_id, factory_id, company_id, 'AIDOP',
-                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.pr_billno')), source_row_id),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.line_no')),
-                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_number')), ''),
-                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.item_name')), ''),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.supplier_number')),
+            (tenant_id, factory_id, company_id, source_system, pr_no, pr_line, work_order, sales_order_entry_id, item_code, item_name, supplier_code, request_qty, request_date, send_date, arrive_date, status, source_biz_key, sync_batch_id, sync_time)
+            SELECT g.tenant_id, g.factory_id, g.company_id, 'AIDOP',
+                   COALESCE(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.pr_billno')), g.source_row_id),
+                   JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.line_no')),
+                   -- Stage-3 订单归因第 1 段:PR 所属工单号(源 srm_pr_main.pr_mono)。
+                   -- 必须把 JSON 的字面量 'null' 归一成真 NULL —— 否则下游 JOIN 会去匹配
+                   -- 一个叫 "null" 的工单号(既有 mdp_std_purchase_order.work_order 正是踩了
+                   -- 这个坑:451 行里 208 行是字面量 'null')。
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.pr_mono')), 'null'), ''),
+                   -- Stage-3 订单归因第 2 段:由工单号派生销售订单明细行 Id。
+                   -- 刻意不读源表 srm_pr_main.sentry_id —— 该字段源侧 1012/1012 全 NULL,
+                   -- 生产代码从未写过;这里改从 S2 标准层(1.0.532 建立的桥)派生。
+                   -- LEFT JOIN:S2 跑批未覆盖的租户得到 NULL,而不是让 PR 整行消失。
+                   wo.sales_order_entry_id,
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.item_number')), ''),
+                   IFNULL(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.item_name')), ''),
+                   JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.supplier_number')),
                    COALESCE(
-                       CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.qty')) AS DECIMAL(18,6)) END,
-                       CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.request_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.request_qty')) AS DECIMAL(18,6)) END,
+                       CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.qty')) AS DECIMAL(18,6)) END,
+                       CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.request_qty')) REGEXP '^-?[0-9]+(\\.[0-9]+)?$' THEN CAST(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.request_qty')) AS DECIMAL(18,6)) END,
                        0),
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.create_time')), 'null'), ''),
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.send_date')), 'null'), ''),
-                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.arrive_date')), 'null'), ''),
-                   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.status')),
-                   source_biz_key, @BatchId, @Now
-            FROM mdp_stg_supply_demand
-            WHERE source_table='srm_pr_main'
-            ON DUPLICATE KEY UPDATE factory_id=VALUES(factory_id), item_code=VALUES(item_code), item_name=VALUES(item_name), supplier_code=VALUES(supplier_code),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.create_time')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.send_date')), 'null'), ''),
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.arrive_date')), 'null'), ''),
+                   JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.status')),
+                   g.source_biz_key, @BatchId, @Now
+            FROM mdp_stg_supply_demand g
+            -- 订单行 Id 派生。ON 里必须【自带】租户谓词:MdpSqlScope.InjectTenantFactory
+            -- 只改写筛选子句,结构上够不到 JOIN...ON;少了它工单号就成了跨租户字符串匹配。
+            -- (这里刻意不写出那个关键字:注入器对 SQL 注释同样生效,写了会被就地改写。)
+            -- 同租户同工单号在 mdp_std_work_order_schedule 内唯一(实测重复组 = 0),故不会扇出。
+            LEFT JOIN mdp_std_work_order_schedule wo
+                   ON wo.tenant_id = g.tenant_id
+                  AND wo.work_order = NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(g.raw_data,'$.pr_mono')), 'null'), '')
+            WHERE g.source_table='srm_pr_main'
+            ON DUPLICATE KEY UPDATE factory_id=VALUES(factory_id),
+                work_order=VALUES(work_order), sales_order_entry_id=VALUES(sales_order_entry_id),
+                item_code=VALUES(item_code), item_name=VALUES(item_name), supplier_code=VALUES(supplier_code),
                 request_qty=VALUES(request_qty), request_date=VALUES(request_date), send_date=VALUES(send_date),
                 arrive_date=VALUES(arrive_date), status=VALUES(status), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
                 update_time=CURRENT_TIMESTAMP
@@ -744,10 +763,15 @@ public class S3MdpSyncTransformService : ITransient
         yield return Cmd(
             """
             INSERT INTO mdp_std_purchase_order
-            (tenant_id, factory_id, company_id, source_system, po_no, po_line, po_type, supplier_code, item_code, item_name, order_qty, received_qty, returned_qty, due_date, need_date, order_date, status, buyer, work_order, source_biz_key, sync_batch_id, sync_time)
+            (tenant_id, factory_id, company_id, source_system, po_no, po_line, purchase_request_no, po_type, supplier_code, item_code, item_name, order_qty, received_qty, returned_qty, due_date, need_date, order_date, status, buyer, work_order, source_biz_key, sync_batch_id, sync_time)
             SELECT d.tenant_id, d.factory_id, d.company_id, 'AIDOP',
                    JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.PurOrd')),
                    CAST(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Line')) AS CHAR),
+                   -- Stage-3 订单归因主键路径:PO 行 → 采购申请单号(源 PurOrdDetail.Req,实测 1:1)。
+                   -- 归因走这里,【不走】下面的 work_order —— 后者只在历史/UAT 批量导入的工单上
+                   -- 有值,而源侧 PurOrdDetail.SalesJob 实测 0/448、BusinessID 实测全 0。
+                   -- 共享采购(同物料跨多工单)因此仍能按 PO 行精确切分到各自订单。
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Req')), 'null'), ''),
                    JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Potype')),
                    JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Supp')),
                    IFNULL(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.ItemNum')), ''),
@@ -763,7 +787,9 @@ public class S3MdpSyncTransformService : ITransient
                    NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.OrdDate')), 'null'), ''),
                    JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Status')),
                    JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.Buyer')),
-                   JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.WorkOrd')),
+                   -- 归一字面量 'null':改造前 451 行中 208 行落的是字符串 "null",
+                   -- 任何按 work_order 的 JOIN 都会被它污染。本列只作参考,不作归因。
+                   NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.WorkOrd')), 'null'), ''),
                    d.source_biz_key, @BatchId, @Now
             FROM mdp_stg_purchase_order d
             -- ── enrichment JOIN 的两个约束,缺一不可 ────────────────────────────────────
@@ -835,7 +861,8 @@ public class S3MdpSyncTransformService : ITransient
             WHERE d.tenant_id=@TenantId AND COALESCE(NULLIF(d.factory_id,0),1)=@FactoryId
               AND d.source_table='PurOrdDetail' AND d.sync_batch_id=@BatchId
               AND IFNULL(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.PurOrd')), '') <> ''
-            ON DUPLICATE KEY UPDATE po_type=VALUES(po_type), supplier_code=VALUES(supplier_code), item_code=VALUES(item_code),
+            ON DUPLICATE KEY UPDATE purchase_request_no=VALUES(purchase_request_no),
+                po_type=VALUES(po_type), supplier_code=VALUES(supplier_code), item_code=VALUES(item_code),
                 item_name=VALUES(item_name), order_qty=VALUES(order_qty), received_qty=VALUES(received_qty), returned_qty=VALUES(returned_qty),
                 due_date=VALUES(due_date), need_date=VALUES(need_date), order_date=VALUES(order_date), status=VALUES(status),
                 buyer=VALUES(buyer), work_order=VALUES(work_order), sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),