Forráskód Böngészése

feat(s5): propagate purchase order document identity to iqc entries

YY968XX 3 napja
szülő
commit
19015e2444

+ 9 - 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.539</AssemblyVersion>
-    <FileVersion>1.0.539</FileVersion>
-    <Version>1.0.539</Version>
+    <AssemblyVersion>1.0.540</AssemblyVersion>
+    <FileVersion>1.0.540</FileVersion>
+    <Version>1.0.540</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -856,6 +856,12 @@
     <None Update="UpdateScripts\1.0.539.warnings.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.540.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.540.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 43 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.540.sql

@@ -0,0 +1,43 @@
+-- =====================================================================================
+-- 1.0.540 · S5 IQC 报检分录补「采购单号」文档级身份(Document Identity Propagation)
+--
+-- ── 背景 ────────────────────────────────────────────────────────────────────────────
+-- `qms_qcp_insappnentry` 是 165(dopdemorq) 侧报检分录的**本地镜像**,由
+-- `DataPlatform/HotWatch/MdpHotWatchService.cs` 的 ApplyIqcAsync 做 UPSERT。
+--
+-- 镜像链在创建那一刻就握有采购单号,却没有落库:
+--   · 热关注登记的是 BizType='PUR_ORDER'、BizKey=<采购单号>
+--   · 取数 SQL 写的是 `... INNER JOIN PurOrdRctDetail d ON d.Receiver=e.FSRCORDERNUM
+--        AND d.ItemNum=e.FMATERIALCFG WHERE d.Domain=@d AND d.OrdNbr=@k`,@k 即该采购单号
+--   · 但 SELECT 只取 `e.*`,随后的 INSERT 也没有承载列 → 采购单号在 QUERY 层被丢弃
+--
+-- ── 为什么必须新增列,而不是复用既有列 ──────────────────────────────────────────────
+--   · `FSRCORDERNUM` **不能用**:上游实测 4/4 装的是**收货单号**(RC2026*),
+--     取数 SQL 自身的 `d.Receiver = e.FSRCORDERNUM` 即其权威证据。写入采购单号会破坏既有语义。
+--   · `FORDERNUM` **不宜用**:它是 165 的镜像列(与 FORDERTYPE 配对),上游实测非空 0/4、
+--     无列注释、本库无消费代码 —— 语义未经证实,且往源镜像列里塞本地派生值会破坏镜像契约,
+--     上游日后一旦开始下发该字段即产生冲突。
+--   · 因此新增独立列,并置于表尾与其它 AiDOP 扩展列(operation_completion_time /
+--     process_instance_id / flowstate)同区,与 165 镜像列区块物理分开。
+--
+-- ── 本脚本不做 ──────────────────────────────────────────────────────────────────────
+-- 不补 PO 行号:上游 `FSRCBILLID` / `FSRCBILLENTRYSEQ` 实测 0/4 全空,属 SOURCE CONTRACT GAP。
+-- 禁止用 (Receiver, ItemNum) / 物料 / 供应商 / 批次 / 日期 反推行号 —— 本库实测该组合
+-- 最多可对应 13 个不同 PO 行(UATRCT-DEMO-0026 + 3521C0015,OrdLine 44~56)。
+-- 不回填历史:修复后由正式 Writer 幂等重新镜像,不手工 UPDATE,不给 fixture 猜值。
+--
+-- ── 为什么可空 ──────────────────────────────────────────────────────────────────────
+-- 本库 18 行分录中 14 行在上游已不存在(fixture/seed 残留),它们没有权威采购单号可填。
+-- 因此该列必须 NULL 且不得给默认值 —— 宁可为空,不造假。
+--
+-- ── 为什么不建索引 ──────────────────────────────────────────────────────────────────
+-- 当前该表 18 行,索引无收益。待下游查询确实证明需要时,再单独加 (tenant_id, purchase_order_no)。
+-- =====================================================================================
+
+SET @ddl := (SELECT IF(EXISTS(
+    SELECT 1 FROM `information_schema`.`COLUMNS`
+     WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'qms_qcp_insappnentry'
+       AND `COLUMN_NAME` = 'purchase_order_no'),
+  'SELECT 1',
+  'ALTER TABLE `qms_qcp_insappnentry` ADD COLUMN `purchase_order_no` VARCHAR(80) NULL COMMENT ''采购单号(文档级)。来源=热关注 BizType=PUR_ORDER 的 BizKey,即取数 SQL 的 d.OrdNbr 作用域键。注意与 FSRCORDERNUM 区分:后者是收货单号。不承载 PO 行号'' AFTER `flowstate`'));
+PREPARE s FROM @ddl; EXECUTE s; DEALLOCATE PREPARE s;

+ 86 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.540.verify.sql

@@ -0,0 +1,86 @@
+-- 1.0.540.verify.sql — HARD ASSERTIONS,每条 SELECT 都必须返回 1
+--
+-- ⚠️ AutoVersionUpdate 按分号切分本文件,并把【每一个切片】都当断言、要求返回 1。因此:
+--      ① 不得放"仅供排查"的 informational 查询
+--      ② 文件必须以最后一条断言的分号结尾,末尾不能留纯注释块
+--      ③ 注释里不得出现任何字面分号
+--
+-- 口径:本批只做【文档级】采购单身份,只断言本批 writer 独占的那一列。
+--   行级身份(PO Line)属 SOURCE CONTRACT GAP —— 上游 FSRCBILLID / FSRCBILLENTRYSEQ 实测 0/4 全空,
+--   本文件因此【刻意不断言】任何 PO 行号,也不断言覆盖率(覆盖率取决于热关注跑批调度)。
+
+-- ── ① 新列已就位,且必须可空 ───────────────────────────────────────────────────────
+--     可空是硬要求:本库 14 行 fixture 分录在上游已不存在,没有权威采购单号可填
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'qms_qcp_insappnentry'
+           AND `COLUMN_NAME` = 'purchase_order_no'
+           AND `IS_NULLABLE` = 'YES') = 1;
+
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'qms_qcp_insappnentry'
+           AND `COLUMN_NAME` = 'purchase_order_no'
+           AND `DATA_TYPE` = 'varchar') = 1;
+
+-- ── ② 不得有默认值:宁可为空,不造假 ───────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'qms_qcp_insappnentry'
+           AND `COLUMN_NAME` = 'purchase_order_no'
+           AND `COLUMN_DEFAULT` IS NOT NULL) = 0;
+
+-- ── ③ 本批不得新增任何 PO 行号载体 ─────────────────────────────────────────────────
+--     行级缺口必须由上游补 FSRCBILLID / FSRCBILLENTRYSEQ,不允许本地自造列顶替
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'qms_qcp_insappnentry'
+           AND `COLUMN_NAME` IN ('purchase_order_line','po_line','purchase_order_entry_seq',
+                                 'source_entry_id','receipt_line')) = 0;
+
+-- ── ④ 既有语义不被破坏:FSRCORDERNUM 仍是收货单号,不得被写成采购单号 ──────────────
+--     判据:凡两列都非空的行,二者不得相等(相等即说明有人把采购单号灌进了收货号列)
+SELECT (SELECT COUNT(*) FROM `qms_qcp_insappnentry`
+         WHERE `purchase_order_no` IS NOT NULL AND `purchase_order_no` <> ''
+           AND `FSRCORDERNUM` IS NOT NULL AND `FSRCORDERNUM` <> ''
+           AND `FSRCORDERNUM` = `purchase_order_no`) = 0;
+
+-- ── ⑤ 不得落字面量 'null' / 空串 / 带空白(writer 已做 Trim 归一)──────────────────
+SELECT (SELECT COUNT(*) FROM `qms_qcp_insappnentry`
+         WHERE `purchase_order_no` IN ('null', '')
+            OR `purchase_order_no` <> TRIM(`purchase_order_no`)) = 0;
+
+-- ── ⑥ 有采购单号的行,租户必须合法 ─────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `qms_qcp_insappnentry`
+         WHERE `purchase_order_no` IS NOT NULL
+           AND (`tenant_id` IS NULL OR `tenant_id` <= 0)) = 0;
+
+-- ── ⑦ 采购单号若能命中标准层,必须是【同租户】命中 ─────────────────────────────────
+--     禁止只按单号跨租户关联。表述为:存在跨租户命中、却不存在同租户命中的行 = 0
+SELECT (SELECT COUNT(*) FROM `qms_qcp_insappnentry` e
+         WHERE e.`purchase_order_no` IS NOT NULL
+           AND EXISTS (SELECT 1 FROM `mdp_std_purchase_order` o
+                        WHERE o.`po_no` = e.`purchase_order_no` AND o.`tenant_id` <> e.`tenant_id`)
+           AND NOT EXISTS (SELECT 1 FROM `mdp_std_purchase_order` o2
+                            WHERE o2.`po_no` = e.`purchase_order_no` AND o2.`tenant_id` = e.`tenant_id`)) = 0;
+
+-- ── ⑧ 一条分录只能有一个采购单号(列本身保证),且不得出现重复放大 ──────────────────
+--     id 是主键,行数必须等于去重后的 id 数 —— 防止镜像扇出造出重复分录
+SELECT (SELECT COUNT(*) FROM `qms_qcp_insappnentry`) =
+       (SELECT COUNT(DISTINCT `id`) FROM `qms_qcp_insappnentry`);
+
+-- ── ⑨ 本批不得改动 IQC 质量语义:pd 仍只允许既有取值 ───────────────────────────────
+SELECT (SELECT COUNT(*) FROM `qms_qcp_inspbill`
+         WHERE `pd` IS NOT NULL AND `pd` NOT IN (0, 1)) = 0;
+
+-- ── ⑩ 1.0.536 采购身份桥不得回归:PR 的订单行 Id 若有值必须仍能同租户命中 ────────────
+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;
+
+-- ── ⑪ 本批不得建 Stage-3 完成事实表 / 完成事件 ─────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` IN ('dwd_procurement_kpi','mdp_std_iqc_inspection','dwd_iqc_completion')) = 0;
+
+SELECT (SELECT COUNT(*) FROM `aidop_action_run_log`
+         WHERE `action_code` = 'S3_PROCUREMENT_COMPLETE') = 0;

+ 212 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/IqcDocumentIdentityContractTests.cs

@@ -0,0 +1,212 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 报检分录「采购单号」文档级身份的生产契约(1.0.540)。
+///
+/// <para>背景:<c>qms_qcp_insappnentry</c> 是 165 侧报检分录的本地镜像。热关注登记的是
+/// <c>BizType='PUR_ORDER'</c> + <c>BizKey=&lt;采购单号&gt;</c>,取数 SQL 也写着
+/// <c>WHERE d.OrdNbr=@k</c> —— 也就是说采购单号在镜像那一刻就是确定的,
+/// 但历史实现只 <c>SELECT e.*</c>,采购单号在 QUERY 层被丢弃。</para>
+///
+/// <para>本文件守住修好之后的形状,尤其守住三条容易被"顺手优化"破坏的边界:
+/// ① 采购单号取<b>标量</b> purOrd,不从 JOIN 行取(否则受扇出与行序影响);
+/// ② <c>FSRCORDERNUM</c> 仍是<b>收货单号</b>,不得被改写成采购单号;
+/// ③ 不得借机补 PO <b>行号</b> —— 上游 <c>FSRCBILLID</c>/<c>FSRCBILLENTRYSEQ</c> 实测全空,
+///    属 SOURCE CONTRACT GAP,任何本地推导都是弱关联。</para>
+/// </summary>
+public class IqcDocumentIdentityContractTests
+{
+    private static string Source(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+
+        var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
+        Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
+        return File.ReadAllText(full);
+    }
+
+    private static string HotWatch() => Source("DataPlatform", "HotWatch", "MdpHotWatchService.cs");
+
+    private static int At(string code, string anchor)
+    {
+        var i = code.IndexOf(anchor, StringComparison.Ordinal);
+        Assert.True(i >= 0, $"未找到锚点:{anchor}");
+        return i;
+    }
+
+    /// <summary>取 <c>qms_qcp_insappnentry</c> 那一条 UPSERT 的完整区间(含参数绑定)。</summary>
+    private static string EntryUpsertBlock()
+    {
+        var code = HotWatch();
+        var start = At(code, "INSERT INTO qms_qcp_insappnentry");
+        var end = At(code, "INSERT INTO qms_qcp_inspbill");
+        Assert.True(end > start, "分录 UPSERT 应出现在检验单 UPSERT 之前");
+        return code[start..end];
+    }
+
+    // ── ① 采购单号确实被写入,且来自 watch.BizKey ──────────────────────────────────
+
+    [Fact]
+    public void 分录UPSERT_必须写入采购单号列()
+    {
+        var block = EntryUpsertBlock();
+        Assert.Contains("purchase_order_no", block, StringComparison.Ordinal);
+        Assert.Contains("@PurchaseOrderNo", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 采购单号必须取标量purOrd_不得从JOIN行取()
+    {
+        var block = EntryUpsertBlock();
+
+        // 必须绑定局部标量 poNo(由 purOrd 归一而来)
+        Assert.Contains("new SugarParameter(\"@PurchaseOrderNo\", poNo", block, StringComparison.Ordinal);
+
+        // 绝不允许从 row 字典里取 —— 那会把 JOIN 扇出与行序引入身份值
+        Assert.DoesNotContain("\"@PurchaseOrderNo\", GetString(row", block, StringComparison.Ordinal);
+        Assert.DoesNotContain("GetString(row, \"OrdNbr\")", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void poNo必须由purOrd归一而来_空值不伪造()
+    {
+        var code = HotWatch();
+        Assert.Contains(
+            "var poNo = string.IsNullOrWhiteSpace(purOrd) ? null : purOrd.Trim();",
+            code,
+            StringComparison.Ordinal);
+    }
+
+    // ── ② 既有语义不被破坏 ────────────────────────────────────────────────────────
+
+    [Fact]
+    public void FSRCORDERNUM必须仍绑定源行的收货单号()
+    {
+        var block = EntryUpsertBlock();
+        Assert.Contains(
+            "new SugarParameter(\"@FSRCORDERNUM\", GetString(row, \"FSRCORDERNUM\")",
+            block,
+            StringComparison.Ordinal);
+
+        // 不得把采购单号灌进收货号列
+        Assert.DoesNotContain("\"@FSRCORDERNUM\", poNo", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 取数SQL不得为了带PO号而改成取JOIN列()
+    {
+        var code = HotWatch();
+        var start = At(code, "case \"QMS_QCP_INSAPPNENTRY\":");
+        var end = At(code, "case \"QMS_QCP_INSPECAPPLYN\":");
+        var sql = code[start..end];
+
+        // 仍只取 e.*:这是"免疫扇出"的设计前提,改成 e.*, d.* 会让一行分录因多个 PO 行而重复
+        Assert.Contains("SELECT TOP 200 e.*", sql, StringComparison.Ordinal);
+        Assert.DoesNotContain("d.OrdNbr AS", sql, StringComparison.Ordinal);
+
+        // 作用域键必须仍是 d.OrdNbr=@k —— 这是 poNo 与每行 d.OrdNbr 恒等的依据
+        Assert.Contains("d.OrdNbr = @k", sql, StringComparison.Ordinal);
+    }
+
+    // ── ③ 不得借机补行级身份 ──────────────────────────────────────────────────────
+
+    [Fact]
+    public void 禁止写入PO行号_行级身份属源契约缺口()
+    {
+        var block = EntryUpsertBlock();
+
+        // FSRCBILLENTRYSEQ / FSRCBILLID 上游实测全空,任何本地推导都是弱关联
+        Assert.DoesNotContain("FSRCBILLENTRYSEQ", block, StringComparison.Ordinal);
+        Assert.DoesNotContain("FSRCBILLID", block, StringComparison.Ordinal);
+        Assert.DoesNotContain("purchase_order_line", block, StringComparison.Ordinal);
+        Assert.DoesNotContain("OrdLine", block, StringComparison.Ordinal);
+    }
+
+    // ── ④ 幂等与租户 ─────────────────────────────────────────────────────────────
+
+    [Fact]
+    public void 分录UPSERT必须按主键幂等_不因扇出重复建行()
+    {
+        var block = EntryUpsertBlock();
+        Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal);
+        // 主键沿用 165 的 id:同一分录被多次镜像只会更新,不会新增
+        Assert.Contains("new SugarParameter(\"@Id\", id)", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 采购单号为空时不得把已有权威值打回NULL()
+    {
+        var block = EntryUpsertBlock();
+        Assert.Contains(
+            "purchase_order_no = IF(IFNULL(VALUES(purchase_order_no),'')='',",
+            block,
+            StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 租户必须来自参数而非远端Domain()
+    {
+        var block = EntryUpsertBlock();
+        Assert.Contains("new SugarParameter(\"@TenantId\", tenantId)", block, StringComparison.Ordinal);
+        // 既有的"租户只在为空时写入"守卫不得丢
+        Assert.Contains(
+            "tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)",
+            block,
+            StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 无法解析租户时必须整体跳过_不得落无主数据()
+    {
+        var code = HotWatch();
+        var start = At(code, "private async Task ApplyIqcAsync");
+        var body = code[start..(start + 1200)];
+        Assert.Contains("if (tenantId <= 0)", body, StringComparison.Ordinal);
+        Assert.Contains("return;", body, StringComparison.Ordinal);
+    }
+
+    // ── ⑤ 身份与质量正交 ─────────────────────────────────────────────────────────
+
+    [Fact]
+    public void 镜像不得按检验判定过滤_身份与质量是正交维度()
+    {
+        var code = HotWatch();
+        var start = At(code, "private async Task ApplyIqcAsync");
+        var end = At(code, "private async Task ApplyPurchaseReceiptAsync");
+        var body = code[start..end];
+
+        // pd=0/pd=1 都必须照常镜像,否则不合格单将拿不到采购单身份
+        Assert.DoesNotContain("pd = 0", body, StringComparison.Ordinal);
+        Assert.DoesNotContain("pd=0", body, StringComparison.Ordinal);
+    }
+
+    // ── ⑥ 检验单经 FK 回到分录的链路不得断 ────────────────────────────────────────
+
+    [Fact]
+    public void 检验单必须保留回到分录的外键()
+    {
+        var code = HotWatch();
+        var start = At(code, "INSERT INTO qms_qcp_inspbill");
+        var block = code[start..(start + 2600)];
+
+        // hid → insappnentry.id,lydjbh → inspecapplyn.FBILLNO:
+        // 有这两把 FK,检验单就不需要重复存采购单号
+        Assert.Contains("hid", block, StringComparison.Ordinal);
+        Assert.Contains("lydjbh", block, StringComparison.Ordinal);
+    }
+
+    // ── ⑦ 1.0.536 采购身份桥不得回归 ──────────────────────────────────────────────
+
+    [Fact]
+    public void S3身份桥的采购申请投影不得被移除()
+    {
+        var s3 = Source("Supply", "S3MdpSyncTransformService.cs");
+        Assert.Contains("purchase_request_no", s3, StringComparison.Ordinal);
+        Assert.Contains("sales_order_entry_id", s3, StringComparison.Ordinal);
+    }
+}

+ 18 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/DataPlatform/HotWatch/MdpHotWatchService.cs

@@ -669,6 +669,10 @@ public sealed class MdpHotWatchService : ITransient
             return;
         }
 
+        // 文档级采购单身份:purOrd 即 watch.BizKey(调用点 BizType 已限定 PUR_ORDER)。
+        // 仅做去空白归一,不做任何补齐/推导;为空则该批分录不写采购单号。
+        var poNo = string.IsNullOrWhiteSpace(purOrd) ? null : purOrd.Trim();
+
         var nApply = 0;
         var nEntry = 0;
         var nBill = 0;
@@ -718,11 +722,13 @@ public sealed class MdpHotWatchService : ITransient
                     INSERT INTO qms_qcp_insappnentry (
                         id, tenant_id, glid, FSEQ, FMATERIALCFG, wlmc, ggxh, FLOTNUMBER,
                         FSRCORDERTYPE, FSRCORDERNUM, FAPPLYQTY, FINSPECTSTATUS,
-                        jykssj, jywcsj, FWAREHOUSEID, FLOCATIONID, FSUPPLIER, gysbm, gysmc, shdh
+                        jykssj, jywcsj, FWAREHOUSEID, FLOCATIONID, FSUPPLIER, gysbm, gysmc, shdh,
+                        purchase_order_no
                     ) VALUES (
                         @Id, @TenantId, @Glid, @FSEQ, @FMATERIALCFG, @Wlmc, @Ggxh, @FLOTNUMBER,
                         @FSRCORDERTYPE, @FSRCORDERNUM, @FAPPLYQTY, @FINSPECTSTATUS,
-                        @Jykssj, @Jywcsj, @FWAREHOUSEID, @FLOCATIONID, @FSUPPLIER, @Gysbm, @Gysmc, @Shdh
+                        @Jykssj, @Jywcsj, @FWAREHOUSEID, @FLOCATIONID, @FSUPPLIER, @Gysbm, @Gysmc, @Shdh,
+                        @PurchaseOrderNo
                     )
                     ON DUPLICATE KEY UPDATE
                         glid = VALUES(glid),
@@ -747,6 +753,10 @@ public sealed class MdpHotWatchService : ITransient
                         gysbm = VALUES(gysbm),
                         gysmc = VALUES(gysmc),
                         shdh = VALUES(shdh),
+                        -- 采购单号只在拿得到权威值时覆盖:@PurchaseOrderNo 为空说明本轮不是
+                        -- PUR_ORDER 作用域(或 BizKey 缺失),此时不得把已有权威值打回 NULL。
+                        purchase_order_no = IF(IFNULL(VALUES(purchase_order_no),'')='',
+                                               purchase_order_no, VALUES(purchase_order_no)),
                         tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)
                     """,
                     new SugarParameter("@Id", id),
@@ -768,7 +778,12 @@ public sealed class MdpHotWatchService : ITransient
                     new SugarParameter("@FSUPPLIER", GetString(row, "FSUPPLIER") ?? (object)DBNull.Value),
                     new SugarParameter("@Gysbm", GetString(row, "gysbm") ?? (object)DBNull.Value),
                     new SugarParameter("@Gysmc", GetString(row, "gysmc") ?? (object)DBNull.Value),
-                    new SugarParameter("@Shdh", GetString(row, "shdh") ?? (object)DBNull.Value));
+                    new SugarParameter("@Shdh", GetString(row, "shdh") ?? (object)DBNull.Value),
+                    // 采购单号取**标量参数** purOrd(= watch.BizKey),不从 JOIN 行取:
+                    //   取数 SQL 已用 `WHERE d.OrdNbr=@k` 把结果限定在该采购单作用域内,
+                    //   故 BizKey 与每一行的 d.OrdNbr 恒等 —— 用标量即天然免疫 JOIN 扇出,
+                    //   也不受返回行顺序影响。空值不伪造(见 ODKU 的守卫)。
+                    new SugarParameter("@PurchaseOrderNo", poNo ?? (object)DBNull.Value));
             }
         }