Browse Source

fix(s5): normalize purchase receipt std nulls and repair UAT-A golden data | server 1.0.473

采购收货单页面大量显示字符串 "null",根因两条,本批各修其一。

Batch 1 · transform 归一
PurchaseReceiptMdpSyncService.TransformStandardAsync 对展示列使用裸
JSON_UNQUOTE(JSON_EXTRACT(...));MySQL 下 JSON null 经 JSON_UNQUOTE 产出长度 4 的
字面量字符串 'null'(IS NULL = 0),落进 mdp_std_purchase_receipt 后前端 || '-'
兜底完全无效。7 个字符串列改用既有 MdpJsonSql.Str,qty_ordered / qty_received /
ord_line 改用 Dec / Int(同时消除 STRICT 模式下 CAST('null' AS ...) 的 500 风险)。
sort_name 补 NULLIF(...,'') 避免落空串。

identity / JOIN 键刻意保留裸取值:Domain / Receiver / Line 是 uk_mdp_std_pur_rct 的
NOT NULL 成分与 source_biz_key 兜底,且头明细 INNER JOIN 依赖 Domain 相等——一并归一
会让 NULL = NULL 不成立、整批收货行从标准层消失。rct_date 既有守卫等价,不重写。
新增 S5PurchaseReceiptDisplayContractTests(19 例)双向钉住:该归一的归一、不该动的没动。

Batch 2 · UAT-A Golden Data 补数
仅修代码不足以让页面有值:本批源单 Domain / Supp / UM 全为 SQL NULL,
ItemMaster / SuppMaster / PurOrdDetail 维表一律关联落空。
- 生成器 run_uat_purchase_receipt_fact_seed.py:Domain 改由 PO 供应商的 SuppMaster
  域解析(回落 PO 行 / PO 头),补写 Supp / UM;并修正 RctType 'RCT' → 'rc'
  (原值与转换层过滤条件 RctType='rc' 不符,重建数据会整批不可见)。
- 1.0.473.sql:订正已生成的存量,范围锁死 tenant 838257186181189 +
  CreateUser='UAT_GENERATOR' + Receiver LIKE 'UATRCT-A-%'。Domain='8010' 的依据是
  该租户 SuppMaster 14 行全部属于 8010、且 PO 头供应商 VEN00060 只在该域有主数据,
  维表命中行全部属本租户、无跨租户读。LotSerial 无上游来源,按不伪造原则留 NULL。
  同时清理补数前的贴源快照——mdp_entity.biz_key_expr='Domain,Receiver,Line',
  Domain 变更后 Pull 是插新行而非就地更新,不清理会新旧各生成一条标准层行。

Runtime:refresh 连跑 3 次均 200;标准层 10 行 / 10 个不同行号、无重复;
本租户 literal 'null' 单元格 0;list API payload 无 :"null";页面 10 条、0 个 null 单元格、
控制台无报错。全量测试 1509 passed / 0 failed。

已知残留(非本批范围,已单列):6 行物料名称仍为空——该租户 ItemMaster 被
run_uat_migration 从两个来源租户复制,主数据分裂在 Domain 8010 / 2410,
其中 5 个料号只存在于 2410,RAW-SUB-01 全库无主数据行。
YY968XX 1 week ago
parent
commit
5ef08657ba

+ 33 - 9
doc/plan/sql/uat-data/run_uat_purchase_receipt_fact_seed.py

@@ -22,14 +22,35 @@ PREFIX = "UATRCT-"
 
 
 def load_po_lines(cursor: Any) -> dict[int, list[dict[str, Any]]]:
+    """Load PO lines plus the supplier / UM / domain the receipt must inherit.
+
+    Domain / Supp / UM must be resolved here, not left NULL: the S5 purchase-receipt
+    MDP transform joins ItemMaster / SuppMaster / PurOrdDetail on (Domain, ...), and a
+    NULL domain makes every dimension lookup miss, so the page shows blank supplier /
+    item name / spec / PO number. Domain is taken from the supplier master of the PO's
+    own supplier — a purchase receipt belongs to the ERP domain its supplier is
+    defined in — falling back to the PO line, then the PO header.
+    """
     tenant_ids = ",".join(str(x) for x in TENANTS)
     cursor.execute(
         f"""
-        SELECT tenant_id,PurOrd,Line,ItemNum,QtyOrded,Potype,Domain,Site
-        FROM PurOrdDetail
-        WHERE tenant_id IN ({tenant_ids})
-          AND IFNULL(PurOrd,'')<>'' AND IFNULL(ItemNum,'')<>'' AND IFNULL(QtyOrded,0)>0
-        ORDER BY tenant_id,PurOrd,Line
+        SELECT d.tenant_id,d.PurOrd,d.Line,d.ItemNum,d.QtyOrded,d.Potype,d.Site,d.UM,
+               (SELECT m.Supp FROM PurOrdMaster m
+                 WHERE m.tenant_id=d.tenant_id AND m.PurOrd=d.PurOrd LIMIT 1) AS Supp,
+               COALESCE(
+                 (SELECT s.Domain FROM SuppMaster s
+                   WHERE s.tenant_id=d.tenant_id
+                     AND s.Supp=(SELECT m2.Supp FROM PurOrdMaster m2
+                                  WHERE m2.tenant_id=d.tenant_id AND m2.PurOrd=d.PurOrd LIMIT 1)
+                   LIMIT 1),
+                 d.Domain,
+                 (SELECT m3.Domain FROM PurOrdMaster m3
+                   WHERE m3.tenant_id=d.tenant_id AND m3.PurOrd=d.PurOrd LIMIT 1)
+               ) AS Domain
+        FROM PurOrdDetail d
+        WHERE d.tenant_id IN ({tenant_ids})
+          AND IFNULL(d.PurOrd,'')<>'' AND IFNULL(d.ItemNum,'')<>'' AND IFNULL(d.QtyOrded,0)>0
+        ORDER BY d.tenant_id,d.PurOrd,d.Line
         """
     )
     rows: dict[int, list[dict[str, Any]]] = defaultdict(list)
@@ -74,17 +95,18 @@ def apply() -> dict[str, Any]:
                     cursor.execute(
                         """
                         INSERT INTO PurOrdRctMaster
-                          (RecID,Domain,RctType,Receiver,RctDate,OrdNbr,PerformDate,Site,BusinessID,
+                          (RecID,Domain,Supp,RctType,Receiver,RctDate,OrdNbr,PerformDate,Site,BusinessID,
                            CreateUser,UpdateUser,CreateTime,UpdateTime,IsActive,IsConfirm,Print,
                            IsChanged,TaxIn,IsExport,IsPlan,IsBackwash,Remark,tenant_id)
                         VALUES
-                          (%s,%s,'RCT',%s,%s,%s,%s,%s,%s,
+                          (%s,%s,%s,'rc',%s,%s,%s,%s,%s,%s,
                            'UAT_GENERATOR','UAT_GENERATOR',NOW(),NOW(),1,1,0,
                            0,0,0,0,0,'UAT_GENERATOR:采购收货业务事实',%s)
                         """,
                         (
                             master_id,
                             first["Domain"],
+                            first["Supp"],
                             receiver,
                             receipt_date,
                             po_no,
@@ -104,14 +126,14 @@ def apply() -> dict[str, Any]:
                         cursor.execute(
                             """
                             INSERT INTO PurOrdRctDetail
-                              (RecID,Domain,RctType,Receiver,Line,ItemNum,Dimension1,Dimension2,
+                              (RecID,Domain,Supp,RctType,Receiver,Line,ItemNum,UM,Dimension1,Dimension2,
                                OrdNbr,OrdLine,RctDate,POCost,QtyOrded,RctQty,QtyReceived,Site,
                                Location,Potype,BusinessID,CreateUser,UpdateUser,CreateTime,UpdateTime,
                                IsActive,IsConfirm,IsChanged,TaxIn,UMConversion,IsClosed,IsRounding,
                                BlanketLine,ProjectLine,RctLine,QtyReturn,PurOrdRctRecID,QcQty,QC,
                                Remark,tenant_id)
                             VALUES
-                              (%s,%s,'RCT',%s,%s,%s,'','',%s,%s,%s,1,%s,%s,%s,%s,
+                              (%s,%s,%s,'rc',%s,%s,%s,%s,'','',%s,%s,%s,1,%s,%s,%s,%s,
                                'UAT',%s,%s,'UAT_GENERATOR','UAT_GENERATOR',NOW(),NOW(),
                                1,1,0,0,1,0,0,0,0,%s,0,%s,%s,'PASS',
                                'UAT_GENERATOR:采购收货明细',%s)
@@ -119,9 +141,11 @@ def apply() -> dict[str, Any]:
                             (
                                 detail_id,
                                 line["Domain"],
+                                line["Supp"],
                                 receiver,
                                 line_index,
                                 line["ItemNum"],
+                                line["UM"],
                                 po_no,
                                 int(line["Line"] or line_index),
                                 receipt_date,

+ 6 - 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.472</AssemblyVersion>
-    <FileVersion>1.0.472</FileVersion>
-    <Version>1.0.472</Version>
+    <AssemblyVersion>1.0.473</AssemblyVersion>
+    <FileVersion>1.0.473</FileVersion>
+    <Version>1.0.473</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -658,6 +658,9 @@
     <None Update="UpdateScripts\1.0.467.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.473.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 144 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.473.sql

@@ -0,0 +1,144 @@
+-- ============================================================================
+-- S5-PURCHASE-RECEIPT-DISPLAY-FIX · Batch 2 存量订正
+--   ① UAT-A Golden Data 补数(源表 Domain / Supp / UM)
+--   ② 标准层旧身份键行清理(domain='null'),由重跑 MDP 链以正确 domain 重建
+--
+-- 背景(根因已在同批代码修复):
+--   PurchaseReceiptMdpSyncService.TransformStandardAsync 原先对展示列使用裸
+--   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.x'))。MySQL 下 JSON null 经 JSON_UNQUOTE
+--   产出**长度为 4 的字面量字符串 'null'**(实测 IS NULL = 0 / CHAR_LENGTH = 4),
+--   于是 mdp_std_purchase_receipt 的 supp / sort_name / um / lot_serial / domain
+--   落成 'null',采购收货单页面直接显示 null,前端 `|| '-'` 兜底对真值字符串无效。
+--   代码侧已统一改走 MdpJsonSql.Str/Int/Dec(identity / JOIN 键刻意保留裸取值)。
+--
+--   但仅修代码不足以让页面有值:本批 UAT Golden Data 的源单本身
+--   Domain / Supp / UM 全为 SQL NULL,维表(ItemMaster / SuppMaster / PurOrdDetail)
+--   一律关联落空。故必须同时补源数据,本脚本即做这件事。
+--
+-- 取值依据(aidopdev 只读取证,2026-09-03):
+--   Domain = '8010'
+--     · 采购收货是采购单据,其域应与供应商主数据所在域一致。
+--     · 本批 5 张收货单对应的采购订单头 PurOrdMaster(UATA-PO-001..005).Supp 均为 'VEN00060'。
+--     · 租户 838257186181189 的 SuppMaster 共 14 行、**全部 Domain='8010'**,
+--       其中 VEN00060 = 瑞贝德。该租户不存在任何其他 Domain 的供应商主数据,
+--       故 '8010' 是唯一能让 供应商 维表命中的取值,且命中行属于**本租户自己**(无跨租户读)。
+--     · 原 PurOrdMaster.Domain='797403760988229' 是租户号误填、非 ERP 域,不采用;
+--       本脚本亦不改 PurOrdMaster(不在采购收货链路上,避免扩大范围)。
+--   Supp = 'VEN00060'(取自对应 PurOrdMaster.Supp,非臆造)
+--   UM   = 取自对应采购订单行 PurOrdDetail(PurOrd=OrdNbr, Line=OrdLine).UM(本批实测均为 'EA')
+--   LotSerial:**刻意不补**。上游无任何批次号来源(采购订单行、质检、库存事务均无),
+--             按"不伪造"原则留 SQL NULL;修复后页面显示空白而非 'null',已满足展示口径。
+--
+-- 写入范围(DDL 0):
+--   PurOrdRctMaster  UPDATE ≤5 行   tenant_id=838257186181189 AND CreateUser='UAT_GENERATOR'
+--                                   AND Receiver LIKE 'UATRCT-A-%' AND RctType='rc'
+--   PurOrdRctDetail  UPDATE ≤10 行  同上范围
+--   PurOrdDetail     UPDATE ≤10 行  tenant_id=838257186181189 AND PurOrd LIKE 'UATA-PO-%'
+--                                   (补 Domain,使收货 → 采购单号/请购单号 维表关联可用)
+--   mdp_stg_purchase_receipt DELETE ≤15 行  tenant_id=838257186181189 且 raw_data.Domain 为 JSON null
+--   mdp_std_purchase_receipt DELETE ≤10 行  tenant_id=838257186181189 AND domain='null'
+--
+-- 明确不做(防止范围蔓延):
+--   · 不动 tenant 838257212780613(UAT-B) / 838257237606469(UAT-DEMO) 的同类数据;
+--   · 不动非 UAT_GENERATOR 的任何业务行;
+--   · 不批量修复 PurOrdRctDetail 全表 240 行 Domain IS NULL;
+--   · 不做全库 'null' 清洗,不碰其他 mdp_std_* 表;
+--   · 不改 PurOrdMaster。
+--
+-- 幂等:源表三条 UPDATE 均以 "Domain IS NULL" 收敛,重复执行 0 行影响;
+--       DELETE 以 domain='null' 收敛,重跑后该条件不再成立。
+-- 顺序要求:本脚本执行后必须重跑 S5 采购收货 MDP 链
+--       (POST /api/AidopKanban/s5-purchase-receipt-mdp/refresh 或等价 inbound),
+--       否则标准层将暂时为空——DELETE 后、重建前存在这一空窗,属预期。
+-- ============================================================================
+
+-- ── 执行前证据 ──
+SELECT 'BEFORE-src' AS phase,
+       SUM(d.Domain IS NULL) AS detail_domain_null,
+       SUM(d.Supp   IS NULL) AS detail_supp_null,
+       SUM(d.UM     IS NULL) AS detail_um_null,
+       COUNT(*)              AS detail_rows
+FROM PurOrdRctDetail d
+WHERE d.tenant_id = 838257186181189
+  AND d.CreateUser = 'UAT_GENERATOR'
+  AND d.Receiver LIKE 'UATRCT-A-%';
+
+SELECT 'BEFORE-std' AS phase,
+       COUNT(*)                    AS rows_,
+       SUM(domain     = 'null')    AS domain_null,
+       SUM(supp       = 'null')    AS supp_null,
+       SUM(sort_name  = 'null')    AS sort_name_null,
+       SUM(um         = 'null')    AS um_null,
+       SUM(lot_serial = 'null')    AS lot_serial_null
+FROM mdp_std_purchase_receipt
+WHERE tenant_id = 838257186181189;
+
+-- ── 1) 收货单主表:补 Domain / Supp(Supp 取自对应采购订单头)──
+UPDATE PurOrdRctMaster m
+SET m.Domain     = '8010',
+    m.Supp       = (SELECT po.Supp FROM PurOrdMaster po
+                    WHERE po.tenant_id = m.tenant_id AND po.PurOrd = m.OrdNbr LIMIT 1),
+    m.UpdateTime = NOW()
+WHERE m.tenant_id = 838257186181189
+  AND m.CreateUser = 'UAT_GENERATOR'
+  AND m.Receiver LIKE 'UATRCT-A-%'
+  AND m.RctType = 'rc'
+  AND m.Domain IS NULL;
+
+-- ── 2) 收货单明细:补 Domain / Supp / UM(UM 取自对应采购订单行)──
+UPDATE PurOrdRctDetail d
+SET d.Domain     = '8010',
+    d.Supp       = (SELECT po.Supp FROM PurOrdMaster po
+                    WHERE po.tenant_id = d.tenant_id AND po.PurOrd = d.OrdNbr LIMIT 1),
+    d.UM         = (SELECT pd.UM FROM PurOrdDetail pd
+                    WHERE pd.tenant_id = d.tenant_id AND pd.PurOrd = d.OrdNbr AND pd.Line = d.OrdLine LIMIT 1),
+    d.UpdateTime = NOW()
+WHERE d.tenant_id = 838257186181189
+  AND d.CreateUser = 'UAT_GENERATOR'
+  AND d.Receiver LIKE 'UATRCT-A-%'
+  AND d.RctType = 'rc'
+  AND d.Domain IS NULL;
+
+-- ── 3) 采购订单明细:补 Domain,使 收货 → 采购单号 / 请购单号 的维表关联成立 ──
+UPDATE PurOrdDetail
+SET Domain     = '8010',
+    UpdateTime = NOW()
+WHERE tenant_id = 838257186181189
+  AND PurOrd LIKE 'UATA-PO-%'
+  AND Domain IS NULL;
+
+-- ── 4) 贴源层:删除补数前的旧快照 ──
+--    mdp_entity.biz_key_expr = 'Domain,Receiver,Line'(明细)/ 'Domain,Receiver'(主表)。
+--    补数前 Domain 为 NULL,贴源行的 source_biz_key 退化成 RecID(如 '900342');
+--    补数后变成 '8010#UATRCT-A-0001#1'。uk_source_key 含 source_biz_key,
+--    因此下一次 Pull 是**插新行而非就地更新**,旧快照(raw_data.Domain 为 JSON null)会残留,
+--    并被 transform 重新生成一批 domain='null' 的标准层行 —— 同一收货行出现新旧两条。
+--    只删"raw_data.Domain 为 JSON null"的旧快照,新快照(Domain='8010')不受影响;
+--    全新环境(先跑本脚本再首次同步)下本语句为 0 行,无副作用。
+DELETE FROM mdp_stg_purchase_receipt
+WHERE tenant_id = 838257186181189
+  AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Receiver')) LIKE 'UATRCT-A-%'
+  AND JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.Domain')) = 'null';
+
+-- ── 5) 标准层:删除旧身份键行(domain='null');重跑 MDP 会以 domain='8010' 重建 ──
+--    唯一键 uk_mdp_std_pur_rct(tenant_id, domain, receiver, line) 含 domain,
+--    源侧 Domain 变更后 upsert 会落新键,若不清理则同一张收货单出现新旧两行。
+DELETE FROM mdp_std_purchase_receipt
+WHERE tenant_id = 838257186181189
+  AND domain = 'null'
+  AND receiver LIKE 'UATRCT-A-%';
+
+-- ── 执行后证据(期望:源表三列 0 个 NULL;标准层本租户 0 行,待重跑重建为 10 行)──
+SELECT 'AFTER-src' AS phase,
+       SUM(d.Domain IS NULL) AS detail_domain_null,
+       SUM(d.Supp   IS NULL) AS detail_supp_null,
+       SUM(d.UM     IS NULL) AS detail_um_null,
+       COUNT(*)              AS detail_rows
+FROM PurOrdRctDetail d
+WHERE d.tenant_id = 838257186181189
+  AND d.CreateUser = 'UAT_GENERATOR'
+  AND d.Receiver LIKE 'UATRCT-A-%';
+
+SELECT 'AFTER-std' AS phase, COUNT(*) AS rows_
+FROM mdp_std_purchase_receipt
+WHERE tenant_id = 838257186181189;

+ 138 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/S5PurchaseReceiptDisplayContractTests.cs

@@ -0,0 +1,138 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5 PURCHASE RECEIPT DISPLAY FIX 源码契约测试。
+///
+/// 缺陷:<c>JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.x'))</c> 对 JSON null 产出的是**长度 4 的字面量字符串 'null'**
+///       (<c>IS NULL = 0</c>),一路落进 <c>mdp_std_purchase_receipt</c> 并在采购收货单页面显示成 `null`,
+///       前端 <c>|| '-'</c> 之类空值兜底对它完全无效。
+///
+/// 修法:展示/属性列一律经 <c>MdpJsonSql.Str / Int / Dec</c>;
+///       <b>identity / JOIN 键刻意保留裸取值</b>——<c>Domain</c> / <c>Receiver</c> / <c>Line</c> 是
+///       <c>uk_mdp_std_pur_rct</c> 的 NOT NULL 组成部分,且头明细 INNER JOIN 依赖 Domain 相等;
+///       若一并归一为 SQL NULL,则 <c>NULL = NULL</c> 不成立、整批收货行会从标准层消失。
+///       本测试同时钉住"该归一的归一"与"不该动的没动"两侧。
+/// </summary>
+public class S5PurchaseReceiptDisplayContractTests
+{
+    private static string SyncService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "PurchaseReceiptMdpSyncService.cs"));
+
+    private static string JsonSql() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpJsonSql.cs"));
+
+    /// <summary>标准层的字符串展示列必须走 MdpJsonSql.Str。</summary>
+    [Theory]
+    [InlineData("p", "Supp")]        // supp
+    [InlineData("d", "Supp")]        // sort_name 的编码部分
+    [InlineData("p", "ItemNum")]     // item_num
+    [InlineData("p", "UM")]          // um
+    [InlineData("p", "LotSerial")]   // lot_serial
+    [InlineData("p", "Location")]    // location
+    [InlineData("p", "OrdNbr")]      // ord_nbr
+    public void StandardTransform_StringDisplayColumn_UsesNullSafeHelper(string alias, string field)
+    {
+        Assert.Contains($"MdpJsonSql.Str(\"{alias}\", \"{field}\")", SyncService());
+    }
+
+    /// <summary>数值展示列用类型 helper,杜绝 STRICT 模式下的 CAST('null' AS ...)。</summary>
+    [Fact]
+    public void StandardTransform_NumericDisplayColumns_UseTypedHelpers()
+    {
+        var s = SyncService();
+        Assert.Contains("MdpJsonSql.Dec(\"p\", \"QtyOrded\", 18, 6)", s);
+        Assert.Contains("MdpJsonSql.Dec(\"p\", \"QtyReceived\", 18, 6)", s);
+        Assert.Contains("MdpJsonSql.Int(\"p\", \"OrdLine\")", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"QtyOrded\")", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"QtyReceived\")", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"OrdLine\")", s);
+    }
+
+    /// <summary>修复前的裸写法不得回归(这些正是把 'null' 写进标准层的那几行)。</summary>
+    [Theory]
+    [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.UM'))")]
+    [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.LotSerial'))")]
+    [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Location'))")]
+    [InlineData("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyOrded')) AS DECIMAL(18,6))")]
+    [InlineData("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyReceived')) AS DECIMAL(18,6))")]
+    public void StandardTransform_BareDisplayExtraction_IsGone(string bareSql)
+    {
+        Assert.DoesNotContain(bareSql, SyncService());
+    }
+
+    /// <summary>
+    /// OrdLine 的裸 CAST 只剩 PurOrdDetail 关联条件里的两处(pd / sd),SELECT 列已改用 helper。
+    /// 这两处是 JOIN 键,按本批约定不动;数量钉死可挡住"顺手也改了 join"或"漏改 SELECT"两种回归。
+    /// </summary>
+    [Fact]
+    public void OrdLineBareCast_OnlyRemainsInJoinConditions()
+    {
+        var s = SyncService();
+        var bare = s.Split("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED)").Length - 1;
+        Assert.Equal(2, bare);
+        Assert.Contains("ELSE CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED) END) = pd.Line", s);
+        Assert.Contains("AND CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED) = sd.Line", s);
+    }
+
+    /// <summary>
+    /// 头明细 INNER JOIN 仍按裸取值比较 Domain / Receiver。
+    /// 这是刻意的:改成 MdpJsonSql.Str 会让源侧 Domain 为 NULL 的收货单 <c>NULL = NULL</c> 不成立、整批消失。
+    /// </summary>
+    [Fact]
+    public void MasterDetailJoinKeys_StayBare_SoRowsDoNotVanish()
+    {
+        var s = SyncService();
+        Assert.Contains(
+            "JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Domain')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Domain'))", s);
+        Assert.Contains(
+            "JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Receiver')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Receiver'))", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"Domain\")", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"Receiver\")", s);
+    }
+
+    /// <summary>identity 列 domain / receiver / line 与 source_biz_key 兜底保持裸取值(NOT NULL 唯一键成分)。</summary>
+    [Fact]
+    public void IdentityColumns_StayBare()
+    {
+        var s = SyncService();
+        Assert.Contains("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Line')) AS SIGNED)", s);
+        Assert.Contains("IFNULL(NULLIF(p.source_biz_key,''), CONCAT(", s);
+        Assert.DoesNotContain("MdpJsonSql.Int(\"p\", \"Line\")", s);
+    }
+
+    /// <summary>rct_date 既有的 'null'/空 守卫不得被去掉(本批不重写它,只保证不回归)。</summary>
+    [Fact]
+    public void RctDate_KeepsExistingNullGuard()
+    {
+        Assert.Contains(
+            "NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RctDate')),'null'),'')", SyncService());
+    }
+
+    /// <summary>RctType='rc' 业务过滤未被本批波及。</summary>
+    [Fact]
+    public void RctTypeFilter_Unchanged()
+    {
+        Assert.Contains("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.RctType'))='rc'", SyncService());
+    }
+
+    /// <summary>helper 语义钉死:JSON null / 字符串 'null' → SQL NULL。</summary>
+    [Fact]
+    public void JsonSqlStrHelper_NormalizesLiteralNullToSqlNull()
+    {
+        Assert.Contains("NULLIF({Ext(alias, field)},'null')", JsonSql());
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 17 - 10
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/PurchaseReceiptMdpSyncService.cs

@@ -281,6 +281,13 @@ public class PurchaseReceiptMdpSyncService : ITransient
 
     /// <summary>
     /// 标准化:stg(PurOrdRctDetail/Master) + 维表(本库) → mdp_std_purchase_receipt。
+    ///
+    /// JSON null 归一:展示/属性列一律经 <see cref="MdpJsonSql"/>(Str/Int/Dec)取值,
+    /// 避免 <c>JSON_UNQUOTE</c> 把 JSON null 产出成长度 4 的字面量字符串 <c>'null'</c> 落进标准层(前端 `|| '-'` 对其无效)。
+    /// <b>身份列与 JOIN 键刻意保留裸取值</b>:<c>Domain</c> / <c>Receiver</c> / <c>Line</c> 是
+    /// <c>uk_mdp_std_pur_rct</c> 的 NOT NULL 组成部分与 source_biz_key 兜底;且头明细 INNER JOIN 依赖 Domain 相等,
+    /// 一旦归一为 SQL NULL 则 <c>NULL = NULL</c> 不成立、整批行会从标准层消失。
+    /// <c>RctType</c> / <c>OrdType</c> 用于 WHERE 过滤与 CASE 判别,同样不归一。
     /// </summary>
     private async Task<int> TransformStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
     {
@@ -313,16 +320,16 @@ public class PurchaseReceiptMdpSyncService : ITransient
                 JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Receiver')),
                 CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Line')) AS SIGNED),
                 STR_TO_DATE(REPLACE(LEFT(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RctDate')),'null'),''), 19), 'T', ' '), '%Y-%m-%d %H:%i:%s'),
-                JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Supp')),
-                TRIM(CONCAT(IFNULL(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Supp')),''), ' ', IFNULL(s.SortName, ''))),
-                JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.ItemNum')), i.Descr, i.Descr1,
-                JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.UM')),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyOrded')) AS DECIMAL(18,6)),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyReceived')) AS DECIMAL(18,6)),
-                JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.LotSerial')),
-                JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Location')),
-                JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdNbr')),
-                CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED),
+                {MdpJsonSql.Str("p", "Supp")},
+                NULLIF(TRIM(CONCAT(IFNULL({MdpJsonSql.Str("d", "Supp")},''), ' ', IFNULL(s.SortName, ''))), ''),
+                {MdpJsonSql.Str("p", "ItemNum")}, i.Descr, i.Descr1,
+                {MdpJsonSql.Str("p", "UM")},
+                {MdpJsonSql.Dec("p", "QtyOrded", 18, 6)},
+                {MdpJsonSql.Dec("p", "QtyReceived", 18, 6)},
+                {MdpJsonSql.Str("p", "LotSerial")},
+                {MdpJsonSql.Str("p", "Location")},
+                {MdpJsonSql.Str("p", "OrdNbr")},
+                {MdpJsonSql.Int("p", "OrdLine")},
                 pd.BlanketLine, pd.PurOrd, pd.Line, pd.SalesJob, a.Address1,
                 (CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.OrdType'))='DO' THEN '' ELSE sd.Req END),
                 sd.ReqLine,