Explorar o código

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 hai 1 día
pai
achega
88eab8dc8b

+ 1 - 1
Web/package.json

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

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

@@ -33,7 +33,12 @@ export interface PurchaseRequestRow {
 export interface PurchaseRequestSaveInput {
 export interface PurchaseRequestSaveInput {
 	id?: number | null;
 	id?: number | null;
 	prBillNo?: string | 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;
 	icitemName?: string | null;
 	prUnit?: string | null;
 	prUnit?: string | null;
 	prPurchaseNumber?: 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) {
 function onPickSource(row: SourceListRow) {
 	form.prPurchaseId = row.supplierId ?? null;
 	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.icitemName = row.icitemName ?? '';
 	form.prPurchaseNumber = row.supplierNumber ?? '';
 	form.prPurchaseNumber = row.supplierNumber ?? '';
 	form.prPurchaseName = row.supplierName ?? '';
 	form.prPurchaseName = row.supplierName ?? '';

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

@@ -11,6 +11,7 @@ describe('成品报检表单默认值', () => {
 		fillFqcApplyFormFromWorkOrder(form, {
 		fillFqcApplyFormFromWorkOrder(form, {
 			recId: 1,
 			recId: 1,
 			batch: '500000010',
 			batch: '500000010',
+			workOrd: 'M500000010',
 			materialCode: '91HC0497',
 			materialCode: '91HC0497',
 			productName: '集成操控设备主机',
 			productName: '集成操控设备主机',
 			productModel: 'AP9',
 			productModel: 'AP9',
@@ -23,4 +24,19 @@ describe('成品报检表单默认值', () => {
 		expect(form.qtyOrded).toBe(965);
 		expect(form.qtyOrded).toBe(965);
 		expect(form.qty).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 {
 export interface FqcApplyForm {
 	workOrdRecId: number | null;
 	workOrdRecId: number | null;
-	batch: string;
+	/** 生产指令单 / 工单号 = WorkOrdMaster.WorkOrd(**不是** Batch 批次号,UAT-S7-02) */
+	workOrd: string;
 	materialCode: string;
 	materialCode: string;
 	productName: string;
 	productName: string;
 	productModel: string;
 	productModel: string;
@@ -20,7 +21,7 @@ export interface FqcApplyForm {
 export function createFqcApplyForm(): FqcApplyForm {
 export function createFqcApplyForm(): FqcApplyForm {
 	return {
 	return {
 		workOrdRecId: null,
 		workOrdRecId: null,
-		batch: '',
+		workOrd: '',
 		materialCode: '',
 		materialCode: '',
 		productName: '',
 		productName: '',
 		productModel: '',
 		productModel: '',
@@ -36,7 +37,7 @@ export function createFqcApplyForm(): FqcApplyForm {
 
 
 export function fillFqcApplyFormFromWorkOrder(form: FqcApplyForm, row: FqcWorkOrderRow) {
 export function fillFqcApplyFormFromWorkOrder(form: FqcApplyForm, row: FqcWorkOrderRow) {
 	form.workOrdRecId = row.recId;
 	form.workOrdRecId = row.recId;
-	form.batch = row.batch || '';
+	form.workOrd = row.workOrd || '';
 	form.materialCode = row.materialCode || '';
 	form.materialCode = row.materialCode || '';
 	form.productName = row.productName || '';
 	form.productName = row.productName || '';
 	form.productModel = row.productModel || '';
 	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-dialog v-model="createVisible" title="新增成品报检" width="640px">
 			<el-form label-width="110px">
 			<el-form label-width="110px">
 				<el-form-item label="生产指令单">
 				<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>
 						<template #append><el-button @click="openWoPick">引用工单</el-button></template>
 					</el-input>
 					</el-input>
 				</el-form-item>
 				</el-form-item>
@@ -66,12 +66,13 @@
 		<!-- 引用完工工单 -->
 		<!-- 引用完工工单 -->
 		<el-dialog v-model="woVisible" title="引用生产指令单(完工工单)" width="900px">
 		<el-dialog v-model="woVisible" title="引用生产指令单(完工工单)" width="900px">
 			<el-form :inline="true" :model="woQuery" class="mb8" @submit.prevent>
 			<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 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-item><el-button type="primary" @click="doWoSearch">查询</el-button></el-form-item>
 			</el-form>
 			</el-form>
 			<el-table :data="woRows" v-loading="woLoading" border stripe size="small" height="380" @row-dblclick="pickWo">
 			<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="materialCode" label="物料" min-width="100" show-overflow-tooltip />
 				<el-table-column prop="productName" label="产品名称" min-width="120" 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 />
 				<el-table-column prop="lotSerial" label="批号" min-width="100" show-overflow-tooltip />
@@ -174,7 +175,7 @@ async function onCreate() {
 
 
 // 引用工单
 // 引用工单
 const woVisible = ref(false);
 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 woRows = ref<FqcWorkOrderRow[]>([]);
 const woTotal = ref(0);
 const woTotal = ref(0);
 const woLoading = ref(false);
 const woLoading = ref(false);

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET 通用权限开发平台</Description>
     <Description>Admin.NET 通用权限开发平台</Description>
-    <AssemblyVersion>1.0.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>
   </PropertyGroup>
 
 
   <ItemGroup>
   <ItemGroup>

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

@@ -136,7 +136,7 @@ public class FqcApplyService : IDynamicApiController, ITransient
 
 
         // 后端按用户选的 WorkOrd 重读真实数据(不信前端回填值)
         // 后端按用户选的 WorkOrd 重读真实数据(不信前端回填值)
         var wo = await _db.Ado.SqlQuerySingleAsync<WorkOrdGenRow>(
         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
                      w.QtyOrded AS QtyOrded, i.Descr AS ProductName, i.Descr1 AS ProductModel
               FROM WorkOrdMaster w
               FROM WorkOrdMaster w
               LEFT JOIN ItemMaster i ON i.ItemNum=w.ItemNum AND i.tenant_id=w.tenant_id
               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("@wlbm", wo.ItemNum),
                     new SugarParameter("@cpmc", (object?)wo.ProductName ?? DBNull.Value),
                     new SugarParameter("@cpmc", (object?)wo.ProductName ?? DBNull.Value),
                     new SugarParameter("@cpxh", (object?)wo.ProductModel ?? 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("@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("@sczldsl", (object?)wo.QtyOrded ?? DBNull.Value),
                     new SugarParameter("@sl", (object?)input.Qty ?? DBNull.Value),
                     new SugarParameter("@sl", (object?)input.Qty ?? DBNull.Value),
                     new SugarParameter("@yxj", (object?)NullIfEmpty(input.Priority) ?? DBNull.Value),
                     new SugarParameter("@yxj", (object?)NullIfEmpty(input.Priority) ?? DBNull.Value),
@@ -458,7 +460,10 @@ public class FqcApplyCreateInput
 internal sealed class WorkOrdGenRow
 internal sealed class WorkOrdGenRow
 {
 {
     public long RecId { get; set; }
     public long RecId { get; set; }
+    /// <summary>批次号(WorkOrdMaster.Batch)——**不是**工单号,勿再当生产指令单用。</summary>
     public string? Batch { get; set; }
     public string? Batch { get; set; }
+    /// <summary>工单号 / 生产指令单(WorkOrdMaster.WorkOrd)。</summary>
+    public string? WorkOrd { get; set; }
     public string? ItemNum { get; set; }
     public string? ItemNum { get; set; }
     public string? LotSerial { get; set; }
     public string? LotSerial { get; set; }
     public decimal? QtyOrded { 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() =>
     private ISugarQueryable<AdoSmartOpsKpiCalcConfig> Query() =>
         _db.Queryable<AdoSmartOpsKpiCalcConfig>().ClearFilter<ITenantIdFilter>();
         _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>
     /// <summary>某 KPI 的全部版本(按版本号倒序)。</summary>
     public async Task<List<KpiCalcConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
     public async Task<List<KpiCalcConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
     {
     {
@@ -112,15 +142,25 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
         if (entity.PublishStatus != StatusDraft)
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
             throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
         ValidateEngineType(dto.CalcEngineType);
         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>
     /// <summary>删除草稿(仅 DRAFT)。</summary>
@@ -130,7 +170,14 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
             ?? throw Oops.Bah("配置版本不存在");
             ?? throw Oops.Bah("配置版本不存在");
         if (entity.PublishStatus != StatusDraft)
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
             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>
     /// <summary>SQL 安全校验(多层非正则)。</summary>
@@ -220,21 +267,28 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
             await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "发布");
             await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "发布");
         }
         }
 
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         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;
         if (!tran.IsSuccess) throw tran.ErrorException;
     }
     }
@@ -249,17 +303,26 @@ public sealed class AdoSmartOpsKpiCalcConfigService : ITransient
         if (entity.CalcEngineType == EngineConfigSql)
         if (entity.CalcEngineType == EngineConfigSql)
             await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "激活");
             await EnsureBusinessReadyAsync(entity.TenantId ?? 0, entity.MetricCode, "激活");
 
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         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;
         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()
         var entity = await Query().Where(x => x.Id == id).FirstAsync()
             ?? throw Oops.Bah("配置版本不存在");
             ?? 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>
     /// <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() =>
     private ISugarQueryable<AdoSmartOpsKpiDimensionConfig> Query() =>
         _db.Queryable<AdoSmartOpsKpiDimensionConfig>().ClearFilter<ITenantIdFilter>();
         _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>
     /// <summary>某 KPI 的全部维度配置版本(按版本号倒序)。</summary>
     public async Task<List<KpiDimensionConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
     public async Task<List<KpiDimensionConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
     {
     {
@@ -102,19 +132,31 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
         if (entity.PublishStatus != StatusDraft)
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
             throw Oops.Bah("仅草稿(DRAFT)可编辑;已发布/停用版本请新建版本");
         ValidateAggregation(dto.AggregationType);
         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>
     /// <summary>删除草稿(仅 DRAFT)。</summary>
@@ -124,7 +166,14 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
             ?? throw Oops.Bah("维度配置版本不存在");
             ?? throw Oops.Bah("维度配置版本不存在");
         if (entity.PublishStatus != StatusDraft)
         if (entity.PublishStatus != StatusDraft)
             throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
             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>
     /// <summary>SQL 安全校验(复用汇总配置的多层非正则校验器)。</summary>
@@ -207,21 +256,28 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
         if (summary.Id != entity.SummaryConfigId || summary.VersionNo != entity.SummaryConfigVersion)
         if (summary.Id != entity.SummaryConfigId || summary.VersionNo != entity.SummaryConfigVersion)
             throw Oops.Bah($"汇总配置已切换到新版本(v{summary.VersionNo}),本维度配置绑定的是 v{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 () =>
         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;
         if (!tran.IsSuccess) throw tran.ErrorException;
     }
     }
@@ -234,17 +290,26 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
         if (entity.PublishStatus != StatusPublished)
         if (entity.PublishStatus != StatusPublished)
             throw Oops.Bah("只能激活已发布(PUBLISHED)版本");
             throw Oops.Bah("只能激活已发布(PUBLISHED)版本");
 
 
-        var tenantId = entity.TenantId;
+        var tenantId = RequireTenantId(entity);
         var tran = await _db.AsTenant().UseTranAsync(async () =>
         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;
         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()
         var entity = await Query().Where(x => x.Id == id).FirstAsync()
             ?? throw Oops.Bah("维度配置版本不存在");
             ?? 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>
     /// <summary>KPI 维度能力:无生效维度配置 → PENDING_DIMENSION_SQL;SUMMARY_ONLY → SUMMARY_ONLY;否则 CONFIGURED。</summary>