Browse Source

feat(mdp): S7 FQC 直读真表→通用管线全迁 std + dopdemorq 双源只读入站(server 1.0.276)

- 新增 FqcMdpSyncService: 源(qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst) 经执行器灌 mdp_stg_fqc_pull(三表共表,source_table 区分) → transform 读 pull stg(MdpJsonSql 类型兼容 + WorkOrdMaster 本库富化 SAP 工单号) → mdp_std_fqc_task/result/result_detail(j1-j50 样本 utf8mb3 控行宽,回填 std_result_id)
- FqcInspBillService: task-list/result-list/detail 三读路径由直读真表改读 std;新增 mdp/refresh 端点(RunFullAsync);id 语义保 source_row_id/glid,前端契约不变;不碰 AidopKanbanController
- CTO 拍板: 可接受批次快照延迟、全迁 std;tenant_id 从 raw 保留(Service 保持 QmsTenantScope 过滤语义)
- 1.0.276.sql: 登记 6 实体(本库 TASK/RESULT/RESULT_DETAIL status=1,DOPDEMORQ_SQLSERVER 三镜像 status=0),全 FULL
- 冒烟: 源全 0 归口空表→refresh 管线 SQL 全跑通(50列 j 展开/WorkOrdMaster 子查询/回填)、3 std 建成(明细72列)、源不写、task/result 两列表真实接口 200 读 std 空态渲染;前端零改
YY968XX 1 week ago
parent
commit
4a584396df

+ 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.275</AssemblyVersion>
-    <FileVersion>1.0.275</FileVersion>
-    <Version>1.0.275</Version>
+    <AssemblyVersion>1.0.276</AssemblyVersion>
+    <FileVersion>1.0.276</FileVersion>
+    <Version>1.0.276</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -241,6 +241,9 @@
     <None Update="UpdateScripts\1.0.273.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.276.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 41 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.276.sql

@@ -0,0 +1,41 @@
+-- 1.0.276:S7 FQC 直读真表→通用管线全迁 std + dopdemorq 双源入站实体登记(本库 status=1 / SQLSERVER status=0)
+--
+-- 背景:FQC(task/result/detail) 由直读 qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst 迁入通用管线(CTO 拍板:可接受批次快照延迟、全迁 std):
+--       源经执行器灌 mdp_stg_fqc_pull(三表共表,source_table 区分)→ FqcMdpSyncService transform(MdpJsonSql 类型兼容 + WorkOrdMaster 本库富化 SAP)
+--       → mdp_std_fqc_task / mdp_std_fqc_result / mdp_std_fqc_result_detail;Service 三读路径改读 std。
+--       登记 6 个入站实体(本库 task/result/detail + dopdemorq SQL Server 三镜像),均落 mdp_stg_fqc_pull、FULL。
+-- 状态:正式脚本(1.0.276,已注册 csproj Copy,随 AutoVersionUpdate 执行)。
+--
+-- 硬约束:
+--   1) 本库实体 status=1:RunFullAsync 迁 stg 管线后依赖它(否则 S7FqcInspBill/mdp/refresh 端点断)。
+--   2) SQLSERVER 实体 status=0:就位不启用,合入后不改变现有运行行为;启用需带外注密码+受控切源。
+--   3) 六实体均 sync_mode='FULL'(qms_* 无 UpdateTime,FULL-only)。
+--   4) source_table_name=qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst(两库同名);transform 按 source_table 过滤,三表共 mdp_stg_fqc_pull。
+--      tenant_id 由入站上下文赋值,本库源含该列(raw 保留),SQL Server 源无此列(回落上下文)。
+-- 幂等:WHERE NOT EXISTS 守卫,可重复执行。
+
+-- 本库入站实体(AIDOPDEV_MYSQL, status=1)
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'), 'S7_FQC_TASK', 'S7 FQC检验任务入站(本库)', 'TABLE', 'qms_fqcbj', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 1, 'S7 FQC 任务 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_TASK');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'), 'S7_FQC_RESULT', 'S7 FQC检验单入站(本库)', 'TABLE', 'qms_qcpp_inspbill', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 1, 'S7 FQC 结果 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='AIDOPDEV_MYSQL'), 'S7_FQC_RESULT_DETAIL', 'S7 FQC检验单明细入站(本库)', 'TABLE', 'qms_qcpp_inspbillst', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 1, 'S7 FQC 明细 本库入站', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT_DETAIL');
+
+-- dopdemorq SQL Server 入站实体(DOPDEMORQ_SQLSERVER, status=0 就位不启用)
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'), 'S7_FQC_TASK_SQLSERVER', 'S7 FQC检验任务入站(SQLServer)', 'TABLE', 'qms_fqcbj', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 0, 'S7 FQC 任务 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_TASK_SQLSERVER');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'), 'S7_FQC_RESULT_SQLSERVER', 'S7 FQC检验单入站(SQLServer)', 'TABLE', 'qms_qcpp_inspbill', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 0, 'S7 FQC 结果 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT_SQLSERVER');
+
+INSERT INTO mdp_entity (tenant_id, source_id, entity_code, entity_name, entity_type, source_table_name, target_table_name, sync_mode, batch_size, biz_key_expr, status, remark, create_time, update_time)
+SELECT 0, (SELECT id FROM mdp_source WHERE source_code='DOPDEMORQ_SQLSERVER'), 'S7_FQC_RESULT_DETAIL_SQLSERVER', 'S7 FQC检验单明细入站(SQLServer)', 'TABLE', 'qms_qcpp_inspbillst', 'mdp_stg_fqc_pull', 'FULL', 5000, 'id', 0, 'S7 FQC 明细 双源 disabled', NOW(), NOW()
+WHERE NOT EXISTS (SELECT 1 FROM mdp_entity WHERE entity_code='S7_FQC_RESULT_DETAIL_SQLSERVER');

+ 143 - 281
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillService.cs

@@ -1,26 +1,21 @@
+using System.Linq;
 using Admin.NET.Plugin.AiDOP.FinishedWarehouse.Dto;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 
 namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
 
 /// <summary>
-/// S7 FQC 成品质量:检验任务列表 / 检验单(结果)列表 只读 list 服务。
+/// S7 FQC 成品质量:检验任务列表 / 检验单(结果)列表 / 检验单详情 只读 list 服务。
 ///
-/// 数据源(均 aidopdev,只读):
-///   - task-list  :qms_fqcbj(37 列,归口自旧系统 dopdemo,空表待上游抽数)。yxj/FINSPECTSTATUS 原样透出,
-///                  rf_dictionary 优先级字典未接(CTO 拍板舍弃)。默认排序 FAPPLYTIME DESC, id DESC。
-///   - result-list:qms_qcpp_inspbill 主表;SAP 工单号经 (SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo
-///                  WHERE wo.Batch = a.sczld) 标量子查询取得(Batch 非唯一仅因 NULL;非空 Batch 现 1:1,
-///                  MIN 保确定性、杜绝行倍增)。默认排序 FINSPEENDDATE DESC, id DESC。
+/// 数据源(2026-07-30 起):DOP 数据中台标准层(CTO 拍板:可接受批次快照延迟、全迁 std):
+///   - task-list  :mdp_std_fqc_task(源 qms_fqcbj)
+///   - result-list:mdp_std_fqc_result(源 qms_qcpp_inspbill + WorkOrdMaster 富化 SAP 工单号)
+///   - detail     :mdp_std_fqc_result 头 + mdp_std_fqc_result_detail 明细(glid=源头 id;j1..j50 样本 → Samples[])
+///   三 std 由 FqcMdpSyncService 从源同步标准化而来,且已双源(dopdemorq status=0 就位不启用)。
 ///
-/// 归口说明(方案 A,复刻已闭环 S6 IPQC IpqcInspectionMdpSyncService.EnsureTablesAsync):
-///   qms_fqcbj 原仅存于旧系统 dopdemo(0 行)、aidopdev 无此表,且 dopdemo 非运行时连接。
-///   EnsureFqcTaskTableAsync 在 aidopdev 建同构空表(CREATE TABLE IF NOT EXISTS,DDL 例外、仅一次、零 DML),
-///   后续由上游一次性抽数填充,届时无需改代码即随查询流通。id 不设 AUTO_INCREMENT(抽数将携旧系统主键)。
-///
-/// 只读边界:纯 SELECT + 一次性 DDL 建空表;绝不 INSERT/UPDATE/DELETE 任何业务表、不写库存/仓储/生产状态、
-///   不接 ApprovalFlow、不做详情/子表(qms_qcpp_inspbillst 留下一批)/录入/审核/处置。
-///   WP0-D4:按 tenant_id 过滤。
+/// 只读边界:纯 SELECT + POST mdp/refresh 触发同步(RunFullAsync,只读源/仅写 mdp_*);
+///   绝不 INSERT/UPDATE/DELETE 业务表、不接 ApprovalFlow、不做录入/审核/处置。WP0-D4:按 tenant_id 过滤(std 保留源 tenant)。
+///   id 语义:list 返回源主键(source_row_id),detail 按 source_row_id/glid 关联,前端契约不变。
 /// </summary>
 [ApiDescriptionSettings(Order = 330, Description = "FQC检验单列表")]
 [Route("api/S7FqcInspBill")]
@@ -30,53 +25,58 @@ public class FqcInspBillService : IDynamicApiController, ITransient
 {
     private readonly ISqlSugarClient _db;
     private readonly UserManager _userManager;
+    private readonly FqcMdpSyncService _fqcMdpSyncService;
 
-    /// <summary>归口空表建表守卫:每进程仅尝试一次 CREATE TABLE IF NOT EXISTS。</summary>
-    private static int _taskTableEnsured;
-
-    /// <summary>检验单明细子表归口空表建表守卫:每进程仅尝试一次。</summary>
-    private static int _detailTableEnsured;
-
-    public FqcInspBillService(ISqlSugarClient db, UserManager userManager)
+    public FqcInspBillService(ISqlSugarClient db, UserManager userManager, FqcMdpSyncService fqcMdpSyncService)
     {
         _db = db;
         _userManager = userManager;
+        _fqcMdpSyncService = fqcMdpSyncService;
     }
 
-    /// <summary>检验任务列表 排序白名单(前端 prop → 物理列,杜绝 orderBy 注入)。</summary>
+    /// <summary>检验任务列表 排序白名单(前端 prop → std 物理列,杜绝 orderBy 注入)。</summary>
     private static readonly Dictionary<string, string> TaskOrderWhitelist = new(StringComparer.OrdinalIgnoreCase)
     {
-        ["applyTime"] = "t.FAPPLYTIME",
-        ["productionBatchNo"] = "t.scph",
-        ["productionOrderNo"] = "t.sczld",
-        ["materialCode"] = "t.wlbm",
-        ["priority"] = "t.yxj",
-        ["inspectStartTime"] = "t.jykssj",
-        ["inspectFinishTime"] = "t.jywcsj",
-        ["inspectProgress"] = "t.FINSPECTSTATUS",
+        ["applyTime"] = "t.apply_time",
+        ["productionBatchNo"] = "t.production_batch_no",
+        ["productionOrderNo"] = "t.production_order_no",
+        ["materialCode"] = "t.material_code",
+        ["priority"] = "t.priority",
+        ["inspectStartTime"] = "t.inspect_start_time",
+        ["inspectFinishTime"] = "t.inspect_finish_time",
+        ["inspectProgress"] = "t.inspect_progress",
     };
 
-    /// <summary>检验单列表 排序白名单(前端 prop → 物理列)。</summary>
+    /// <summary>检验单列表 排序白名单(前端 prop → std 物理列)。</summary>
     private static readonly Dictionary<string, string> ResultOrderWhitelist = new(StringComparer.OrdinalIgnoreCase)
     {
-        ["inspectTime"] = "a.FINSPEENDDATE",
-        ["billNo"] = "a.FBILLNO",
-        ["productionBatchNo"] = "a.scph",
-        ["materialCode"] = "a.FMATERIALCFG",
-        ["inspector"] = "a.jyr",
-        ["inspectQty"] = "a.jysj",
-        ["productionOrderNo"] = "a.sczld",
+        ["inspectTime"] = "a.inspect_time",
+        ["billNo"] = "a.bill_no",
+        ["productionBatchNo"] = "a.production_batch_no",
+        ["materialCode"] = "a.material_code",
+        ["inspector"] = "a.inspector",
+        ["inspectQty"] = "a.inspect_qty",
+        ["productionOrderNo"] = "a.production_order_no",
     };
 
     /// <summary>
-    /// FQC 检验任务列表(只读分页查询,源 qms_fqcbj)。
+    /// FQC 数据中台同步刷新(RunFullAsync:只读源 qms_fqcbj/qms_qcpp_inspbill/qms_qcpp_inspbillst,仅写 mdp_*)。
+    /// </summary>
+    [DisplayName("FQC数据中台刷新")]
+    [HttpPost("mdp/refresh")]
+    public async Task<object> RefreshMdp(CancellationToken cancellationToken)
+    {
+        var r = await _fqcMdpSyncService.RunFullAsync(cancellationToken, "MANUAL");
+        return new { ok = true, batchId = r.BatchId, stageRows = r.StageRows, taskRows = r.TaskRows, resultRows = r.ResultRows, detailRows = r.DetailRows };
+    }
+
+    /// <summary>
+    /// FQC 检验任务列表(只读分页查询,读标准层 mdp_std_fqc_task)。
     /// </summary>
     [DisplayName("FQC检验任务列表")]
     [HttpGet("task-list")]
     public async Task<object> GetTaskList([FromQuery] FqcTaskListInput input)
     {
-        await EnsureFqcTaskTableAsync();
-
         var page = input.Page <= 0 ? 1 : input.Page;
         var pageSize = input.PageSize <= 0 ? 10 : (input.PageSize > 200 ? 200 : input.PageSize);
         var offset = (page - 1) * pageSize;
@@ -92,55 +92,55 @@ public class FqcInspBillService : IDynamicApiController, ITransient
             pars.Add(new SugarParameter($"@{p}", $"%{v.Trim()}%"));
         }
 
-        AddLike(input.ProductionOrderNo, "t.sczld", "ProductionOrderNo");
-        AddLike(input.ProductionBatchNo, "t.scph", "ProductionBatchNo");
-        AddLike(input.MaterialCode, "t.wlbm", "MaterialCode");
-        AddLike(input.Priority, "t.yxj", "Priority");
-        AddLike(input.Inspector, "t.jyfzr", "Inspector");
+        AddLike(input.ProductionOrderNo, "t.production_order_no", "ProductionOrderNo");
+        AddLike(input.ProductionBatchNo, "t.production_batch_no", "ProductionBatchNo");
+        AddLike(input.MaterialCode, "t.material_code", "MaterialCode");
+        AddLike(input.Priority, "t.priority", "Priority");
+        AddLike(input.Inspector, "t.inspector", "Inspector");
 
         if (!string.IsNullOrWhiteSpace(input.InspectProgress))
         {
-            where.Add("t.FINSPECTSTATUS = @InspectProgress");
+            where.Add("t.inspect_progress = @InspectProgress");
             pars.Add(new SugarParameter("@InspectProgress", input.InspectProgress.Trim()));
         }
         if (!string.IsNullOrWhiteSpace(input.ApplyTimeStart))
         {
-            where.Add("t.FAPPLYTIME >= @ApplyTimeStart");
+            where.Add("t.apply_time >= @ApplyTimeStart");
             pars.Add(new SugarParameter("@ApplyTimeStart", $"{input.ApplyTimeStart.Trim()} 00:00:00"));
         }
         if (!string.IsNullOrWhiteSpace(input.ApplyTimeEnd))
         {
-            where.Add("t.FAPPLYTIME <= @ApplyTimeEnd");
+            where.Add("t.apply_time <= @ApplyTimeEnd");
             pars.Add(new SugarParameter("@ApplyTimeEnd", $"{input.ApplyTimeEnd.Trim()} 23:59:59"));
         }
 
         var whereSql = string.Join(" AND ", where);
-        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, TaskOrderWhitelist, "t.FAPPLYTIME DESC, t.id DESC", "t.id");
+        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, TaskOrderWhitelist, "t.apply_time DESC, t.id DESC", "t.id");
 
-        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM qms_fqcbj t WHERE {whereSql}", pars);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_std_fqc_task t WHERE {whereSql}", pars);
 
         var list = await _db.Ado.SqlQueryAsync<FqcTaskListRow>(
             $"""
             SELECT
-                t.id            AS Id,
-                t.FBILLNO       AS BillNo,
-                t.sczld         AS ProductionOrderNo,
-                t.FORGID        AS ApplyOrgId,
-                t.FAPPLYTIME    AS ApplyTime,
-                t.wlbm          AS MaterialCode,
-                t.cpmc          AS ProductName,
-                t.cpxh          AS ProductModel,
-                t.scph          AS ProductionBatchNo,
-                t.sczldsl       AS OrderQty,
-                t.sl            AS Qty,
-                t.FAPPLYUSER    AS Applicant,
-                t.FCOMMENT      AS Remark,
-                t.yxj           AS Priority,
-                t.jykssj        AS InspectStartTime,
-                t.jywcsj        AS InspectFinishTime,
-                t.jyfzr         AS Inspector,
-                t.FINSPECTSTATUS AS InspectProgress
-            FROM qms_fqcbj t
+                CAST(t.source_row_id AS SIGNED) AS Id,
+                t.bill_no             AS BillNo,
+                t.production_order_no AS ProductionOrderNo,
+                t.apply_org_id        AS ApplyOrgId,
+                t.apply_time          AS ApplyTime,
+                t.material_code       AS MaterialCode,
+                t.product_name        AS ProductName,
+                t.product_model       AS ProductModel,
+                t.production_batch_no AS ProductionBatchNo,
+                t.order_qty           AS OrderQty,
+                t.qty                 AS Qty,
+                t.applicant           AS Applicant,
+                t.remark              AS Remark,
+                t.priority            AS Priority,
+                t.inspect_start_time  AS InspectStartTime,
+                t.inspect_finish_time AS InspectFinishTime,
+                t.inspector           AS Inspector,
+                t.inspect_progress    AS InspectProgress
+            FROM mdp_std_fqc_task t
             WHERE {whereSql}
             ORDER BY {orderSql}
             LIMIT {pageSize} OFFSET {offset}
@@ -151,7 +151,7 @@ public class FqcInspBillService : IDynamicApiController, ITransient
     }
 
     /// <summary>
-    /// FQC 检验单(结果)列表(只读分页查询,源 qms_qcpp_inspbill + WorkOrdMaster 取 SAP 工单号)。
+    /// FQC 检验单(结果)列表(只读分页查询,读标准层 mdp_std_fqc_result)。
     /// </summary>
     [DisplayName("FQC检验单结果列表")]
     [HttpGet("result-list")]
@@ -172,55 +172,55 @@ public class FqcInspBillService : IDynamicApiController, ITransient
             pars.Add(new SugarParameter($"@{p}", $"%{v.Trim()}%"));
         }
 
-        AddLike(input.BillNo, "a.FBILLNO", "BillNo");
-        AddLike(input.ProductionBatchNo, "a.scph", "ProductionBatchNo");
-        AddLike(input.MaterialCode, "a.FMATERIALCFG", "MaterialCode");
-        AddLike(input.MaterialName, "a.wlmc", "MaterialName");
-        AddLike(input.Inspector, "a.jyr", "Inspector");
-        AddLike(input.SourceBillNo, "a.lydjbh", "SourceBillNo");
-        AddLike(input.ProductionOrderNo, "a.sczld", "ProductionOrderNo");
+        AddLike(input.BillNo, "a.bill_no", "BillNo");
+        AddLike(input.ProductionBatchNo, "a.production_batch_no", "ProductionBatchNo");
+        AddLike(input.MaterialCode, "a.material_code", "MaterialCode");
+        AddLike(input.MaterialName, "a.material_name", "MaterialName");
+        AddLike(input.Inspector, "a.inspector", "Inspector");
+        AddLike(input.SourceBillNo, "a.source_bill_no", "SourceBillNo");
+        AddLike(input.ProductionOrderNo, "a.production_order_no", "ProductionOrderNo");
 
         if (!string.IsNullOrWhiteSpace(input.Judgment))
         {
-            where.Add("a.pd = @Judgment");
+            where.Add("a.judgment = @Judgment");
             pars.Add(new SugarParameter("@Judgment", input.Judgment.Trim()));
         }
         if (!string.IsNullOrWhiteSpace(input.InspectDateStart))
         {
-            where.Add("a.FINSPEENDDATE >= @InspectDateStart");
+            where.Add("a.inspect_time >= @InspectDateStart");
             pars.Add(new SugarParameter("@InspectDateStart", $"{input.InspectDateStart.Trim()} 00:00:00"));
         }
         if (!string.IsNullOrWhiteSpace(input.InspectDateEnd))
         {
-            where.Add("a.FINSPEENDDATE <= @InspectDateEnd");
+            where.Add("a.inspect_time <= @InspectDateEnd");
             pars.Add(new SugarParameter("@InspectDateEnd", $"{input.InspectDateEnd.Trim()} 23:59:59"));
         }
 
         var whereSql = string.Join(" AND ", where);
-        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, ResultOrderWhitelist, "a.FINSPEENDDATE DESC, a.id DESC", "a.id");
+        var orderSql = ResolveOrder(input.OrderBy, input.OrderDir, ResultOrderWhitelist, "a.inspect_time DESC, a.id DESC", "a.id");
 
-        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM qms_qcpp_inspbill a WHERE {whereSql}", pars);
+        var total = await _db.Ado.GetIntAsync($"SELECT COUNT(1) FROM mdp_std_fqc_result a WHERE {whereSql}", pars);
 
         var list = await _db.Ado.SqlQueryAsync<FqcResultListRow>(
             $"""
             SELECT
-                a.id            AS Id,
-                a.FBILLNO       AS BillNo,
-                a.FINSPEENDDATE AS InspectTime,
-                a.scph          AS ProductionBatchNo,
-                a.FMATERIALCFG  AS MaterialCode,
-                a.wlmc          AS MaterialName,
-                a.ggxh          AS Spec,
-                a.jyr           AS Inspector,
-                a.jysj          AS InspectQty,
-                a.lydjbh        AS SourceBillNo,
-                a.sczld         AS ProductionOrderNo,
-                a.sczldsl       AS OrderQty,
-                (SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = a.sczld) AS SapWorkOrderNo,
-                a.pd            AS Judgment,
-                a.hgsl          AS QualifiedQty,
-                a.bhgsl         AS UnqualifiedQty
-            FROM qms_qcpp_inspbill a
+                CAST(a.source_row_id AS SIGNED) AS Id,
+                a.bill_no             AS BillNo,
+                a.inspect_time        AS InspectTime,
+                a.production_batch_no AS ProductionBatchNo,
+                a.material_code       AS MaterialCode,
+                a.material_name       AS MaterialName,
+                a.spec                AS Spec,
+                a.inspector           AS Inspector,
+                a.inspect_qty         AS InspectQty,
+                a.source_bill_no      AS SourceBillNo,
+                a.production_order_no AS ProductionOrderNo,
+                a.order_qty           AS OrderQty,
+                a.sap_work_order_no   AS SapWorkOrderNo,
+                a.judgment            AS Judgment,
+                a.qualified_qty       AS QualifiedQty,
+                a.unqualified_qty     AS UnqualifiedQty
+            FROM mdp_std_fqc_result a
             WHERE {whereSql}
             ORDER BY {orderSql}
             LIMIT {pageSize} OFFSET {offset}
@@ -231,75 +231,68 @@ public class FqcInspBillService : IDynamicApiController, ITransient
     }
 
     /// <summary>
-    /// FQC 检验单只读详情(源 qms_qcpp_inspbill 头 + qms_qcpp_inspbillst 明细,glid=id)。
-    ///
-    /// 契约对齐 S6 IPQC:id&lt;=0 或未命中返回 null(HTTP 200/204);扁平头 + Lines[]。
-    /// 明细首查前 EnsureFqcDetailTableAsync 幂等建归口空表;样本 j1..j50 收敛为 Samples[](末尾空值裁剪)。
-    /// 仅 SELECT:无任何写入。按 tenant_id 过滤。
+    /// FQC 检验单只读详情(读标准层 mdp_std_fqc_result 头 + mdp_std_fqc_result_detail 明细,glid=源头 id)。
+    /// 契约对齐 S6 IPQC:id&lt;=0 或未命中返回 null(HTTP 200/204);扁平头 + Lines[];样本 j1..j50 收敛 Samples[]。仅 SELECT。
     /// </summary>
     [DisplayName("FQC检验单详情")]
     [HttpGet("detail")]
     public async Task<FqcInspBillDetailDto?> GetDetail([FromQuery] long id, [FromQuery] long? tenantId)
     {
         if (id <= 0) return null;
-        await EnsureFqcDetailTableAsync();
 
         var tid = QmsTenantScope.Resolve(tenantId, _userManager.TenantId);
         var head = (await _db.Ado.SqlQueryAsync<FqcInspBillDetailDto>(
             """
             SELECT
-                a.id            AS Id,
-                a.FBILLNO       AS BillNo,
-                a.FINSPEENDDATE AS InspectTime,
-                a.lydjbh        AS SourceBillNo,
-                a.jyr           AS Inspector,
-                a.pd            AS Judgment,
-                a.FMATERIALCFG  AS MaterialCode,
-                a.wlmc          AS MaterialName,
-                a.ggxh          AS Spec,
-                a.scph          AS ProductionBatchNo,
-                a.sczld         AS ProductionOrderNo,
-                a.sczldsl       AS OrderQty,
-                (SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = a.sczld) AS SapWorkOrderNo,
-                a.jysj          AS InspectQty,
-                a.hgsl          AS QualifiedQty,
-                a.bhgsl         AS UnqualifiedQty,
-                a.jfbh          AS FireNo,
-                a.jgbh          AS InspectSpecNo,
-                a.jgbb          AS InspectSpecVersion
-            FROM qms_qcpp_inspbill a
-            WHERE a.id = @id AND a.tenant_id = @TenantFilter
+                CAST(a.source_row_id AS SIGNED) AS Id,
+                a.bill_no             AS BillNo,
+                a.inspect_time        AS InspectTime,
+                a.source_bill_no      AS SourceBillNo,
+                a.inspector           AS Inspector,
+                a.judgment            AS Judgment,
+                a.material_code       AS MaterialCode,
+                a.material_name       AS MaterialName,
+                a.spec                AS Spec,
+                a.production_batch_no AS ProductionBatchNo,
+                a.production_order_no AS ProductionOrderNo,
+                a.order_qty           AS OrderQty,
+                a.sap_work_order_no   AS SapWorkOrderNo,
+                a.inspect_qty         AS InspectQty,
+                a.qualified_qty       AS QualifiedQty,
+                a.unqualified_qty     AS UnqualifiedQty,
+                a.fire_no             AS FireNo,
+                a.inspect_spec_no     AS InspectSpecNo,
+                a.inspect_spec_version AS InspectSpecVersion
+            FROM mdp_std_fqc_result a
+            WHERE a.source_row_id = @id AND a.tenant_id = @TenantFilter
             LIMIT 1
             """,
             new List<SugarParameter> { new("@id", id), new("@TenantFilter", tid) })).FirstOrDefault();
 
         if (head == null || head.Id <= 0) return null;
 
+        var jSelect = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"st.j{i}"));
         var lineRows = await _db.Ado.SqlQueryAsync<FqcInspBillDetailLineRow>(
-            """
+            $"""
             SELECT
-                st.id   AS Id,
-                st.xh   AS SeqNo,
-                st.jyxm AS InspectionItem,
-                st.jsbz AS AcceptStandard,
-                st.jsyq AS TechRequirement,
-                st.cyfa AS SamplingPlan,
-                st.ybl  AS SampleQty,
-                st.sx   AS UpperLimit,
-                st.xx   AS LowerLimit,
-                st.SizeSpecifications AS SizeSpecification,
-                st.pd   AS Judgment,
-                st.NonNumericalTypeOK AS NonNumericOk,
-                st.NonNumericalTypeNG AS NonNumericNg,
-                st.bz   AS Remark,
-                st.j1,st.j2,st.j3,st.j4,st.j5,st.j6,st.j7,st.j8,st.j9,st.j10,
-                st.j11,st.j12,st.j13,st.j14,st.j15,st.j16,st.j17,st.j18,st.j19,st.j20,
-                st.j21,st.j22,st.j23,st.j24,st.j25,st.j26,st.j27,st.j28,st.j29,st.j30,
-                st.j31,st.j32,st.j33,st.j34,st.j35,st.j36,st.j37,st.j38,st.j39,st.j40,
-                st.j41,st.j42,st.j43,st.j44,st.j45,st.j46,st.j47,st.j48,st.j49,st.j50
-            FROM qms_qcpp_inspbillst st
+                st.id                 AS Id,
+                st.seq_no             AS SeqNo,
+                st.inspection_item    AS InspectionItem,
+                st.accept_standard    AS AcceptStandard,
+                st.tech_requirement   AS TechRequirement,
+                st.sampling_plan      AS SamplingPlan,
+                st.sample_qty         AS SampleQty,
+                st.upper_limit        AS UpperLimit,
+                st.lower_limit        AS LowerLimit,
+                st.size_specification AS SizeSpecification,
+                st.judgment           AS Judgment,
+                st.non_numeric_ok     AS NonNumericOk,
+                st.non_numeric_ng     AS NonNumericNg,
+                st.remark             AS Remark,
+                {jSelect}
+            FROM mdp_std_fqc_result_detail st
             WHERE st.glid = @id AND st.tenant_id = @TenantFilter
-            ORDER BY st.xh, st.id
+            ORDER BY st.seq_no, st.id
             """,
             new List<SugarParameter> { new("@id", id), new("@TenantFilter", tid) });
 
@@ -319,135 +312,4 @@ public class FqcInspBillService : IDynamicApiController, ITransient
         }
         return fallback;
     }
-
-    /// <summary>
-    /// 归口空表建表(方案 A,DDL 例外、每进程一次、零 DML):
-    /// aidopdev 无 qms_fqcbj 时 CREATE TABLE IF NOT EXISTS 建同构 dopdemo 的 37 列空表,待上游抽数填充。
-    /// 仅建表,不写任何数据行。
-    /// </summary>
-    private async Task EnsureFqcTaskTableAsync()
-    {
-        if (Interlocked.Exchange(ref _taskTableEnsured, 1) == 1) return;
-        try
-        {
-            await _db.Ado.ExecuteCommandAsync(
-                """
-                CREATE TABLE IF NOT EXISTS qms_fqcbj (
-                    id BIGINT NOT NULL PRIMARY KEY COMMENT '主表id',
-                    tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
-                    FBILLNO VARCHAR(80) NULL COMMENT '单据编号',
-                    FBILLSTATUS VARCHAR(50) NULL COMMENT '单据状态',
-                    FCREATORID BIGINT NULL,
-                    FMODIFIERID BIGINT NULL,
-                    FAUDITORID BIGINT NULL,
-                    FAUDITDATE DATETIME NULL,
-                    FMODIFYTIME DATETIME NULL,
-                    FCREATETIME DATETIME NULL,
-                    FORGID BIGINT NULL COMMENT '申请组织',
-                    FQUALITYORG BIGINT NULL,
-                    FAPPLYUSER VARCHAR(50) NULL COMMENT '申请人',
-                    FAPPLYTIME DATETIME NULL COMMENT '申请时间',
-                    FBILLTYPE VARCHAR(50) NULL COMMENT '单据类型',
-                    FBIZTYPE BIGINT NULL COMMENT '业务类型',
-                    FINSPECORGID BIGINT NULL,
-                    FCOMMENT TEXT NULL COMMENT '备注',
-                    FAUTHORIZEOBJID BIGINT NULL,
-                    FINTERFACEID VARCHAR(50) NULL,
-                    cpmc VARCHAR(255) NULL COMMENT '产品名称',
-                    cpxh VARCHAR(255) NULL COMMENT '产品型号',
-                    scph VARCHAR(255) NULL COMMENT '生产批号',
-                    sl DECIMAL(10,0) NULL COMMENT '数量',
-                    jfbh TEXT NULL COMMENT '击发编号',
-                    wlbm VARCHAR(255) NULL COMMENT '物料编码',
-                    jyfzr VARCHAR(255) NULL COMMENT '检验负责人',
-                    yxj VARCHAR(255) NULL COMMENT '检验优先级',
-                    jykssj DATETIME NULL COMMENT '检验开始时间',
-                    jywcsj DATETIME NULL COMMENT '检验完成时间',
-                    mjpc VARCHAR(255) NULL,
-                    tsyq VARCHAR(255) NULL,
-                    FINSPECTSTATUS VARCHAR(255) NULL COMMENT '检验进度',
-                    sczld VARCHAR(255) NULL COMMENT '生产指令单',
-                    sczldsl DECIMAL(10,0) NULL COMMENT '指令单数量',
-                    lydjbh VARCHAR(100) NULL COMMENT '来源单据编号',
-                    ISscrap INT NULL,
-                    CirculationCard TEXT NULL
-                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验任务(报检) 归口自旧系统dopdemo,空表待上游抽数'
-                """);
-        }
-        catch
-        {
-            // 建表失败(如权限/并发)不吞成静默通过:复位守卫,让后续调用可再尝试,并向上抛出真实错误。
-            Interlocked.Exchange(ref _taskTableEnsured, 0);
-            throw;
-        }
-    }
-
-    /// <summary>
-    /// 检验单明细子表归口空表建表(方案 A,DDL 例外、每进程一次、零 DML):
-    /// aidopdev 无 qms_qcpp_inspbillst 时 CREATE TABLE IF NOT EXISTS 建同构 dopdemo 的 82 列空表(glid→主表 id),
-    /// 待上游抽数填充。样本列 j1..j50 一并建。仅建表,不写任何数据行。
-    /// 字符集忠实复刻 dopdemo:13 个列用 utf8mb3(3 字节),其余表默认 utf8mb4;否则 ~68 个 VARCHAR(255) 全 utf8mb4
-    /// 会超 MySQL 单行 65535 字节上限(Row size too large)。join 键 glid/id 为 bigint,字符集不影响。
-    /// </summary>
-    private async Task EnsureFqcDetailTableAsync()
-    {
-        if (Interlocked.Exchange(ref _detailTableEnsured, 1) == 1) return;
-        try
-        {
-            await _db.Ado.ExecuteCommandAsync(
-                """
-                CREATE TABLE IF NOT EXISTS qms_qcpp_inspbillst (
-                    id BIGINT NOT NULL PRIMARY KEY COMMENT '子表id',
-                    tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
-                    glid BIGINT NULL COMMENT '关联id(=qms_qcpp_inspbill.id)',
-                    jyxm VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '检验项目',
-                    sx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '上限',
-                    xx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '下限',
-                    bz VARCHAR(255) NULL COMMENT '备注',
-                    czf VARCHAR(255) NULL,
-                    j1 VARCHAR(255) NULL, j2 VARCHAR(255) NULL, j3 VARCHAR(255) NULL, j4 VARCHAR(255) NULL, j5 VARCHAR(255) NULL,
-                    j6 VARCHAR(255) NULL, j7 VARCHAR(255) NULL, j8 VARCHAR(255) NULL, j9 VARCHAR(255) NULL, j10 VARCHAR(255) NULL,
-                    j11 VARCHAR(255) NULL, j12 VARCHAR(255) NULL, j13 VARCHAR(255) NULL, j14 VARCHAR(255) NULL, j15 VARCHAR(255) NULL,
-                    j16 VARCHAR(255) NULL, j17 VARCHAR(255) NULL, j18 VARCHAR(255) NULL, j19 VARCHAR(255) NULL, j20 VARCHAR(255) NULL,
-                    j21 VARCHAR(255) NULL, j22 VARCHAR(255) NULL, j23 VARCHAR(255) NULL, j24 VARCHAR(255) NULL, j25 VARCHAR(255) NULL,
-                    j26 VARCHAR(255) NULL, j27 VARCHAR(255) NULL, j28 VARCHAR(255) NULL, j29 VARCHAR(255) NULL, j30 VARCHAR(255) NULL,
-                    j31 VARCHAR(255) NULL, j32 VARCHAR(255) NULL, j33 VARCHAR(255) NULL, j34 VARCHAR(255) NULL, j35 VARCHAR(255) NULL,
-                    j36 VARCHAR(255) NULL, j37 VARCHAR(255) NULL, j38 VARCHAR(255) NULL, j39 VARCHAR(255) NULL, j40 VARCHAR(255) NULL,
-                    j41 VARCHAR(255) NULL, j42 VARCHAR(255) NULL, j43 VARCHAR(255) NULL, j44 VARCHAR(255) NULL, j45 VARCHAR(255) NULL,
-                    j46 VARCHAR(255) NULL, j47 VARCHAR(255) NULL, j48 VARCHAR(255) NULL, j49 VARCHAR(255) NULL, j50 VARCHAR(255) NULL,
-                    pd VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '判定',
-                    ybl VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '样本量',
-                    txfl BIGINT NULL COMMENT '指标分类',
-                    bztk TEXT NULL,
-                    jsyq TEXT NULL COMMENT '技术要求',
-                    jyyqsbgj TEXT NULL COMMENT '检验仪器设备工具',
-                    jyff TEXT NULL,
-                    jytk TEXT NULL,
-                    jsbz TEXT NULL COMMENT '接收标准',
-                    cyfa TEXT NULL COMMENT '抽样方案',
-                    jl TEXT NULL,
-                    jlqjbh VARCHAR(255) NULL COMMENT '计量器具编号',
-                    jlbh VARCHAR(255) NULL COMMENT '计量器具编号',
-                    jf1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发1',
-                    jf2 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发2',
-                    jf3 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发3',
-                    jf4 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发4',
-                    jf5 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发5',
-                    NonNumericalTypeOK BIGINT NULL COMMENT '非数值OK',
-                    NonNumericalTypeNG BIGINT NULL COMMENT '非数值NG',
-                    ThresholdSwitch VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '自动判定按钮',
-                    ThresholdSwitchInt BIGINT NULL COMMENT '自动判定按钮',
-                    SizeSpecifications VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '规范标准',
-                    ybl1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
-                    xh INT NULL COMMENT '序号',
-                    KEY idx_qms_qcpp_inspbillst_glid (glid)
-                ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验单明细(品检项目) 归口自旧系统dopdemo,空表待上游抽数'
-                """);
-        }
-        catch
-        {
-            Interlocked.Exchange(ref _detailTableEnsured, 0);
-            throw;
-        }
-    }
 }

+ 615 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs

@@ -0,0 +1,615 @@
+using System.Linq;
+using Admin.NET.Plugin.AiDOP.DataPlatform;
+using Admin.NET.Plugin.AiDOP.DataPlatform.Executors;
+
+namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
+
+/// <summary>
+/// S7 FQC 成品质量 数据中台只读同步转换服务(DOP 内部,独立于 KPI 管线)。
+///
+/// B-①/C 迁通用管线(CTO 拍板:可接受批次快照延迟、全迁 std):
+///   源(qms_fqcbj 任务 / qms_qcpp_inspbill 结果头 / qms_qcpp_inspbillst 明细) 经执行器灌 mdp_stg_fqc_pull(三表共表,source_table 区分)
+///   → transform 读 pull stg(MdpJsonSql 跨源类型兼容)→ mdp_std_fqc_task / mdp_std_fqc_result / mdp_std_fqc_result_detail。
+///   result 富化 SAP 工单号:本库 WorkOrdMaster 相关子查询 MIN(WorkOrd) WHERE Batch=sczld(维表本库,不入 stg)。
+///   result_detail 回填 std_result_id:glid → mdp_std_fqc_result.source_row_id。样本 j1..j50 逐列落 std(utf8mb3 控行宽)。
+///
+/// 双源:本库实体 S7_FQC_TASK/RESULT/RESULT_DETAIL(AIDOPDEV_MYSQL, status=1);
+///   dopdemorq 第二源 *_SQLSERVER(DOPDEMORQ_SQLSERVER, status=0 就位不启用)。qms_* 无 UpdateTime → FULL。
+///
+/// 约束:只读源/贴源,仅写 mdp_stg_fqc_pull / mdp_std_fqc_*;绝不 INSERT/UPDATE/DELETE 业务源表;不接 ApprovalFlow、不做录入/审核/处置。
+///   tenant_id 从 raw_data 保留(源含该列 → std 租户不回归;SQL Server 源无此列 → 回落入站上下文)。
+///   源为 0 行时转换成功完成、处理数 0,不报错。切源 FULL Replace 三表各一次单事务、仅按 tenant、Pull 成功后才 destructive。
+/// </summary>
+public class FqcMdpSyncService : ITransient
+{
+    private const string JobCode = "S7_FQC_MDP_SYNC";
+    private const string InboundTaskEntityCode = "S7_FQC_TASK";
+    private const string InboundResultEntityCode = "S7_FQC_RESULT";
+    private const string InboundDetailEntityCode = "S7_FQC_RESULT_DETAIL";
+
+    private const string SqlServerSourceCode = "DOPDEMORQ_SQLSERVER";
+    private const string SqlServerTaskEntityCode = "S7_FQC_TASK_SQLSERVER";
+    private const string SqlServerResultEntityCode = "S7_FQC_RESULT_SQLSERVER";
+    private const string SqlServerDetailEntityCode = "S7_FQC_RESULT_DETAIL_SQLSERVER";
+
+    private const string StdTaskTable = "mdp_std_fqc_task";
+    private const string StdResultTable = "mdp_std_fqc_result";
+    private const string StdResultDetailTable = "mdp_std_fqc_result_detail";
+
+    private readonly ISqlSugarClient _db;
+    private readonly MdpSourcePullDispatcher _pullDispatcher;
+
+    public FqcMdpSyncService(ISqlSugarClient db, MdpSourcePullDispatcher pullDispatcher)
+    {
+        _db = db;
+        _pullDispatcher = pullDispatcher;
+    }
+
+    /// <summary>全量:本地 DB 执行器灌 pull stg(任务/结果/明细) → 标准层三表(读 stg)。</summary>
+    public async Task<FqcMdpSyncResult> RunFullAsync(CancellationToken cancellationToken = default, string triggerType = "AUTO")
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var batchId = $"S7_FQC_FULL_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            var pullCtx = new MdpPullContext
+            {
+                TenantId = 0,
+                FullRefresh = true,
+                TaskCode = "S7_FQC_INBOUND",
+                BatchId = $"{batchId}_PULL"
+            };
+            var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+            result.StageRows = pull.RowsWritten;
+            result.TaskRows = await TransformTaskStandardAsync(batchId, now);
+            result.ResultRows = await TransformResultStandardAsync(batchId, now);
+            result.DetailRows = await TransformResultDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>双模式入站:执行器抽三表 → pull stg,再跑标准层三表(读 stg)。</summary>
+    public async Task<FqcInboundResult> RunInboundAsync(
+        long tenantId = 0,
+        bool fullRefresh = false,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = fullRefresh,
+            TaskCode = "S7_FQC_INBOUND",
+            BatchId = $"S7_FQC_IN_{now:yyyyMMddHHmmss}"
+        };
+        var pull = await PopulateStgAsync(pullCtx, cancellationToken);
+
+        var batchId = $"S7_FQC_STD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "INBOUND");
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.TaskRows = await TransformTaskStandardAsync(batchId, now);
+            result.ResultRows = await TransformResultStandardAsync(batchId, now);
+            result.DetailRows = await TransformResultDetailStandardAsync(batchId, now);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new FqcInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = pull.RowsPulled,
+            RowsWrittenStg = pull.RowsWritten,
+            NewCursor = pull.NewCursor,
+            TransformBatchId = batchId,
+            TaskRows = result.TaskRows,
+            ResultRows = result.ResultRows,
+            DetailRows = result.DetailRows
+        };
+    }
+
+    /// <summary>
+    /// 双源切换 FULL Replace(三表):从指定 DB 源(默认 DOPDEMORQ_SQLSERVER)PullAll 全量灌 pull stg,
+    /// 成功后 task/result/result_detail 各一次单事务 FULL 重建(按 tenant 精确隔离)。
+    /// 顺序:result 先(写 std 结果,供明细回填 std_result_id),再 result_detail;task 独立。
+    /// SQLSERVER 实体默认 status=0(就位不启用);dopdemorq 三表当前空,未实际执行 destructive 切换。
+    /// </summary>
+    public async Task<FqcInboundResult> RunSourceSwitchFullAsync(
+        string sourceCode = SqlServerSourceCode,
+        string taskEntityCode = SqlServerTaskEntityCode,
+        string resultEntityCode = SqlServerResultEntityCode,
+        string detailEntityCode = SqlServerDetailEntityCode,
+        long tenantId = 0,
+        CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        await EnsureTablesAsync();
+        await EnsurePullStgTableAsync();
+
+        var now = DateTime.Now;
+        var pullCtx = new MdpPullContext
+        {
+            TenantId = tenantId,
+            FullRefresh = true,
+            TaskCode = "S7_FQC_INBOUND",
+            BatchId = $"S7_FQC_SW_{now:yyyyMMddHHmmss}"
+        };
+        var task = await _pullDispatcher.PullAllByEntityCodeAsync(taskEntityCode, pullCtx, cancellationToken);
+        var res = await _pullDispatcher.PullAllByEntityCodeAsync(resultEntityCode, pullCtx, cancellationToken);
+        var det = await _pullDispatcher.PullAllByEntityCodeAsync(detailEntityCode, pullCtx, cancellationToken);
+
+        var batchId = $"S7_FQC_SWSTD_{now:yyyyMMddHHmmss}";
+        var runLogId = await InsertRunLogAsync(batchId, now, "SOURCE_SWITCH");
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+        try
+        {
+            result.TaskRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdTaskTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformTaskStandardAsync(batchId, now, sourceCode), cancellationToken);
+            result.ResultRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdResultTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformResultStandardAsync(batchId, now, sourceCode), cancellationToken);
+            result.DetailRows = await MdpStdFullReplace.ReplaceAsync(
+                _db, StdResultDetailTable, tenantId, extraWhere: null,
+                insertScopedAsync: () => TransformResultDetailStandardAsync(batchId, now, sourceCode), cancellationToken);
+            await MarkRunSuccessAsync(runLogId, now, result);
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+
+        return new FqcInboundResult
+        {
+            PullBatchId = pullCtx.BatchId,
+            RowsPulled = task.RowsPulled + res.RowsPulled + det.RowsPulled,
+            RowsWrittenStg = task.RowsWritten + res.RowsWritten + det.RowsWritten,
+            NewCursor = det.NewCursor ?? res.NewCursor ?? task.NewCursor,
+            TransformBatchId = batchId,
+            TaskRows = result.TaskRows,
+            ResultRows = result.ResultRows,
+            DetailRows = result.DetailRows
+        };
+    }
+
+    private async Task<(int RowsPulled, int RowsWritten, string? NewCursor)> PopulateStgAsync(
+        MdpPullContext pullCtx, CancellationToken cancellationToken)
+    {
+        var task = await _pullDispatcher.PullByEntityCodeAsync(InboundTaskEntityCode, pullCtx, cancellationToken);
+        var res = await _pullDispatcher.PullByEntityCodeAsync(InboundResultEntityCode, pullCtx, cancellationToken);
+        var det = await _pullDispatcher.PullByEntityCodeAsync(InboundDetailEntityCode, pullCtx, cancellationToken);
+        return (
+            task.RowsPulled + res.RowsPulled + det.RowsPulled,
+            task.RowsWritten + res.RowsWritten + det.RowsWritten,
+            det.NewCursor ?? res.NewCursor ?? task.NewCursor);
+    }
+
+    private async Task EnsurePullStgTableAsync()
+    {
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_stg_fqc_pull (
+                id BIGINT PRIMARY KEY AUTO_INCREMENT,
+                tenant_id BIGINT NOT NULL,
+                source_system VARCHAR(50) NULL,
+                source_table VARCHAR(200),
+                source_row_id VARCHAR(200),
+                source_biz_key VARCHAR(300) NULL,
+                raw_data JSON,
+                sync_batch_id VARCHAR(100),
+                sync_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP,
+                process_status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
+                process_message VARCHAR(500) NULL,
+                create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
+                update_time DATETIME NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_source_key (source_system, source_table, source_biz_key),
+                KEY idx_batch (sync_batch_id),
+                KEY idx_src (source_table, source_row_id),
+                KEY idx_tenant (tenant_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='S7 FQC 执行器贴源层(任务/结果/明细共表,source_table 区分)'
+            """);
+    }
+
+    /// <summary>
+    /// 防御式建表(幂等):归口源空表(qms_fqcbj/qms_qcpp_inspbillst,方案 A,供执行器贴源) + 任务 std + 结果 std + 明细 std(j1..j50 utf8mb3 控行宽)。
+    /// qms_qcpp_inspbill(结果头) 由既有 FQC 批次建,沿用原 Service 假设已存在。
+    /// </summary>
+    private async Task EnsureTablesAsync()
+    {
+        // 归口源空表(方案 A,DDL 例外、幂等、零 DML):同构自旧系统 dopdemo,待上游抽数;供执行器 pull 贴源。
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS qms_fqcbj (
+                id BIGINT NOT NULL PRIMARY KEY COMMENT '主表id',
+                tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
+                FBILLNO VARCHAR(80) NULL COMMENT '单据编号',
+                FBILLSTATUS VARCHAR(50) NULL COMMENT '单据状态',
+                FCREATORID BIGINT NULL, FMODIFIERID BIGINT NULL, FAUDITORID BIGINT NULL,
+                FAUDITDATE DATETIME NULL, FMODIFYTIME DATETIME NULL, FCREATETIME DATETIME NULL,
+                FORGID BIGINT NULL COMMENT '申请组织', FQUALITYORG BIGINT NULL,
+                FAPPLYUSER VARCHAR(50) NULL COMMENT '申请人', FAPPLYTIME DATETIME NULL COMMENT '申请时间',
+                FBILLTYPE VARCHAR(50) NULL COMMENT '单据类型', FBIZTYPE BIGINT NULL COMMENT '业务类型',
+                FINSPECORGID BIGINT NULL, FCOMMENT TEXT NULL COMMENT '备注', FAUTHORIZEOBJID BIGINT NULL, FINTERFACEID VARCHAR(50) NULL,
+                cpmc VARCHAR(255) NULL COMMENT '产品名称', cpxh VARCHAR(255) NULL COMMENT '产品型号',
+                scph VARCHAR(255) NULL COMMENT '生产批号', sl DECIMAL(10,0) NULL COMMENT '数量',
+                jfbh TEXT NULL COMMENT '击发编号', wlbm VARCHAR(255) NULL COMMENT '物料编码',
+                jyfzr VARCHAR(255) NULL COMMENT '检验负责人', yxj VARCHAR(255) NULL COMMENT '检验优先级',
+                jykssj DATETIME NULL COMMENT '检验开始时间', jywcsj DATETIME NULL COMMENT '检验完成时间',
+                mjpc VARCHAR(255) NULL, tsyq VARCHAR(255) NULL,
+                FINSPECTSTATUS VARCHAR(255) NULL COMMENT '检验进度', sczld VARCHAR(255) NULL COMMENT '生产指令单',
+                sczldsl DECIMAL(10,0) NULL COMMENT '指令单数量', lydjbh VARCHAR(100) NULL COMMENT '来源单据编号',
+                ISscrap INT NULL, CirculationCard TEXT NULL
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验任务(报检) 归口自旧系统dopdemo,空表待上游抽数'
+            """);
+        var jGkDdl = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i} VARCHAR(255) NULL"));
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            CREATE TABLE IF NOT EXISTS qms_qcpp_inspbillst (
+                id BIGINT NOT NULL PRIMARY KEY COMMENT '子表id',
+                tenant_id BIGINT NOT NULL DEFAULT 1300000000001 COMMENT 'tenant_id WP0-D4',
+                glid BIGINT NULL COMMENT '关联id(=qms_qcpp_inspbill.id)',
+                jyxm VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '检验项目',
+                sx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '上限',
+                xx VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '下限',
+                bz VARCHAR(255) NULL COMMENT '备注', czf VARCHAR(255) NULL,
+                {jGkDdl},
+                pd VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '判定',
+                ybl VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '样本量',
+                txfl BIGINT NULL COMMENT '指标分类', bztk TEXT NULL, jsyq TEXT NULL COMMENT '技术要求',
+                jyyqsbgj TEXT NULL COMMENT '检验仪器设备工具', jyff TEXT NULL, jytk TEXT NULL,
+                jsbz TEXT NULL COMMENT '接收标准', cyfa TEXT NULL COMMENT '抽样方案', jl TEXT NULL,
+                jlqjbh VARCHAR(255) NULL COMMENT '计量器具编号', jlbh VARCHAR(255) NULL COMMENT '计量器具编号',
+                jf1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发1',
+                jf2 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发2',
+                jf3 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发3',
+                jf4 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发4',
+                jf5 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '击发5',
+                NonNumericalTypeOK BIGINT NULL COMMENT '非数值OK', NonNumericalTypeNG BIGINT NULL COMMENT '非数值NG',
+                ThresholdSwitch VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '自动判定按钮',
+                ThresholdSwitchInt BIGINT NULL COMMENT '自动判定按钮',
+                SizeSpecifications VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL COMMENT '规范标准',
+                ybl1 VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                xh INT NULL COMMENT '序号',
+                KEY idx_qms_qcpp_inspbillst_glid (glid)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='FQC检验单明细(品检项目) 归口自旧系统dopdemo,空表待上游抽数'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_fqc_task (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                bill_no VARCHAR(80) NULL,
+                production_order_no VARCHAR(255) NULL,
+                apply_org_id BIGINT NULL,
+                apply_time DATETIME NULL,
+                material_code VARCHAR(255) NULL,
+                product_name VARCHAR(255) NULL,
+                product_model VARCHAR(255) NULL,
+                production_batch_no VARCHAR(255) NULL,
+                order_qty DECIMAL(18,6) NULL,
+                qty DECIMAL(18,6) NULL,
+                applicant VARCHAR(50) NULL,
+                remark TEXT NULL,
+                priority VARCHAR(255) NULL,
+                inspect_start_time DATETIME NULL,
+                inspect_finish_time DATETIME NULL,
+                inspector VARCHAR(255) NULL,
+                inspect_progress VARCHAR(255) NULL,
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_fqc_task (tenant_id, source_row_id),
+                KEY idx_mdp_std_fqc_task_bill (tenant_id, bill_no),
+                KEY idx_mdp_std_fqc_task_apply (tenant_id, apply_time)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S7 FQC 检验任务标准层'
+            """);
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            CREATE TABLE IF NOT EXISTS mdp_std_fqc_result (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                source_system VARCHAR(50) NOT NULL DEFAULT 'AIDOP',
+                bill_no VARCHAR(80) NULL,
+                inspect_time DATETIME NULL,
+                production_batch_no VARCHAR(255) NULL,
+                material_code VARCHAR(255) NULL,
+                material_name VARCHAR(255) NULL,
+                spec VARCHAR(255) NULL,
+                inspector VARCHAR(255) NULL,
+                inspect_qty DECIMAL(18,6) NULL,
+                source_bill_no VARCHAR(100) NULL,
+                production_order_no VARCHAR(255) NULL,
+                order_qty DECIMAL(18,6) NULL,
+                sap_work_order_no VARCHAR(64) NULL,
+                judgment VARCHAR(32) NULL,
+                qualified_qty DECIMAL(18,6) NULL,
+                unqualified_qty DECIMAL(18,6) NULL,
+                fire_no TEXT NULL,
+                inspect_spec_no VARCHAR(255) NULL,
+                inspect_spec_version VARCHAR(255) NULL,
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_fqc_result (tenant_id, source_row_id),
+                KEY idx_mdp_std_fqc_result_bill (tenant_id, bill_no),
+                KEY idx_mdp_std_fqc_result_inspect (tenant_id, inspect_time)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S7 FQC 检验单(结果)标准层'
+            """);
+        var jDdl = string.Join(",\n                ", Enumerable.Range(1, 50).Select(i =>
+            $"j{i} VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL"));
+        await _db.Ado.ExecuteCommandAsync(
+            $"""
+            CREATE TABLE IF NOT EXISTS mdp_std_fqc_result_detail (
+                id BIGINT AUTO_INCREMENT PRIMARY KEY,
+                tenant_id BIGINT NOT NULL DEFAULT 0,
+                std_result_id BIGINT NULL,
+                glid VARCHAR(100) NULL,
+                seq_no INT NULL,
+                inspection_item TEXT NULL,
+                accept_standard TEXT NULL,
+                tech_requirement TEXT NULL,
+                sampling_plan TEXT NULL,
+                sample_qty VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                upper_limit VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                lower_limit VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                size_specification VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                judgment VARCHAR(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci NULL,
+                non_numeric_ok BIGINT NULL,
+                non_numeric_ng BIGINT NULL,
+                remark TEXT NULL,
+                {jDdl},
+                source_row_id VARCHAR(100) NOT NULL,
+                source_biz_key VARCHAR(200) NULL,
+                sync_batch_id VARCHAR(100) NOT NULL,
+                sync_time DATETIME NOT NULL,
+                update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+                UNIQUE KEY uk_mdp_std_fqc_result_dtl (tenant_id, source_row_id),
+                KEY idx_mdp_std_fqc_result_dtl_head (tenant_id, glid),
+                KEY idx_mdp_std_fqc_result_dtl_stdres (std_result_id)
+            ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='S7 FQC 检验单明细标准层(j1-j50样本)'
+            """);
+    }
+
+    /// <summary>标准化任务:pull stg(qms_fqcbj) → mdp_std_fqc_task。返回处理行数。</summary>
+    private async Task<int> TransformTaskStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    {
+        var srcClause = sourceSystem == null ? "" : " AND t.source_system=@Src";
+        var tTenant = $"IFNULL({MdpJsonSql.Int("t", "tenant_id")}, IFNULL(t.tenant_id, 0))";
+        var where = $"t.source_table='qms_fqcbj'{srcClause}";
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_stg_fqc_pull t WHERE {where}", countPars);
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_fqc_task
+            (tenant_id, source_system, bill_no, production_order_no, apply_org_id, apply_time, material_code,
+             product_name, product_model, production_batch_no, order_qty, qty, applicant, remark, priority,
+             inspect_start_time, inspect_finish_time, inspector, inspect_progress,
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                {tTenant}, IFNULL(NULLIF(t.source_system,''), 'AIDOP'),
+                {MdpJsonSql.Str("t", "FBILLNO")}, {MdpJsonSql.Str("t", "sczld")}, {MdpJsonSql.Int("t", "FORGID")}, {MdpJsonSql.DateTimeSec("t", "FAPPLYTIME")}, {MdpJsonSql.Str("t", "wlbm")},
+                {MdpJsonSql.Str("t", "cpmc")}, {MdpJsonSql.Str("t", "cpxh")}, {MdpJsonSql.Str("t", "scph")}, {MdpJsonSql.Dec("t", "sczldsl", 18, 6)}, {MdpJsonSql.Dec("t", "sl", 18, 6)}, {MdpJsonSql.Str("t", "FAPPLYUSER")}, {MdpJsonSql.Str("t", "FCOMMENT")}, {MdpJsonSql.Str("t", "yxj")},
+                {MdpJsonSql.DateTimeSec("t", "jykssj")}, {MdpJsonSql.DateTimeSec("t", "jywcsj")}, {MdpJsonSql.Str("t", "jyfzr")}, {MdpJsonSql.Str("t", "FINSPECTSTATUS")},
+                IFNULL(t.source_row_id, {MdpJsonSql.Str("t", "id")}), IFNULL(NULLIF(t.source_biz_key,''), {MdpJsonSql.Str("t", "FBILLNO")}),
+                @BatchId, @Now
+            FROM mdp_stg_fqc_pull t
+            WHERE {where}
+            ON DUPLICATE KEY UPDATE
+                bill_no=VALUES(bill_no), production_order_no=VALUES(production_order_no), apply_org_id=VALUES(apply_org_id),
+                apply_time=VALUES(apply_time), material_code=VALUES(material_code), product_name=VALUES(product_name),
+                product_model=VALUES(product_model), production_batch_no=VALUES(production_batch_no), order_qty=VALUES(order_qty),
+                qty=VALUES(qty), applicant=VALUES(applicant), remark=VALUES(remark), priority=VALUES(priority),
+                inspect_start_time=VALUES(inspect_start_time), inspect_finish_time=VALUES(inspect_finish_time),
+                inspector=VALUES(inspector), inspect_progress=VALUES(inspect_progress),
+                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) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
+        return rows;
+    }
+
+    /// <summary>标准化结果:pull stg(qms_qcpp_inspbill) + WorkOrdMaster(本库富化 SAP 工单号) → mdp_std_fqc_result。</summary>
+    private async Task<int> TransformResultStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    {
+        var srcClause = sourceSystem == null ? "" : " AND a.source_system=@Src";
+        var aTenant = $"IFNULL({MdpJsonSql.Int("a", "tenant_id")}, IFNULL(a.tenant_id, 0))";
+        var where = $"a.source_table='qms_qcpp_inspbill'{srcClause}";
+        var sapSub = $"(SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = {MdpJsonSql.Str("a", "sczld")})";
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync(
+            $"SELECT COUNT(1) FROM mdp_stg_fqc_pull a WHERE {where}", countPars);
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_fqc_result
+            (tenant_id, source_system, bill_no, inspect_time, production_batch_no, material_code, material_name, spec,
+             inspector, inspect_qty, source_bill_no, production_order_no, order_qty, sap_work_order_no, judgment,
+             qualified_qty, unqualified_qty, fire_no, inspect_spec_no, inspect_spec_version,
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                {aTenant}, IFNULL(NULLIF(a.source_system,''), 'AIDOP'),
+                {MdpJsonSql.Str("a", "FBILLNO")}, {MdpJsonSql.DateTimeSec("a", "FINSPEENDDATE")}, {MdpJsonSql.Str("a", "scph")}, {MdpJsonSql.Str("a", "FMATERIALCFG")}, {MdpJsonSql.Str("a", "wlmc")}, {MdpJsonSql.Str("a", "ggxh")},
+                {MdpJsonSql.Str("a", "jyr")}, {MdpJsonSql.Dec("a", "jysj", 18, 6)}, {MdpJsonSql.Str("a", "lydjbh")}, {MdpJsonSql.Str("a", "sczld")}, {MdpJsonSql.Dec("a", "sczldsl", 18, 6)}, {sapSub}, {MdpJsonSql.Str("a", "pd")},
+                {MdpJsonSql.Dec("a", "hgsl", 18, 6)}, {MdpJsonSql.Dec("a", "bhgsl", 18, 6)}, {MdpJsonSql.Str("a", "jfbh")}, {MdpJsonSql.Str("a", "jgbh")}, {MdpJsonSql.Str("a", "jgbb")},
+                IFNULL(a.source_row_id, {MdpJsonSql.Str("a", "id")}), IFNULL(NULLIF(a.source_biz_key,''), {MdpJsonSql.Str("a", "FBILLNO")}),
+                @BatchId, @Now
+            FROM mdp_stg_fqc_pull a
+            WHERE {where}
+            ON DUPLICATE KEY UPDATE
+                bill_no=VALUES(bill_no), inspect_time=VALUES(inspect_time), production_batch_no=VALUES(production_batch_no),
+                material_code=VALUES(material_code), material_name=VALUES(material_name), spec=VALUES(spec),
+                inspector=VALUES(inspector), inspect_qty=VALUES(inspect_qty), source_bill_no=VALUES(source_bill_no),
+                production_order_no=VALUES(production_order_no), order_qty=VALUES(order_qty), sap_work_order_no=VALUES(sap_work_order_no),
+                judgment=VALUES(judgment), qualified_qty=VALUES(qualified_qty), unqualified_qty=VALUES(unqualified_qty),
+                fire_no=VALUES(fire_no), inspect_spec_no=VALUES(inspect_spec_no), inspect_spec_version=VALUES(inspect_spec_version),
+                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) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
+        return rows;
+    }
+
+    /// <summary>标准化明细:pull stg(qms_qcpp_inspbillst) → mdp_std_fqc_result_detail(回填 std_result_id,j1..j50 逐列)。</summary>
+    private async Task<int> TransformResultDetailStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    {
+        var srcClause = sourceSystem == null ? "" : " AND d.source_system=@Src";
+        var dTenant = $"IFNULL({MdpJsonSql.Int("d", "tenant_id")}, IFNULL(d.tenant_id, 0))";
+        var fromWhere =
+            $"""
+            FROM mdp_stg_fqc_pull d
+            LEFT JOIN mdp_std_fqc_result r
+              ON r.tenant_id = {dTenant} AND r.source_row_id = {MdpJsonSql.Str("d", "glid")}
+            WHERE d.source_table='qms_qcpp_inspbillst'{srcClause}
+            """;
+
+        var countPars = new List<SugarParameter>();
+        if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        var rows = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromWhere}", countPars);
+
+        var jCols = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i}"));
+        var jSelect = string.Join(", ", Enumerable.Range(1, 50).Select(i => MdpJsonSql.Str("d", $"j{i}")));
+        var jUpdate = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i}=VALUES(j{i})"));
+
+        var insertSql =
+            $"""
+            INSERT INTO mdp_std_fqc_result_detail
+            (tenant_id, std_result_id, glid, seq_no, inspection_item, accept_standard, tech_requirement, sampling_plan,
+             sample_qty, upper_limit, lower_limit, size_specification, judgment, non_numeric_ok, non_numeric_ng, remark,
+             {jCols},
+             source_row_id, source_biz_key, sync_batch_id, sync_time)
+            SELECT
+                {dTenant}, r.id, {MdpJsonSql.Str("d", "glid")}, {MdpJsonSql.Int("d", "xh")},
+                {MdpJsonSql.Str("d", "jyxm")}, {MdpJsonSql.Str("d", "jsbz")}, {MdpJsonSql.Str("d", "jsyq")}, {MdpJsonSql.Str("d", "cyfa")},
+                {MdpJsonSql.Str("d", "ybl")}, {MdpJsonSql.Str("d", "sx")}, {MdpJsonSql.Str("d", "xx")}, {MdpJsonSql.Str("d", "SizeSpecifications")}, {MdpJsonSql.Str("d", "pd")}, {MdpJsonSql.Int("d", "NonNumericalTypeOK")}, {MdpJsonSql.Int("d", "NonNumericalTypeNG")}, {MdpJsonSql.Str("d", "bz")},
+                {jSelect},
+                IFNULL(d.source_row_id, {MdpJsonSql.Str("d", "id")}), IFNULL(NULLIF(d.source_biz_key,''), {MdpJsonSql.Str("d", "id")}),
+                @BatchId, @Now
+            {fromWhere}
+            ON DUPLICATE KEY UPDATE
+                std_result_id=VALUES(std_result_id), glid=VALUES(glid), seq_no=VALUES(seq_no),
+                inspection_item=VALUES(inspection_item), accept_standard=VALUES(accept_standard), tech_requirement=VALUES(tech_requirement),
+                sampling_plan=VALUES(sampling_plan), sample_qty=VALUES(sample_qty), upper_limit=VALUES(upper_limit),
+                lower_limit=VALUES(lower_limit), size_specification=VALUES(size_specification), judgment=VALUES(judgment),
+                non_numeric_ok=VALUES(non_numeric_ok), non_numeric_ng=VALUES(non_numeric_ng), remark=VALUES(remark),
+                {jUpdate},
+                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) };
+        if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        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, 'S7 FQC MDP同步与标准化转换', @TriggerType, @BatchId, 'RUNNING', @StartTime)
+            """,
+            new SugarParameter("@JobCode", JobCode),
+            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 MarkRunSuccessAsync(long runLogId, DateTime startedAt, FqcMdpSyncResult result)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='SUCCESS', end_time=@EndTime, duration_ms=@DurationMs,
+                stage_rows=@StageRows, standard_rows=@StandardRows, dwd_rows=0, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@StageRows", result.StageRows),
+            new SugarParameter("@StandardRows", result.TaskRows + result.ResultRows + result.DetailRows),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private async Task MarkRunFailedAsync(long runLogId, DateTime startedAt, string message)
+    {
+        var finishedAt = DateTime.Now;
+        await _db.Ado.ExecuteCommandAsync(
+            """
+            UPDATE mdp_transform_run_log
+            SET status='FAILED', end_time=@EndTime, duration_ms=@DurationMs,
+                error_message=@ErrorMessage, update_time=CURRENT_TIMESTAMP
+            WHERE id=@Id
+            """,
+            new SugarParameter("@EndTime", finishedAt),
+            new SugarParameter("@DurationMs", (int)(finishedAt - startedAt).TotalMilliseconds),
+            new SugarParameter("@ErrorMessage", message.Length > 2000 ? message[..2000] : message),
+            new SugarParameter("@Id", runLogId));
+    }
+
+    private static string NormalizeTriggerType(string? triggerType)
+        => string.IsNullOrWhiteSpace(triggerType) ? "AUTO" : triggerType.Trim().ToUpperInvariant();
+}
+
+/// <summary>S7 FQC MDP 同步转换结果。</summary>
+public sealed class FqcMdpSyncResult
+{
+    public long RunLogId { get; set; }
+    public string BatchId { get; set; } = string.Empty;
+    public int StageRows { get; set; }
+    public int TaskRows { get; set; }
+    public int ResultRows { get; set; }
+    public int DetailRows { get; set; }
+}
+
+/// <summary>S7 FQC 双模式入站结果。</summary>
+public sealed class FqcInboundResult
+{
+    public string PullBatchId { get; set; } = string.Empty;
+    public int RowsPulled { get; set; }
+    public int RowsWrittenStg { get; set; }
+    public string? NewCursor { get; set; }
+    public string TransformBatchId { get; set; } = string.Empty;
+    public int TaskRows { get; set; }
+    public int ResultRows { get; set; }
+    public int DetailRows { get; set; }
+}