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

fix(s7): FQC 检验结果提交后标准层立即可见 + 审批中心 openBillId 深链

UAT-S7-05 修复。检验结果写入 qms_qcpp_inspbill 正常,但 result-list/detail 读
mdp_std_fqc_result,而标准层此前只在「报检创建」与「手动 refresh」时全量同步,
submit-result 不触发,导致主管通过后整单结果字段仍为 NULL,直到别人新建报检才
被动自愈。

FIX-A 单据级 targeted 同步(FqcMdpSyncService.SyncBillAsync)
- 源 → stg 贴源 upsert(raw_data 按源表全列动态生成 JSON_OBJECT,与
  MdpDbPullExecutor 信封形状一致,实测 55/55 键完全相同,无字段漂移)
- 复用全量同一套 transform,仅加单据作用域,不另起一套字段映射
- 覆盖 result / result_detail / task 三表;task 因 submit-result 与退回会改
  qms_fqcbj.FINSPECTSTATUS(即任务列表「检验进度」)而必须同步
- 全程 tenant_id 强约束(源查询 + transform 双重),跨租户不可命中
- 纯 upsert,不 DELETE、不跑 orphan purge、不抢全量 GET_LOCK
- 实测 887ms,对比全量 12.1~14.9s

FIX-A2 判定码表归一
- std transform 统一 0→合格 / 1→不合格,其余历史值原样透传
- 源表 pd 保持 0/1 不变,QE 网关与 outbox 判据不受影响

触发点按证据逐项判定(不机械 refresh)
- submit-result:同步(写结果 + 任务进度)
- supervisor-reject:同步(改 FINSPECTSTATUS)
- save-detail / supervisor-approve / qe-submit-disposition:不同步
- 同步在业务事务之外执行,失败只告警不回滚已提交的业务写入

FIX-B 审批中心 openBillId 深链(纯前端,不改 ApprovalFlow 插件)
- 复用既有 flowInstance/getByBiz 定实例状态决定 tab 顺序,再按
  bizType 服务端过滤有界翻页匹配 row.bizId
- 命中后自动切 tab、翻到目标页并高亮该行
YY968XX 2 дней назад
Родитель
Сommit
a5b4447ee0

+ 1 - 1
Web/package.json

@@ -1,7 +1,7 @@
 {
 	"name": "admin.net",
 	"type": "module",
-	"version": "2.4.364",
+	"version": "2.4.365",
 	"packageManager": "pnpm@10.32.1",
 	"lastBuildTime": "2026.03.15",
 	"description": "Admin.NET 站在巨人肩膀上的 .NET 通用权限开发框架",

+ 4 - 2
Web/src/views/approvalFlow/center/components/DoneList.vue

@@ -1,5 +1,5 @@
 <template>
-	<el-table :data="data" v-loading="loading" border style="width: 100%">
+	<el-table :data="data" v-loading="loading" border style="width: 100%" :row-class-name="rowClassName">
 		<el-table-column type="index" label="#" width="55" align="center" />
 		<el-table-column prop="title" label="流程标题" show-overflow-tooltip />
 		<el-table-column prop="bizType" label="业务类型" width="140" class-name="mobile-hide" label-class-name="mobile-hide" />
@@ -22,7 +22,9 @@
 </template>
 
 <script setup lang="ts">
-defineProps<{ data: any[]; loading: boolean }>();
+const props = defineProps<{ data: any[]; loading: boolean; highlightBizId?: string }>();
+// 深链定位高亮:openBillId 命中的业务单所在行加底色
+const rowClassName = ({ row }: { row: any }) => (props.highlightBizId && String(row?.bizId ?? '') === props.highlightBizId ? 'flow-deeplink-hit' : '');
 const emit = defineEmits<{ (e: 'timeline', row: any): void; (e: 'viewBiz', row: any): void }>();
 
 const taskStatusLabel = (s: number) => {

+ 4 - 2
Web/src/views/approvalFlow/center/components/InitiatedList.vue

@@ -1,5 +1,5 @@
 <template>
-	<el-table :data="data" v-loading="loading" border style="width: 100%">
+	<el-table :data="data" v-loading="loading" border style="width: 100%" :row-class-name="rowClassName">
 		<el-table-column type="index" label="#" width="55" align="center" />
 		<el-table-column prop="title" label="流程标题" show-overflow-tooltip />
 		<el-table-column prop="bizType" label="业务类型" width="140" class-name="mobile-hide" label-class-name="mobile-hide" />
@@ -29,7 +29,9 @@
 </template>
 
 <script setup lang="ts">
-defineProps<{ data: any[]; loading: boolean }>();
+const props = defineProps<{ data: any[]; loading: boolean; highlightBizId?: string }>();
+// 深链定位高亮:openBillId 命中的业务单所在行加底色
+const rowClassName = ({ row }: { row: any }) => (props.highlightBizId && String(row?.bizId ?? '') === props.highlightBizId ? 'flow-deeplink-hit' : '');
 const emit = defineEmits<{
 	(e: 'timeline', row: any): void;
 	(e: 'urge', row: any): void;

+ 4 - 2
Web/src/views/approvalFlow/center/components/PendingList.vue

@@ -5,7 +5,7 @@
 			<el-button type="success" size="small" @click="emit('batchApprove', selectedRows)">批量同意</el-button>
 			<el-button type="danger" size="small" @click="emit('batchReject', selectedRows)">批量拒绝</el-button>
 		</div>
-		<el-table :data="data" v-loading="loading" border style="width: 100%" @selection-change="onSelectionChange">
+		<el-table :data="data" v-loading="loading" border style="width: 100%" :row-class-name="rowClassName" @selection-change="onSelectionChange">
 			<el-table-column type="selection" width="45" align="center" />
 			<el-table-column type="index" label="#" width="55" align="center" />
 			<el-table-column prop="title" label="流程标题" show-overflow-tooltip>
@@ -117,7 +117,9 @@ function goToImprovement(bizId: number | string) {
 	router.push(`/aidop/smart-ops/improvement-plans?id=${bizId}`);
 }
 
-defineProps<{ data: any[]; loading: boolean }>();
+const props = defineProps<{ data: any[]; loading: boolean; highlightBizId?: string }>();
+// 深链定位高亮:openBillId 命中的业务单所在行加底色,让用户一眼看到系统定位到了哪一单
+const rowClassName = ({ row }: { row: any }) => (props.highlightBizId && String(row?.bizId ?? '') === props.highlightBizId ? 'flow-deeplink-hit' : '');
 const emit = defineEmits<{
 	(e: 'approve', row: any): void;
 	(e: 'reject', row: any): void;

+ 84 - 4
Web/src/views/approvalFlow/center/index.vue

@@ -31,6 +31,7 @@
 				v-if="activeTab === 'pending'"
 				:data="tableData"
 				:loading="loading"
+				:highlight-biz-id="highlightBizId"
 				@approve="openDialog('approve', $event)"
 				@reject="openDialog('reject', $event)"
 				@transfer="openDialog('transfer', $event)"
@@ -42,8 +43,17 @@
 				@batchReject="doBatchReject"
 				@viewBiz="openBizForm"
 			/>
-			<DoneList v-if="activeTab === 'done'" :data="tableData" :loading="loading" @timeline="openTimeline" @viewBiz="openBizForm" />
-			<InitiatedList v-if="activeTab === 'initiated'" :data="tableData" :loading="loading" @timeline="openTimeline" @urge="doUrge" @withdraw="doWithdraw" @viewBiz="openBizForm" />
+			<DoneList v-if="activeTab === 'done'" :data="tableData" :loading="loading" :highlight-biz-id="highlightBizId" @timeline="openTimeline" @viewBiz="openBizForm" />
+			<InitiatedList
+				v-if="activeTab === 'initiated'"
+				:data="tableData"
+				:loading="loading"
+				:highlight-biz-id="highlightBizId"
+				@timeline="openTimeline"
+				@urge="doUrge"
+				@withdraw="doWithdraw"
+				@viewBiz="openBizForm"
+			/>
 
 			<el-pagination
 				v-model:page-size="pageSize"
@@ -98,6 +108,7 @@ import {
 	getBizTypeList,
 	batchApprove,
 	batchReject,
+	getInstanceByBiz,
 } from '../api';
 import PendingList from './components/PendingList.vue';
 import DoneList from './components/DoneList.vue';
@@ -130,6 +141,7 @@ const loadBizTypes = async () => {
 const resetFilter = () => {
 	filterBizType.value = '';
 	currentPage.value = 1;
+	highlightBizId.value = '';
 	loadData();
 };
 
@@ -153,14 +165,74 @@ const bizFormTitle = ref('');
 const route = useRoute();
 const highlightTaskId = ref(String(route.query.taskId || ''));
 
-onMounted(() => {
+// ── 业务单深链:/aidop/flowManage/approvalFlowCenter?openBillId=<BizId>[&bizType=<Code>] ──
+// openBillId 是业务主键(= ApprovalFlowTask.BizId,如 qms_qcpp_inspbill.id),不是 taskId。
+// 定位策略:先用既有 flowInstance/getByBiz 确认流程存在并按实例状态决定 tab 顺序,
+// 再用三个分页接口(服务端支持 bizType 过滤)有界翻页按 row.bizId 精确命中,
+// 不把全部审批任务拉到前端,也不改 ApprovalFlow 插件本身。
+const openBillId = ref(String(route.query.openBillId || ''));
+const openBizType = ref(String(route.query.bizType || ''));
+const highlightBizId = ref('');
+/** 深链翻页上界:单 tab 最多扫这么多页,避免大数据量下无界翻页 */
+const DEEPLINK_MAX_PAGES = 20;
+
+onMounted(async () => {
 	loadBizTypes();
-	loadData();
 	loadPendingCount();
+	if (openBillId.value) await locateByBizId();
+	else loadData();
 });
 
+/** 按实例状态给出 tab 优先顺序:审批中优先待办,已结束优先已办;未知则按待办→已办→我发起 */
+const resolveTabOrder = async (): Promise<Array<'pending' | 'done' | 'initiated'>> => {
+	if (!openBizType.value) return ['pending', 'done', 'initiated'];
+	try {
+		const res = await getInstanceByBiz(openBizType.value, Number(openBillId.value));
+		const status = res.data?.result?.status;
+		// 1=审批中(Running);其余为终态
+		if (status === 1) return ['pending', 'done', 'initiated'];
+		if (status !== undefined && status !== null) return ['done', 'initiated', 'pending'];
+	} catch {
+		/* 查不到实例不阻断:仍按默认顺序扫,扫不到再提示 */
+	}
+	return ['pending', 'done', 'initiated'];
+};
+
+/** 在指定 tab 内有界翻页查找 bizId,命中则把该页真正加载出来并高亮 */
+const locateByBizId = async () => {
+	const target = openBillId.value;
+	const order = await resolveTabOrder();
+	loading.value = true;
+	try {
+		for (const tab of order) {
+			for (let page = 1; page <= DEEPLINK_MAX_PAGES; page++) {
+				const params: any = { page, pageSize: pageSize.value };
+				if (openBizType.value) params.bizType = openBizType.value;
+				const res = tab === 'pending' ? await myPendingPage(params) : tab === 'done' ? await myDonePage(params) : await myInitiatedPage(params);
+				const items = res.data?.result?.items ?? [];
+				if (!items.length) break;
+				if (items.some((x: any) => String(x.bizId ?? '') === target)) {
+					activeTab.value = tab;
+					currentPage.value = page;
+					highlightBizId.value = target;
+					if (openBizType.value) filterBizType.value = openBizType.value;
+					await loadData();
+					return;
+				}
+				if (items.length < pageSize.value) break;
+			}
+		}
+		ElMessage.warning(`未找到业务单 ${target} 对应的审批任务(可能不在当前用户的待办/已办/我发起范围内)`);
+		await loadData();
+	} finally {
+		loading.value = false;
+	}
+};
+
 const onTabChange = () => {
 	currentPage.value = 1;
+	// 用户手动切 tab 后,深链定位不再成立,清掉高亮避免误导
+	highlightBizId.value = '';
 	loadData();
 };
 
@@ -343,4 +415,12 @@ const openTimeline = async (row: any) => {
 .tab-badge {
 	margin-left: 4px;
 }
+
+/* 深链(openBillId)命中行高亮:el-table 的 row-class-name 落在 tr 上,需穿透 scoped */
+.approval-center :deep(.el-table .flow-deeplink-hit) > td.el-table__cell {
+	background-color: var(--el-color-primary-light-9) !important;
+}
+.approval-center :deep(.el-table .flow-deeplink-hit) > td.el-table__cell:first-child {
+	box-shadow: inset 3px 0 0 0 var(--el-color-primary);
+}
 </style>

+ 3 - 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.464</AssemblyVersion>
-    <FileVersion>1.0.464</FileVersion>
-    <Version>1.0.464</Version>
+    <AssemblyVersion>1.0.465</AssemblyVersion>
+    <FileVersion>1.0.465</FileVersion>
+    <Version>1.0.465</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 42 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcInspBillFlowService.cs

@@ -5,6 +5,7 @@ using Admin.NET.Plugin.AiDOP.FinishedWarehouse.Dto;
 using Admin.NET.Plugin.AiDOP.Infrastructure;
 using Admin.NET.Plugin.ApprovalFlow;
 using Admin.NET.Plugin.ApprovalFlow.Service;
+using Microsoft.Extensions.Logging;
 using Yitter.IdGenerator;
 
 namespace Admin.NET.Plugin.AiDOP.FinishedWarehouse;
@@ -30,12 +31,42 @@ public class FqcInspBillFlowService : IDynamicApiController, ITransient
     private readonly ISqlSugarClient _db;
     private readonly FlowEngineService _flowEngine;
     private readonly UserManager _userManager;
-
-    public FqcInspBillFlowService(ISqlSugarClient db, FlowEngineService flowEngine, UserManager userManager)
+    private readonly FqcMdpSyncService _fqcMdpSyncService;
+    private readonly ILogger<FqcInspBillFlowService> _logger;
+
+    public FqcInspBillFlowService(
+        ISqlSugarClient db,
+        FlowEngineService flowEngine,
+        UserManager userManager,
+        FqcMdpSyncService fqcMdpSyncService,
+        ILoggerFactory loggerFactory)
     {
         _db = db;
         _flowEngine = flowEngine;
         _userManager = userManager;
+        _fqcMdpSyncService = fqcMdpSyncService;
+        _logger = loggerFactory.CreateLogger<FqcInspBillFlowService>();
+    }
+
+    /// <summary>
+    /// 业务写入落库后,把本单据同步到数据中台标准层(result-list / detail / task-list 均读 std)。
+    ///
+    /// 必须放在业务事务<b>之外</b>(事务内跑同步会把 std 写入绑进业务事务,放大锁范围);
+    /// 走单据级 <see cref="FqcMdpSyncService.SyncBillAsync"/> 而非 12s 的全量 RunFullAsync。
+    /// 同步失败只告警不抛:此刻业务事务已提交且流程已推进,让 API 失败会诱导用户重试,
+    /// 而重试必然报"当前不在检验录入节点"——反而更难排查。std 可由下一次 targeted/全量刷新自愈。
+    /// </summary>
+    private async Task SyncBillToStdSafeAsync(long billId, string triggerType)
+    {
+        try
+        {
+            var tid = ResolveTenantOrThrow();
+            await _fqcMdpSyncService.SyncBillAsync(billId, tid, triggerType);
+        }
+        catch (Exception ex)
+        {
+            _logger.LogWarning(ex, "[FqcFlow] 单据 {BillId} 业务写入已成功,但标准层 targeted 同步失败({Trigger}),列表可能仍显示旧值", billId, triggerType);
+        }
     }
 
     /// <summary>严格可信租户解析,见 <see cref="Infrastructure.AidopTenantScope.ResolveOrThrow"/>。</summary>
@@ -162,6 +193,11 @@ public class FqcInspBillFlowService : IDynamicApiController, ITransient
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
 
+        // ④ 标准层同步(事务外):①写的 pd/hgsl/bhgsl/jyr/FINSPEENDDATE → mdp_std_fqc_result,
+        //    save-detail 已写的 qms_qcpp_inspbillst 明细 → mdp_std_fqc_result_detail(本单一次性全量收口,
+        //    故 save-detail 逐项保存时不必同步),②写的 qms_fqcbj 进度 → mdp_std_fqc_task。
+        await SyncBillToStdSafeAsync(input.Id, "SUBMIT_RESULT");
+
         return await BuildStateAsync(input.Id);
     }
 
@@ -214,6 +250,10 @@ public class FqcInspBillFlowService : IDynamicApiController, ITransient
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
 
+        // 退回改写了 qms_fqcbj.FINSPECTSTATUS,该列即检验任务列表的「检验进度」(mdp_std_fqc_task.inspect_progress),
+        // 不同步则任务列表停留在"检验完成"。检验结果 pd/hgsl/bhgsl 本动作不改,同步对其为幂等重写。
+        await SyncBillToStdSafeAsync(input.Id, "SUP_REJECT");
+
         return await BuildStateAsync(input.Id);
     }
 

+ 142 - 5
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcMdpSyncService.cs

@@ -101,6 +101,123 @@ public class FqcMdpSyncService : ITransient
     /// <summary>FQC 全量刷新并发锁键(同库同时只跑一个)。</summary>
     private const string FqcRefreshLockKey = "aidop:s7:fqc:refresh";
 
+    /// <summary>本库 MySQL 贴源 source_system(与 MdpDbPullExecutor 落 stg 的取值一致,决定 uk_source_key 归属)。</summary>
+    private const string NativeSourceSystem = "AIDOPDEV_MYSQL";
+
+    /// <summary>targeted sync 的单据作用域(租户 + 检验单 id + 报检单号)。</summary>
+    private sealed class FqcBillScope
+    {
+        public long TenantId { get; init; }
+        public long BillId { get; init; }
+        /// <summary>qms_qcpp_inspbill.lydjbh = qms_fqcbj.FBILLNO;为空则跳过任务表同步。</summary>
+        public string? SourceBillNo { get; init; }
+    }
+
+    /// <summary>把作用域参数补进 transform 的参数表(未启用作用域时不加,保持全量 SQL 原样)。</summary>
+    private static void AddScopePars(List<SugarParameter> pars, FqcBillScope? scope)
+    {
+        if (scope == null) return;
+        pars.Add(new SugarParameter("@ScopeTenant", scope.TenantId));
+        pars.Add(new SugarParameter("@ScopeBillId", scope.BillId.ToString()));
+        pars.Add(new SugarParameter("@ScopeBillNo", scope.SourceBillNo));
+    }
+
+    /// <summary>
+    /// 单据级 targeted 同步(业务写入后立即可见,替代把 12s 全量 <see cref="RunFullAsync"/> 挂到高频提交路径)。
+    ///
+    /// 只处理 billId 这一张检验单:源 → stg(贴源 upsert)→ std 三表(复用全量同一套 transform,字段映射零分叉)。
+    ///   qms_qcpp_inspbill  (id=billId)                → mdp_std_fqc_result
+    ///   qms_qcpp_inspbillst(glid=billId)              → mdp_std_fqc_result_detail
+    ///   qms_fqcbj          (FBILLNO=inspbill.lydjbh)  → mdp_std_fqc_task(submit-result/退回会改 FINSPECTSTATUS 等进度列)
+    ///
+    /// 边界:只读业务源、只写 mdp_stg_fqc_pull / mdp_std_fqc_*;全程 tenant_id 强约束(源查询与 transform 双重);
+    ///   纯 upsert,不 DELETE、不跑 orphan purge、不抢全量 GET_LOCK、不产生 S7_FQC_FULL 批次。
+    /// </summary>
+    public async Task<FqcMdpSyncResult> SyncBillAsync(
+        long billId, long tenantId, string triggerType = "BILL", CancellationToken cancellationToken = default)
+    {
+        cancellationToken.ThrowIfCancellationRequested();
+        if (billId <= 0 || tenantId <= 0) return new FqcMdpSyncResult { BatchId = "SKIPPED_INVALID_SCOPE", Skipped = true };
+
+        var now = DateTime.Now;
+        var batchId = $"S7_FQC_BILL_{now:yyyyMMddHHmmssfff}_{billId}";
+        var runLogId = await InsertRunLogAsync(batchId, now, triggerType);
+        var result = new FqcMdpSyncResult { BatchId = batchId, RunLogId = runLogId };
+
+        try
+        {
+            // 来源单号:决定是否需要同步任务表;同时用租户约束再证一次单据归属
+            var sourceBillNo = await _db.Ado.SqlQuerySingleAsync<string>(
+                "SELECT lydjbh FROM qms_qcpp_inspbill WHERE id=@id AND tenant_id=@tid LIMIT 1",
+                new SugarParameter("@id", billId), new SugarParameter("@tid", tenantId));
+            var scope = new FqcBillScope { TenantId = tenantId, BillId = billId, SourceBillNo = sourceBillNo };
+
+            // ① 贴源:把该单据的源行刷进 stg(transform 一律读 stg,故必须先刷,否则同步的还是旧快照)
+            result.StageRows = await UpsertStgFromSourceAsync(
+                "qms_qcpp_inspbill", "s.id=@id AND s.tenant_id=@tid",
+                new List<SugarParameter> { new("@id", billId), new("@tid", tenantId) }, batchId, now);
+            result.StageRows += await UpsertStgFromSourceAsync(
+                "qms_qcpp_inspbillst", "s.glid=@id AND s.tenant_id=@tid",
+                new List<SugarParameter> { new("@id", billId), new("@tid", tenantId) }, batchId, now);
+            if (!string.IsNullOrWhiteSpace(sourceBillNo))
+                result.StageRows += await UpsertStgFromSourceAsync(
+                    "qms_fqcbj", "s.FBILLNO=@bjbh AND s.tenant_id=@tid",
+                    new List<SugarParameter> { new("@bjbh", sourceBillNo), new("@tid", tenantId) }, batchId, now);
+
+            // ② 标准层:复用全量 transform,仅加单据作用域。result 必须先于 detail(detail 回填 std_result_id)
+            result.ResultRows = await TransformResultStandardAsync(batchId, now, null, scope);
+            result.DetailRows = await TransformResultDetailStandardAsync(batchId, now, null, scope);
+            if (!string.IsNullOrWhiteSpace(sourceBillNo))
+                result.TaskRows = await TransformTaskStandardAsync(batchId, now, null, scope);
+
+            await MarkRunSuccessAsync(runLogId, now, result);
+            return result;
+        }
+        catch (Exception ex)
+        {
+            await MarkRunFailedAsync(runLogId, now, ex.Message);
+            throw;
+        }
+    }
+
+    /// <summary>
+    /// 单表贴源 upsert:源行 → mdp_stg_fqc_pull,raw_data 按源表<b>全列</b>动态生成 JSON_OBJECT,
+    /// 与 MdpDbPullExecutor 的信封形状一致(source_row_id=source_biz_key=主键,JSON 键=源列名),
+    /// 因此绝不会因为 transform 未来新增字段而漂移。命中既有 uk_source_key 则原地更新,不产生重复 stg 行。
+    /// </summary>
+    private async Task<int> UpsertStgFromSourceAsync(
+        string sourceTable, string whereSql, List<SugarParameter> pars, 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=@t ORDER BY ORDINAL_POSITION",
+            new List<SugarParameter> { new("@t", sourceTable) });
+        // 列名来自 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}`")) + ")";
+        var allPars = new List<SugarParameter>(pars)
+        {
+            new("@BatchId", batchId),
+            new("@Now", now),
+            new("@SrcSys", NativeSourceSystem),
+            new("@SrcTab", sourceTable),
+        };
+
+        return await _db.Ado.ExecuteCommandAsync(
+            $"""
+            INSERT INTO mdp_stg_fqc_pull
+            (tenant_id, source_system, source_table, source_row_id, source_biz_key, raw_data, sync_batch_id, sync_time, process_status)
+            SELECT s.tenant_id, @SrcSys, @SrcTab, CAST(s.id AS CHAR), CAST(s.id AS CHAR), {jsonObj}, @BatchId, @Now, 'PENDING'
+            FROM `{sourceTable}` s
+            WHERE {whereSql}
+            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), update_time=CURRENT_TIMESTAMP
+            """,
+            allPars);
+    }
+
     /// <summary>
     /// 全量刷新后清理本库(AIDOP)贴源 orphan:源单据(qms_qcpp_inspbill / qms_fqcbj / qms_qcpp_inspbillst)
     /// 已删除但 std 层仍残留的行。仅清 source_system='AIDOP'(含历史 NULL/空)的行,SQLSERVER 双源行不受影响;
@@ -458,14 +575,18 @@ public class FqcMdpSyncService : ITransient
     }
 
     /// <summary>标准化任务:pull stg(qms_fqcbj) → mdp_std_fqc_task。返回处理行数。</summary>
-    private async Task<int> TransformTaskStandardAsync(string batchId, DateTime now, string? sourceSystem = null)
+    private async Task<int> TransformTaskStandardAsync(string batchId, DateTime now, string? sourceSystem = null, FqcBillScope? scope = null)
     {
         var srcClause = sourceSystem == null ? "" : " AND t.source_system=@Src";
         var tTenant = MdpJsonSql.TenantFromStgJsonCol("t", MdpJsonSql.Int("t", "tenant_id"));
         var where = $"t.source_table='qms_fqcbj'{srcClause} AND {MdpJsonSql.TenantGuard(tTenant)}";
+        // 单据级作用域(targeted sync):任务经报检单号关联(qms_fqcbj.FBILLNO = inspbill.lydjbh),并强制租户相等
+        if (scope != null)
+            where += $" AND ({tTenant})=@ScopeTenant AND {MdpJsonSql.Str("t", "FBILLNO")}=@ScopeBillNo";
 
         var countPars = new List<SugarParameter>();
         if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        AddScopePars(countPars, scope);
         var rows = await _db.Ado.GetIntAsync(
             $"SELECT COUNT(1) FROM mdp_stg_fqc_pull t WHERE {where}", countPars);
 
@@ -496,20 +617,29 @@ public class FqcMdpSyncService : ITransient
             """;
         var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
         if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        AddScopePars(insPars, scope);
         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)
+    private async Task<int> TransformResultStandardAsync(string batchId, DateTime now, string? sourceSystem = null, FqcBillScope? scope = null)
     {
         var srcClause = sourceSystem == null ? "" : " AND a.source_system=@Src";
         var aTenant = MdpJsonSql.TenantFromStgJsonCol("a", MdpJsonSql.Int("a", "tenant_id"));
         var where = $"a.source_table='qms_qcpp_inspbill'{srcClause} AND {MdpJsonSql.TenantGuard(aTenant)}";
+        // 单据级作用域(targeted sync):按 stg 的 source_row_id(=inspbill.id)收敛,并强制租户相等,杜绝跨租户命中
+        if (scope != null)
+            where += $" AND ({aTenant})=@ScopeTenant AND IFNULL(a.source_row_id, {MdpJsonSql.Str("a", "id")})=@ScopeBillId";
         var sapSub = $"(SELECT MIN(wo.WorkOrd) FROM WorkOrdMaster wo WHERE wo.Batch = {MdpJsonSql.Str("a", "sczld")})";
+        // 判定码表归一(FIX-A2):源 pd 存两套编码——业务写入 0/1(FqcInspBillFlowService 校验口径 0=合格/1=不合格),
+        // 历史归口数据直存中文。std 面向展示统一为中文;非 0/1 的历史值 ELSE 原样透传,绝不破坏既有数据。
+        // 仅映射整单 pd;明细行 pd(qms_qcpp_inspbillst.pd)源本身即中文,不映射。源表 pd 保持 0/1 不动。
+        var judgmentExpr = $"CASE {MdpJsonSql.Str("a", "pd")} WHEN '0' THEN '合格' WHEN '1' THEN '不合格' ELSE {MdpJsonSql.Str("a", "pd")} END";
 
         var countPars = new List<SugarParameter>();
         if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        AddScopePars(countPars, scope);
         var rows = await _db.Ado.GetIntAsync(
             $"SELECT COUNT(1) FROM mdp_stg_fqc_pull a WHERE {where}", countPars);
 
@@ -523,7 +653,7 @@ public class FqcMdpSyncService : ITransient
             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.Str("a", "jyr")}, {MdpJsonSql.Dec("a", "jysj", 18, 6)}, {MdpJsonSql.Str("a", "lydjbh")}, {MdpJsonSql.Str("a", "sczld")}, {MdpJsonSql.Dec("a", "sczldsl", 18, 6)}, {sapSub}, {judgmentExpr},
                 {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
@@ -540,26 +670,32 @@ public class FqcMdpSyncService : ITransient
             """;
         var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
         if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        AddScopePars(insPars, scope);
         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)
+    private async Task<int> TransformResultDetailStandardAsync(string batchId, DateTime now, string? sourceSystem = null, FqcBillScope? scope = null)
     {
         var srcClause = sourceSystem == null ? "" : " AND d.source_system=@Src";
         var dTenant = MdpJsonSql.TenantFromStgJsonCol("d", MdpJsonSql.Int("d", "tenant_id"));
+        // 单据级作用域(targeted sync):明细按 glid(=inspbill.id)收敛,并强制租户相等
+        var scopeClause = scope == null
+            ? ""
+            : $"\n              AND ({dTenant})=@ScopeTenant AND {MdpJsonSql.Str("d", "glid")}=@ScopeBillId";
         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}
-              AND {MdpJsonSql.TenantGuard(dTenant)}
+              AND {MdpJsonSql.TenantGuard(dTenant)}{scopeClause}
             """;
 
         var countPars = new List<SugarParameter>();
         if (sourceSystem != null) countPars.Add(new SugarParameter("@Src", sourceSystem));
+        AddScopePars(countPars, scope);
         var rows = await _db.Ado.GetIntAsync($"SELECT COUNT(1) {fromWhere}", countPars);
 
         var jCols = string.Join(", ", Enumerable.Range(1, 50).Select(i => $"j{i}"));
@@ -592,6 +728,7 @@ public class FqcMdpSyncService : ITransient
             """;
         var insPars = new List<SugarParameter> { new("@BatchId", batchId), new("@Now", now) };
         if (sourceSystem != null) insPars.Add(new SugarParameter("@Src", sourceSystem));
+        AddScopePars(insPars, scope);
         await _db.Ado.ExecuteCommandAsync(insertSql, insPars);
         return rows;
     }