Просмотр исходного кода

fix(s6): normalize IPQC std nulls and split completion messages

D-04 数据归一:TransformHeadStandardAsync 的 18 个字符串列改用既有
MdpJsonSql.Str,JSON null 不再经 JSON_UNQUOTE 落成字面量 'null'
(detail transform 本就正确,本次把同一写法补齐到 head 侧);
ybl/sxrq 改用 MdpJsonSql.Dec/DateTimeSec。身份键 source_row_id 与
source_biz_key 的两处兜底刻意保留裸取值(NOT NULL upsert 身份列,非展示列)。
配套 1.0.467.sql 订正存量:10 列 / 6 行,仅限能回溯到源单的行。

D-01 语义修正:完成态 message 改由 businessStatus 分支。此前 APPROVED 与
DISPOSED 共用「流程已完成(主管审核通过)」,把整批不合格转返工处置完成的单
描述成审核通过,业务含义相反。现分别为「整批合格·主管审核通过」与
「整批不合格·已处置」,业务状态缺失时返回中性文案。
状态机 / can* / assignee / 网关判据 / 节点跳转均未改动。

新增 S6IpqcDisplayContractTests(26 passed)钉住上述两项与 detail 无回归。

chore: bump version server 1.0.467
YY968XX 1 неделя назад
Родитель
Сommit
e3705995c8

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

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

+ 91 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.467.sql

@@ -0,0 +1,91 @@
+-- ============================================================================
+-- S6-DISPLAY-FIX-B1 · D-04 存量订正:mdp_std_ipqc_inspection 字面量 'null' → SQL NULL
+--
+-- 背景(根因已在同批代码修复):
+--   IpqcInspectionMdpSyncService.TransformHeadStandardAsync 原先对字符串列使用裸
+--   JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.x'))。MySQL 下 JSON null 经 JSON_UNQUOTE
+--   会产出**长度为 4 的字面量字符串 'null'**(实测:其 IS NULL = 0),
+--   于是 API 返回 "formNo":"null",前端 `|| '-'` 兜底对真值字符串无效,页面直接显示 null。
+--   同文件的 detail transform 因使用 MdpJsonSql.Str()(= NULLIF(...,'null'))从未受影响。
+--   代码侧已改为统一走 MdpJsonSql.Str/Dec/DateTimeSec,此后不再产生新污染;
+--   本脚本只订正**已经落库的存量**。
+--
+-- 证据(aidopdev,2026-09-02 只读取证):
+--   ① 受影响 10 列,7/8 行命中(form_no / version_no / inspec_standard_code /
+--      inspec_standard_version / production_person / attachment 各 7 行,remark 3 行,
+--      process_code / process_name / production_work_order 各 2 行)。
+--   ② 逐行 JOIN 回源表 qms_gcjyd 证明:凡 STD 为 'null' 者,其源列**均为 SQL NULL**,
+--      不存在"业务真的存了文本 null"的反例。
+--   ③ 全表扫描 qms_gcjyd 的 17 个对应源列,**没有任何一列持有字面量文本 'null'**。
+--   故 'null' 100% 来自转换污染,可安全归一为 SQL NULL。
+--
+-- 写入范围(INSERT 0 · UPDATE ≤8 行 · DELETE 0 · DDL 0):
+--   表:mdp_std_ipqc_inspection(仅标准层,不碰任何业务源表)
+--   列白名单(10 列,均为可空展示列;不含 NOT NULL 列,不含身份键):
+--     attachment, form_no, inspec_standard_code, inspec_standard_version,
+--     process_code, process_name, production_person, production_work_order,
+--     remark, version_no
+--   行范围:仅限**能回溯到源单**的行(EXISTS qms_gcjyd 同 id 同 tenant_id)。
+--
+-- 刻意不处理(无法自证,遵循"证明不了就不清洗"):
+--   2 行孤儿 STD(source_row_id 在 qms_gcjyd 无对应行)——
+--   id=22(source_system=WMS_API 契约测试夹具,tenant 1300000000001)与
+--   id=32(tenant_id=0,本身即已知脏行)。
+--   这两行的贴源记录仍在 mdp_stg_ipqc_pull 中,**下一次 inbound 会由已修复的转换代码
+--   自动 upsert 订正**,无需在此强行 UPDATE。
+--
+-- 幂等:WHERE col='null' 决定重复执行为 0 行影响。
+-- 回滚:本操作把污染值归一为源真值(NULL),属**数据订正而非数据变更**,不提供反向脚本
+--       (反向写回 'null' 只会复原缺陷)。如确需还原,可对同一行集执行
+--       UPDATE ... SET col='null' WHERE col IS NULL,但需先确认该列源值确为 NULL。
+-- ============================================================================
+
+-- ── 执行前计数(期望:与上文证据一致)──
+SELECT 'BEFORE' AS phase,
+       SUM(attachment              = 'null') AS attachment,
+       SUM(form_no                 = 'null') AS form_no,
+       SUM(inspec_standard_code    = 'null') AS inspec_standard_code,
+       SUM(inspec_standard_version = 'null') AS inspec_standard_version,
+       SUM(process_code            = 'null') AS process_code,
+       SUM(process_name            = 'null') AS process_name,
+       SUM(production_person       = 'null') AS production_person,
+       SUM(production_work_order   = 'null') AS production_work_order,
+       SUM(remark                  = 'null') AS remark,
+       SUM(version_no              = 'null') AS version_no
+FROM mdp_std_ipqc_inspection;
+
+-- ── 订正:仅可回溯到源单的行,且仅当该列恰为字面量 'null' ──
+UPDATE mdp_std_ipqc_inspection m
+SET m.attachment              = NULLIF(m.attachment,              'null'),
+    m.form_no                 = NULLIF(m.form_no,                 'null'),
+    m.inspec_standard_code    = NULLIF(m.inspec_standard_code,    'null'),
+    m.inspec_standard_version = NULLIF(m.inspec_standard_version, 'null'),
+    m.process_code            = NULLIF(m.process_code,            'null'),
+    m.process_name            = NULLIF(m.process_name,            'null'),
+    m.production_person       = NULLIF(m.production_person,       'null'),
+    m.production_work_order   = NULLIF(m.production_work_order,   'null'),
+    m.remark                  = NULLIF(m.remark,                  'null'),
+    m.version_no              = NULLIF(m.version_no,              'null')
+WHERE EXISTS (
+        SELECT 1 FROM qms_gcjyd s
+        WHERE s.id = m.source_row_id AND s.tenant_id = m.tenant_id
+      )
+  AND (m.attachment = 'null' OR m.form_no = 'null'
+    OR m.inspec_standard_code = 'null' OR m.inspec_standard_version = 'null'
+    OR m.process_code = 'null' OR m.process_name = 'null'
+    OR m.production_person = 'null' OR m.production_work_order = 'null'
+    OR m.remark = 'null' OR m.version_no = 'null');
+
+-- ── 执行后计数(期望:可回溯行全部归零;仅可能残留上文声明的 2 行孤儿)──
+SELECT 'AFTER' AS phase,
+       SUM(attachment              = 'null') AS attachment,
+       SUM(form_no                 = 'null') AS form_no,
+       SUM(inspec_standard_code    = 'null') AS inspec_standard_code,
+       SUM(inspec_standard_version = 'null') AS inspec_standard_version,
+       SUM(process_code            = 'null') AS process_code,
+       SUM(process_name            = 'null') AS process_name,
+       SUM(production_person       = 'null') AS production_person,
+       SUM(production_work_order   = 'null') AS production_work_order,
+       SUM(remark                  = 'null') AS remark,
+       SUM(version_no              = 'null') AS version_no
+FROM mdp_std_ipqc_inspection;

+ 149 - 0
server/Plugins/Admin.NET.Plugin.AiDOP.Tests/S6/Manufacturing/S6IpqcDisplayContractTests.cs

@@ -0,0 +1,149 @@
+using Xunit;
+
+namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
+
+/// <summary>
+/// S6 DISPLAY FIX · Batch 1 源码契约测试。
+///
+/// D-04:IPQC head transform 的字符串列必须经 <c>MdpJsonSql.Str</c> 把 JSON null 归一为 SQL NULL,
+///       否则 <c>JSON_UNQUOTE(JSON_EXTRACT(...))</c> 会产出字面量字符串 'null' 并一路透到页面。
+///       detail transform 早已正确,本测试同时钉住其不回归。
+/// D-01:完成态 message 必须区分 APPROVED(整批合格)与 DISPOSED(整批不合格已处置),
+///       不得只依据 ApprovalFlowInstance.Status 一律说"主管审核通过"。
+/// </summary>
+public class S6IpqcDisplayContractTests
+{
+    private static string SyncService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionMdpSyncService.cs"));
+
+    private static string FlowService() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Manufacturing", "IpqcInspectionFlowService.cs"));
+
+    private static string JsonSql() => File.ReadAllText(FindFile(
+        "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpJsonSql.cs"));
+
+    /// <summary>head transform 的每个字符串列都必须走 MdpJsonSql.Str。</summary>
+    [Theory]
+    [InlineData("djbh")]
+    [InlineData("cplx")]
+    [InlineData("scph")]
+    [InlineData("lydjbh")]
+    [InlineData("jgpd")]
+    [InlineData("fj")]
+    [InlineData("bz")]
+    [InlineData("jyr")]
+    [InlineData("gxbm")]
+    [InlineData("gxmc")]
+    [InlineData("sczyry")]
+    [InlineData("bdbh")]
+    [InlineData("bbh")]
+    [InlineData("wlbm")]
+    [InlineData("wlmc")]
+    [InlineData("jgbb")]
+    [InlineData("jgbh")]
+    [InlineData("status")]
+    public void HeadTransform_StringField_UsesNullSafeHelper(string field)
+    {
+        Assert.Contains($"MdpJsonSql.Str(\"m\", \"{field}\")", SyncService());
+    }
+
+    /// <summary>数值/日期列用对应类型 helper,不得被误改成字符串 helper。</summary>
+    [Fact]
+    public void HeadTransform_NumericAndDateFields_UseTypedHelpers()
+    {
+        var s = SyncService();
+        Assert.Contains("MdpJsonSql.Dec(\"m\", \"ybl\", 18, 6)", s);
+        Assert.Contains("MdpJsonSql.DateTimeSec(\"m\", \"sxrq\")", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"m\", \"ybl\")", s);
+        Assert.DoesNotContain("MdpJsonSql.Str(\"m\", \"sxrq\")", s);
+    }
+
+    /// <summary>
+    /// 除身份键 source_row_id / source_biz_key 的两处兜底外,转换 SQL 不得再出现裸 JSON_UNQUOTE。
+    /// 这两处刻意保留:它们是 NOT NULL 的 upsert 身份列,不是展示列,改动会影响唯一键语义。
+    /// </summary>
+    [Fact]
+    public void TransformSql_NoBareJsonUnquote_ExceptIdentityKeyFallbacks()
+    {
+        var s = SyncService();
+        var bare = s.Split("JSON_UNQUOTE(JSON_EXTRACT(").Length - 1;
+        Assert.Equal(2, bare);
+        Assert.Contains("IFNULL(m.source_row_id, JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.id')))", s);
+        Assert.Contains("IFNULL(NULLIF(m.source_biz_key,''), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.djbh')))", s);
+    }
+
+    /// <summary>detail transform 既有的 null 安全行为不得回归。</summary>
+    [Fact]
+    public void DetailTransform_StillUsesNullSafeHelpers()
+    {
+        var s = SyncService();
+        Assert.Contains("MdpJsonSql.Str(\"d\", \"jyxm\")", s);
+        Assert.Contains("MdpJsonSql.Str(\"d\", \"pd\")", s);
+        Assert.Contains("MdpJsonSql.Dec(\"d\", \"jysl\", 18, 6)", s);
+        Assert.Contains("MdpJsonSql.DateTimeSec(\"d\", \"InspectionTime\")", s);
+    }
+
+    /// <summary>helper 语义钉死:JSON null / 字符串 'null' → SQL NULL。</summary>
+    [Fact]
+    public void JsonSqlStrHelper_NormalizesLiteralNullToSqlNull()
+    {
+        Assert.Contains("NULLIF({Ext(alias, field)},'null')", JsonSql());
+    }
+
+    /// <summary>完成态文案必须由 businessStatus 决定,而非只看实例状态。</summary>
+    [Fact]
+    public void CompletedMessage_BranchesOnBusinessStatus()
+    {
+        var s = FlowService();
+        Assert.Contains("BuildCompletedMessage(output.BusinessStatus)", s);
+        Assert.Contains("IpqcInspectionFlowConst.StatusApproved => \"流程已完成(整批合格·主管审核通过)\"", s);
+        Assert.Contains("IpqcInspectionFlowConst.StatusDisposed => \"流程已完成(整批不合格·已处置)\"", s);
+    }
+
+    /// <summary>两个完成终态的文案不得相同——这正是 D-01 的缺陷本身。</summary>
+    [Fact]
+    public void CompletedMessage_ApprovedAndDisposed_AreNotIdentical()
+    {
+        var s = FlowService();
+        Assert.DoesNotContain(
+            "inst.Status == FlowInstanceStatusEnum.Approved ? \"流程已完成(主管审核通过)\"", s);
+        Assert.Contains("_ => \"流程已完成\",", s);   // 业务状态缺失时中性表述,不臆断"审核通过"
+    }
+
+    /// <summary>非 Approved 的结束态文案保持原样,未被本批波及。</summary>
+    [Fact]
+    public void NonApprovedTerminal_MessageUnchanged()
+    {
+        Assert.Contains("$\"流程已结束({inst.Status})\"", FlowService());
+    }
+
+    /// <summary>
+    /// 状态机未被改动:节点码、网关判据、审批人解析、can* 判据、任务归属均保持原样。
+    /// D-01 只允许改"给 UI 的解释性文案"。
+    /// </summary>
+    [Fact]
+    public void StateMachine_Untouched()
+    {
+        var s = FlowService();
+        Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeInspect", s);
+        Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeSupervisor", s);
+        Assert.Contains("myTask.NodeId == IpqcInspectionFlowConst.NodeDisposition", s);
+        Assert.Contains("output.CanSupervisorApprove = true;", s);
+        Assert.Contains("output.CanSupervisorReturn = true;", s);
+        Assert.Contains("output.CanSubmitDisposition = true;", s);
+        // 整批判定仍驱动网关,未被文案改动波及
+        Assert.Contains("var dispositionRequired = input.Qualified ? 0 : 1;", s);
+    }
+
+    private static string FindFile(params string[] parts)
+    {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+        while (dir != null)
+        {
+            var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
+            if (File.Exists(candidate)) return candidate;
+            dir = dir.Parent;
+        }
+        throw new FileNotFoundException(string.Join("/", parts));
+    }
+}

+ 19 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionFlowService.cs

@@ -332,6 +332,22 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         await _db.Updateable(existing).ExecuteCommandAsync();
     }
 
+    /// <summary>
+    /// 完成态解释文案。IPQC 有两个合法完成终态,业务语义不同,不能都说"主管审核通过":
+    /// <list type="bullet">
+    /// <item>APPROVED = 整批合格、主管审核通过后直接结束;</item>
+    /// <item>DISPOSED = 整批不合格,经 N3 质量处置完成后结束(ApprovalFlow 实例同样是 Approved)。</item>
+    /// </list>
+    /// 仅生成给 UI 的解释性文字,<b>不参与任何状态判定、不影响状态机</b>。
+    /// 业务状态缺失时返回中性文案,不臆断"审核通过"。
+    /// </summary>
+    private static string BuildCompletedMessage(string? businessStatus) => businessStatus switch
+    {
+        IpqcInspectionFlowConst.StatusApproved => "流程已完成(整批合格·主管审核通过)",
+        IpqcInspectionFlowConst.StatusDisposed => "流程已完成(整批不合格·已处置)",
+        _ => "流程已完成",
+    };
+
     /// <summary>组装流程状态输出。</summary>
     private async Task<IpqcFlowStateOutput> BuildStateAsync(long bizId)
     {
@@ -369,7 +385,9 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         if (inst.Status != FlowInstanceStatusEnum.Running)
         {
             output.IsCompleted = true;
-            output.Message = inst.Status == FlowInstanceStatusEnum.Approved ? "流程已完成(主管审核通过)" : $"流程已结束({inst.Status})";
+            output.Message = inst.Status == FlowInstanceStatusEnum.Approved
+                ? BuildCompletedMessage(output.BusinessStatus)
+                : $"流程已结束({inst.Status})";
             return output;
         }
 

+ 20 - 20
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionMdpSyncService.cs

@@ -561,26 +561,26 @@ public class IpqcInspectionMdpSyncService : ITransient
              source_row_id, source_biz_key, sync_batch_id, sync_time)
             SELECT
                 {mTenant}, 1, IFNULL(NULLIF(m.source_system,''), 'AIDOP'),
-                IFNULL(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.djbh')), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.id'))),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.cplx')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.scph')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.lydjbh')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.jgpd')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.fj')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.bz')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.jyr')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.gxbm')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.gxmc')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.sczyry')),
-                CAST(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.ybl')),'null') AS DECIMAL(18,6)),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.bdbh')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.bbh')),
-                STR_TO_DATE(REPLACE(LEFT(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.sxrq')),'null'),''), 19), 'T', ' '), '%Y-%m-%d %H:%i:%s'),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.wlbm')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.wlmc')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.jgbb')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.jgbh')),
-                JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.status')),
+                IFNULL({MdpJsonSql.Str("m", "djbh")}, {MdpJsonSql.Str("m", "id")}),
+                {MdpJsonSql.Str("m", "cplx")},
+                {MdpJsonSql.Str("m", "scph")},
+                {MdpJsonSql.Str("m", "lydjbh")},
+                {MdpJsonSql.Str("m", "jgpd")},
+                {MdpJsonSql.Str("m", "fj")},
+                {MdpJsonSql.Str("m", "bz")},
+                {MdpJsonSql.Str("m", "jyr")},
+                {MdpJsonSql.Str("m", "gxbm")},
+                {MdpJsonSql.Str("m", "gxmc")},
+                {MdpJsonSql.Str("m", "sczyry")},
+                {MdpJsonSql.Dec("m", "ybl", 18, 6)},
+                {MdpJsonSql.Str("m", "bdbh")},
+                {MdpJsonSql.Str("m", "bbh")},
+                {MdpJsonSql.DateTimeSec("m", "sxrq")},
+                {MdpJsonSql.Str("m", "wlbm")},
+                {MdpJsonSql.Str("m", "wlmc")},
+                {MdpJsonSql.Str("m", "jgbb")},
+                {MdpJsonSql.Str("m", "jgbh")},
+                {MdpJsonSql.Str("m", "status")},
                 IFNULL(m.source_row_id, JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.id'))),
                 IFNULL(NULLIF(m.source_biz_key,''), JSON_UNQUOTE(JSON_EXTRACT(m.raw_data,'$.djbh'))),
                 @BatchId, @Now