Browse Source

feat(s8): add stage3 PO completion fact | server 1.0.544

采购订单的生命周期在拆库后由两个系统分阶段共管:建单归 Ai-DOP,
收货关闭归 WMS/MES。两侧 Status 值域实测零重叠(本库 R/O 共 448 行、
0 个 C;Source B 空串 97 行 + C 1 行、0 个 R/O),RecID 也分属不相交
ID 空间,因此不能把执行态覆盖进 mdp_std_purchase_order.status —— 那会
把两个系统的事实混进同一列、抹掉 provenance。本批改为落一张独立事实表。

mdp_std_purchase_order_completion
  Grain = (tenant_id, po_no, po_line)
  domain / potype / source_row_id / source_id 是 provenance,不进唯一键

三态而非布尔:'C'→COMPLETED,空串→NOT_COMPLETED,NULL 与未知值→UNKNOWN。
刻意不写 IFNULL(status,''):NULL 是「源侧没给值」,未知值是「给了我们不
认识的值」,都不等于「已确认尚未完成」,滑成 NOT_COMPLETED 会让下游把
未知当确定结论。Source B 没有该行时不落行(NOT_OBSERVED),绝不由
Source A 反向造行 —— 实测 351/449 组属此类,多数只是尚未推送到 WMS。

上游写法是 Status=(case when 收满 then 'C' else '' end),退货会把 C 打回
空串,所以这是当前状态而非事件:UPSERT 无条件覆盖结论,支持
COMPLETED → NOT_COMPLETED 回退。

Full Reconciliation 是正确性主路径,不是优化项:HotWatch 只覆盖活动中的
采购单,实测 5 个 PUR_ORDER 关注中 3 个已提前终止(且无一是因采购完成
而终止),98 条源行里 88 条没有对应关注。故新增 30 分钟周期作业。

源绑定走配置(AiDOP:PoCompletion:SourceCode),业务代码内不出现具体源
编码。非开发环境命中 DevOnlySourceCodes 即 fail-closed;另外结构性拦截
ReferenceEquals(remote, _db) —— MdpSourceScopeFactory 对本库样板源与未配
账号的源会静默返回主库连接,不挡住的话会把 Source A 的本地表当成 WMS
执行态读进来。

租户只由 watch/本地 PO 身份唯一解析,歧义与未解析一律跳过;绝不用 Domain
反推(ado_source_domain_tenant_map 全表一行,用它会把 838186 错记到 797)。
淘汰只在源读取与写入都成功之后执行,且 DELETE 谓词显式 tenant-safe。

不建 completion_time:Source B 的 UpdateTime 会被 PO_WMS_POD 回推刷新
(98 行里 90 行最后更新者是 DOP 侧或 MDP_HOT),无法表达「何时变成 C」。

实测:源侧 98 行全部唯一命中本地采购行,1 COMPLETED / 97 NOT_COMPLETED /
0 UNKNOWN / 0 skipped;连续两轮计数一致;租户 797 单独刷新未触碰租户
838186 的 25 行;20 条 HARD 断言在真实数据下全过;mdp_std_purchase_order
与 mdp_std_iqc_inspection 的内容 MD5 与实施前逐字节一致;Source B 零写入。
YY968XX 1 day ago
parent
commit
f23d8e6c22

+ 10 - 0
server/Admin.NET.Application/Configuration/App.json

@@ -75,6 +75,16 @@
       "LiveTimeoutSeconds": 5,
       "DefaultDomain": "8010"
     },
+    "PoCompletion": {
+      // 承担 S8_PO_COMPLETION_SOURCE 角色(WMS/MES 收货执行)的 mdp_source.source_code。
+      // 下面这个值是开发/UAT 实例;部署到非开发环境时必须改成真实 WMS 源,
+      // 否则 DevOnlySourceCodes 会让同步 fail-closed 直接失败,不会静默使用测试库。
+      "SourceCode": "DOPDEMORQ_SQLSERVER",
+      "DevOnlySourceCodes": [ "DOPDEMORQ_SQLSERVER" ],
+      "SourcePotype": "po", // 与 PurOrdWmsPushService 自建单推送口径一致
+      "MaxSourceRows": 50000,
+      "UpsertBatchSize": 200
+    },
     "S1Rebuild": {
       "MaxParallelTenants": 2
     },

+ 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.543</AssemblyVersion>
-    <FileVersion>1.0.543</FileVersion>
-    <Version>1.0.543</Version>
+    <AssemblyVersion>1.0.544</AssemblyVersion>
+    <FileVersion>1.0.544</FileVersion>
+    <Version>1.0.544</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -874,6 +874,12 @@
     <None Update="UpdateScripts\1.0.543.verify.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.544.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
+    <None Update="UpdateScripts\1.0.544.verify.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
     <None Update="UpdateScripts\UAT-PLACEHOLDER-MENU-HIDE.ops.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>

+ 70 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.544.sql

@@ -0,0 +1,70 @@
+-- =====================================================================================
+-- 1.0.544 · S8 Stage-3 采购订单完成态标准事实层(mdp_std_purchase_order_completion)
+--
+-- ── 为什么必须是独立一张表 ──────────────────────────────────────────────────────────
+-- 采购订单的生命周期在拆库后由两个系统分阶段共管(见 doc/plan/旧DOP-MES-WMS/对接任务书/
+-- 附-字段共管矩阵.md §6/§7,以及 DataPlatform/Wms/PurOrdWmsPushService.cs 的
+-- 「Status 只建行写空串;收货态由 WMS 维护」):
+--   建单阶段  → Ai-DOP(本库 PurOrdDetail,投影为 mdp_std_purchase_order.status)
+--   收货/关闭 → WMS/MES(Source B 的 PurOrdDetail.Status)
+-- 两侧值域实测零重叠(本库 R/O 各 208/240 行、0 个 C;Source B 空串 97 行 + C 1 行、
+-- 0 个 R/O),且两侧 RecID 分属不相交 ID 空间,因此【不能】把 Source B 的执行态
+-- 覆盖进 mdp_std_purchase_order.status —— 那会把两个系统的事实混进同一列、抹掉 provenance。
+--
+-- ── Grain ───────────────────────────────────────────────────────────────────────────
+-- CANONICAL_GRAIN = (tenant_id, po_no, po_line)
+-- 依据:① Source B 的业务键是 (Domain, PurOrd, Line),而 Domain 在 165 实测只有 8010
+--        一个账套,退化为 (PurOrd, Line);
+--      ② 实测本库 449/449 组 (po_no, po_line) 单租户、147/147 个 po_no 单租户,
+--        与 MdpHotWatchService 的「采购单号本库唯一」断言一致;
+--      ③ mdp_std_purchase_order 无 domain 列,消费侧只能按 (tenant_id, po_no, po_line) 关联。
+-- domain / potype / source_row_id / source_id 是 provenance,【不进】唯一键。
+--
+-- ── 三态与可逆 ──────────────────────────────────────────────────────────────────────
+--   raw_status='C'   → COMPLETED
+--   raw_status=''    → NOT_COMPLETED
+--   raw_status IS NULL 或其它非空值 → UNKNOWN(绝不滑成 NOT_COMPLETED)
+--   Source B 无该行  → NOT_OBSERVED:【本表不落行】,绝不由 Source A 反向造行
+-- 上游 pr_WMS_UpdatePurOrdRctTransactionProcessing2 的写法是
+-- `Status=(case when ... then 'C' else '' end)`,退货会把 C 打回空串,
+-- 故本表是【当前状态】而非一次性事件,Writer 必须允许 COMPLETED → NOT_COMPLETED 回退。
+--
+-- ── 刻意不做 ────────────────────────────────────────────────────────────────────────
+-- 不建 completion_time:Source B 的 UpdateTime 会被 Ai-DOP 自己的 PO_WMS_POD 回推刷新
+-- (实测 98 行里 90 行的最后更新者是 DOP 用户或 MDP_HOT),无法表达「何时变成 C」。
+-- observed_at / sync_time 是观测时刻,【禁止】冒充业务完成时间。
+-- 不做订单级完成聚合;不做 ObjectCompleted;不读 IQC;不写任何源表。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `mdp_std_purchase_order_completion` (
+  `id`                  BIGINT        NOT NULL AUTO_INCREMENT,
+  `tenant_id`           BIGINT        NOT NULL                COMMENT '租户。权威 = HotWatch watch.TenantId 或本地 PO 身份反查;绝不由 Domain 推导',
+  `factory_id`          BIGINT        NULL     DEFAULT 1      COMMENT '工厂。Source B 无该概念,恒 1,保留以对齐 mdp_std_* 形状',
+
+  `po_no`               VARCHAR(100)  NOT NULL                COMMENT '采购单号 = Source B PurOrdDetail.PurOrd(canonical)',
+  `po_line`             VARCHAR(50)   NOT NULL                COMMENT '采购行号 = Source B PurOrdDetail.Line(canonical)',
+
+  `domain`              VARCHAR(24)   NULL                    COMMENT 'Source B 账套 = PurOrdDetail.Domain(实测恒 8010)。仅作 provenance,【不是租户边界】,不进唯一键',
+  `potype`              VARCHAR(20)   NULL                    COMMENT 'Source B PurOrdDetail.Potype,仅作上下文',
+
+  `raw_status`          VARCHAR(8)    NULL                    COMMENT 'Source B PurOrdDetail.Status 原值(已 TRIM):C=收完关闭 / 空串=未完成 / NULL=源列为空',
+  `completion_status`   VARCHAR(20)   NOT NULL                COMMENT '完成结论:COMPLETED(C) / NOT_COMPLETED(空串) / UNKNOWN(NULL 或未知值)。派生自 raw_status,绝不来自 Source A',
+
+  `source_system`       VARCHAR(50)   NOT NULL                COMMENT '产出该事实的源系统编码(mdp_source.source_code),明示这是 WMS 侧事实',
+  `source_id`           BIGINT        NULL                    COMMENT 'mdp_source.id,便于切源后追溯',
+  `source_row_id`       VARCHAR(100)  NULL                    COMMENT 'Source B PurOrdDetail.RecID。source-local identity,禁止与本库 RecID 比较或判新旧',
+  `source_update_time`  DATETIME      NULL                    COMMENT 'Source B UpdateTime。⚠️ 非权威:会被 PO_WMS_POD 回推刷新,禁止当作完成时间',
+  `source_update_user`  VARCHAR(50)   NULL                    COMMENT 'Source B UpdateUser。用于区分 WMS 动作与 Ai-DOP 回推(MDP_HOT)',
+
+  `observed_at`         DATETIME      NOT NULL                COMMENT '本次观测到该状态的时刻。⚠️ 观测时间,禁止冒充业务完成时间',
+  `sync_batch_id`       VARCHAR(100)  NOT NULL                COMMENT '同步批次',
+  `sync_time`           DATETIME      NOT NULL                COMMENT '同步时间',
+  `create_time`         DATETIME      NULL     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+  `update_time`         DATETIME      NULL     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_mdp_std_po_completion` (`tenant_id`, `po_no`, `po_line`),
+  KEY `idx_std_po_compl_status` (`tenant_id`, `completion_status`),
+  KEY `idx_std_po_compl_batch` (`sync_batch_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
+  COMMENT='S8 Stage-3 采购订单完成态标准事实层。Grain = 一条采购行(tenant_id + po_no + po_line)。来源 = WMS/MES 执行系统,当前状态可逆';

+ 120 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.544.verify.sql

@@ -0,0 +1,120 @@
+-- 1.0.544.verify.sql — HARD ASSERTIONS,每条 SELECT 都必须返回 1
+--
+-- ⚠️ AutoVersionUpdate 按分号切分本文件,并把【每一个切片】都当断言、要求返回 1。因此:
+--      ① 不得放"仅供排查"的 informational 查询
+--      ② 文件必须以最后一条断言的分号结尾,末尾不能留纯注释块
+--      ③ 注释里不得出现任何字面分号
+--
+-- 口径:只断言本批 Writer 独占产出的 mdp_std_purchase_order_completion。
+--   结构断言迁移后即可全过;数据断言在表为空时也成立,Writer 跑过之后才真正有判别力。
+
+-- ── ① 表与 Canonical 唯一键就位 ─────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` = 'mdp_std_purchase_order_completion') = 1;
+
+SELECT (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX`)
+          FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` = 'mdp_std_purchase_order_completion'
+           AND `INDEX_NAME` = 'uk_mdp_std_po_completion') = 'tenant_id,po_no,po_line';
+
+-- ①b provenance 列绝不得混进业务唯一键
+SELECT (SELECT COUNT(*) FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` = 'mdp_std_purchase_order_completion'
+           AND `INDEX_NAME` = 'uk_mdp_std_po_completion'
+           AND `COLUMN_NAME` IN ('domain','source_row_id','source_id','potype')) = 0;
+
+-- ── ② Canonical 无重复:一条采购行只能一行 ──────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`) =
+       (SELECT COUNT(DISTINCT `tenant_id`, `po_no`, `po_line`)
+          FROM `mdp_std_purchase_order_completion`);
+
+-- ── ③ 租户合法:绝不落无主 Fact ─────────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `tenant_id` IS NULL OR `tenant_id` <= 0) = 0;
+
+-- ── ④ completion_status 取值域封闭 ──────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `completion_status` NOT IN ('COMPLETED','NOT_COMPLETED','UNKNOWN')) = 0;
+
+-- ── ⑤ 映射必须严格由 raw_status 派生 ────────────────────────────────────────────────
+-- ⑤a raw_status 不是 'C' 就绝不能是 COMPLETED(IFNULL 是必需的,NULL 比较不会为真)
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `completion_status` = 'COMPLETED'
+           AND IFNULL(`raw_status`, '#') <> 'C') = 0;
+
+-- ⑤b raw_status='C' 必须 COMPLETED
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `raw_status` = 'C' AND `completion_status` <> 'COMPLETED') = 0;
+
+-- ⑤c raw_status 为空串必须 NOT_COMPLETED
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `raw_status` = '' AND `completion_status` <> 'NOT_COMPLETED') = 0;
+
+-- ⑤d raw_status 为 NULL 绝不能滑成 NOT_COMPLETED —— 未知不得当未完成
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `raw_status` IS NULL AND `completion_status` <> 'UNKNOWN') = 0;
+
+-- ⑤e 既非 'C' 也非空串的已知值一律 UNKNOWN
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE `raw_status` IS NOT NULL AND `raw_status` NOT IN ('C','')
+           AND `completion_status` <> 'UNKNOWN') = 0;
+
+-- ── ⑥ provenance 必须完整:Fact 只能由实际观测到的 Source B 行产生 ──────────────────
+--     由 Source A 反向捏造的行拿不到 source_row_id,本条即是 NOT_OBSERVED 不得物化的结构守卫
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion`
+         WHERE IFNULL(`source_system`, '') = ''
+            OR IFNULL(`source_row_id`, '') = ''
+            OR `observed_at` IS NULL
+            OR IFNULL(`sync_batch_id`, '') = '') = 0;
+
+-- ⑥b source_system 必须是已登记且启用的数据源
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion` c
+         WHERE NOT EXISTS (SELECT 1 FROM `mdp_source` s
+                            WHERE s.`source_code` = c.`source_system`
+                              AND s.`status` = 1)) = 0;
+
+-- ── ⑦ 每条 Fact 必须同租户命中唯一的本地采购行 ──────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion` c
+         WHERE NOT EXISTS (SELECT 1 FROM `mdp_std_purchase_order` o
+                            WHERE o.`tenant_id` = c.`tenant_id`
+                              AND o.`po_no` = c.`po_no`
+                              AND o.`po_line` = c.`po_line`)) = 0;
+
+-- ⑦b 禁止跨租户错配:同一 (po_no, po_line) 不得在 Fact 与本地 PO 之间分属不同租户
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_order_completion` c
+          JOIN `mdp_std_purchase_order` o
+            ON o.`po_no` = c.`po_no` AND o.`po_line` = c.`po_line`
+         WHERE o.`tenant_id` <> c.`tenant_id`) = 0;
+
+-- ── ⑧ 本批不得引入完成时间列 —— 当前无权威 ──────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` = 'mdp_std_purchase_order_completion'
+           AND `COLUMN_NAME` IN ('completion_time','completed_at','procurement_completion_time')) = 0;
+
+-- ── ⑨ 本批不得建 Stage-3 聚合 / Read Model ──────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` IN ('mdp_std_stage3_completion','dwd_stage3_completion',
+                                'dwd_procurement_completion')) = 0;
+
+-- ── ⑩ 上游批次资产不得回归 ──────────────────────────────────────────────────────────
+--     1.0.541:IQC 标准事实层仍在且 Grain 未被改动
+SELECT (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX`)
+          FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_iqc_inspection'
+           AND `INDEX_NAME` = 'uk_mdp_std_iqc_insp') = 'tenant_id,source_row_id';
+
+--     1.0.540:分录上的采购单号列仍在且仍可空
+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;
+
+--     Source A 基础事实的唯一键不得被本批改动
+SELECT (SELECT GROUP_CONCAT(`COLUMN_NAME` ORDER BY `SEQ_IN_INDEX`)
+          FROM `information_schema`.`STATISTICS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_purchase_order'
+           AND `INDEX_NAME` = 'uk_po_line') = 'tenant_id,po_no,po_line';

+ 512 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S8/Supply/PurchaseOrderCompletionContractTests.cs

@@ -0,0 +1,512 @@
+using Admin.NET.Plugin.AiDOP;
+using Admin.NET.Plugin.AiDOP.Supply;
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S8.Supply;
+
+/// <summary>
+/// S8 Stage-3 采购完成态事实(<c>mdp_std_purchase_order_completion</c>,1.0.544)的生产契约。
+///
+/// <para>这条链让 S8 在<b>不覆盖</b> <c>mdp_std_purchase_order.status</c> 的前提下拿到 WMS 侧
+/// 「这条采购行是否收货关闭」。一旦被"顺手优化"破坏,S8 会在毫无征兆的情况下
+/// 把未知当成未完成、把已回退的行当成仍然完成,或者把事实写到别的租户名下。</para>
+///
+/// <para>本文件守住六条最易被破坏的边界:
+/// ① 三态映射 —— NULL / 未知值绝不能滑成 NOT_COMPLETED;
+/// ② 可逆 —— UPSERT 必须无条件覆盖结论,不许有"一旦完成永远完成"的守卫;
+/// ③ 租户 —— 唯一才用,歧义与未解析一律跳过,绝不"取第一个",绝不用 Domain 推租户;
+/// ④ 淘汰 —— 只在源侧读取成功后执行,且必须 tenant-safe;
+/// ⑤ 源绑定 —— 配置化 + 非开发环境 fail-closed,且必须挡住"回落主库";
+/// ⑥ 边界 —— 不读 IQC、不用数量推完成、不写任何源表。</para>
+/// </summary>
+public class PurchaseOrderCompletionContractTests
+{
+    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 Service() => Source("Supply", "PurchaseOrderCompletionMdpSyncService.cs");
+
+    /// <summary>
+    /// 去掉 XML 文档注释后的代码体。禁止类断言必须扫这个 —— 文档注释需要点名
+    /// 「不读 IQC」「回到 NOT_OBSERVED」这些概念来说明边界,那是说明而不是违规。
+    /// </summary>
+    private static string ServiceBody()
+        => string.Join('\n', Service().Split('\n').Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
+
+    private static string Migration(string name)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
+            dir = dir.Parent;
+        Assert.NotNull(dir);
+        var full = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", name);
+        Assert.True(File.Exists(full), $"迁移脚本不存在:{full}");
+        return File.ReadAllText(full);
+    }
+
+    private static int At(string code, string anchor)
+    {
+        var i = code.IndexOf(anchor, StringComparison.Ordinal);
+        Assert.True(i >= 0, $"未找到锚点:{anchor}");
+        return i;
+    }
+
+    private static string Between(string code, string startAnchor, string endAnchor)
+    {
+        var s = At(code, startAnchor);
+        var e = At(code, endAnchor);
+        Assert.True(e > s, $"锚点顺序不符:{startAnchor} 应在 {endAnchor} 之前");
+        return code[s..e];
+    }
+
+    // ── ① 三态映射 ───────────────────────────────────────────────────────────────
+
+    [Theory]
+    [InlineData("C")]
+    [InlineData("c")]
+    [InlineData(" C ")]
+    public void 状态C一律映射为COMPLETED(string raw)
+        => Assert.Equal("COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
+
+    [Theory]
+    [InlineData("")]
+    [InlineData("   ")]
+    public void 空串映射为NOT_COMPLETED(string raw)
+        => Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
+
+    [Fact]
+    public void NULL绝不能滑成NOT_COMPLETED()
+    {
+        // 「源侧没给值」不等于「已确认尚未完成」,下游会把它当确定结论用
+        Assert.Equal("UNKNOWN", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(null));
+    }
+
+    [Theory]
+    [InlineData("R")]
+    [InlineData("O")]
+    [InlineData("S")]
+    [InlineData("X")]
+    public void 未知非空值一律UNKNOWN(string raw)
+        => Assert.Equal("UNKNOWN", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
+
+    [Fact]
+    public void 映射取值域封闭()
+    {
+        var allowed = new[] { "COMPLETED", "NOT_COMPLETED", "UNKNOWN" };
+        foreach (var raw in new string?[] { null, "", " ", "C", "c", "R", "O", "Z", "完成" })
+            Assert.Contains(PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw), allowed);
+    }
+
+    [Fact]
+    public void raw_status归一保留NULL()
+    {
+        Assert.Null(PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(null));
+        Assert.Equal("", PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus("  "));
+        Assert.Equal("C", PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(" C "));
+    }
+
+    [Fact]
+    public void 禁止用IFNULL把NULL和空串合并()
+    {
+        var code = Service();
+        var mapper = Between(code, "public static string MapCompletionStatus", "public static string? NormalizeRawStatus");
+        // 出现 ?? "" 或 IFNULL 语义即意味着 NULL 被当成空串处理
+        Assert.DoesNotContain("?? \"\"", mapper, StringComparison.Ordinal);
+        Assert.DoesNotContain("?? string.Empty", mapper, StringComparison.Ordinal);
+        Assert.Contains("if (rawStatus is null) return StatusUnknown;", mapper, StringComparison.Ordinal);
+    }
+
+    // ── ② 可逆:COMPLETED → NOT_COMPLETED 必须能回退 ──────────────────────────────
+
+    [Fact]
+    public void 回退序列_空到C再回空()
+    {
+        // 同一 Grain 连续三次观测,结论必须跟着源侧走
+        Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(""));
+        Assert.Equal("COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus("C"));
+        Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(""));
+    }
+
+    [Fact]
+    public void UPSERT必须无条件覆盖结论_不得有一旦完成永远完成的守卫()
+    {
+        var block = Between(Service(), "ON DUPLICATE KEY UPDATE", "await _db.Ado.ExecuteCommandAsync(sb.ToString()");
+
+        Assert.Contains("raw_status=VALUES(raw_status)", block, StringComparison.Ordinal);
+        Assert.Contains("completion_status=VALUES(completion_status)", block, StringComparison.Ordinal);
+
+        // 任何 IF(...) / CASE / COALESCE 形式的保留旧值写法都会让回退静默失效
+        foreach (var forbidden in new[]
+                 {
+                     "completion_status = IF(", "completion_status=IF(",
+                     "raw_status = IF(", "raw_status=IF(",
+                     "CASE WHEN completion_status", "COALESCE(completion_status",
+                 })
+            Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 同一Grain只能一行_唯一键就位()
+    {
+        var sql = Migration("1.0.544.sql");
+        Assert.Contains(
+            "UNIQUE KEY `uk_mdp_std_po_completion` (`tenant_id`, `po_no`, `po_line`)",
+            sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void provenance列不得混进业务唯一键()
+    {
+        var sql = Migration("1.0.544.sql");
+        foreach (var forbidden in new[]
+                 {
+                     "uk_mdp_std_po_completion` (`tenant_id`, `domain`",
+                     "uk_mdp_std_po_completion` (`tenant_id`, `source_row_id`",
+                 })
+            Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
+    }
+
+    // ── ③ 租户归属:唯一才用 ─────────────────────────────────────────────────────
+
+    private static List<PoTenantCandidate> Candidates(params (string po, string dom, long tid)[] rows)
+        => rows.Select(r => new PoTenantCandidate { PoNo = r.po, Domain = r.dom, TenantId = r.tid }).ToList();
+
+    [Fact]
+    public void 租户唯一时使用()
+    {
+        var c = Candidates(("PO1", "", 797L), ("PO2", "", 838L));
+        Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
+    }
+
+    [Fact]
+    public void 租户无候选时返回0_不得臆造()
+    {
+        var c = Candidates(("PO1", "", 797L));
+        Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO-NOT-EXIST", "8010"));
+    }
+
+    [Fact]
+    public void 租户歧义时返回0_绝不取第一个()
+    {
+        // 同一采购单号在两个租户下都存在 —— 必须整体跳过,不能任选
+        var c = Candidates(("PO1", "", 797L), ("PO1", "", 838L));
+        Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
+    }
+
+    [Fact]
+    public void 空Domain视为通配_自建单可命中()
+    {
+        var c = Candidates(("PO1", "", 797L));
+        Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
+    }
+
+    [Fact]
+    public void Domain不一致的候选被排除()
+    {
+        var c = Candidates(("PO1", "9999", 797L));
+        Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
+    }
+
+    [Fact]
+    public void 相同Domain不会把不同租户混成一个()
+    {
+        // Domain 8010 横跨多个租户,绝不能因为 Domain 相同就认为是同一租户
+        var c = Candidates(("PO1", "8010", 797L), ("PO1", "8010", 838L));
+        Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
+    }
+
+    [Fact]
+    public void 同一租户多行不算歧义()
+    {
+        var c = Candidates(("PO1", "", 797L), ("PO1", "8010", 797L));
+        Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
+    }
+
+    [Fact]
+    public void 禁止用Domain映射表反推租户()
+    {
+        var code = ServiceBody();
+        // ado_source_domain_tenant_map 全表只有一行,用它会把 838 的数据错记到 797 名下
+        Assert.DoesNotContain("ado_source_domain_tenant_map", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("ResolveDomainAsync", code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 租户解析不到必须跳过_不得落无主Fact()
+    {
+        var run = Between(Service(), "var resolved = ResolveTenant(", "// 单租户刷新");
+        Assert.Contains("if (resolved <= 0)", run, StringComparison.Ordinal);
+        Assert.Contains("continue;", run, StringComparison.Ordinal);
+    }
+
+    // ── ④ 本地采购行校验 ─────────────────────────────────────────────────────────
+
+    [Fact]
+    public void 多命中必须硬失败_不得取第一条()
+    {
+        var run = Between(Service(), "var matches = poLineIndex.TryGetValue", "result.MatchedRows++");
+        Assert.Contains("if (matches > 1)", run, StringComparison.Ordinal);
+        Assert.Contains("throw new InvalidOperationException", run, StringComparison.Ordinal);
+        Assert.Contains("if (matches == 0)", run, StringComparison.Ordinal);
+
+        foreach (var forbidden in new[] { "LIMIT 1", ".First()", ".Min()", ".Max()" })
+            Assert.DoesNotContain(forbidden, run, StringComparison.Ordinal);
+    }
+
+    // ── ⑤ 淘汰:只在成功后、且 tenant-safe ───────────────────────────────────────
+
+    [Fact]
+    public void 淘汰必须在源读取与写入成功之后()
+    {
+        var code = Service();
+        var upsert = At(code, "result.WrittenRows = await UpsertAsync");
+        var retire = At(code, "result.StaleRemoved = await RetireStaleAsync");
+        var read = At(code, "var sourceRows = await ReadSourceRowsAsync");
+        Assert.True(read < upsert, "必须先读源再写入");
+        Assert.True(upsert < retire, "必须先写入再淘汰,读取失败时异常会跳过淘汰");
+    }
+
+    [Fact]
+    public void 异常分支绝不淘汰()
+    {
+        var catchBlock = Between(Service(), "catch (Exception ex)", "// ── 源绑定与 fail-closed");
+        Assert.DoesNotContain("RetireStaleAsync", catchBlock, StringComparison.Ordinal);
+        Assert.DoesNotContain("DELETE", catchBlock, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 单租户刷新只淘汰该租户()
+    {
+        var retire = Between(Service(), "private async Task<int> RetireStaleAsync", "// ── run log");
+        Assert.Contains("WHERE tenant_id=@TenantId AND IFNULL(sync_batch_id,'') <> @BatchId", retire, StringComparison.Ordinal);
+        // 全量路径也必须显式排除无主行
+        Assert.Contains("WHERE tenant_id > 0 AND IFNULL(sync_batch_id,'') <> @BatchId", retire, StringComparison.Ordinal);
+        // 绝不允许无条件 DELETE
+        Assert.DoesNotContain("DELETE FROM mdp_std_purchase_order_completion\"", retire, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 单租户刷新时其它租户的源行不参与本轮()
+    {
+        // 否则它们会因为没盖上本批次号而被误删
+        var run = Between(Service(), "// 单租户刷新", "var matches = poLineIndex.TryGetValue");
+        Assert.Contains("if (tenantId > 0 && resolved != tenantId)", run, StringComparison.Ordinal);
+        Assert.Contains("continue;", run, StringComparison.Ordinal);
+    }
+
+    // ── ⑥ 源绑定与 fail-closed ───────────────────────────────────────────────────
+
+    [Fact]
+    public void 源编码必须来自配置_业务代码内不得出现具体源字面量()
+    {
+        var code = Service();
+        Assert.DoesNotContain("DOPDEMORQ", code, StringComparison.OrdinalIgnoreCase);
+        Assert.Contains("_opt.SourceCode", code, StringComparison.Ordinal);
+        Assert.Contains("_scopeFactory.GetScopeAsync(binding.SourceCode", code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 配置类提供默认值与DEV白名单()
+    {
+        var opt = Source("Option", "AidopPoCompletionOptions.cs");
+        Assert.Contains("[OptionsSettings(\"AiDOP:PoCompletion\")]", opt, StringComparison.Ordinal);
+        Assert.Contains("IConfigurableOptions", opt, StringComparison.Ordinal);
+        Assert.Contains("public string SourceCode", opt, StringComparison.Ordinal);
+        Assert.Contains("public string[] DevOnlySourceCodes", opt, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 非开发环境命中DEV源必须fail_closed()
+    {
+        var bind = Between(Service(), "private async Task<SourceBinding> ResolveSourceBindingAsync", "// ── 源侧读取");
+        Assert.Contains("_env.IsDevelopment()", bind, StringComparison.Ordinal);
+        Assert.Contains("DevOnlySourceCodes", bind, StringComparison.Ordinal);
+        Assert.Contains("throw new InvalidOperationException", bind, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 源未配置或被禁用必须失败()
+    {
+        var bind = Between(Service(), "private async Task<SourceBinding> ResolveSourceBindingAsync", "// ── 源侧读取");
+        Assert.Contains("code.Length == 0", bind, StringComparison.Ordinal);
+        Assert.Contains("status=1", bind, StringComparison.Ordinal);
+        Assert.Contains("mdp_source 未找到启用源", bind, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 必须挡住回落主库()
+    {
+        var code = Service();
+        // 作用域工厂对本库样板源与未配账号的源会直接返回主库连接,
+        // 那会让本服务把 Source A 的本地表当成 WMS 执行态读进来
+        Assert.Contains("ReferenceEquals(remote, _db)", code, StringComparison.Ordinal);
+        var bind = Between(code, "private async Task<SourceBinding> ResolveSourceBindingAsync", "// ── 源侧读取");
+        Assert.Contains("IsNullOrWhiteSpace(src.DbUser)", bind, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 回执与日志不得暴露连接信息()
+    {
+        var code = Service();
+        foreach (var forbidden in new[] { "ConnectionString", "DbPassword", "db_password", "Password=" })
+            Assert.DoesNotContain(forbidden, code, StringComparison.OrdinalIgnoreCase);
+    }
+
+    // ── ⑦ 边界:唯一 Authority 是源侧 Status ─────────────────────────────────────
+
+    [Fact]
+    public void 禁止用数量重新推导完成()
+    {
+        var code = ServiceBody();
+        foreach (var forbidden in new[]
+                 {
+                     "received_qty", "order_qty", "QtyOrded", "QtyReceived", "RctQty",
+                     "QtyReturned", "ReceiptQty", "mdp_std_purchase_receipt",
+                 })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 禁止读IQC或算ObjectCompleted()
+    {
+        var code = ServiceBody();
+        foreach (var forbidden in new[]
+                 {
+                     "mdp_std_iqc_inspection", "quality_result", "flow_status",
+                     "ObjectCompleted", "QualityAccepted", "StageComplete",
+                 })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 只写completion事实表与跑批日志()
+    {
+        var code = Service();
+        var targets = code.Split("INSERT INTO ").Skip(1)
+            .Select(x => x.Split('\n')[0].Trim().Split(' ')[0].Trim())
+            .Distinct().OrderBy(x => x, StringComparer.Ordinal).ToArray();
+        Assert.Equal(new[] { "mdp_std_purchase_order_completion", "mdp_transform_run_log" }, targets);
+    }
+
+    [Fact]
+    public void 绝不更新SourceA的基础事实()
+    {
+        var code = ServiceBody();
+        foreach (var forbidden in new[]
+                 {
+                     "UPDATE mdp_std_purchase_order\n", "UPDATE mdp_std_purchase_order ",
+                     "UPDATE PurOrdDetail", "UPDATE PurOrdMaster",
+                     "INSERT INTO mdp_std_purchase_order\n", "INSERT INTO PurOrdDetail",
+                 })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 源侧只读()
+    {
+        var read = Between(Service(), "private async Task<List<SourceRow>> ReadSourceRowsAsync", "// ── 租户归属");
+        Assert.Contains("GetDataTableAsync", read, StringComparison.Ordinal);
+        foreach (var forbidden in new[] { "UPDATE ", "INSERT ", "DELETE ", "MERGE " })
+            Assert.DoesNotContain(forbidden, read, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 入账范围与推送口径一致()
+    {
+        var read = Between(Service(), "private async Task<List<SourceRow>> ReadSourceRowsAsync", "// ── 租户归属");
+        Assert.Contains("Potype", read, StringComparison.Ordinal);
+        Assert.Contains("_opt.SourcePotype", read, StringComparison.Ordinal);
+    }
+
+    // ── ⑧ 完成时间:当前无权威,不得落库 ────────────────────────────────────────
+
+    [Fact]
+    public void 不得创建completion_time列()
+    {
+        var sql = Migration("1.0.544.sql");
+        foreach (var forbidden in new[] { "`completion_time`", "`completed_at`", "`procurement_completion_time`" })
+            Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 不得把观测时间当完成时间()
+    {
+        var code = ServiceBody();
+        Assert.DoesNotContain("completion_time", code, StringComparison.Ordinal);
+        Assert.DoesNotContain("CompletionTime", code, StringComparison.Ordinal);
+    }
+
+    // ── ⑨ NOT_OBSERVED 不得物化 ─────────────────────────────────────────────────
+
+    [Fact]
+    public void 不得遍历SourceA反向造行()
+    {
+        var code = Service();
+        // 驱动集合只能来自源侧读取结果
+        Assert.Contains("foreach (var row in sourceRows)", code, StringComparison.Ordinal);
+        // 本库采购行只用于校验与租户归属,不得成为写入驱动
+        var run = Between(code, "var pending = new List<CompletionRow>", "// ④ UPSERT");
+        Assert.DoesNotContain("foreach (var row in poLineIndex", run, StringComparison.Ordinal);
+        // NOT_OBSERVED 是「不落行」的口头表达(注释里可以出现),
+        // 但绝不能变成落库的字符串字面量、枚举值或列默认值
+        Assert.DoesNotContain("\"NOT_OBSERVED\"", ServiceBody(), StringComparison.Ordinal);
+        Assert.DoesNotContain("'NOT_OBSERVED'", Migration("1.0.544.sql"), StringComparison.Ordinal);
+    }
+
+    // ── ⑩ 调度与手动入口 ────────────────────────────────────────────────────────
+
+    [Fact]
+    public void 周期性全量对账必须存在且启用()
+    {
+        var job = Source("Job", "S3PoCompletionMdpSyncJob.cs");
+        Assert.Contains("[JobDetail(\"job_s3_po_completion_mdp_sync\"", job, StringComparison.Ordinal);
+        Assert.Contains("[Period(", job, StringComparison.Ordinal);
+        Assert.Contains("RunFullAsync(0, \"AUTO\"", job, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 手动刷新入口就位且不回显连接信息()
+    {
+        var ctrl = Source("Controllers", "AidopKanbanController.cs");
+        Assert.Contains("[HttpPost(\"s3-po-completion-mdp/refresh\")]", ctrl, StringComparison.Ordinal);
+        var block = Between(ctrl, "[HttpPost(\"s3-po-completion-mdp/refresh\")]", "staleRemoved = r.StaleRemoved");
+        foreach (var forbidden in new[] { "ConnectionString", "Password", "db_user" })
+            Assert.DoesNotContain(forbidden, block, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public void 跑批日志复用既有表与固定JobCode()
+    {
+        var code = Service();
+        Assert.Contains("\"S3_PO_COMPLETION_MDP_SYNC\"", code, StringComparison.Ordinal);
+        Assert.Contains("INSERT INTO mdp_transform_run_log", code, StringComparison.Ordinal);
+        foreach (var s in new[] { "'RUNNING'", "status='SUCCESS'", "status='FAILED'" })
+            Assert.Contains(s, code, StringComparison.Ordinal);
+    }
+
+    // ── ⑪ 上游批次资产不得回归 ──────────────────────────────────────────────────
+
+    [Fact]
+    public void IQC标准事实层与采购身份桥不得被本批改动()
+    {
+        var iqc = Migration("1.0.541.sql");
+        Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", iqc, StringComparison.Ordinal);
+
+        var sql = Migration("1.0.544.sql");
+        foreach (var forbidden in new[]
+                 {
+                     "ALTER TABLE `mdp_std_purchase_order`", "ALTER TABLE `mdp_std_iqc_inspection`",
+                     "UPDATE `mdp_std_purchase_order`", "DROP TABLE",
+                 })
+            Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
+    }
+}

+ 36 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.cs

@@ -24,6 +24,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
     private readonly S3MdpSyncTransformService _s3MdpSyncTransformService;
     private readonly MaterialWarehouse.IqcInspectionMdpSyncService _iqcInspectionMdpSyncService;
+    private readonly PurchaseOrderCompletionMdpSyncService _poCompletionMdpSyncService;
     private readonly S4MdpSyncTransformService _s4MdpSyncTransformService;
     private readonly S5MdpSyncTransformService _s5MdpSyncTransformService;
     private readonly S6MdpSyncTransformService _s6MdpSyncTransformService;
@@ -46,6 +47,7 @@ public partial class AidopKanbanController : ControllerBase
         S2MdpSyncTransformService s2MdpSyncTransformService,
         S3MdpSyncTransformService s3MdpSyncTransformService,
         MaterialWarehouse.IqcInspectionMdpSyncService iqcInspectionMdpSyncService,
+        PurchaseOrderCompletionMdpSyncService poCompletionMdpSyncService,
         S4MdpSyncTransformService s4MdpSyncTransformService,
         S5MdpSyncTransformService s5MdpSyncTransformService,
         S6MdpSyncTransformService s6MdpSyncTransformService,
@@ -67,6 +69,7 @@ public partial class AidopKanbanController : ControllerBase
         _s2MdpSyncTransformService = s2MdpSyncTransformService;
         _s3MdpSyncTransformService = s3MdpSyncTransformService;
         _iqcInspectionMdpSyncService = iqcInspectionMdpSyncService;
+        _poCompletionMdpSyncService = poCompletionMdpSyncService;
         _s4MdpSyncTransformService = s4MdpSyncTransformService;
         _s5MdpSyncTransformService = s5MdpSyncTransformService;
         _s6MdpSyncTransformService = s6MdpSyncTransformService;
@@ -709,6 +712,39 @@ LIMIT 60
         });
     }
 
+    /// <summary>
+    /// S8 Stage-3 采购完成态全量对账手动入口。
+    /// Source B(WMS/MES 的 PurOrdDetail)→ mdp_std_purchase_order_completion。
+    /// Grain = 一条采购行;只读源侧、只写 completion 事实表;
+    /// 绝不覆盖 mdp_std_purchase_order.status,也不用数量重新推导完成。
+    /// tenantId 省略或 0 表示全租户;指定时只写、只淘汰该租户。
+    /// </summary>
+    [HttpPost("s3-po-completion-mdp/refresh")]
+    public async Task<IActionResult> RefreshS3PoCompletionMdp(
+        [FromQuery] long? tenantId,
+        CancellationToken cancellationToken = default)
+    {
+        var r = await _poCompletionMdpSyncService.RunFullAsync(
+            tenantId ?? 0, "MANUAL", cancellationToken);
+        return Ok(new
+        {
+            ok = true,
+            status = "SUCCESS",
+            batchId = r.BatchId,
+            sourceCode = r.SourceCode,
+            sourceRows = r.SourceRows,
+            matchedRows = r.MatchedRows,
+            writtenRows = r.WrittenRows,
+            skippedRows = r.SkippedRows,
+            tenantUnresolvedRows = r.TenantUnresolvedRows,
+            unmatchedRows = r.UnmatchedRows,
+            completedRows = r.CompletedRows,
+            notCompletedRows = r.NotCompletedRows,
+            unknownStatusRows = r.UnknownStatusRows,
+            staleRemoved = r.StaleRemoved
+        });
+    }
+
     /// <summary>
     /// S5 采购收货双模式入站:MdpDbPullExecutor → mdp_stg_purchase_receipt,再跑标准层转换。
     /// 独立作业模式仍可用 s5-purchase-receipt-mdp/refresh(直读源表建 std)。

+ 57 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Job/S3PoCompletionMdpSyncJob.cs

@@ -0,0 +1,57 @@
+using Admin.NET.Plugin.AiDOP.Supply;
+using Furion.Schedule;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+
+namespace Admin.NET.Plugin.AiDOP.Job;
+
+/// <summary>
+/// S8 Stage-3 采购完成态周期性全量对账。
+///
+/// <para><b>这是正确性主路径,不是优化项。</b> HotWatch 只覆盖活动中的采购单:实测 5 个
+/// <c>PUR_ORDER</c> 关注中 3 个已提前终止(且无一是因采购完成而终止,全部走 IQC 路径终止),
+/// 终止后 <c>Status=1</c> 的关注永不再轮询;另有 88/98 条源行根本没有对应关注。
+/// 因此仅靠增量必然漏判完成与回退,全量对账不可省。</para>
+///
+/// <para>周期取 30 分钟:与 <c>S5InventoryInboundJob</c> 的既有半小时级节奏一致。
+/// 源侧 Status 是收货过程产物,30 分钟的对账延迟对 Stage-3 口径可接受;
+/// 若日后接入 HotWatch 增量钩子,延迟会降到关注轮询级,但本 Job 仍必须保留。</para>
+/// </summary>
+[JobDetail("job_s3_po_completion_mdp_sync", Description = "S3 采购完成态(Source B)全量对账", GroupName = "default", Concurrent = false)]
+[Period(1800000, TriggerId = "trigger_s3_po_completion_mdp_sync", Description = "每30分钟执行")]
+public class S3PoCompletionMdpSyncJob : IJob
+{
+    private readonly IServiceScopeFactory _scopeFactory;
+    private readonly ILogger _logger;
+
+    public S3PoCompletionMdpSyncJob(IServiceScopeFactory scopeFactory, ILoggerFactory loggerFactory)
+    {
+        _scopeFactory = scopeFactory;
+        _logger = loggerFactory.CreateLogger(nameof(S3PoCompletionMdpSyncJob));
+    }
+
+    public async Task ExecuteAsync(JobExecutingContext context, CancellationToken stoppingToken)
+    {
+        using var scope = _scopeFactory.CreateScope();
+        try
+        {
+            var svc = scope.ServiceProvider.GetRequiredService<PurchaseOrderCompletionMdpSyncService>();
+            var r = await svc.RunFullAsync(0, "AUTO", stoppingToken);
+            _logger.LogInformation(
+                "S3PoCompletionMdpSyncJob 完成 batch={Batch} source={Src} rows={Rows} written={Written} "
+                + "completed={Completed} notCompleted={NotCompleted} unknown={Unknown} skipped={Skipped} stale={Stale}",
+                r.BatchId, r.SourceCode, r.SourceRows, r.WrittenRows,
+                r.CompletedRows, r.NotCompletedRows, r.UnknownStatusRows, r.SkippedRows, r.StaleRemoved);
+        }
+        catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
+        {
+            _logger.LogInformation("S3PoCompletionMdpSyncJob 收到停止信号,结束本轮对账");
+        }
+        catch (Exception ex)
+        {
+            // 源绑定不合法(未配置 / 指向 DEV 源 / 源被禁用)会走到这里:
+            // 运行日志已在 Service 内标 FAILED,这里只保证调度线程不被打断、也不吞掉异常信息。
+            _logger.LogError(ex, "S3PoCompletionMdpSyncJob 执行失败");
+        }
+    }
+}

+ 44 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Option/AidopPoCompletionOptions.cs

@@ -0,0 +1,44 @@
+using Furion.ConfigurableOptions;
+
+namespace Admin.NET.Plugin.AiDOP;
+
+/// <summary>
+/// S8 Stage-3 采购完成态数据源绑定。配置节 <c>AiDOP:PoCompletion</c>。
+///
+/// <para>承担的角色是 <b>S8_PO_COMPLETION_SOURCE</b>:接收 Ai-DOP 推送的采购单、执行收货、
+/// 并维护 <c>PurOrdDetail.Status</c>(<c>'C'</c>=收完关闭)。该角色由 WMS/MES 承担,
+/// 与建单方(本库)是两个系统。</para>
+///
+/// <para><b>为什么源编码必须留在配置里</b>:仓库现有代码把 <c>DOPDEMORQ_SQLSERVER</c>
+/// 硬编码进了 9+ 处业务服务,导致换环境必须改代码。本选项沿用
+/// <see cref="AidopInventoryOptions"/> 已在生产使用的配置驱动绑定模式,
+/// 业务 Service 内不出现任何具体源编码字面量。</para>
+///
+/// <para><b>非开发环境必须显式绑定真实 WMS 源</b>:默认值指向开发/UAT 实例,
+/// <see cref="DevOnlySourceCodes"/> 中的编码在非开发环境会被 fail-closed 拒绝,
+/// 绝不静默使用。</para>
+/// </summary>
+[OptionsSettings("AiDOP:PoCompletion")]
+public sealed class AidopPoCompletionOptions : IConfigurableOptions
+{
+    /// <summary>承担 S8_PO_COMPLETION_SOURCE 角色的 <c>mdp_source.source_code</c>。</summary>
+    public string SourceCode { get; set; } = "DOPDEMORQ_SQLSERVER";
+
+    /// <summary>
+    /// 仅限开发/UAT 使用的源编码。非开发环境命中其中任意一个即拒绝运行(fail-closed),
+    /// 防止把测试库的执行态当成生产采购完成事实。
+    /// </summary>
+    public string[] DevOnlySourceCodes { get; set; } = new[] { "DOPDEMORQ_SQLSERVER" };
+
+    /// <summary>
+    /// 入账范围:只消费该 Potype 的采购行。与 <c>PurOrdWmsPushService</c> 的自建单推送口径
+    /// (<c>Potype='po'</c>)对齐 —— 没被推过去的单据,WMS 侧的状态不属于本角色。
+    /// </summary>
+    public string SourcePotype { get; set; } = "po";
+
+    /// <summary>单轮从源侧读取的行数上限,防止异常配置下拉爆内存。</summary>
+    public int MaxSourceRows { get; set; } = 50000;
+
+    /// <summary>UPSERT 分片大小。</summary>
+    public int UpsertBatchSize { get; set; } = 200;
+}

+ 1 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Startup.cs

@@ -42,6 +42,7 @@ public class Startup : AppStartup
         services.AddConfigurableOptions<AidopPickBillOptions>();
         services.AddConfigurableOptions<AidopStockOptions>();
         services.AddConfigurableOptions<AidopInventoryOptions>();
+        services.AddConfigurableOptions<AidopPoCompletionOptions>();
         services.AddTransient<IInventoryBalanceReader, InventoryBalanceReader>();
         services.AddTransient<IInventoryTransactionReader, StdInventoryTransactionReader>();
         // WP9:IPickBillCreator 唯一实现为 Facade(避免 Local/Mes 多实现竞注)

+ 668 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Supply/PurchaseOrderCompletionMdpSyncService.cs

@@ -0,0 +1,668 @@
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using SqlSugar;
+using System.Data;
+using System.Text;
+
+namespace Admin.NET.Plugin.AiDOP.Supply;
+
+/// <summary>
+/// S8 Stage-3 采购订单完成态标准事实层投影服务。
+///
+/// <para>链路:Source B(WMS/MES 的 <c>PurOrdDetail</c>)→ <c>mdp_std_purchase_order_completion</c>。
+/// 目的是让 S8 Stage-3 能在【不覆盖】<c>mdp_std_purchase_order.status</c> 的前提下拿到
+/// 「这条采购行收货是否已关闭」这一 WMS 侧事实。</para>
+///
+/// <para><b>Grain = 一条采购行</b>(<c>tenant_id + po_no + po_line</c>)。
+/// <c>domain</c> / <c>potype</c> / <c>source_row_id</c> / <c>source_id</c> 是 provenance,不进唯一键。</para>
+///
+/// <para><b>当前状态,不是事件</b>:上游收货过程的写法是
+/// <c>Status = (case when 收满 then 'C' else '' end)</c>,退货会把 <c>'C'</c> 打回空串。
+/// 因此本服务的 UPSERT 必须无条件刷新结论,允许 COMPLETED → NOT_COMPLETED 回退。</para>
+///
+/// <para><b>Full Reconciliation 是正确性主路径</b>:HotWatch 只覆盖活动中的采购单,
+/// 实测 5 个 <c>PUR_ORDER</c> 关注里 3 个已提前终止(且无一是因采购完成而终止),
+/// 98 条源行里 88 条根本没有对应关注。所以单靠增量必然漏判,周期性全量对账不可省。</para>
+///
+/// <para><b>刻意不做</b>:不读 IQC、不算 ObjectCompleted、不做订单级聚合、
+/// 不用数量重新推导完成(唯一 Authority 是源侧 Status)、不写任何源表、不改 Source A 的任何列。</para>
+/// </summary>
+public class PurchaseOrderCompletionMdpSyncService : ITransient
+{
+    private const string JobCode = "S3_PO_COMPLETION_MDP_SYNC";
+    private const string JobName = "S3采购订单完成态标准事实层投影";
+
+    /// <summary>完成结论取值域。</summary>
+    public const string StatusCompleted = "COMPLETED";
+    public const string StatusNotCompleted = "NOT_COMPLETED";
+    public const string StatusUnknown = "UNKNOWN";
+
+    private readonly ISqlSugarClient _db;
+    private readonly DataPlatform.MdpSourceScopeFactory _scopeFactory;
+    private readonly AidopPoCompletionOptions _opt;
+    private readonly IHostEnvironment _env;
+    private readonly ILogger<PurchaseOrderCompletionMdpSyncService> _logger;
+
+    public PurchaseOrderCompletionMdpSyncService(
+        ISqlSugarClient db,
+        DataPlatform.MdpSourceScopeFactory scopeFactory,
+        IOptions<AidopPoCompletionOptions> opt,
+        IHostEnvironment env,
+        ILogger<PurchaseOrderCompletionMdpSyncService> logger)
+    {
+        _db = db;
+        _scopeFactory = scopeFactory;
+        _opt = opt.Value;
+        _env = env;
+        _logger = logger;
+    }
+
+    /// <summary>
+    /// 完成态映射。<b>这是全链路唯一的状态映射入口</b>——HotWatch 增量若接入,必须复用本方法,
+    /// 不得另写一套。
+    ///
+    /// <para><c>'C'</c> → COMPLETED;空串 → NOT_COMPLETED;
+    /// <c>null</c> 与任何其它非空值 → UNKNOWN。</para>
+    ///
+    /// <para>注意这里刻意<b>不</b>写成 <c>IFNULL(status,'') == ""</c>:
+    /// NULL 表示「源侧没给值」,未知值表示「源侧给了我们不认识的值」,
+    /// 两者都不等于「已确认尚未完成」,滑成 NOT_COMPLETED 会让下游把未知当成确定结论。</para>
+    /// </summary>
+    public static string MapCompletionStatus(string? rawStatus)
+    {
+        if (rawStatus is null) return StatusUnknown;
+        var s = rawStatus.Trim();
+        if (s.Length == 0) return StatusNotCompleted;
+        return string.Equals(s, "C", StringComparison.OrdinalIgnoreCase)
+            ? StatusCompleted
+            : StatusUnknown;
+    }
+
+    /// <summary>写入本表时使用的 raw_status 归一:保留 NULL,其余 TRIM。</summary>
+    public static string? NormalizeRawStatus(string? rawStatus) => rawStatus?.Trim();
+
+    /// <summary>
+    /// 全量对账(Full Reconciliation)。
+    /// <paramref name="tenantId"/> 为 0 表示全租户;&gt;0 时只写/只淘汰该租户,绝不触碰其它租户。
+    /// </summary>
+    public async Task<PurchaseOrderCompletionSyncResult> RunFullAsync(
+        long tenantId = 0,
+        string triggerType = "AUTO",
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var now = DateTime.Now;
+        var batchId = $"S3_PO_COMPL_{(tenantId > 0 ? tenantId + "_" : "")}{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType, tenantId);
+        var result = new PurchaseOrderCompletionSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            // ① 源绑定:非开发环境必须显式绑定真实 WMS 源,绝不静默回落
+            var binding = await ResolveSourceBindingAsync(cancellationToken);
+            result.SourceCode = binding.SourceCode;
+
+            var remote = await _scopeFactory.GetScopeAsync(binding.SourceCode, cancellationToken);
+
+            // 作用域工厂对「本库样板源」与「未配账号的源」会直接返回主库连接。
+            // 那会让本服务把 Source A 的本地表当成 WMS 执行态读进来,结论会整体错误,
+            // 所以这里必须结构性挡住,而不是依赖配置写对。
+            if (ReferenceEquals(remote, _db))
+                throw new InvalidOperationException(
+                    $"源 {binding.SourceCode} 解析结果是主库连接,说明它是本库样板源或未配置独立账号,"
+                    + "不能承担 S8_PO_COMPLETION_SOURCE 角色");
+
+            // ② 读源:只取本角色范围内的采购行
+            var sourceRows = await ReadSourceRowsAsync(remote, cancellationToken);
+            result.SourceRows = sourceRows.Count;
+
+            // ③ 归属与校验
+            var tenantIndex = await LoadLocalTenantIndexAsync(cancellationToken);
+            var poLineIndex = await LoadLocalPoLineIndexAsync(cancellationToken);
+            var pending = new List<CompletionRow>(sourceRows.Count);
+
+            foreach (var row in sourceRows)
+            {
+                cancellationToken.ThrowIfCancellationRequested();
+
+                if (string.IsNullOrWhiteSpace(row.PoNo) || string.IsNullOrWhiteSpace(row.PoLine))
+                {
+                    result.SkippedRows++;
+                    continue;
+                }
+
+                var resolved = ResolveTenant(tenantIndex, row.PoNo, row.Domain);
+                if (resolved <= 0)
+                {
+                    result.SkippedRows++;
+                    result.TenantUnresolvedRows++;
+                    _logger.LogWarning(
+                        "[PoCompletion] 跳过:采购单 {PurOrd} 行 {Line} 无法唯一解析租户(候选数 {Count})",
+                        row.PoNo, row.PoLine, CountTenantCandidates(tenantIndex, row.PoNo, row.Domain));
+                    continue;
+                }
+
+                // 单租户刷新:其它租户的源行本轮不参与,也因此不能被本轮淘汰
+                if (tenantId > 0 && resolved != tenantId)
+                    continue;
+
+                var matches = poLineIndex.TryGetValue(LineKey(resolved, row.PoNo, row.PoLine), out var c) ? c : 0;
+                if (matches > 1)
+                    throw new InvalidOperationException(
+                        $"数据契约破坏:租户 {resolved} 的采购行 {row.PoNo}#{row.PoLine} 在本库命中 {matches} 条,"
+                        + "唯一键 uk_po_line 应当保证唯一");
+                if (matches == 0)
+                {
+                    result.SkippedRows++;
+                    result.UnmatchedRows++;
+                    _logger.LogWarning(
+                        "[PoCompletion] 跳过:源行 {PurOrd}#{Line} 在本库租户 {Tenant} 下无对应采购行",
+                        row.PoNo, row.PoLine, resolved);
+                    continue;
+                }
+
+                result.MatchedRows++;
+                var raw = NormalizeRawStatus(row.RawStatus);
+                var completion = MapCompletionStatus(row.RawStatus);
+                if (completion == StatusCompleted) result.CompletedRows++;
+                else if (completion == StatusNotCompleted) result.NotCompletedRows++;
+                else result.UnknownStatusRows++;
+
+                pending.Add(new CompletionRow
+                {
+                    TenantId = resolved,
+                    PoNo = row.PoNo.Trim(),
+                    PoLine = row.PoLine.Trim(),
+                    Domain = string.IsNullOrWhiteSpace(row.Domain) ? null : row.Domain.Trim(),
+                    Potype = string.IsNullOrWhiteSpace(row.Potype) ? null : row.Potype.Trim(),
+                    RawStatus = raw,
+                    CompletionStatus = completion,
+                    SourceSystem = binding.SourceCode,
+                    SourceId = binding.SourceId,
+                    SourceRowId = string.IsNullOrWhiteSpace(row.SourceRowId) ? null : row.SourceRowId.Trim(),
+                    SourceUpdateTime = row.SourceUpdateTime,
+                    SourceUpdateUser = string.IsNullOrWhiteSpace(row.SourceUpdateUser) ? null : row.SourceUpdateUser.Trim(),
+                });
+            }
+
+            // ④ UPSERT:无条件刷新结论,支持 COMPLETED → NOT_COMPLETED 回退
+            result.WrittenRows = await UpsertAsync(pending, batchId, now, cancellationToken);
+
+            // ⑤ 淘汰:源侧已不存在的行回到 NOT_OBSERVED(本表的表达方式就是「没有这一行」)
+            //    只在本轮源侧读取成功后执行;任何异常都会在上面抛出并跳过这里。
+            result.StaleRemoved = await RetireStaleAsync(batchId, tenantId, cancellationToken);
+
+            await CompleteRunLogAsync(runLogId, result, now);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex, "[PoCompletion] 批次 {BatchId} 失败", batchId);
+            await FailRunLogAsync(runLogId, ex.Message);
+            throw;
+        }
+    }
+
+    // ── 源绑定与 fail-closed ────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 解析并校验源绑定。非开发环境下,未配置、配置为空、或仍指向 DEV/UAT 默认源,
+    /// 一律直接失败(fail-closed),不得静默继续。
+    /// </summary>
+    private async Task<SourceBinding> ResolveSourceBindingAsync(CancellationToken ct)
+    {
+        var code = (_opt.SourceCode ?? string.Empty).Trim();
+        var isDev = _env.IsDevelopment();
+
+        if (code.Length == 0)
+            throw new InvalidOperationException(
+                "AiDOP:PoCompletion:SourceCode 未配置,无法确定承担 S8_PO_COMPLETION_SOURCE 角色的数据源");
+
+        if (!isDev && (_opt.DevOnlySourceCodes ?? Array.Empty<string>())
+                .Any(x => string.Equals((x ?? string.Empty).Trim(), code, StringComparison.OrdinalIgnoreCase)))
+            throw new InvalidOperationException(
+                $"当前环境 {_env.EnvironmentName} 非开发环境,禁止使用 DEV/UAT 数据源 {code};"
+                + "请在 AiDOP:PoCompletion:SourceCode 显式绑定真实 WMS/MES 源");
+
+        var src = await _db.Ado.SqlQuerySingleAsync<SourceBindingRow>(
+            """
+            SELECT id AS Id, source_type AS SourceType, IFNULL(db_user,'') AS DbUser
+              FROM mdp_source
+             WHERE source_code=@Code AND status=1
+             LIMIT 1
+            """,
+            new List<SugarParameter> { new("@Code", code) });
+
+        if (src == null)
+            throw new InvalidOperationException($"mdp_source 未找到启用源:{code}");
+        if (!string.Equals(src.SourceType, "DB", StringComparison.OrdinalIgnoreCase))
+            throw new InvalidOperationException($"源 {code} 的 source_type={src.SourceType},不是 DB");
+        if (string.IsNullOrWhiteSpace(src.DbUser))
+            throw new InvalidOperationException(
+                $"源 {code} 未配置数据库账号,连接会回落到主库,不能承担 S8_PO_COMPLETION_SOURCE 角色");
+
+        return new SourceBinding { SourceCode = code, SourceId = src.Id };
+    }
+
+    // ── 源侧读取 ────────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 读 Source B 的采购行。范围 = 与自建单推送口径一致的 Potype,
+    /// 不做无差别全表扫,也不顺带拉无关列。
+    /// </summary>
+    private async Task<List<SourceRow>> ReadSourceRowsAsync(ISqlSugarClient remote, CancellationToken ct)
+    {
+        var top = _opt.MaxSourceRows > 0 ? _opt.MaxSourceRows : 50000;
+        var potype = (_opt.SourcePotype ?? string.Empty).Trim();
+
+        var sql =
+            $"""
+            SELECT TOP {top}
+                   RecID, Domain, Potype, PurOrd, Line, Status, UpdateTime, UpdateUser
+              FROM PurOrdDetail
+             WHERE (@Potype = '' OR LOWER(LTRIM(RTRIM(ISNULL(Potype,'')))) = LOWER(@Potype))
+            """;
+
+        var table = await remote.Ado.GetDataTableAsync(sql, new List<SugarParameter> { new("@Potype", potype) });
+        var list = new List<SourceRow>(table.Rows.Count);
+        foreach (DataRow r in table.Rows)
+        {
+            ct.ThrowIfCancellationRequested();
+            list.Add(new SourceRow
+            {
+                SourceRowId = Str(r, "RecID"),
+                Domain = Str(r, "Domain"),
+                Potype = Str(r, "Potype"),
+                PoNo = Str(r, "PurOrd"),
+                PoLine = Str(r, "Line"),
+                RawStatus = Str(r, "Status"),
+                SourceUpdateTime = Dt(r, "UpdateTime"),
+                SourceUpdateUser = Str(r, "UpdateUser"),
+            });
+        }
+        return list;
+    }
+
+    // ── 租户归属 ────────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 本地采购单 → 租户候选集。Domain 只用于放宽匹配(自建单本库不填 Domain),
+    /// <b>绝不</b>用 Domain 反推租户 —— 实测同一个 Domain 8010 横跨多个 Ai-DOP 租户。
+    /// </summary>
+    private async Task<Dictionary<string, List<PoTenantCandidate>>> LoadLocalTenantIndexAsync(CancellationToken ct)
+    {
+        var rows = await _db.Ado.SqlQueryAsync<PoTenantCandidate>(
+            """
+            SELECT PurOrd AS PoNo, IFNULL(Domain,'') AS Domain, tenant_id AS TenantId
+              FROM PurOrdDetail
+             WHERE IFNULL(tenant_id,0) > 0
+             GROUP BY PurOrd, IFNULL(Domain,''), tenant_id
+            """);
+
+        var map = new Dictionary<string, List<PoTenantCandidate>>(StringComparer.OrdinalIgnoreCase);
+        foreach (var r in rows)
+        {
+            if (string.IsNullOrWhiteSpace(r.PoNo)) continue;
+            var key = r.PoNo.Trim();
+            if (!map.TryGetValue(key, out var bucket))
+                map[key] = bucket = new List<PoTenantCandidate>();
+            bucket.Add(r);
+        }
+        return map;
+    }
+
+    /// <summary>
+    /// 候选租户集合。Domain 只用于<b>放宽</b>匹配(自建单本库不填 Domain,空 Domain 视为通配),
+    /// <b>绝不</b>反过来用 Domain 推租户 —— 实测同一个 Domain 8010 横跨多个 Ai-DOP 租户。
+    /// </summary>
+    public static IEnumerable<long> TenantCandidates(
+        IEnumerable<PoTenantCandidate> candidates, string poNo, string? domain)
+    {
+        var po = (poNo ?? string.Empty).Trim();
+        var dom = (domain ?? string.Empty).Trim();
+        return candidates
+            .Where(x => string.Equals((x.PoNo ?? string.Empty).Trim(), po, StringComparison.OrdinalIgnoreCase))
+            .Where(x => (x.Domain ?? string.Empty).Trim().Length == 0
+                        || string.Equals((x.Domain ?? string.Empty).Trim(), dom, StringComparison.OrdinalIgnoreCase))
+            .Select(x => x.TenantId)
+            .Distinct();
+    }
+
+    /// <summary>
+    /// 唯一才用:0 个(UNRESOLVED)或 &gt;1 个(AMBIGUOUS)一律返回 0,交由调用方跳过。
+    /// <b>禁止</b>退化成「取第一个」—— 那会在多租户重名时把事实写到别人名下。
+    /// </summary>
+    public static long ResolveTenantId(
+        IEnumerable<PoTenantCandidate> candidates, string poNo, string? domain)
+    {
+        var hits = TenantCandidates(candidates, poNo, domain).Take(2).ToList();
+        return hits.Count == 1 ? hits[0] : 0;
+    }
+
+    private static long ResolveTenant(
+        Dictionary<string, List<PoTenantCandidate>> index, string poNo, string? domain)
+        => index.TryGetValue(poNo.Trim(), out var bucket) ? ResolveTenantId(bucket, poNo, domain) : 0;
+
+    private static int CountTenantCandidates(
+        Dictionary<string, List<PoTenantCandidate>> index, string poNo, string? domain)
+        => index.TryGetValue(poNo.Trim(), out var bucket)
+            ? TenantCandidates(bucket, poNo, domain).Count()
+            : 0;
+
+    // ── 本地采购行校验 ──────────────────────────────────────────────────────────────
+
+    private async Task<Dictionary<string, int>> LoadLocalPoLineIndexAsync(CancellationToken ct)
+    {
+        var rows = await _db.Ado.SqlQueryAsync<PoLineKeyRow>(
+            "SELECT tenant_id AS TenantId, po_no AS PoNo, po_line AS PoLine FROM mdp_std_purchase_order");
+
+        var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
+        foreach (var r in rows)
+        {
+            var key = LineKey(r.TenantId, r.PoNo ?? string.Empty, r.PoLine ?? string.Empty);
+            map[key] = map.TryGetValue(key, out var n) ? n + 1 : 1;
+        }
+        return map;
+    }
+
+    private static string LineKey(long tenantId, string poNo, string poLine)
+        => $"{tenantId}|{poNo.Trim()}|{poLine.Trim()}";
+
+    // ── 写入 ────────────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 幂等 UPSERT。结论列一律 <c>VALUES(...)</c> 无条件覆盖——这是支持
+    /// COMPLETED → NOT_COMPLETED 回退的前提,绝不能改成「仅当新值为 C 才更新」。
+    /// </summary>
+    private async Task<int> UpsertAsync(
+        List<CompletionRow> rows, string batchId, DateTime now, CancellationToken ct)
+    {
+        if (rows.Count == 0) return 0;
+
+        var size = _opt.UpsertBatchSize > 0 ? _opt.UpsertBatchSize : 200;
+        var written = 0;
+
+        foreach (var chunk in Chunk(rows, size))
+        {
+            ct.ThrowIfCancellationRequested();
+
+            var sb = new StringBuilder();
+            sb.Append("""
+                INSERT INTO mdp_std_purchase_order_completion
+                (tenant_id, factory_id, po_no, po_line, domain, potype,
+                 raw_status, completion_status,
+                 source_system, source_id, source_row_id, source_update_time, source_update_user,
+                 observed_at, sync_batch_id, sync_time)
+                VALUES
+                """);
+
+            var pars = new List<SugarParameter> { new("@Observed", now), new("@Batch", batchId), new("@Now", now) };
+            for (var i = 0; i < chunk.Count; i++)
+            {
+                var r = chunk[i];
+                if (i > 0) sb.Append(',');
+                sb.Append($"(@t{i},1,@p{i},@l{i},@d{i},@y{i},@r{i},@c{i},@s{i},@i{i},@x{i},@u{i},@w{i},@Observed,@Batch,@Now)");
+                pars.Add(new SugarParameter($"@t{i}", r.TenantId));
+                pars.Add(new SugarParameter($"@p{i}", r.PoNo));
+                pars.Add(new SugarParameter($"@l{i}", r.PoLine));
+                pars.Add(new SugarParameter($"@d{i}", (object?)r.Domain ?? DBNull.Value));
+                pars.Add(new SugarParameter($"@y{i}", (object?)r.Potype ?? DBNull.Value));
+                pars.Add(new SugarParameter($"@r{i}", (object?)r.RawStatus ?? DBNull.Value));
+                pars.Add(new SugarParameter($"@c{i}", r.CompletionStatus));
+                pars.Add(new SugarParameter($"@s{i}", r.SourceSystem));
+                pars.Add(new SugarParameter($"@i{i}", (object?)r.SourceId ?? DBNull.Value));
+                pars.Add(new SugarParameter($"@x{i}", (object?)r.SourceRowId ?? DBNull.Value));
+                pars.Add(new SugarParameter($"@u{i}", (object?)r.SourceUpdateTime ?? DBNull.Value));
+                pars.Add(new SugarParameter($"@w{i}", (object?)r.SourceUpdateUser ?? DBNull.Value));
+            }
+
+            sb.Append("""
+                 ON DUPLICATE KEY UPDATE
+                    factory_id=VALUES(factory_id), domain=VALUES(domain), potype=VALUES(potype),
+                    raw_status=VALUES(raw_status), completion_status=VALUES(completion_status),
+                    source_system=VALUES(source_system), source_id=VALUES(source_id),
+                    source_row_id=VALUES(source_row_id),
+                    source_update_time=VALUES(source_update_time),
+                    source_update_user=VALUES(source_update_user),
+                    observed_at=VALUES(observed_at), sync_batch_id=VALUES(sync_batch_id),
+                    sync_time=VALUES(sync_time), update_time=CURRENT_TIMESTAMP
+                """);
+
+            await _db.Ado.ExecuteCommandAsync(sb.ToString(), pars);
+            written += chunk.Count;
+        }
+
+        return written;
+    }
+
+    /// <summary>
+    /// 淘汰本轮未覆盖的行 —— 它们在源侧已不存在,应回到 NOT_OBSERVED(即本表无此行)。
+    ///
+    /// <para>只在本方法被调用时执行,而调用点在源侧读取与写入全部成功之后;
+    /// 读取失败会在更早处抛出,因此「源侧抓取失败却把旧事实删掉」不可能发生。</para>
+    ///
+    /// <para>单租户刷新只淘汰该租户 —— 租户 A 的刷新绝不允许删掉租户 B 的事实。</para>
+    /// </summary>
+    private async Task<int> RetireStaleAsync(string batchId, long tenantId, CancellationToken ct)
+    {
+        ct.ThrowIfCancellationRequested();
+
+        if (tenantId > 0)
+        {
+            return await _db.Ado.ExecuteCommandAsync(
+                """
+                DELETE FROM mdp_std_purchase_order_completion
+                 WHERE tenant_id=@TenantId AND IFNULL(sync_batch_id,'') <> @BatchId
+                """,
+                new List<SugarParameter> { new("@TenantId", tenantId), new("@BatchId", batchId) });
+        }
+
+        return await _db.Ado.ExecuteCommandAsync(
+            """
+            DELETE FROM mdp_std_purchase_order_completion
+             WHERE tenant_id > 0 AND IFNULL(sync_batch_id,'') <> @BatchId
+            """,
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    // ── run log ─────────────────────────────────────────────────────────────────────
+
+    private async Task<long> InsertRunLogAsync(string batchId, DateTime startedAt, string triggerType, long tenantId)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_transform_run_log
+            (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
+            VALUES (@TenantId, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            new SugarParameter("@TenantId", tenantId),
+            new SugarParameter("@JobCode", JobCode),
+            new SugarParameter("@JobName", JobName),
+            new SugarParameter("@TriggerType", NormalizeTriggerType(triggerType)),
+            new SugarParameter("@BatchId", batchId),
+            new SugarParameter("@StartTime", startedAt));
+
+        return await _db.Ado.GetLongAsync(
+            "SELECT id FROM mdp_transform_run_log WHERE batch_id=@BatchId ORDER BY id DESC LIMIT 1",
+            new List<SugarParameter> { new("@BatchId", batchId) });
+    }
+
+    private async Task CompleteRunLogAsync(long runLogId, PurchaseOrderCompletionSyncResult r, DateTime startedAt)
+    {
+        var endedAt = DateTime.Now;
+
+        // summary_json 是 JSON 列,必须写合法 JSON —— 纯文本会被 MySQL 直接拒绝。
+        // 这里只放计数与源编码,不放任何连接信息。
+        var summary = System.Text.Json.JsonSerializer.Serialize(new
+        {
+            sourceCode = r.SourceCode,
+            sourceRows = r.SourceRows,
+            matchedRows = r.MatchedRows,
+            writtenRows = r.WrittenRows,
+            completedRows = r.CompletedRows,
+            notCompletedRows = r.NotCompletedRows,
+            unknownStatusRows = r.UnknownStatusRows,
+            skippedRows = r.SkippedRows,
+            tenantUnresolvedRows = r.TenantUnresolvedRows,
+            unmatchedRows = r.UnmatchedRows,
+            staleRemoved = r.StaleRemoved,
+        });
+
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+               SET status='SUCCESS', stage_rows=@SrcRows, standard_rows=@StdRows, end_time=@EndTime,
+                   duration_ms=@Duration, summary_json=@Summary, update_time=CURRENT_TIMESTAMP
+             WHERE id=@Id
+            """,
+            new SugarParameter("@SrcRows", r.SourceRows),
+            new SugarParameter("@StdRows", r.WrittenRows),
+            new SugarParameter("@EndTime", endedAt),
+            new SugarParameter("@Duration", (int)(endedAt - startedAt).TotalMilliseconds),
+            // 刻意不截断:这是固定形状的计数 JSON(长度有界),截断会产生非法 JSON 并让整轮失败
+            new SugarParameter("@Summary", summary),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task FailRunLogAsync(long runLogId, string message)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+               SET status='FAILED', end_time=@EndTime, error_message=@Msg, update_time=CURRENT_TIMESTAMP
+             WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", DateTime.Now),
+            new SugarParameter("@Msg", Truncate(message, 900)),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    // ── 工具 ────────────────────────────────────────────────────────────────────────
+
+    private static string NormalizeTriggerType(string triggerType)
+        => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+
+    private static string Truncate(string? s, int max)
+        => string.IsNullOrEmpty(s) ? string.Empty : (s.Length <= max ? s : s[..max]);
+
+    private static string? Str(DataRow row, string col)
+    {
+        if (!row.Table.Columns.Contains(col)) return null;
+        var v = row[col];
+        return v == null || v == DBNull.Value ? null : Convert.ToString(v);
+    }
+
+    private static DateTime? Dt(DataRow row, string col)
+    {
+        if (!row.Table.Columns.Contains(col)) return null;
+        var v = row[col];
+        if (v == null || v == DBNull.Value) return null;
+        return Convert.ToDateTime(v);
+    }
+
+    private static IEnumerable<List<T>> Chunk<T>(List<T> source, int size)
+    {
+        for (var i = 0; i < source.Count; i += size)
+            yield return source.GetRange(i, Math.Min(size, source.Count - i));
+    }
+
+    // ── 内部模型 ────────────────────────────────────────────────────────────────────
+
+    private sealed class SourceRow
+    {
+        public string? SourceRowId { get; set; }
+        public string? Domain { get; set; }
+        public string? Potype { get; set; }
+        public string PoNo { get; set; } = string.Empty;
+        public string PoLine { get; set; } = string.Empty;
+        public string? RawStatus { get; set; }
+        public DateTime? SourceUpdateTime { get; set; }
+        public string? SourceUpdateUser { get; set; }
+    }
+
+    private sealed class CompletionRow
+    {
+        public long TenantId { get; set; }
+        public string PoNo { get; set; } = string.Empty;
+        public string PoLine { get; set; } = string.Empty;
+        public string? Domain { get; set; }
+        public string? Potype { get; set; }
+        public string? RawStatus { get; set; }
+        public string CompletionStatus { get; set; } = StatusUnknown;
+        public string SourceSystem { get; set; } = string.Empty;
+        public long? SourceId { get; set; }
+        public string? SourceRowId { get; set; }
+        public DateTime? SourceUpdateTime { get; set; }
+        public string? SourceUpdateUser { get; set; }
+    }
+
+    private sealed class PoLineKeyRow
+    {
+        public long TenantId { get; set; }
+        public string? PoNo { get; set; }
+        public string? PoLine { get; set; }
+    }
+
+    private sealed class SourceBinding
+    {
+        public string SourceCode { get; set; } = string.Empty;
+        public long? SourceId { get; set; }
+    }
+
+    private sealed class SourceBindingRow
+    {
+        public long Id { get; set; }
+        public string? SourceType { get; set; }
+        public string? DbUser { get; set; }
+    }
+}
+
+/// <summary>
+/// 本地采购单 → 租户候选。<c>Domain</c> 为空表示自建单(本库不填 165 账套),视为通配。
+/// 仅用于把 Source B 的行归到某个 Ai-DOP 租户,<b>不是</b>租户边界本身。
+/// </summary>
+public sealed class PoTenantCandidate
+{
+    public string PoNo { get; set; } = string.Empty;
+    public string Domain { get; set; } = string.Empty;
+    public long TenantId { get; set; }
+}
+
+/// <summary>S3 采购完成态投影结果。</summary>
+public sealed class PurchaseOrderCompletionSyncResult
+{
+    public string BatchId { get; set; } = string.Empty;
+    public long RunLogId { get; set; }
+
+    /// <summary>承担本次角色的数据源编码(不含任何连接信息)。</summary>
+    public string SourceCode { get; set; } = string.Empty;
+
+    /// <summary>源侧读到的行数。</summary>
+    public int SourceRows { get; set; }
+
+    /// <summary>唯一命中本地采购行的行数。</summary>
+    public int MatchedRows { get; set; }
+
+    /// <summary>实际写入(新增或刷新)的行数。</summary>
+    public int WrittenRows { get; set; }
+
+    /// <summary>被跳过的源行数(租户不可解析 + 本库无对应采购行 + 缺关键字段)。</summary>
+    public int SkippedRows { get; set; }
+
+    /// <summary>其中:租户无法唯一解析。</summary>
+    public int TenantUnresolvedRows { get; set; }
+
+    /// <summary>其中:本库无对应采购行。</summary>
+    public int UnmatchedRows { get; set; }
+
+    public int CompletedRows { get; set; }
+    public int NotCompletedRows { get; set; }
+
+    /// <summary>源侧给了 NULL 或无法识别的状态值。</summary>
+    public int UnknownStatusRows { get; set; }
+
+    /// <summary>本轮源侧已不存在、被回收为 NOT_OBSERVED 的行数。</summary>
+    public int StaleRemoved { get; set; }
+}