浏览代码

feat(s5): project iqc inspection facts to mdp

YY968XX 5 天之前
父节点
当前提交
156007412d

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

+ 80 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.541.sql

@@ -0,0 +1,80 @@
+-- =====================================================================================
+-- 1.0.541 · S5 IQC 来料检验标准事实层(mdp_std_iqc_inspection)
+--
+-- ── 为什么建这张表 ──────────────────────────────────────────────────────────────────
+-- S8 Stage-3 目前若要拿 IQC 结论,只能直读四张业务/框架表:
+--   qms_qcp_inspbill · qms_qcp_inspecapplyn · qms_qcp_insappnentry · ApprovalFlowInstance
+-- 这违反「S8 只消费中台」的边界。本表把已确权的 IQC 事实一次性投影到标准层。
+--
+-- ── Grain(先证后建,不是拍脑袋)────────────────────────────────────────────────────
+-- CANONICAL_GRAIN = (tenant_id, iqc_bill_id)   —— BILL GRAIN
+-- 实测依据(全库 23 张检验单):
+--   · 每张 Bill 命中的报检分录数 entries ≤ 1(无一例外)
+--   · 每张 Bill 可达的采购单数 distinct_po ≤ 1
+--   · 每张 Bill 的审批流实例数 ≤ 1(多实例 BizId 组数 = 0)
+-- 结构保证来自 qms_qcp_inspbill.hid —— 它是指向报检分录的【单值外键】(14/23 非空且互异)。
+-- 因此不需要 Bill × Entry 网格,也不需要 Fact + Bridge 两张表。
+--
+-- ── 两条正交轴必须分开存 ────────────────────────────────────────────────────────────
+--   质量轴:qms_qcp_inspbill.pd     0=合格 / 1=不合格 / NULL=未判定
+--   流程轴:ApprovalFlowInstance.Status(2=Approved)与 .EndTime
+-- 实测 4 张单同时是「检验完成」且 pd=1 —— PROCESS_COMPLETED ≠ QUALITY_ACCEPTED。
+-- 故本表同时保留 raw_pd / quality_result / flow_status / completion_time 四列,
+-- 【不】落单一 is_completed 布尔,否则上面那四种状态组合将不可表达。
+-- 亦【不】使用 FBILLSTATUS 作质量 Authority:实测 15 条 Approved 中 9 条该列未回写,是有损镜像。
+--
+-- ── 行级身份刻意缺席 ────────────────────────────────────────────────────────────────
+-- 本表【没有】po_line / receipt_line / source_entry_seq 列,这是有意的:
+-- 上游 FSRCBILLID / FSRCBILLENTRYSEQ 实测 0/4 全空,属 SOURCE CONTRACT GAP。
+-- 禁止用 物料 / 收货单+物料 / 批次 / 供应商 / 数量 反推行号 —— 那是弱关联。
+--
+-- ── 租户来源 ────────────────────────────────────────────────────────────────────────
+-- ApprovalFlowInstance 没有 TenantId 列(实测)。因此租户【只能】来自 qms_qcp_inspbill.tenant_id,
+-- 审批流实例仅按 (BizType='S5_IQC_INSPBILL', BizId=bill.id) 反查,绝不反向驱动租户。
+--
+-- ── 本脚本不做 ──────────────────────────────────────────────────────────────────────
+-- 不建 DWD;不做订单级完成聚合;不回填任何业务源表;不修 PR.work_order;
+-- 不复用 mdp_std_s4_iqc(其真实 Grain 是采购收货明细、qc_result 由退货数推导,非 IQC 事实);
+-- 不复用 mdp_std_iqc_receipt_state(0 行、未接线、历史设计依赖已证无效的 Receipt 桥)。
+-- =====================================================================================
+
+CREATE TABLE IF NOT EXISTS `mdp_std_iqc_inspection` (
+  `id`                  BIGINT        NOT NULL AUTO_INCREMENT,
+  `tenant_id`           BIGINT        NOT NULL                COMMENT '租户。唯一权威 = qms_qcp_inspbill.tenant_id,绝不取自审批流',
+  `factory_id`          BIGINT        NULL                    COMMENT '工厂。S5 IQC 源无该概念,恒 1,保留以对齐 mdp_std_* 形状',
+  `source_system`       VARCHAR(50)   NOT NULL                COMMENT '来源系统',
+
+  `iqc_bill_id`         BIGINT        NOT NULL                COMMENT '检验单 id = qms_qcp_inspbill.id(Grain 主体)',
+  `iqc_bill_no`         VARCHAR(64)   NULL                    COMMENT '检验单号 = qms_qcp_inspbill.FBILLNO',
+  `application_id`      BIGINT        NULL                    COMMENT '报检申请 id = qms_qcp_inspecapplyn.id',
+  `application_no`      VARCHAR(64)   NULL                    COMMENT '报检申请单号 = qms_qcp_inspbill.lydjbh = inspecapplyn.FBILLNO',
+  `application_entry_id` BIGINT       NULL                    COMMENT '报检分录 id = qms_qcp_insappnentry.id。经 bill.hid 单值外键解析;解析不唯一时留 NULL,不猜',
+
+  `purchase_order_no`   VARCHAR(80)   NULL                    COMMENT '采购单号(文档级)= insappnentry.purchase_order_no(1.0.540 正式 Writer 所写)。不含行号',
+  `receipt_no`          VARCHAR(80)   NULL                    COMMENT '收货单号 = insappnentry.FSRCORDERNUM。与采购单号是两个不同字段,禁止互相覆盖',
+
+  `material_code`       VARCHAR(255)  NULL                    COMMENT '物料编码 = insappnentry.FMATERIALCFG',
+  `material_name`       VARCHAR(255)  NULL                    COMMENT '物料名称 = insappnentry.wlmc',
+  `lot_serial`          VARCHAR(50)   NULL                    COMMENT '批次号 = insappnentry.FLOTNUMBER',
+  `supplier_code`       VARCHAR(255)  NULL                    COMMENT '供应商编码 = insappnentry.gysbm',
+  `supplier_name`       VARCHAR(255)  NULL                    COMMENT '供应商名称 = insappnentry.gysmc',
+
+  `raw_pd`              INT           NULL                    COMMENT '质量判定原值 = qms_qcp_inspbill.pd,原样保留:0=合格 1=不合格 NULL=未判定',
+  `quality_result`      VARCHAR(16)   NULL                    COMMENT '质量结论:PASS(pd=0) / FAIL(pd=1) / PENDING(pd IS NULL)。派生自 raw_pd,不来自 FBILLSTATUS',
+
+  `flow_status`         INT           NULL                    COMMENT '审批流状态原值 = ApprovalFlowInstance.Status(1=Running 2=Approved 3=Rejected)。无实例时 NULL',
+  `flow_instance_id`    BIGINT        NULL                    COMMENT '被采纳的审批流实例 id。实测每张单至多一个实例,故无需取最新',
+  `completion_time`     DATETIME      NULL                    COMMENT '流程完成时间 = ApprovalFlowInstance.EndTime。这是【流程】完成时间,不等于质量合格,须与 quality_result 配对使用',
+
+  `source_row_id`       VARCHAR(100)  NOT NULL                COMMENT '源行 id = iqc_bill_id 的字符串形式,参与唯一键(对齐 mdp_std_* convention)',
+  `source_biz_key`      VARCHAR(200)  NULL                    COMMENT '源业务键 = 检验单号',
+  `sync_batch_id`       VARCHAR(100)  NOT NULL                COMMENT '同步批次',
+  `sync_time`           DATETIME      NOT NULL                COMMENT '同步时间',
+  `update_time`         DATETIME      NULL                    COMMENT '更新时间',
+
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`),
+  KEY `idx_mdp_std_iqc_insp_bill` (`tenant_id`, `iqc_bill_no`),
+  KEY `idx_mdp_std_iqc_insp_po` (`tenant_id`, `purchase_order_no`),
+  KEY `idx_mdp_std_iqc_insp_result` (`tenant_id`, `quality_result`, `flow_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S5 来料检验标准事实层。Grain = 一张检验单(tenant_id + iqc_bill_id)';

+ 133 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.541.verify.sql

@@ -0,0 +1,133 @@
+-- 1.0.541.verify.sql — HARD ASSERTIONS,每条 SELECT 都必须返回 1
+--
+-- ⚠️ AutoVersionUpdate 按分号切分本文件,并把【每一个切片】都当断言、要求返回 1。因此:
+--      ① 不得放"仅供排查"的 informational 查询
+--      ② 文件必须以最后一条断言的分号结尾,末尾不能留纯注释块
+--      ③ 注释里不得出现任何字面分号
+--
+-- 口径:只断言本批 Writer 独占产出的 mdp_std_iqc_inspection。
+--   全局历史卫生(如 fixture 残留、PR.work_order 为空)一律不写成 HARD,只在报告里登记 WARNING。
+--   结构断言迁移后即可全过;数据断言在表为空时也成立,投影跑过之后才真正有判别力。
+
+-- ── ① 表与唯一键就位,且 Grain = (tenant_id, source_row_id) ────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_iqc_inspection') = 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_iqc_inspection'
+           AND `INDEX_NAME` = 'uk_mdp_std_iqc_insp') = 'tenant_id,source_row_id';
+
+-- ── ② 两条正交轴必须都在:不得只留一个 is_completed 布尔 ───────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_iqc_inspection'
+           AND `COLUMN_NAME` IN ('raw_pd','quality_result','flow_status','completion_time')) = 4;
+
+-- ── ③ 本批绝不得引入任何行级身份列 ─────────────────────────────────────────────────
+--     上游 FSRCBILLID / FSRCBILLENTRYSEQ 实测全空,行级归因属 SOURCE CONTRACT GAP
+SELECT (SELECT COUNT(*) FROM `information_schema`.`COLUMNS`
+         WHERE `TABLE_SCHEMA` = DATABASE() AND `TABLE_NAME` = 'mdp_std_iqc_inspection'
+           AND `COLUMN_NAME` IN ('po_line','purchase_order_line','receipt_line',
+                                 'source_entry_seq','source_bill_entry_seq')) = 0;
+
+-- ── ④ 租户合法:本批 Writer 只投 tenant_id > 0 的检验单 ────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `tenant_id` IS NULL OR `tenant_id` <= 0) = 0;
+
+-- ── ⑤ 质量映射必须严格来自 raw_pd ──────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `raw_pd` = 0 AND `quality_result` <> 'PASS') = 0;
+
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `raw_pd` = 1 AND `quality_result` <> 'FAIL') = 0;
+
+-- ⑤c pd 为 NULL 时绝不能是 PASS —— 这是「未判定不得滑成合格」的硬底线
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `raw_pd` IS NULL AND `quality_result` <> 'PENDING') = 0;
+
+-- ⑤d 取值域封闭
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `quality_result` NOT IN ('PASS','FAIL','PENDING')) = 0;
+
+-- ── ⑥ 流程与质量是正交轴:流程完成不得被当成质量合格 ───────────────────────────────
+--     判据:存在 flow_status=2(Approved)却 quality_result='FAIL' 的行是【合法】的,
+--     所以这里不能断言二者一致。真正要守的是反向:不得因为流程完成就把 PASS 写上去。
+--     表述为:quality_result 必须能由 raw_pd 单独推出(⑤ 已保证),此处补断流程列不污染质量列。
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `quality_result` = 'PASS' AND (`raw_pd` IS NULL OR `raw_pd` <> 0)) = 0;
+
+-- ── ⑦ 完成时间只能来自终态审批流,Running 状态不得伪造 ─────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `completion_time` IS NOT NULL AND IFNULL(`flow_status`, -1) <> 2) = 0;
+
+-- ⑦b 完成时间必须逐行等于被采纳实例的 EndTime,不得自造
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection` s
+          JOIN `ApprovalFlowInstance` i ON i.`Id` = s.`flow_instance_id`
+         WHERE s.`completion_time` IS NOT NULL
+           AND s.`completion_time` <> i.`EndTime`) = 0;
+
+-- ── ⑧ 租户 Authority 单向:投出来的租户必须等于源检验单的租户 ───────────────────────
+--     审批流表没有 TenantId,绝不允许它反向决定租户
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection` s
+          JOIN `qms_qcp_inspbill` b ON b.`id` = s.`iqc_bill_id`
+         WHERE b.`tenant_id` <> s.`tenant_id`) = 0;
+
+-- ── ⑨ 收货单号与采购单号互不覆盖 ───────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `receipt_no` IS NOT NULL AND `purchase_order_no` IS NOT NULL
+           AND `receipt_no` = `purchase_order_no`) = 0;
+
+-- ⑨b 采购单号必须逐行等于分录上的权威值(1.0.540 所写),不得另行推导
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection` s
+          JOIN `qms_qcp_insappnentry` e
+            ON e.`id` = s.`application_entry_id` AND e.`tenant_id` = s.`tenant_id`
+         WHERE IFNULL(s.`purchase_order_no`, '#') <> IFNULL(NULLIF(e.`purchase_order_no`, ''), '#')) = 0;
+
+-- ⑨c 收货单号同理
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection` s
+          JOIN `qms_qcp_insappnentry` e
+            ON e.`id` = s.`application_entry_id` AND e.`tenant_id` = s.`tenant_id`
+         WHERE IFNULL(s.`receipt_no`, '#') <> IFNULL(NULLIF(e.`FSRCORDERNUM`, ''), '#')) = 0;
+
+-- ── ⑩ 分录只能经 hid 单值外键解析,不得"从申请单下挑一条" ──────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection` s
+          JOIN `qms_qcp_inspbill` b ON b.`id` = s.`iqc_bill_id`
+         WHERE IFNULL(s.`application_entry_id`, -1) <> IFNULL(b.`hid`, -1)) = 0;
+
+-- ── ⑪ 无采购身份的行必须如实为 NULL,禁止猜值 ──────────────────────────────────────
+--     判据:分录本身没有采购单号时,标准层也必须是 NULL
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection` s
+          LEFT JOIN `qms_qcp_insappnentry` e
+                 ON e.`id` = s.`application_entry_id` AND e.`tenant_id` = s.`tenant_id`
+         WHERE s.`purchase_order_no` IS NOT NULL
+           AND (e.`id` IS NULL OR IFNULL(e.`purchase_order_no`, '') = '')) = 0;
+
+-- ── ⑫ Canonical 无重复:一张检验单只能一行 ─────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`) =
+       (SELECT COUNT(DISTINCT `tenant_id`, `iqc_bill_id`) FROM `mdp_std_iqc_inspection`);
+
+-- ── ⑬ 不得落字面量 'null' / 空串 ───────────────────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `mdp_std_iqc_inspection`
+         WHERE `purchase_order_no` IN ('null','') OR `receipt_no` IN ('null','')
+            OR `iqc_bill_no` IN ('null','') OR `material_code` IN ('null','')) = 0;
+
+-- ── ⑭ 本批不得建 Stage-3 聚合事实 / 完成事件 ───────────────────────────────────────
+SELECT (SELECT COUNT(*) FROM `information_schema`.`TABLES`
+         WHERE `TABLE_SCHEMA` = DATABASE()
+           AND `TABLE_NAME` IN ('dwd_iqc_completion','dwd_procurement_kpi','mdp_std_stage3_completion')) = 0;
+
+SELECT (SELECT COUNT(*) FROM `aidop_action_run_log`
+         WHERE `action_code` = 'S3_PROCUREMENT_COMPLETE') = 0;
+
+-- ── ⑮ 上游批次的既有资产不得回归 ───────────────────────────────────────────────────
+--     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;
+
+--     1.0.536:采购身份桥的订单行 Id 若有值仍须同租户命中
+SELECT (SELECT COUNT(*) FROM `mdp_std_purchase_request` p
+         WHERE p.`sales_order_entry_id` IS NOT NULL
+           AND 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;

+ 250 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S5/MaterialWarehouse/IqcInspectionMdpProjectionContractTests.cs

@@ -0,0 +1,250 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
+
+/// <summary>
+/// S5 IQC 标准事实层投影(<c>mdp_std_iqc_inspection</c>,1.0.541)的生产契约。
+///
+/// <para>这条链的价值在于让 S8 Stage-3 不再直读 <c>qms_qcp_inspbill</c> /
+/// <c>qms_qcp_inspecapplyn</c> / <c>qms_qcp_insappnentry</c> / <c>ApprovalFlowInstance</c>。
+/// 一旦投影逻辑被"顺手优化"破坏,S8 会在毫无征兆的情况下拿到错误的质量结论。</para>
+///
+/// <para>本文件守住四条最易被破坏的边界:
+/// ① 质量结论只能由 <c>pd</c> 派生,<c>pd IS NULL</c> 绝不能滑成 PASS;
+/// ② 流程完成 ≠ 质量合格,两轴必须分开存;
+/// ③ 租户只能来自检验单,审批流表没有 TenantId、绝不可反向驱动;
+/// ④ 报检分录只认 <c>hid</c> 单值外键,不得"从申请单下挑一条",更不得推 PO 行号。</para>
+/// </summary>
+public class IqcInspectionMdpProjectionContractTests
+{
+    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("MaterialWarehouse", "IqcInspectionMdpSyncService.cs");
+
+    private static string Migration()
+    {
+        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", "1.0.541.sql");
+        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 InsertBlock()
+    {
+        var code = Service();
+        var start = At(code, "INSERT INTO mdp_std_iqc_inspection");
+        var end = At(code, "private async Task<long> InsertRunLogAsync");
+        Assert.True(end > start);
+        return code[start..end];
+    }
+
+    // ── ① 质量结论只能由 pd 派生 ──────────────────────────────────────────────────
+
+    [Fact]
+    public void 质量结论必须严格由raw_pd派生()
+    {
+        var block = InsertBlock();
+        Assert.Contains("CASE WHEN b.pd = 0 THEN 'PASS'", block, StringComparison.Ordinal);
+        Assert.Contains("WHEN b.pd = 1 THEN 'FAIL'", block, StringComparison.Ordinal);
+        Assert.Contains("ELSE 'PENDING' END", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 禁止用FBILLSTATUS当质量Authority()
+    {
+        // 只扫 SQL 区间:服务头部的说明注释里正当地解释了"为什么不用它",不算违规
+        var block = InsertBlock();
+        // 实测 15 条 Approved 中 9 条 FBILLSTATUS 未回写,是有损镜像,绝不可作判据
+        Assert.DoesNotContain("FBILLSTATUS", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void raw_pd原值必须一并保留_不得只落结论()
+    {
+        var block = InsertBlock();
+        Assert.Contains("raw_pd", block, StringComparison.Ordinal);
+        Assert.Contains("b.pd,", block, StringComparison.Ordinal);
+    }
+
+    // ── ② 流程与质量分轴 ─────────────────────────────────────────────────────────
+
+    [Fact]
+    public void 流程轴与质量轴必须各自成列()
+    {
+        var block = InsertBlock();
+        foreach (var col in new[] { "raw_pd", "quality_result", "flow_status", "completion_time" })
+            Assert.Contains(col, block, StringComparison.Ordinal);
+
+        // 不得塌缩成单一布尔,否则 pd/flow 的四种组合无法表达
+        Assert.DoesNotContain("is_completed", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 完成时间只在流程终态才投影()
+    {
+        var block = InsertBlock();
+        Assert.Contains("CASE WHEN i.Status = 2 THEN i.EndTime ELSE NULL END", block, StringComparison.Ordinal);
+    }
+
+    // ── ③ 租户方向单向 ───────────────────────────────────────────────────────────
+
+    [Fact]
+    public void 租户必须来自检验单而非审批流()
+    {
+        var block = InsertBlock();
+        // 投出来的 tenant 只能是 b.tenant_id
+        Assert.Contains("SELECT\n                b.tenant_id,", block, StringComparison.Ordinal);
+        // 审批流只按 BizType+BizId 反查,不参与租户判定
+        Assert.Contains("i.BizType = @FlowBizType", block, StringComparison.Ordinal);
+        Assert.Contains("i.BizId = b.id", block, StringComparison.Ordinal);
+        Assert.DoesNotContain("i.TenantId", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 本地业务JOIN必须显式带租户()
+    {
+        var block = InsertBlock();
+        Assert.Contains("a.tenant_id = b.tenant_id", block, StringComparison.Ordinal);
+        Assert.Contains("e.tenant_id = b.tenant_id", block, StringComparison.Ordinal);
+        // Domain 不是租户边界,不得作为关联依据
+        Assert.DoesNotContain("Domain =", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 只投租户合法的检验单()
+    {
+        var block = InsertBlock();
+        Assert.Contains("WHERE b.tenant_id > 0", block, StringComparison.Ordinal);
+    }
+
+    // ── ④ 分录解析与行级身份 ─────────────────────────────────────────────────────
+
+    [Fact]
+    public void 分录只认hid单值外键_不得从申请单挑一条()
+    {
+        var block = InsertBlock();
+        Assert.Contains("e.id = b.hid", block, StringComparison.Ordinal);
+        Assert.Contains("b.hid IS NOT NULL", block, StringComparison.Ordinal);
+        // 不得出现 e.glid = a.id 这类"申请单下任取一条"的回退
+        Assert.DoesNotContain("e.glid = a.id", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 禁止投影任何行级身份()
+    {
+        // 只扫 SQL 区间:文档注释需要点名这些字段来说明"为什么刻意不投"
+        var block = InsertBlock();
+        foreach (var forbidden in new[] { "po_line", "OrdLine", "FSRCBILLENTRYSEQ", "FSRCBILLID", "receipt_line" })
+            Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 表结构不得含行级身份列()
+    {
+        var sql = Migration();
+        foreach (var forbidden in new[] { "`po_line`", "`purchase_order_line`", "`receipt_line`", "`source_entry_seq`" })
+            Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
+    }
+
+    // ── ⑤ 身份字段不得互相覆盖 ───────────────────────────────────────────────────
+
+    [Fact]
+    public void 采购单号与收货单号取自各自权威列()
+    {
+        var block = InsertBlock();
+        Assert.Contains("NULLIF(e.purchase_order_no, '')", block, StringComparison.Ordinal);
+        Assert.Contains("NULLIF(e.FSRCORDERNUM, '')", block, StringComparison.Ordinal);
+    }
+
+    // ── ⑥ Grain 与幂等 ───────────────────────────────────────────────────────────
+
+    [Fact]
+    public void Canonical键必须是租户加检验单()
+    {
+        var sql = Migration();
+        Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", sql, StringComparison.Ordinal);
+        // source_row_id 必须就是检验单 id,否则 Grain 名不副实
+        var block = InsertBlock();
+        Assert.Contains("CAST(b.id AS CHAR)", block, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 采购单号不得作为唯一键()
+    {
+        var sql = Migration();
+        Assert.DoesNotContain("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `purchase_order_no`)", sql, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 必须是幂等UPSERT()
+    {
+        var block = InsertBlock();
+        Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal);
+        // 后补的身份与流程结果都必须能刷新
+        foreach (var col in new[] { "purchase_order_no=VALUES(purchase_order_no)",
+                                    "raw_pd=VALUES(raw_pd)",
+                                    "quality_result=VALUES(quality_result)",
+                                    "flow_status=VALUES(flow_status)",
+                                    "completion_time=VALUES(completion_time)" })
+            Assert.Contains(col, block, StringComparison.Ordinal);
+    }
+
+    // ── ⑦ 边界:只读源、不做聚合 ─────────────────────────────────────────────────
+
+    [Fact]
+    public void 只写标准层_不得写任何业务源表()
+    {
+        var code = Service();
+
+        // 允许且仅允许两个写入目标:标准层事实表 + 中台跑批日志
+        var targets = code.Split("INSERT INTO ").Skip(1)
+            .Select(x => x.Split('\n')[0].Trim())
+            .Distinct()
+            .OrderBy(x => x, StringComparer.Ordinal)
+            .ToArray();
+        Assert.Equal(new[] { "mdp_std_iqc_inspection", "mdp_transform_run_log" }, targets);
+
+        // 业务源表一律只读
+        foreach (var src in new[] { "UPDATE qms_qcp", "INSERT INTO qms_qcp", "DELETE FROM qms_qcp",
+                                    "UPDATE ApprovalFlowInstance", "INSERT INTO ApprovalFlowInstance" })
+            Assert.DoesNotContain(src, code, StringComparison.Ordinal);
+    }
+
+    [Fact]
+    public void 本批不得做订单级完成聚合()
+    {
+        var code = Service();
+        foreach (var forbidden in new[] { "MAX(i.EndTime)", "MAX(completion_time)", "ALL_IQC", "S3_PROCUREMENT_COMPLETE" })
+            Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
+    }
+
+    // ── ⑧ 上游批次资产不得回归 ───────────────────────────────────────────────────
+
+    [Fact]
+    public void 依赖的1_0_540采购单号列必须仍被读取()
+    {
+        var block = InsertBlock();
+        Assert.Contains("e.purchase_order_no", block, StringComparison.Ordinal);
+    }
+}

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

@@ -23,6 +23,7 @@ public partial class AidopKanbanController : ControllerBase
     private readonly S1MdpSyncTransformService _s1MdpSyncTransformService;
     private readonly S2MdpSyncTransformService _s2MdpSyncTransformService;
     private readonly S3MdpSyncTransformService _s3MdpSyncTransformService;
+    private readonly MaterialWarehouse.IqcInspectionMdpSyncService _iqcInspectionMdpSyncService;
     private readonly S4MdpSyncTransformService _s4MdpSyncTransformService;
     private readonly S5MdpSyncTransformService _s5MdpSyncTransformService;
     private readonly S6MdpSyncTransformService _s6MdpSyncTransformService;
@@ -44,6 +45,7 @@ public partial class AidopKanbanController : ControllerBase
         S1MdpSyncTransformService s1MdpSyncTransformService,
         S2MdpSyncTransformService s2MdpSyncTransformService,
         S3MdpSyncTransformService s3MdpSyncTransformService,
+        MaterialWarehouse.IqcInspectionMdpSyncService iqcInspectionMdpSyncService,
         S4MdpSyncTransformService s4MdpSyncTransformService,
         S5MdpSyncTransformService s5MdpSyncTransformService,
         S6MdpSyncTransformService s6MdpSyncTransformService,
@@ -64,6 +66,7 @@ public partial class AidopKanbanController : ControllerBase
         _s1MdpSyncTransformService = s1MdpSyncTransformService;
         _s2MdpSyncTransformService = s2MdpSyncTransformService;
         _s3MdpSyncTransformService = s3MdpSyncTransformService;
+        _iqcInspectionMdpSyncService = iqcInspectionMdpSyncService;
         _s4MdpSyncTransformService = s4MdpSyncTransformService;
         _s5MdpSyncTransformService = s5MdpSyncTransformService;
         _s6MdpSyncTransformService = s6MdpSyncTransformService;
@@ -685,6 +688,27 @@ LIMIT 60
         });
     }
 
+    /// <summary>
+    /// S5 来料检验(IQC)标准事实层投影手动刷新。
+    /// qms_qcp_inspbill(+报检申请/分录+审批流实例)→ mdp_std_iqc_inspection。
+    /// Grain = 一张检验单;只读业务表、只写 mdp_std_iqc_inspection;不做订单级完成聚合;不投 PO 行号。
+    /// tenantId 省略或 0 表示全租户。
+    /// </summary>
+    [HttpPost("s5-iqc-inspection-mdp/refresh")]
+    public async Task<IActionResult> RefreshS5IqcInspectionMdp(
+        [FromQuery] long? tenantId,
+        CancellationToken cancellationToken = default)
+    {
+        var result = await _iqcInspectionMdpSyncService.RunFullAsync(
+            tenantId ?? 0, "MANUAL", cancellationToken);
+        return Ok(new
+        {
+            ok = true,
+            batchId = result.BatchId,
+            stdRows = result.StdRows
+        });
+    }
+
     /// <summary>
     /// S5 采购收货双模式入站:MdpDbPullExecutor → mdp_stg_purchase_receipt,再跑标准层转换。
     /// 独立作业模式仍可用 s5-purchase-receipt-mdp/refresh(直读源表建 std)。

+ 235 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/MaterialWarehouse/IqcInspectionMdpSyncService.cs

@@ -0,0 +1,235 @@
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
+
+/// <summary>
+/// S5 来料检验(IQC)标准事实层投影服务。
+///
+/// <para>链路:<c>qms_qcp_inspbill</c>(+ 报检申请/分录 + 审批流实例)→ <c>mdp_std_iqc_inspection</c>。
+/// 目的是让 S8 Stage-3 不再直读业务表与框架表 —— 那四张表此后只由本 Writer 读。</para>
+///
+/// <para><b>Grain = 一张检验单</b>(<c>tenant_id + iqc_bill_id</c>)。先证后建:全库 23 张单实测
+/// entries ≤ 1、distinct_po ≤ 1、flow_instances ≤ 1,结构保证来自 <c>inspbill.hid</c> 这把
+/// 指向报检分录的单值外键。</para>
+///
+/// <para><b>两条正交轴分开存</b>:质量轴取 <c>pd</c>(0 合格 / 1 不合格 / NULL 未判定),
+/// 流程轴取审批流 <c>Status</c> 与 <c>EndTime</c>。实测存在「检验完成且 pd=1」的单,
+/// 所以绝不可把流程完成当成质量合格,也绝不用 <c>FBILLSTATUS</c> 当质量 Authority。</para>
+///
+/// <para><b>租户方向是单向的</b>:<c>ApprovalFlowInstance</c> 没有 TenantId 列,
+/// 因此租户只能来自 <c>qms_qcp_inspbill.tenant_id</c>,审批流仅按 (BizType, BizId) 反查,
+/// 绝不反向驱动租户。</para>
+///
+/// <para><b>刻意不做</b>:不投 PO 行号 / 收货行号(上游 <c>FSRCBILLID</c>、
+/// <c>FSRCBILLENTRYSEQ</c> 实测全空,属 SOURCE CONTRACT GAP);不做订单级完成聚合;
+/// 不写任何业务源表。</para>
+/// </summary>
+public class IqcInspectionMdpSyncService : ITransient
+{
+    private const string JobCode = "S5_IQC_INSPECTION_MDP_SYNC";
+    private const string JobName = "S5来料检验标准事实层投影";
+
+    /// <summary>审批流业务类型,与 <c>IqcInspBillFlowConst.BizType</c> 一致。</summary>
+    private const string FlowBizType = "S5_IQC_INSPBILL";
+
+    private readonly ISqlSugarClient _db;
+
+    public IqcInspectionMdpSyncService(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    /// <summary>
+    /// 全量投影(UPSERT)。可按租户收窄;<paramref name="tenantId"/> 为 0 表示全租户。
+    /// 幂等:同一检验单重复运行只更新不新增,唯一键 (tenant_id, source_row_id)。
+    /// </summary>
+    public async Task<IqcInspectionMdpSyncResult> RunFullAsync(
+        long tenantId = 0,
+        string triggerType = "AUTO",
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+
+        var now = DateTime.Now;
+        var batchId = $"S5_IQC_STD_{(tenantId > 0 ? tenantId + "_" : "")}{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new IqcInspectionMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            result.StdRows = await TransformStandardAsync(batchId, now, tenantId);
+            await CompleteRunLogAsync(runLogId, result.StdRows, now);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await FailRunLogAsync(runLogId, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>
+    /// 标准层投影。
+    ///
+    /// <para>报检分录用 <c>LEFT JOIN</c> 且【只走 <c>hid</c> 这把单值外键】:<c>hid</c> 为空的历史单
+    /// 得到 NULL 的采购/收货/物料身份,而不是去申请单下"挑一条"分录 —— 宁可为空,不猜。</para>
+    ///
+    /// <para>审批流同样 <c>LEFT JOIN</c>:尚未发起流程的单落 NULL,不伪造完成时间。</para>
+    /// </summary>
+    private async Task<int> TransformStandardAsync(string batchId, DateTime now, long tenantId)
+    {
+        // 租户收窄:>0 时只投该租户,=0 时全投。两处(COUNT 与 INSERT)共用同一谓词,防止口径漂移。
+        var tenantClause = tenantId > 0 ? " AND b.tenant_id=@TenantId" : "";
+
+        var countPars = new List<SugarParameter>();
+        if (tenantId > 0) countPars.Add(new SugarParameter("@TenantId", tenantId));
+
+        var rows = await _db.Ado.GetIntAsync(
+            $"""
+            SELECT COUNT(1) FROM qms_qcp_inspbill b
+            WHERE b.tenant_id > 0{tenantClause}
+            """, countPars);
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_iqc_inspection
+            (tenant_id, factory_id, source_system,
+             iqc_bill_id, iqc_bill_no, application_id, application_no, application_entry_id,
+             purchase_order_no, receipt_no,
+             material_code, material_name, lot_serial, supplier_code, supplier_name,
+             raw_pd, quality_result,
+             flow_status, flow_instance_id, completion_time,
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                b.tenant_id, 1, 'AIDOP',
+                b.id,
+                NULLIF(b.FBILLNO, ''),
+                a.id,
+                NULLIF(b.lydjbh, ''),
+                e.id,
+                -- 采购/收货身份只从 hid 解析出的那一条分录取,取不到就是 NULL
+                NULLIF(e.purchase_order_no, ''),
+                NULLIF(e.FSRCORDERNUM, ''),
+                NULLIF(e.FMATERIALCFG, ''),
+                NULLIF(e.wlmc, ''),
+                NULLIF(e.FLOTNUMBER, ''),
+                NULLIF(e.gysbm, ''),
+                NULLIF(e.gysmc, ''),
+                b.pd,
+                -- 质量结论只由 pd 派生。pd 为 NULL 时必须是 PENDING,绝不能滑成 PASS
+                CASE WHEN b.pd = 0 THEN 'PASS'
+                     WHEN b.pd = 1 THEN 'FAIL'
+                     ELSE 'PENDING' END,
+                i.Status,
+                i.Id,
+                -- 只有流程走到终态(Approved)才认完成时间;Running 状态下不得投影 EndTime
+                CASE WHEN i.Status = 2 THEN i.EndTime ELSE NULL END,
+                CAST(b.id AS CHAR),
+                NULLIF(b.FBILLNO, ''),
+                @BatchId, @Now
+            FROM qms_qcp_inspbill b
+            -- 申请单:租户显式对齐(Domain 不是租户边界,不可用作关联依据)
+            LEFT JOIN qms_qcp_inspecapplyn a
+                   ON a.tenant_id = b.tenant_id
+                  AND a.FBILLNO = b.lydjbh
+                  AND IFNULL(b.lydjbh, '') <> ''
+            -- 报检分录:只认 hid 单值外键。不做「申请单下任取一条」的回退 —— 那会把多分录场景
+            -- 静默变成随机挑一个 PO,是弱关联。
+            LEFT JOIN qms_qcp_insappnentry e
+                   ON e.tenant_id = b.tenant_id
+                  AND e.id = b.hid
+                  AND b.hid IS NOT NULL
+            -- 审批流实例:租户方向单向 —— 由已确定租户的检验单去查流程,绝不反向。
+            -- 实测每个 BizId 至多一个实例,故无需 MAX(EndTime) 之类的挑选规则。
+            LEFT JOIN ApprovalFlowInstance i
+                   ON i.BizType = @FlowBizType
+                  AND i.BizId = b.id
+            WHERE b.tenant_id > 0{tenantClause}
+            ON DUPLICATE KEY UPDATE
+                factory_id=VALUES(factory_id), source_system=VALUES(source_system),
+                iqc_bill_no=VALUES(iqc_bill_no),
+                application_id=VALUES(application_id), application_no=VALUES(application_no),
+                application_entry_id=VALUES(application_entry_id),
+                purchase_order_no=VALUES(purchase_order_no), receipt_no=VALUES(receipt_no),
+                material_code=VALUES(material_code), material_name=VALUES(material_name),
+                lot_serial=VALUES(lot_serial), supplier_code=VALUES(supplier_code), supplier_name=VALUES(supplier_name),
+                raw_pd=VALUES(raw_pd), quality_result=VALUES(quality_result),
+                flow_status=VALUES(flow_status), flow_instance_id=VALUES(flow_instance_id),
+                completion_time=VALUES(completion_time),
+                source_biz_key=VALUES(source_biz_key),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+                update_time=CURRENT_TIMESTAMP
+            """;
+
+        var insPars = new List<SugarParameter>
+        {
+            new("@BatchId", batchId),
+            new("@Now", now),
+            new("@FlowBizType", FlowBizType),
+        };
+        if (tenantId > 0) insPars.Add(new SugarParameter("@TenantId", tenantId));
+
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
+        return rows;
+    }
+
+    private async Task<long> InsertRunLogAsync(string batchId, DateTime startedAt, string triggerType)
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            INSERT INTO mdp_transform_run_log
+            (tenant_id, job_code, job_name, trigger_type, batch_id, status, start_time)
+            VALUES (0, @JobCode, @JobName, @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            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, int stdRows, DateTime startedAt)
+    {
+        var endedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+               SET status='SUCCESS', standard_rows=@StdRows, end_time=@EndTime,
+                   duration_ms=@Duration, update_time=CURRENT_TIMESTAMP
+             WHERE id=@Id
+            """,
+            new SugarParameter("@StdRows", stdRows),
+            new SugarParameter("@EndTime", endedAt),
+            new SugarParameter("@Duration", (int)(endedAt - startedAt).TotalMilliseconds),
+            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", message.Length > 900 ? message[..900] : message),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private static string NormalizeTriggerType(string triggerType)
+        => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+}
+
+/// <summary>S5 IQC 标准层投影结果。</summary>
+public sealed class IqcInspectionMdpSyncResult
+{
+    public string BatchId { get; set; } = string.Empty;
+    public long RunLogId { get; set; }
+
+    /// <summary>本轮投影覆盖的检验单数(去重后的 Grain 行数,非 JOIN 扇出行数)。</summary>
+    public int StdRows { get; set; }
+}