Ver Fonte

fix(s0,s3,s7): repair three independent UAT defects that returned 200 without persisting

Web 2.4.359 / server 1.0.460

三个缺陷表征相同(接口回 200 但数据没落库),根因互不相干,分别修复。

S0/S9 — KPI 配置写路径被全局租户过滤器吞掉(UAT-S0-07 / UAT-S9-01)
  MoreSettings.IsAutoUpdateQueryFilter/IsAutoDeleteQueryFilter=true 会给 ORM 的
  UPDATE/DELETE 自动追加登录 JWT 租户条件,而 KPI 配置按 ResolveKpiTenantId 落在
  KPI 数据租户;两者不一致时匹配 0 行且不抛错,publish 返回 ok:true 但状态不变。
  SqlSugar 5.1.4 的 IUpdateable/IDeleteable 无法关闭该过滤器(ClearFilter 仅存在于
  ISugarQueryable,EnableQueryFilter 只能开不能关),故写路径改走原生参数化 SQL,
  每条语句显式带 TenantId + 业务状态条件,禁止按 Id 裸写。
  覆盖 AdoSmartOpsKpiCalcConfigService 与 AdoSmartOpsKpiDimensionConfigService 的
  UpdateDraft / Delete / Publish / Activate / Retire。

S3 — 雪花 ID 被前端 Number() 截断(UAT-S3-04)
  purchaseRequestForm.vue 对货源清单的 icitemId 做 Number(),
  1825547780274257932 被截成 1825547780274258000,落库后 JOIN ic_item 打空,
  列表物料编码/描述/规格型号全为 null。后端已 CAST AS CHAR(30),改前端保持字符串语义。

S7 — 工单号错误映射到 WorkOrdMaster.Batch(UAT-S7-02)
  生产指令单应取 WorkOrd,实取 Batch(批次号,多数为 NULL),导致报检单
  sczld 落 NULL、选择器工单号列空白、工单号筛选打错列。
  修正落库映射与前端列/表单/筛选口径;scph 仍取 LotSerial,源为空即 NULL,不 fallback。

统一加固:本轮涉及的 UPDATE/DELETE 一律校验 affected rows,为 0 时抛业务异常并区分
「目标不存在 / 租户不匹配 / 状态不满足 / 并发状态变化」,不再静默返回成功。

验证:普通用户 AIDOPDemo(AccountType=777) 跨租户跑通配置全生命周期并落库;
S3 前端选中值 / HTTP payload / srm_pr_main.icitem_id / ic_item.Id 四者逐位一致;
S7 由 Batch=NULL 的工单创建报检,sczld 正确落 WorkOrd、scph 保持 NULL。
后端 dotnet build 0 error;前端 pnpm build 通过;vue-tsc 581 ≤ 597 基线且改动文件净增 0。
YY968XX há 1 dia atrás
pai
commit
88eab8dc8b

+ 1 - 1
Web/package.json

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

+ 6 - 1
Web/src/views/aidop/s3/api/purchaseRequest.ts

@@ -33,7 +33,12 @@ export interface PurchaseRequestRow {
 export interface PurchaseRequestSaveInput {
 	id?: number | null;
 	prBillNo?: string | null;
-	icitemId?: number | null;
+	/**
+	 * 物料 ID(雪花 19 位)。后端 long 经 AddLongTypeConverters 序列化为 JSON 字符串,
+	 * 全链路必须保持字符串语义——一旦 Number() 化会丢精度(1825547780274257952 → 1825547780274258000),
+	 * 落库后 JOIN ic_item 打空,列表物料编码/描述/规格型号全为 null(UAT-S3-04)。
+	 */
+	icitemId?: string | null;
 	icitemName?: string | null;
 	prUnit?: string | null;
 	prPurchaseNumber?: string | null;

+ 2 - 1
Web/src/views/aidop/s3/supply/purchaseRequestForm.vue

@@ -124,7 +124,8 @@ function toDateString(dt: Date) {
 
 function onPickSource(row: SourceListRow) {
 	form.prPurchaseId = row.supplierId ?? null;
-	form.icitemId = row.icitemId ? Number(row.icitemId) : null;
+	// 雪花 19 位 ID 必须保持字符串:Number() 会把 1825547780274257952 截成 1825547780274258000(UAT-S3-04)
+	form.icitemId = row.icitemId ?? null;
 	form.icitemName = row.icitemName ?? '';
 	form.prPurchaseNumber = row.supplierNumber ?? '';
 	form.prPurchaseName = row.supplierName ?? '';

+ 16 - 0
Web/src/views/aidop/s7/fqc/fqcApplyForm.spec.ts

@@ -11,6 +11,7 @@ describe('成品报检表单默认值', () => {
 		fillFqcApplyFormFromWorkOrder(form, {
 			recId: 1,
 			batch: '500000010',
+			workOrd: 'M500000010',
 			materialCode: '91HC0497',
 			productName: '集成操控设备主机',
 			productModel: 'AP9',
@@ -23,4 +24,19 @@ describe('成品报检表单默认值', () => {
 		expect(form.qtyOrded).toBe(965);
 		expect(form.qty).toBe(965);
 	});
+
+	// UAT-S7-02 回归:生产指令单必须取 WorkOrd,不得取 Batch(Batch 常为 NULL,且语义是批次号)
+	it('引用工单时生产指令单取 WorkOrd 而非 Batch', () => {
+		const form = createFqcApplyForm();
+		fillFqcApplyFormFromWorkOrder(form, {
+			recId: 281,
+			batch: undefined,
+			workOrd: 'UATA-WO-001',
+			materialCode: '3121C0035',
+			qtyOrded: 1500,
+			status: 'r',
+		});
+
+		expect(form.workOrd).toBe('UATA-WO-001');
+	});
 });

+ 4 - 3
Web/src/views/aidop/s7/fqc/fqcApplyForm.ts

@@ -4,7 +4,8 @@ export const FQC_PRIORITY_OPTIONS = ['正常', '紧急'] as const;
 
 export interface FqcApplyForm {
 	workOrdRecId: number | null;
-	batch: string;
+	/** 生产指令单 / 工单号 = WorkOrdMaster.WorkOrd(**不是** Batch 批次号,UAT-S7-02) */
+	workOrd: string;
 	materialCode: string;
 	productName: string;
 	productModel: string;
@@ -20,7 +21,7 @@ export interface FqcApplyForm {
 export function createFqcApplyForm(): FqcApplyForm {
 	return {
 		workOrdRecId: null,
-		batch: '',
+		workOrd: '',
 		materialCode: '',
 		productName: '',
 		productModel: '',
@@ -36,7 +37,7 @@ export function createFqcApplyForm(): FqcApplyForm {
 
 export function fillFqcApplyFormFromWorkOrder(form: FqcApplyForm, row: FqcWorkOrderRow) {
 	form.workOrdRecId = row.recId;
-	form.batch = row.batch || '';
+	form.workOrd = row.workOrd || '';
 	form.materialCode = row.materialCode || '';
 	form.productName = row.productName || '';
 	form.productModel = row.productModel || '';

+ 5 - 4
Web/src/views/aidop/s7/fqc/fqcApplyList.vue

@@ -36,7 +36,7 @@
 		<el-dialog v-model="createVisible" title="新增成品报检" width="640px">
 			<el-form label-width="110px">
 				<el-form-item label="生产指令单">
-					<el-input :model-value="form.batch" readonly placeholder="点右侧「引用工单」选择">
+					<el-input :model-value="form.workOrd" readonly placeholder="点右侧「引用工单」选择">
 						<template #append><el-button @click="openWoPick">引用工单</el-button></template>
 					</el-input>
 				</el-form-item>
@@ -66,12 +66,13 @@
 		<!-- 引用完工工单 -->
 		<el-dialog v-model="woVisible" title="引用生产指令单(完工工单)" width="900px">
 			<el-form :inline="true" :model="woQuery" class="mb8" @submit.prevent>
-				<el-form-item label="工单号"><el-input v-model="woQuery.batch" clearable style="width: 130px" /></el-form-item>
+				<el-form-item label="工单号"><el-input v-model="woQuery.workOrd" clearable style="width: 130px" /></el-form-item>
 				<el-form-item label="物料"><el-input v-model="woQuery.materialCode" clearable style="width: 130px" /></el-form-item>
 				<el-form-item><el-button type="primary" @click="doWoSearch">查询</el-button></el-form-item>
 			</el-form>
 			<el-table :data="woRows" v-loading="woLoading" border stripe size="small" height="380" @row-dblclick="pickWo">
-				<el-table-column prop="batch" label="工单号" min-width="110" show-overflow-tooltip />
+				<!-- 工单号 = WorkOrd;Batch 是批次号、语义不同,不可混用(UAT-S7-02) -->
+				<el-table-column prop="workOrd" label="工单号" min-width="110" show-overflow-tooltip />
 				<el-table-column prop="materialCode" label="物料" min-width="100" show-overflow-tooltip />
 				<el-table-column prop="productName" label="产品名称" min-width="120" show-overflow-tooltip />
 				<el-table-column prop="lotSerial" label="批号" min-width="100" show-overflow-tooltip />
@@ -174,7 +175,7 @@ async function onCreate() {
 
 // 引用工单
 const woVisible = ref(false);
-const woQuery = reactive({ batch: '', materialCode: '', page: 1, pageSize: 10 });
+const woQuery = reactive({ workOrd: '', materialCode: '', page: 1, pageSize: 10 });
 const woRows = ref<FqcWorkOrderRow[]>([]);
 const woTotal = ref(0);
 const woLoading = ref(false);

+ 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.459</AssemblyVersion>
-    <FileVersion>1.0.459</FileVersion>
-    <Version>1.0.459</Version>
+    <AssemblyVersion>1.0.460</AssemblyVersion>
+    <FileVersion>1.0.460</FileVersion>
+    <Version>1.0.460</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 7 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/FqcApplyService.cs

@@ -136,7 +136,7 @@ public class FqcApplyService : IDynamicApiController, ITransient
 
         // 后端按用户选的 WorkOrd 重读真实数据(不信前端回填值)
         var wo = await _db.Ado.SqlQuerySingleAsync<WorkOrdGenRow>(
-            @"SELECT w.RecID AS RecId, w.Batch AS Batch, w.ItemNum AS ItemNum, w.LotSerial AS LotSerial,
+            @"SELECT w.RecID AS RecId, w.Batch AS Batch, w.WorkOrd AS WorkOrd, w.ItemNum AS ItemNum, w.LotSerial AS LotSerial,
                      w.QtyOrded AS QtyOrded, i.Descr AS ProductName, i.Descr1 AS ProductModel
               FROM WorkOrdMaster w
               LEFT JOIN ItemMaster i ON i.ItemNum=w.ItemNum AND i.tenant_id=w.tenant_id
@@ -173,8 +173,10 @@ public class FqcApplyService : IDynamicApiController, ITransient
                     new SugarParameter("@wlbm", wo.ItemNum),
                     new SugarParameter("@cpmc", (object?)wo.ProductName ?? DBNull.Value),
                     new SugarParameter("@cpxh", (object?)wo.ProductModel ?? DBNull.Value),
+                    // scph=生产批号 ← LotSerial;源为空即保持 NULL,不 fallback 到 Batch、不虚构批号
                     new SugarParameter("@scph", (object?)wo.LotSerial ?? DBNull.Value),
-                    new SugarParameter("@sczld", (object?)wo.Batch ?? DBNull.Value),
+                    // sczld=生产指令单/工单号 ← WorkOrd(**不是** Batch:Batch 是批次号,另有语义;UAT-S7-02)
+                    new SugarParameter("@sczld", (object?)wo.WorkOrd ?? DBNull.Value),
                     new SugarParameter("@sczldsl", (object?)wo.QtyOrded ?? DBNull.Value),
                     new SugarParameter("@sl", (object?)input.Qty ?? DBNull.Value),
                     new SugarParameter("@yxj", (object?)NullIfEmpty(input.Priority) ?? DBNull.Value),
@@ -458,7 +460,10 @@ public class FqcApplyCreateInput
 internal sealed class WorkOrdGenRow
 {
     public long RecId { get; set; }
+    /// <summary>批次号(WorkOrdMaster.Batch)——**不是**工单号,勿再当生产指令单用。</summary>
     public string? Batch { get; set; }
+    /// <summary>工单号 / 生产指令单(WorkOrdMaster.WorkOrd)。</summary>
+    public string? WorkOrd { get; set; }
     public string? ItemNum { get; set; }
     public string? LotSerial { get; set; }
     public decimal? QtyOrded { get; set; }

+ 107 - 40
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiCalcConfigService.cs

@@ -54,6 +54,36 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
     private ISugarQueryable<AdoSmartOpsKpiCalcConfig> Query() =>
         _db.Queryable<AdoSmartOpsKpiCalcConfig>().ClearFilter<ITenantIdFilter>();
 
+    /// <summary>
+    /// 写路径一律走原生参数化 SQL(表名与实体 SugarTable 一致,列名 PascalCase:EnableUnderLine=false)。
+    /// 原因:全局 MoreSettings.IsAutoUpdateQueryFilter / IsAutoDeleteQueryFilter=true 会给 ORM 的 UPDATE/DELETE
+    /// 自动追加「登录 JWT 租户」条件,而本表按 <see cref="ResolveKpiTenantId"/> 落在 KPI 数据租户,
+    /// 两者不一致时静默 0 行(UAT-S0-07 / UAT-S9-01);SqlSugar 5.1.4 的 IUpdateable/IDeleteable
+    /// 又无法关闭该过滤器(EnableQueryFilter 只能开不能关,且 ClearFilter 仅存在于 ISugarQueryable)。
+    /// 每条写语句都必须显式带 TenantId + 业务状态条件,**禁止按 Id 裸写**。
+    /// </summary>
+    private const string TableName = "ado_smart_ops_kpi_calc_config";
+
+    /// <summary>取配置归属租户;缺失即数据异常,直接拒绝,避免降级成按 Id 跨租户裸写。</summary>
+    private static long RequireTenantId(AdoSmartOpsKpiCalcConfig e) =>
+        e.TenantId ?? throw Oops.Bah($"配置版本 Id={e.Id} 缺少 TenantId,数据异常,拒绝写入");
+
+    /// <summary>
+    /// 0 行写入归因:区分「目标不存在 / 租户不匹配 / 状态不满足 / 并发状态变化」,绝不静默返回成功。
+    /// </summary>
+    private async Task<Exception> ExplainZeroRowAsync(
+        long id, long? expectedTenantId, string action, string statusRequirement, params string[] acceptableStatuses)
+    {
+        var now = await Query().Where(x => x.Id == id).FirstAsync();
+        if (now == null)
+            return Oops.Bah($"{action}失败:配置版本 Id={id} 不存在(可能已被并发删除)");
+        if (now.TenantId != expectedTenantId)
+            return Oops.Bah($"{action}失败:配置版本 Id={id} 归属租户 {now.TenantId},与本次作用域租户 {expectedTenantId} 不一致");
+        if (acceptableStatuses.Length > 0 && !acceptableStatuses.Contains(now.PublishStatus))
+            return Oops.Bah($"{action}失败:配置版本 Id={id} 当前状态为 {now.PublishStatus},不满足要求的 {statusRequirement}(并发修改,请刷新后重试)");
+        return Oops.Bah($"{action}失败:配置版本 Id={id}(租户 {now.TenantId} / 状态 {now.PublishStatus})未命中任何行,疑似并发状态变化,请刷新后重试");
+    }
+
     /// <summary>某 KPI 的全部版本(按版本号倒序)。</summary>
     public async Task<List<KpiCalcConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
     {
@@ -112,15 +142,25 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
         ValidateEngineType(dto.CalcEngineType);
-        entity.CalcEngineType = dto.CalcEngineType;
-        entity.DataSourceCode = dto.DataSourceCode;
-        entity.SqlScript = dto.SqlScript;
-        entity.SqlParametersJson = dto.SqlParametersJson;
-        entity.TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds);
-        entity.Remark = dto.Remark;
-        entity.UpdatedBy = operatorName;
-        entity.UpdatedAt = DateTime.Now;
-        await _db.Updateable(entity).ExecuteCommandAsync();
+
+        var tenantId = RequireTenantId(entity);
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $@"UPDATE {TableName}
+               SET CalcEngineType=@engine, DataSourceCode=@ds, SqlScript=@sql, SqlParametersJson=@pars,
+                   TimeoutSeconds=@timeout, Remark=@remark, UpdatedBy=@op, UpdatedAt=@now
+               WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@draft",
+            new SugarParameter("@engine", dto.CalcEngineType),
+            new SugarParameter("@ds", dto.DataSourceCode),
+            new SugarParameter("@sql", dto.SqlScript),
+            new SugarParameter("@pars", dto.SqlParametersJson),
+            new SugarParameter("@timeout", NormalizeTimeout(dto.TimeoutSeconds)),
+            new SugarParameter("@remark", dto.Remark),
+            new SugarParameter("@op", operatorName),
+            new SugarParameter("@now", DateTime.Now),
+            new SugarParameter("@id", id),
+            new SugarParameter("@tenant", tenantId),
+            new SugarParameter("@draft", StatusDraft));
+        if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "编辑草稿", "DRAFT", StatusDraft);
     }
 
     /// <summary>删除草稿(仅 DRAFT)。</summary>
@@ -130,7 +170,14 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
             ?? throw Oops.Bah("配置版本不存在");
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
-        await _db.Deleteable<AdoSmartOpsKpiCalcConfig>().Where(x => x.Id == id).ExecuteCommandAsync();
+
+        var tenantId = RequireTenantId(entity);
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $"DELETE FROM {TableName} WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@draft",
+            new SugarParameter("@id", id),
+            new SugarParameter("@tenant", tenantId),
+            new SugarParameter("@draft", StatusDraft));
+        if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "删除草稿", "DRAFT", StatusDraft);
     }
 
     /// <summary>SQL 安全校验(多层非正则)。</summary>
@@ -220,21 +267,28 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
             await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "发布");
         }
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         {
-            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
-                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
-                .ExecuteCommandAsync();
-            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiCalcConfig
-                {
-                    PublishStatus = StatusPublished, IsCurrent = true,
-                    PublishedBy = operatorName, PublishedAt = DateTime.Now,
-                })
-                .Where(x => x.Id == id)
-                .ExecuteCommandAsync();
+            // 旧 current 置 0:0 行是合法结果(本 KPI 此前无生效版本),故不做 affected 断言
+            await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName} SET IsCurrent=0
+                   WHERE MetricCode=@metric AND TenantId=@tenant AND Id<>@id AND IsCurrent=1",
+                new SugarParameter("@metric", entity.MetricCode),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@id", id));
+            var affected = await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName}
+                   SET PublishStatus=@published, IsCurrent=1, PublishedBy=@op, PublishedAt=@now
+                   WHERE Id=@id AND TenantId=@tenant AND PublishStatus<>@retired",
+                new SugarParameter("@published", StatusPublished),
+                new SugarParameter("@op", operatorName),
+                new SugarParameter("@now", DateTime.Now),
+                new SugarParameter("@id", id),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@retired", StatusRetired));
+            if (affected <= 0)
+                throw await ExplainZeroRowAsync(id, tenantId, "发布", "非 RETIRED", StatusDraft, StatusPublished);
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
     }
@@ -249,17 +303,26 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
         if (entity.CalcEngineType == EngineConfigSql)
             await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "激活");
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         {
-            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = false })
-                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
-                .ExecuteCommandAsync();
-            await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiCalcConfig { IsCurrent = true, UpdatedBy = operatorName, UpdatedAt = DateTime.Now })
-                .Where(x => x.Id == id)
-                .ExecuteCommandAsync();
+            // 旧 current 置 0:0 行是合法结果(本 KPI 此前无生效版本),故不做 affected 断言
+            await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName} SET IsCurrent=0
+                   WHERE MetricCode=@metric AND TenantId=@tenant AND Id<>@id AND IsCurrent=1",
+                new SugarParameter("@metric", entity.MetricCode),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@id", id));
+            var affected = await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName} SET IsCurrent=1, UpdatedBy=@op, UpdatedAt=@now
+                   WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@published",
+                new SugarParameter("@op", operatorName),
+                new SugarParameter("@now", DateTime.Now),
+                new SugarParameter("@id", id),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@published", StatusPublished));
+            if (affected <= 0)
+                throw await ExplainZeroRowAsync(id, tenantId, "激活", "PUBLISHED", StatusPublished);
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
     }
@@ -269,14 +332,18 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
     {
         var entity = await Query().Where(x => x.Id == id).FirstAsync()
             ?? throw Oops.Bah("配置版本不存在");
-        await _db.Updateable<AdoSmartOpsKpiCalcConfig>()
-            .SetColumns(x => new AdoSmartOpsKpiCalcConfig
-            {
-                PublishStatus = StatusRetired, IsCurrent = false,
-                RetiredBy = operatorName, RetiredAt = DateTime.Now,
-            })
-            .Where(x => x.Id == id)
-            .ExecuteCommandAsync();
+
+        var tenantId = RequireTenantId(entity);
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $@"UPDATE {TableName}
+               SET PublishStatus=@retired, IsCurrent=0, RetiredBy=@op, RetiredAt=@now
+               WHERE Id=@id AND TenantId=@tenant",
+            new SugarParameter("@retired", StatusRetired),
+            new SugarParameter("@op", operatorName),
+            new SugarParameter("@now", DateTime.Now),
+            new SugarParameter("@id", id),
+            new SugarParameter("@tenant", tenantId));
+        if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "停用", "任意状态");
     }
 
     /// <summary>已登记数据源列表(第一版仅本地中台库)。</summary>

+ 113 - 44
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiDimensionConfigService.cs

@@ -34,6 +34,36 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
     private ISugarQueryable<AdoSmartOpsKpiDimensionConfig> Query() =>
         _db.Queryable<AdoSmartOpsKpiDimensionConfig>().ClearFilter<ITenantIdFilter>();
 
+    /// <summary>
+    /// 写路径一律走原生参数化 SQL(表名与实体 SugarTable 一致,列名 PascalCase:EnableUnderLine=false)。
+    /// 原因:全局 MoreSettings.IsAutoUpdateQueryFilter / IsAutoDeleteQueryFilter=true 会给 ORM 的 UPDATE/DELETE
+    /// 自动追加「登录 JWT 租户」条件,而本表按 ResolveKpiTenantId 落在 KPI 数据租户,
+    /// 两者不一致时静默 0 行(同 UAT-S0-07 / UAT-S9-01 缺陷);SqlSugar 5.1.4 的 IUpdateable/IDeleteable
+    /// 又无法关闭该过滤器(EnableQueryFilter 只能开不能关,且 ClearFilter 仅存在于 ISugarQueryable)。
+    /// 每条写语句都必须显式带 TenantId + 业务状态条件,**禁止按 Id 裸写**。
+    /// </summary>
+    private const string TableName = "ado_smart_ops_kpi_dimension_config";
+
+    /// <summary>取配置归属租户;缺失即数据异常,直接拒绝,避免降级成按 Id 跨租户裸写。</summary>
+    private static long RequireTenantId(AdoSmartOpsKpiDimensionConfig e) =>
+        e.TenantId ?? throw Oops.Bah($"维度配置版本 Id={e.Id} 缺少 TenantId,数据异常,拒绝写入");
+
+    /// <summary>
+    /// 0 行写入归因:区分「目标不存在 / 租户不匹配 / 状态不满足 / 并发状态变化」,绝不静默返回成功。
+    /// </summary>
+    private async Task<Exception> ExplainZeroRowAsync(
+        long id, long? expectedTenantId, string action, string statusRequirement, params string[] acceptableStatuses)
+    {
+        var now = await Query().Where(x => x.Id == id).FirstAsync();
+        if (now == null)
+            return Oops.Bah($"{action}失败:维度配置版本 Id={id} 不存在(可能已被并发删除)");
+        if (now.TenantId != expectedTenantId)
+            return Oops.Bah($"{action}失败:维度配置版本 Id={id} 归属租户 {now.TenantId},与本次作用域租户 {expectedTenantId} 不一致");
+        if (acceptableStatuses.Length > 0 && !acceptableStatuses.Contains(now.PublishStatus))
+            return Oops.Bah($"{action}失败:维度配置版本 Id={id} 当前状态为 {now.PublishStatus},不满足要求的 {statusRequirement}(并发修改,请刷新后重试)");
+        return Oops.Bah($"{action}失败:维度配置版本 Id={id}(租户 {now.TenantId} / 状态 {now.PublishStatus})未命中任何行,疑似并发状态变化,请刷新后重试");
+    }
+
     /// <summary>某 KPI 的全部维度配置版本(按版本号倒序)。</summary>
     public async Task<List<KpiDimensionConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
     {
@@ -102,19 +132,31 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
         ValidateAggregation(dto.AggregationType);
-        entity.DataSourceCode = dto.DataSourceCode;
-        entity.SqlScript = dto.SqlScript;
-        entity.AggregationType = dto.AggregationType;
-        entity.SupportedDimensionsJson = dto.SupportedDimensionsJson;
-        entity.OutputContractJson = dto.OutputContractJson;
-        entity.ParameterContractJson = dto.ParameterContractJson;
-        entity.TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds);
-        entity.BusinessSqlSource = dto.BusinessSqlSource;
-        entity.ChangeRemark = dto.ChangeRemark;
-        entity.Remark = dto.Remark;
-        entity.UpdatedBy = operatorName;
-        entity.UpdatedAt = DateTime.Now;
-        await _db.Updateable(entity).ExecuteCommandAsync();
+
+        var tenantId = RequireTenantId(entity);
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $@"UPDATE {TableName}
+               SET DataSourceCode=@ds, SqlScript=@sql, AggregationType=@agg,
+                   SupportedDimensionsJson=@dims, OutputContractJson=@output, ParameterContractJson=@paramContract,
+                   TimeoutSeconds=@timeout, BusinessSqlSource=@bizSrc, ChangeRemark=@chg, Remark=@remark,
+                   UpdatedBy=@op, UpdatedAt=@now
+               WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@draft",
+            new SugarParameter("@ds", dto.DataSourceCode),
+            new SugarParameter("@sql", dto.SqlScript),
+            new SugarParameter("@agg", dto.AggregationType),
+            new SugarParameter("@dims", dto.SupportedDimensionsJson),
+            new SugarParameter("@output", dto.OutputContractJson),
+            new SugarParameter("@paramContract", dto.ParameterContractJson),
+            new SugarParameter("@timeout", NormalizeTimeout(dto.TimeoutSeconds)),
+            new SugarParameter("@bizSrc", dto.BusinessSqlSource),
+            new SugarParameter("@chg", dto.ChangeRemark),
+            new SugarParameter("@remark", dto.Remark),
+            new SugarParameter("@op", operatorName),
+            new SugarParameter("@now", DateTime.Now),
+            new SugarParameter("@id", id),
+            new SugarParameter("@tenant", tenantId),
+            new SugarParameter("@draft", StatusDraft));
+        if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "编辑草稿", "DRAFT", StatusDraft);
     }
 
     /// <summary>删除草稿(仅 DRAFT)。</summary>
@@ -124,7 +166,14 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
             ?? throw Oops.Bah("维度配置版本不存在");
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
-        await _db.Deleteable<AdoSmartOpsKpiDimensionConfig>().Where(x => x.Id == id).ExecuteCommandAsync();
+
+        var tenantId = RequireTenantId(entity);
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $"DELETE FROM {TableName} WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@draft",
+            new SugarParameter("@id", id),
+            new SugarParameter("@tenant", tenantId),
+            new SugarParameter("@draft", StatusDraft));
+        if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "删除草稿", "DRAFT", StatusDraft);
     }
 
     /// <summary>SQL 安全校验(复用汇总配置的多层非正则校验器)。</summary>
@@ -207,21 +256,28 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
         if (summary.Id != entity.SummaryConfigId || summary.VersionNo != entity.SummaryConfigVersion)
             throw Oops.Bah($"汇总配置已切换到新版本(v{summary.VersionNo}),本维度配置绑定的是 v{entity.SummaryConfigVersion},请基于当前汇总版本新建维度配置");
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         {
-            await _db.Updateable<AdoSmartOpsKpiDimensionConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiDimensionConfig { IsCurrent = false })
-                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
-                .ExecuteCommandAsync();
-            await _db.Updateable<AdoSmartOpsKpiDimensionConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiDimensionConfig
-                {
-                    PublishStatus = StatusPublished, IsCurrent = true,
-                    PublishedBy = operatorName, PublishedAt = DateTime.Now,
-                })
-                .Where(x => x.Id == id)
-                .ExecuteCommandAsync();
+            // 旧 current 置 0:0 行是合法结果(本 KPI 此前无生效维度版本),故不做 affected 断言
+            await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName} SET IsCurrent=0
+                   WHERE MetricCode=@metric AND TenantId=@tenant AND Id<>@id AND IsCurrent=1",
+                new SugarParameter("@metric", entity.MetricCode),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@id", id));
+            var affected = await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName}
+                   SET PublishStatus=@published, IsCurrent=1, PublishedBy=@op, PublishedAt=@now
+                   WHERE Id=@id AND TenantId=@tenant AND PublishStatus<>@retired",
+                new SugarParameter("@published", StatusPublished),
+                new SugarParameter("@op", operatorName),
+                new SugarParameter("@now", DateTime.Now),
+                new SugarParameter("@id", id),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@retired", StatusRetired));
+            if (affected <= 0)
+                throw await ExplainZeroRowAsync(id, tenantId, "发布", "非 RETIRED", StatusDraft, StatusPublished);
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
     }
@@ -234,17 +290,26 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
         if (entity.PublishStatus != StatusPublished)
             throw Oops.Bah("只能激活已发布(PUBLISHED)版本");
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         {
-            await _db.Updateable<AdoSmartOpsKpiDimensionConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiDimensionConfig { IsCurrent = false })
-                .Where(x => x.MetricCode == entity.MetricCode && x.TenantId == tenantId && x.Id != id && x.IsCurrent)
-                .ExecuteCommandAsync();
-            await _db.Updateable<AdoSmartOpsKpiDimensionConfig>()
-                .SetColumns(x => new AdoSmartOpsKpiDimensionConfig { IsCurrent = true, UpdatedBy = operatorName, UpdatedAt = DateTime.Now })
-                .Where(x => x.Id == id)
-                .ExecuteCommandAsync();
+            // 旧 current 置 0:0 行是合法结果(本 KPI 此前无生效维度版本),故不做 affected 断言
+            await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName} SET IsCurrent=0
+                   WHERE MetricCode=@metric AND TenantId=@tenant AND Id<>@id AND IsCurrent=1",
+                new SugarParameter("@metric", entity.MetricCode),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@id", id));
+            var affected = await _db.Ado.ExecuteCommandAsync(
+                $@"UPDATE {TableName} SET IsCurrent=1, UpdatedBy=@op, UpdatedAt=@now
+                   WHERE Id=@id AND TenantId=@tenant AND PublishStatus=@published",
+                new SugarParameter("@op", operatorName),
+                new SugarParameter("@now", DateTime.Now),
+                new SugarParameter("@id", id),
+                new SugarParameter("@tenant", tenantId),
+                new SugarParameter("@published", StatusPublished));
+            if (affected <= 0)
+                throw await ExplainZeroRowAsync(id, tenantId, "激活", "PUBLISHED", StatusPublished);
         });
         if (!tran.IsSuccess) throw tran.ErrorException;
     }
@@ -254,14 +319,18 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
     {
         var entity = await Query().Where(x => x.Id == id).FirstAsync()
             ?? throw Oops.Bah("维度配置版本不存在");
-        await _db.Updateable<AdoSmartOpsKpiDimensionConfig>()
-            .SetColumns(x => new AdoSmartOpsKpiDimensionConfig
-            {
-                PublishStatus = StatusRetired, IsCurrent = false,
-                RetiredBy = operatorName, RetiredAt = DateTime.Now,
-            })
-            .Where(x => x.Id == id)
-            .ExecuteCommandAsync();
+
+        var tenantId = RequireTenantId(entity);
+        var affected = await _db.Ado.ExecuteCommandAsync(
+            $@"UPDATE {TableName}
+               SET PublishStatus=@retired, IsCurrent=0, RetiredBy=@op, RetiredAt=@now
+               WHERE Id=@id AND TenantId=@tenant",
+            new SugarParameter("@retired", StatusRetired),
+            new SugarParameter("@op", operatorName),
+            new SugarParameter("@now", DateTime.Now),
+            new SugarParameter("@id", id),
+            new SugarParameter("@tenant", tenantId));
+        if (affected <= 0) throw await ExplainZeroRowAsync(id, tenantId, "停用", "任意状态");
     }
 
     /// <summary>KPI 维度能力:无生效维度配置 → PENDING_DIMENSION_SQL;SUMMARY_ONLY → SUMMARY_ONLY;否则 CONFIGURED。</summary>