Browse Source

fix(s6): IPQC 检验结果写入后标准层立即可见(单据级 targeted sync)

修复已确认的 TYPE-A 读写一致性缺陷:submit-inspection / supervisor-approve 写
qms_gcjyd 成功,但标准层无任何同步绑定(全仓仅 AidopKanbanController 两个手动
HTTP 入口,无 Job/Cron/HostedService),而 list/detail 只读 mdp_std_ipqc_inspection
且无 live 回退,导致页面停留在上一次手动同步的快照。

FIX-A 单据级 targeted 同步(IpqcInspectionMdpSyncService.SyncInspectionAsync)
- 源 → stg 贴源 upsert:raw_data 按 qms_gcjyd 全列动态生成 JSON_OBJECT,与
  MdpDbPullExecutor/MdpStagingWriter 信封形状一致(实测 32/32 键完全相同);
  source_biz_key 复刻 BuildBizKey 语义(djbh 取不到则回落 id),命中既有
  uk_source_key 原地更新,实测不产生重复 stg 行
- 标准层复用全量同一套 TransformHeadStandardAsync,仅加单据作用域,字段映射零分叉
- 只同步头表:qms_gcjydzb 全仓无任何应用写入,源不变则明细 std 不会滞后,
  纳入即为无依据开销,故刻意排除
- 租户双重收口(源查询 + transform 均按业务单租户),不依赖 RunFullAsync 的
  pbxfxp 账套映射;纯 upsert,无 DELETE / 无 orphan purge / 无 FullReplace
- 实测 495ms / 438ms,对比现有 full/inbound 2518~2691ms

触发点按证据逐项判定
- submit-inspection:同步(写 jgpd/jyhgsl/jybhgsl/ybl/jyr/bz)
- supervisor-approve:**仅 input.Jgpd.HasValue 时**同步(未传则只动 flow_state,
  流程状态由 live /state 读,无条件同步等于无意义写入)
- supervisor-return / QE disposition:不同步(不写源表;已实测无同步产生)
- 同步在业务事务之外,失败只记 Error 日志不回滚已提交的检验结果

run log 使用独立前缀 S6_IPQC_BILL_ 与真实 trigger_type,不伪装成 MANUAL/INBOUND。
YY968XX 1 day ago
parent
commit
ff82cfb3f1

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.467</AssemblyVersion>
-    <FileVersion>1.0.467</FileVersion>
-    <Version>1.0.467</Version>
+    <AssemblyVersion>1.0.468</AssemblyVersion>
+    <FileVersion>1.0.468</FileVersion>
+    <Version>1.0.468</Version>
   </PropertyGroup>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

+ 45 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Manufacturing/IpqcInspectionFlowService.cs

@@ -4,6 +4,7 @@ using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
 using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
 using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
 using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow.Service;
 using Admin.NET.Plugin.ApprovalFlow.Service;
+using Microsoft.Extensions.Logging;
 using Yitter.IdGenerator;
 using Yitter.IdGenerator;
 
 
 namespace Admin.NET.Plugin.AiDOP.Manufacturing;
 namespace Admin.NET.Plugin.AiDOP.Manufacturing;
@@ -30,12 +31,44 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly ISqlSugarClient _db;
     private readonly FlowEngineService _flowEngine;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
     private readonly UserManager _userManager;
-
-    public IpqcInspectionFlowService(ISqlSugarClient db, FlowEngineService flowEngine, UserManager userManager)
+    private readonly IpqcInspectionMdpSyncService _mdpSyncService;
+    private readonly ILogger<IpqcInspectionFlowService> _logger;
+
+    public IpqcInspectionFlowService(
+        ISqlSugarClient db,
+        FlowEngineService flowEngine,
+        UserManager userManager,
+        IpqcInspectionMdpSyncService mdpSyncService,
+        ILoggerFactory loggerFactory)
     {
     {
         _db = db;
         _db = db;
         _flowEngine = flowEngine;
         _flowEngine = flowEngine;
         _userManager = userManager;
         _userManager = userManager;
+        _mdpSyncService = mdpSyncService;
+        _logger = loggerFactory.CreateLogger<IpqcInspectionFlowService>();
+    }
+
+    /// <summary>
+    /// 源表 qms_gcjyd 写入落库后,把本单据同步到标准层(list/detail 只读 mdp_std_ipqc_inspection,无 live 回退)。
+    ///
+    /// 必须放在业务事务<b>之外</b>:源表是权威数据,同步只是派生物,不能让派生失败回滚已成立的检验结果。
+    /// 走单据级 <see cref="IpqcInspectionMdpSyncService.SyncInspectionAsync"/>,不调 RunFullAsync/RunInboundAsync
+    /// (二者为全表扫描,且 RunFullAsync 的租户写死为 pbxfxp 账套映射,对其他租户捞不到行)。
+    /// 失败只记 Error 日志不抛:此刻业务事务已提交且流程已推进,让 API 失败会诱导用户重试,
+    /// 而重试必然报"当前不在检验录入节点"。标准层可由下一次 targeted / 手动 inbound 自愈。
+    /// </summary>
+    private async Task SyncInspectionToStdSafeAsync(long inspectionId, long tenantId, string triggerType)
+    {
+        try
+        {
+            await _mdpSyncService.SyncInspectionAsync(inspectionId, tenantId, triggerType);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogError(ex,
+                "[IpqcFlow] 检验单 {InspectionId}(租户 {TenantId}) 业务写入已提交,但标准层 targeted 同步失败({Trigger}),列表/详情可能仍显示旧值",
+                inspectionId, tenantId, triggerType);
+        }
     }
     }
 
 
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
     /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
@@ -134,6 +167,10 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         });
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
         if (!tran.IsSuccess) throw tran.ErrorException;
 
 
+        // 事务外 targeted 同步:本动作写了 qms_gcjyd 的 jgpd/jyhgsl/jybhgsl/ybl/jyr/bz,
+        // 其中 jgpd/jyr/ybl/bz 由 list/detail 投影渲染,不同步则页面停留在上一次快照。
+        await SyncInspectionToStdSafeAsync(input.Id, ResolveTenantOrThrow(), "SUBMIT_INSPECTION");
+
         return await BuildStateAsync(input.Id);
         return await BuildStateAsync(input.Id);
     }
     }
 
 
@@ -172,6 +209,12 @@ public class IpqcInspectionFlowService : IDynamicApiController, ITransient
         });
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
         if (!tran.IsSuccess) throw tran.ErrorException;
 
 
+        // 事务外 targeted 同步:**仅当本次确实改写了源表 jgpd 时**才同步。
+        // 主管未传 jgpd 时本动作只动 flow_state(流程状态由 live 的 /state 接口读),标准层内容不变,
+        // 无条件同步等于每次审批都做一次无意义写入。
+        if (input.Jgpd.HasValue)
+            await SyncInspectionToStdSafeAsync(input.Id, ResolveTenantOrThrow(), "SUPERVISOR_APPROVE");
+
         // 双模式出站:整批合格结束时写 Outbox(QMS_API 占位;任务书 action=S6_REPORT_PUSH)
         // 双模式出站:整批合格结束时写 Outbox(QMS_API 占位;任务书 action=S6_REPORT_PUSH)
         if (input.Qualified)
         if (input.Qualified)
             await TryEnqueueIpqcPassOutboxAsync(input.Id, billNo);
             await TryEnqueueIpqcPassOutboxAsync(input.Id, billNo);

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

@@ -95,6 +95,103 @@ public class IpqcInspectionMdpSyncService : ITransient
         }
         }
     }
     }
 
 
+    /// <summary>本库 MySQL 贴源 source_system(与 MdpDbPullExecutor 落 stg 的取值一致,决定 uk_source_key 归属)。</summary>
+    private const string NativeSourceSystem = "AIDOPDEV_MYSQL";
+
+    /// <summary>targeted sync 的单据作用域(租户 + 检验单 id)。</summary>
+    private sealed class IpqcBillScope
+    {
+        public long TenantId { get; init; }
+        public long InspectionId { get; init; }
+    }
+
+    /// <summary>
+    /// 单据级 targeted 同步(业务写入后立即可见),替代把 Full/Inbound 全表扫描挂到高频提交路径。
+    ///
+    /// 只处理 inspectionId 这一张过程检验单的<b>头表</b>:
+    ///   qms_gcjyd (id=inspectionId AND tenant_id=tenantId) → mdp_stg_ipqc_pull → mdp_std_ipqc_inspection
+    ///
+    /// <b>刻意不同步明细</b>(qms_gcjydzb → mdp_std_ipqc_inspection_detail):该表全仓无任何应用
+    /// INSERT/UPDATE/DELETE,源不变则标准层不可能因业务写入而滞后,纳入即为无依据的额外开销。
+    ///
+    /// 边界:只读业务源、只写 mdp_stg_ipqc_pull / mdp_std_ipqc_inspection / run_log;
+    ///   租户双重收口(源查询 + transform 均按 tenantId);纯 upsert,无 DELETE / 无 orphan purge /
+    ///   无 MdpStdFullReplace / 不复用 RunFullAsync 的 pbxfxp 账套映射租户。
+    /// </summary>
+    public async Task<IpqcInspectionMdpSyncResult> SyncInspectionAsync(
+        long inspectionId, long tenantId, string triggerType = "BILL", CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        if (inspectionId <= 0 || tenantId <= 0)
+            return new IpqcInspectionMdpSyncResult { BatchId = "SKIPPED_INVALID_SCOPE" };
+
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var batchId = $"S6_IPQC_BILL_{now:yyyyMMddHHmmssfff}_{inspectionId}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new IpqcInspectionMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        _tenantSkipWarnings.Clear();
+
+        try
+        {
+            var scope = new IpqcBillScope { TenantId = tenantId, InspectionId = inspectionId };
+            // ① 贴源:把该单据的源行刷进 pull stg(transform 一律读 stg,不先刷就还是旧快照)
+            result.HeadStageRows = await UpsertHeadStgFromSourceAsync(scope, batchId, now);
+            // ② 标准层:复用全量同一套 transform,仅加单据作用域,字段映射零分叉
+            result.HeadStandardRows = await TransformHeadStandardAsync(batchId, now, null, scope);
+            result.TenantSkipWarnings = _tenantSkipWarnings.ToList();
+            await MarkRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>
+    /// 单据级头表贴源 upsert:qms_gcjyd 源行 → mdp_stg_ipqc_pull。
+    /// raw_data 按源表<b>全列</b>动态生成 JSON_OBJECT,与 MdpDbPullExecutor/MdpStagingWriter 的信封形状一致
+    /// (JSON 键 = 源列名;source_row_id = id;source_biz_key = djbh,空则回落 id,与 MdpStagingWriter.BuildBizKey
+    /// 的 "biz_key_expr=djbh,取不到则回落 sourceRowId" 语义一致),因此命中既有 uk_source_key 行原地更新,
+    /// 不产生重复 stg 行,也不会因 transform 将来新增字段而漂移。
+    /// </summary>
+    private async Task<int> UpsertHeadStgFromSourceAsync(IpqcBillScope scope, string batchId, DateTime now)
+    {
+        var cols = await _db.Ado.SqlQueryAsync<string>(
+            "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='qms_gcjyd' ORDER BY ORDINAL_POSITION");
+        // 列名取自 information_schema 且强制标识符白名单,非用户输入,无注入面
+        cols = cols.Where(c => !string.IsNullOrWhiteSpace(c) && System.Text.RegularExpressions.Regex.IsMatch(c, "^[A-Za-z0-9_]+$")).ToList();
+        if (cols.Count == 0) return 0;
+
+        var jsonObj = "JSON_OBJECT(" + string.Join(", ", cols.Select(c => $"'{c}', s.`{c}`")) + ")";
+        return await _db.Ado.ExecuteCommandAsync(
+            $"""
+            INSERT INTO mdp_stg_ipqc_pull
+            (tenant_id, source_system, source_table, source_row_id, source_biz_key, raw_data, sync_batch_id, sync_time, process_status, create_time)
+            SELECT s.tenant_id, @SrcSys, 'qms_gcjyd', CAST(s.id AS CHAR),
+                   IFNULL(NULLIF(s.djbh,''), CAST(s.id AS CHAR)), {jsonObj},
+                   @BatchId, @Now, 'PENDING', @Now
+            FROM qms_gcjyd s
+            WHERE s.id=@Id AND s.tenant_id=@TenantId
+            ON DUPLICATE KEY UPDATE
+                tenant_id=VALUES(tenant_id), source_row_id=VALUES(source_row_id), raw_data=VALUES(raw_data),
+                sync_batch_id=VALUES(sync_batch_id), sync_time=VALUES(sync_time),
+                process_status='PENDING', update_time=CURRENT_TIMESTAMP
+            """,
+            new List<SugarParameter>
+            {
+                new("@SrcSys", NativeSourceSystem),
+                new("@BatchId", batchId),
+                new("@Now", now),
+                new("@Id", scope.InspectionId),
+                new("@TenantId", scope.TenantId),
+            });
+    }
+
     /// <summary>
     /// <summary>
     /// 双模式入站:执行器抽数落 pull-stg,再跑标准层(头读 pull stg)。
     /// 双模式入站:执行器抽数落 pull-stg,再跑标准层(头读 pull stg)。
     /// </summary>
     /// </summary>
@@ -532,7 +629,7 @@ public class IpqcInspectionMdpSyncService : ITransient
     }
     }
 
 
     /// <summary>标准化头:mdp_stg_ipqc_pull(qms_gcjyd) → mdp_std_ipqc_inspection。</summary>
     /// <summary>标准化头:mdp_stg_ipqc_pull(qms_gcjyd) → mdp_std_ipqc_inspection。</summary>
-    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    private async Task<int> TransformHeadStandardAsync(string batchId, DateTime now, string? sourceSystem = null, IpqcBillScope? scope = null)
     {
     {
         // 双源:sourceSystem 非空时仅统计/转换当前 source(切源 FULL Replace 用);为空时保持既有全源行为不变。
         // 双源:sourceSystem 非空时仅统计/转换当前 source(切源 FULL Replace 用);为空时保持既有全源行为不变。
         var countPars = new List<SugarParameter>();
         var countPars = new List<SugarParameter>();
@@ -545,6 +642,16 @@ public class IpqcInspectionMdpSyncService : ITransient
             countPars.Add(new SugarParameter("@Src", sourceSystem));
             countPars.Add(new SugarParameter("@Src", sourceSystem));
         }
         }
         var mTenant = MdpJsonSql.TenantFromStg("m");
         var mTenant = MdpJsonSql.TenantFromStg("m");
+        // 单据作用域(targeted sync):按 source_row_id 收敛到单张检验单,并强制租户相等。
+        // 租户显式来自业务单,不依赖 RunFullAsync 的 pbxfxp 账套映射上下文。scope 为 null 时全量行为逐字不变。
+        if (scope != null)
+        {
+            var scopeSql = $" AND m.source_row_id=@ScopeRowId AND ({mTenant})=@ScopeTenant";
+            countSrc += scopeSql;
+            insertSrc += scopeSql;
+            countPars.Add(new SugarParameter("@ScopeRowId", scope.InspectionId.ToString()));
+            countPars.Add(new SugarParameter("@ScopeTenant", scope.TenantId));
+        }
         var rows = await _db.Ado.GetIntAsync(
         var rows = await _db.Ado.GetIntAsync(
             $"SELECT COUNT(1) FROM mdp_stg_ipqc_pull m WHERE m.source_table='qms_gcjyd'{countSrc} AND {MdpJsonSql.TenantGuard(mTenant)}", countPars);
             $"SELECT COUNT(1) FROM mdp_stg_ipqc_pull m WHERE m.source_table='qms_gcjyd'{countSrc} AND {MdpJsonSql.TenantGuard(mTenant)}", countPars);
         var skipped = await _db.Ado.GetIntAsync(
         var skipped = await _db.Ado.GetIntAsync(
@@ -603,6 +710,11 @@ public class IpqcInspectionMdpSyncService : ITransient
             new("@Now", now)
             new("@Now", now)
         };
         };
         if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
         if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        if (scope != null)
+        {
+            insPars.Add(new SugarParameter("@ScopeRowId", scope.InspectionId.ToString()));
+            insPars.Add(new SugarParameter("@ScopeTenant", scope.TenantId));
+        }
         await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
         return rows;
     }
     }