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

feat(kpi): add dimension SQL configuration model + execution pipeline

Framework foundation for configurable DIMENSION_SQL (filter/drilldown), bound
to the active summary CONFIG_SQL version. Inert this commit: no dashboard
cutover, no cron, no frontend — CodeFirst creates the 3 tables on next restart.

- entities: ado_smart_ops_kpi_dimension_config / _value_day / _run_log
- KpiDimensionSqlExecutor: read-only multi-row executor (MDP_LOCAL, START
  TRANSACTION READ ONLY), parses value_date/dimension_type/dimension_code
  contract + optional metric/numerator/denominator/sum/sample dims
- AdoSmartOpsKpiDimensionConfigService: draft/validate/preview/publish/activate
  /retire state machine, binds SummaryConfigId+Version, single-current invariant
- KpiDimensionRunService.RunDimensionAsync: generic entry, FULL REPLACE write +
  run log, NOT_CONFIGURED/VERSION_MISMATCH guards, no per-KPI switch
- AdoSmartOpsKpiDimensionConfigController: config/validate/preview/publish
  /activate/retire/capability/run-log endpoints (login-required)
- bump version server 1.0.283
YY968XX 1 неделя назад
Родитель
Сommit
0d98d55069

+ 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.282</AssemblyVersion>
-    <FileVersion>1.0.282</FileVersion>
-    <Version>1.0.282</Version>
+    <AssemblyVersion>1.0.283</AssemblyVersion>
+    <FileVersion>1.0.283</FileVersion>
+    <Version>1.0.283</Version>
   </PropertyGroup>
 
   <ItemGroup>

+ 134 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiDimensionConfigController.cs

@@ -0,0 +1,134 @@
+using System.Security.Claims;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.Entity;
+using Admin.NET.Plugin.AiDOP.SmartOps;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Controllers;
+
+/// <summary>
+/// KPI 维度计算配置(DIMENSION_SQL 配置化)接口。**要求登录**——SQL 编辑/发布是高危面。
+/// 租户由服务端按 moduleCode 解析;操作人取自 JWT。
+/// </summary>
+[ApiController]
+[Route("api/[controller]")]
+[Authorize]
+public class AdoSmartOpsKpiDimensionConfigController : ControllerBase
+{
+    private readonly AdoSmartOpsKpiDimensionConfigService _service;
+    private readonly ISqlSugarClient _db;
+
+    public AdoSmartOpsKpiDimensionConfigController(AdoSmartOpsKpiDimensionConfigService service, ISqlSugarClient db)
+    {
+        _service = service;
+        _db = db;
+    }
+
+    private string Operator() =>
+        User?.FindFirstValue("RealName") ?? User?.FindFirstValue("Account") ?? User?.Identity?.Name ?? "unknown";
+
+    /// <summary>某 KPI 的全部维度配置版本。</summary>
+    [HttpGet("by-metric/{metricCode}")]
+    public async Task<IActionResult> GetByMetric(string metricCode, [FromQuery] string moduleCode)
+    {
+        if (string.IsNullOrWhiteSpace(moduleCode))
+            return BadRequest(new { message = "moduleCode 必填" });
+        return Ok(await _service.GetByMetricAsync(metricCode, moduleCode));
+    }
+
+    /// <summary>已登记数据源列表。</summary>
+    [HttpGet("data-sources")]
+    public IActionResult DataSources() => Ok(_service.ListDataSources());
+
+    /// <summary>KPI 维度能力(驱动前端筛选/下钻可用性)。</summary>
+    [HttpGet("capability/{metricCode}")]
+    public async Task<IActionResult> Capability(string metricCode, [FromQuery] string moduleCode)
+    {
+        if (string.IsNullOrWhiteSpace(moduleCode))
+            return BadRequest(new { message = "moduleCode 必填" });
+        return Ok(await _service.GetCapabilityAsync(metricCode, moduleCode));
+    }
+
+    /// <summary>新增草稿。</summary>
+    [HttpPost]
+    public async Task<IActionResult> Create([FromBody] KpiDimensionConfigUpsertDto dto)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode) || string.IsNullOrWhiteSpace(dto.ModuleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        return Ok(await _service.CreateDraftAsync(dto, Operator()));
+    }
+
+    /// <summary>编辑草稿。</summary>
+    [HttpPut("{id:long}")]
+    public async Task<IActionResult> Update(long id, [FromBody] KpiDimensionConfigUpsertDto dto)
+    {
+        await _service.UpdateDraftAsync(id, dto, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>删除草稿。</summary>
+    [HttpDelete("{id:long}")]
+    public async Task<IActionResult> Delete(long id)
+    {
+        await _service.DeleteAsync(id);
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>SQL 安全校验。</summary>
+    [HttpPost("validate")]
+    public IActionResult Validate([FromBody] KpiSqlValidateDto dto) => Ok(_service.Validate(dto.SqlScript));
+
+    /// <summary>试算(不写维度结果;服务端重校验 + 只读多行执行)。</summary>
+    [HttpPost("preview")]
+    public async Task<IActionResult> Preview([FromBody] KpiDimensionPreviewDto dto, CancellationToken ct)
+    {
+        if (string.IsNullOrWhiteSpace(dto.MetricCode) || string.IsNullOrWhiteSpace(dto.ModuleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        return Ok(await _service.PreviewAsync(dto, ct));
+    }
+
+    /// <summary>发布(事务:单生效版本 + 绑定汇总版本一致性)。</summary>
+    [HttpPost("{id:long}/publish")]
+    public async Task<IActionResult> Publish(long id)
+    {
+        await _service.PublishAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>激活/回滚。</summary>
+    [HttpPost("{id:long}/activate")]
+    public async Task<IActionResult> Activate(long id)
+    {
+        await _service.ActivateAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>停用。</summary>
+    [HttpPost("{id:long}/retire")]
+    public async Task<IActionResult> Retire(long id)
+    {
+        await _service.RetireAsync(id, Operator());
+        return Ok(new { ok = true });
+    }
+
+    /// <summary>某 KPI 的维度运行日志(分页,脱敏,租户后端解析)。</summary>
+    [HttpGet("run-log/{metricCode}")]
+    public async Task<IActionResult> RunLog(
+        string metricCode, [FromQuery] string moduleCode,
+        [FromQuery] int page = 1, [FromQuery] int pageSize = 20, [FromQuery] string? status = null)
+    {
+        if (string.IsNullOrWhiteSpace(metricCode) || string.IsNullOrWhiteSpace(moduleCode))
+            return BadRequest(new { message = "metricCode / moduleCode 必填" });
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+        RefAsync<int> total = 0;
+        var list = await _db.Queryable<AdoSmartOpsKpiDimensionRunLog>().ClearFilter<ITenantIdFilter>()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
+            .WhereIF(!string.IsNullOrWhiteSpace(status), x => x.Status == status)
+            .OrderBy(x => x.StartedAt, OrderByType.Desc)
+            .ToPageListAsync(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, total);
+        return Ok(new { list, total = total.Value });
+    }
+}

+ 94 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Dto/SmartOps/KpiDimensionConfigDtos.cs

@@ -0,0 +1,94 @@
+namespace Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+
+/// <summary>维度配置读出 DTO。</summary>
+public sealed class KpiDimensionConfigDto
+{
+    public long Id { get; set; }
+    public long? TenantId { get; set; }
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public long SummaryConfigId { get; set; }
+    public int SummaryConfigVersion { get; set; }
+    public int DimensionConfigVersion { get; set; }
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string AggregationType { get; set; } = "SUMMARY_ONLY";
+    public string? SupportedDimensionsJson { get; set; }
+    public string? OutputContractJson { get; set; }
+    public string? ParameterContractJson { get; set; }
+    public int TimeoutSeconds { get; set; }
+    public string PublishStatus { get; set; } = "DRAFT";
+    public bool IsCurrent { get; set; }
+    public string? BusinessSqlSource { get; set; }
+    public string? ChangeRemark { get; set; }
+    public string? Remark { get; set; }
+    public string? CreatedBy { get; set; }
+    public DateTime CreatedAt { get; set; }
+    public string? PublishedBy { get; set; }
+    public DateTime? PublishedAt { get; set; }
+}
+
+/// <summary>维度配置新增/编辑草稿 DTO。</summary>
+public sealed class KpiDimensionConfigUpsertDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string AggregationType { get; set; } = "SUMMARY_ONLY";
+    public string? SupportedDimensionsJson { get; set; }
+    public string? OutputContractJson { get; set; }
+    public string? ParameterContractJson { get; set; }
+    public int TimeoutSeconds { get; set; }
+    public string? BusinessSqlSource { get; set; }
+    public string? ChangeRemark { get; set; }
+    public string? Remark { get; set; }
+}
+
+/// <summary>维度试算入参。</summary>
+public sealed class KpiDimensionPreviewDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string? SqlScript { get; set; }
+    public string AggregationType { get; set; } = "SUMMARY_ONLY";
+    public int TimeoutSeconds { get; set; }
+    public DateTime? BizDate { get; set; }
+}
+
+/// <summary>维度试算结果(含前 N 行样本 + 契约校验)。</summary>
+public sealed class KpiDimensionPreviewResultDto
+{
+    public bool Ok { get; set; }
+    public string MetricCode { get; set; } = string.Empty;
+    public string? DataSourceCode { get; set; }
+    public string BizDate { get; set; } = string.Empty;
+    public string ValidationStatus { get; set; } = string.Empty;
+    public string ResultStatus { get; set; } = string.Empty;
+    public long DurationMs { get; set; }
+    public int RowCount { get; set; }
+    public List<string> OutputColumns { get; set; } = new();
+    public List<string> SupportedDimensions { get; set; } = new();
+    public List<Dictionary<string, object?>> SampleRows { get; set; } = new();
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string ExecutedParameters { get; set; } = string.Empty;
+}
+
+/// <summary>KPI 维度能力(驱动前端筛选/下钻可用性)。</summary>
+public sealed class KpiDimensionCapabilityDto
+{
+    public string MetricCode { get; set; } = string.Empty;
+    public string ModuleCode { get; set; } = string.Empty;
+    /// <summary>是否已配置并激活维度(PENDING_DIMENSION_SQL 时 false)。</summary>
+    public bool Supported { get; set; }
+    /// <summary>能力状态 PENDING_DIMENSION_SQL / CONFIGURED / SUMMARY_ONLY。</summary>
+    public string CapabilityStatus { get; set; } = "PENDING_DIMENSION_SQL";
+    public int? SummaryConfigVersion { get; set; }
+    public int? DimensionConfigVersion { get; set; }
+    public List<string> SupportedDimensions { get; set; } = new();
+    public string? AggregationType { get; set; }
+    public string? LastRunStatus { get; set; }
+    public DateTime? LastRunAt { get; set; }
+}

+ 96 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiDimensionConfig.cs

@@ -0,0 +1,96 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 维度计算配置(DIMENSION_SQL 配置化)。为筛选/下钻产生可按维度拆分的结果,
+/// 与当前生效的汇总配置(ado_smart_ops_kpi_calc_config)版本绑定。
+/// 独立于汇总配置模型,绝不改动 SUMMARY_SQL。
+/// 同一 (TenantId, MetricCode) 仅一个 IsCurrent=1 的生效维度版本,由服务层事务保证。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_dimension_config", "KPI 维度计算配置(DIMENSION_SQL 配置化)")]
+[SugarIndex("uk_kpi_dim_cfg_metric_sum_ver", nameof(MetricCode), OrderByType.Asc, nameof(TenantId), OrderByType.Asc, nameof(SummaryConfigId), OrderByType.Asc, nameof(DimensionConfigVersion), OrderByType.Asc, true)]
+public class AdoSmartOpsKpiDimensionConfig : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标编码 如 S7_L1_001", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "绑定的汇总配置 Id(ado_smart_ops_kpi_calc_config.Id)")]
+    public long SummaryConfigId { get; set; }
+
+    [SugarColumn(ColumnDescription = "绑定的汇总配置版本号")]
+    public int SummaryConfigVersion { get; set; }
+
+    [SugarColumn(ColumnDescription = "维度配置版本号(同 KPI+汇总版本内递增)")]
+    public int DimensionConfigVersion { get; set; }
+
+    [SugarColumn(ColumnDescription = "数据源编码(已登记数据源,禁连接串)", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "维度只读 SQL 脚本", ColumnDataType = "text", IsNullable = true)]
+    public string? SqlScript { get; set; }
+
+    [SugarColumn(ColumnDescription = "聚合类型 DIRECT_VALUE / RATIO_OF_SUMS / AVERAGE_OF_SUMS / SUMMARY_ONLY", Length = 30)]
+    public string AggregationType { get; set; } = "SUMMARY_ONLY";
+
+    [SugarColumn(ColumnDescription = "支持维度声明 JSON 数组(如 [\"value_date\",\"order_no\"])", ColumnDataType = "text", IsNullable = true)]
+    public string? SupportedDimensionsJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "输出契约声明 JSON(元数据)", ColumnDataType = "text", IsNullable = true)]
+    public string? OutputContractJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "参数声明 JSON(元数据,运行绑定由引擎注入)", ColumnDataType = "text", IsNullable = true)]
+    public string? ParameterContractJson { get; set; }
+
+    [SugarColumn(ColumnDescription = "查询超时(秒),受系统上限约束")]
+    public int TimeoutSeconds { get; set; } = 60;
+
+    [SugarColumn(ColumnDescription = "发布状态 DRAFT / PUBLISHED / RETIRED", Length = 20)]
+    public string PublishStatus { get; set; } = "DRAFT";
+
+    [SugarColumn(ColumnDescription = "是否当前生效版本(同 KPI 唯一)")]
+    public bool IsCurrent { get; set; }
+
+    [SugarColumn(ColumnDescription = "业务 SQL 来源", Length = 200, IsNullable = true)]
+    public string? BusinessSqlSource { get; set; }
+
+    [SugarColumn(ColumnDescription = "变更说明", ColumnDataType = "text", IsNullable = true)]
+    public string? ChangeRemark { get; set; }
+
+    [SugarColumn(ColumnDescription = "备注", ColumnDataType = "text", IsNullable = true)]
+    public string? Remark { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建人", Length = 100, IsNullable = true)]
+    public string? CreatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新人", Length = 100, IsNullable = true)]
+    public string? UpdatedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新时间", IsNullable = true)]
+    public DateTime? UpdatedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "发布人", Length = 100, IsNullable = true)]
+    public string? PublishedBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "发布时间", IsNullable = true)]
+    public DateTime? PublishedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "停用人", Length = 100, IsNullable = true)]
+    public string? RetiredBy { get; set; }
+
+    [SugarColumn(ColumnDescription = "停用时间", IsNullable = true)]
+    public DateTime? RetiredAt { get; set; }
+}

+ 73 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiDimensionRunLog.cs

@@ -0,0 +1,73 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 维度执行运行日志。记录每次维度 SQL 执行的配置版本/数据源/状态/行数/耗时/错误/SQL 指纹。
+/// 不落明文 SQL;同批次同指标唯一。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_dimension_run_log", "KPI 维度执行运行日志")]
+[SugarIndex("uk_kpi_dim_run_metric_batch", nameof(MetricCode), OrderByType.Asc, nameof(BatchId), OrderByType.Asc, true)]
+public class AdoSmartOpsKpiDimensionRunLog : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "批次号", Length = 100)]
+    public string BatchId { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "模块", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "指标编码", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "维度配置 Id", IsNullable = true)]
+    public long? DimensionConfigId { get; set; }
+
+    [SugarColumn(ColumnDescription = "维度配置版本号", IsNullable = true)]
+    public int? DimensionConfigVersion { get; set; }
+
+    [SugarColumn(ColumnDescription = "绑定的汇总配置版本号", IsNullable = true)]
+    public int? SummaryConfigVersion { get; set; }
+
+    [SugarColumn(ColumnDescription = "数据源编码", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "业务日期")]
+    public DateTime BizDate { get; set; }
+
+    [SugarColumn(ColumnDescription = "开始时间")]
+    public DateTime StartedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "结束时间", IsNullable = true)]
+    public DateTime? FinishedAt { get; set; }
+
+    [SugarColumn(ColumnDescription = "耗时毫秒", IsNullable = true)]
+    public long? DurationMs { get; set; }
+
+    [SugarColumn(ColumnDescription = "状态 SUCCESS/NO_DATA/FAILED/NOT_CONFIGURED/VERSION_MISMATCH/VALIDATION_FAILED/TIMEOUT", Length = 30)]
+    public string Status { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "结果行数")]
+    public int RowCount { get; set; }
+
+    [SugarColumn(ColumnDescription = "错误码", Length = 60, IsNullable = true)]
+    public string? ErrorCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "错误摘要", ColumnDataType = "text", IsNullable = true)]
+    public string? ErrorMessage { get; set; }
+
+    [SugarColumn(ColumnDescription = "SQL 指纹(SHA256 hex,无明文)", Length = 64, IsNullable = true)]
+    public string? SqlHash { get; set; }
+
+    [SugarColumn(ColumnDescription = "触发类型 AUTO/MANUAL/PREVIEW", Length = 20, IsNullable = true)]
+    public string? TriggerType { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreateTime { get; set; }
+}

+ 117 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Entity/AdoSmartOpsKpiDimensionValueDay.cs

@@ -0,0 +1,117 @@
+using Admin.NET.Core;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.Entity;
+
+/// <summary>
+/// KPI 维度结果(配置化维度执行产物)。由 DIMENSION_SQL 经数据中台执行后落库,
+/// 供筛选与下钻读取。每行记录归属的汇总/维度配置版本,查询时校验版本一致。
+/// 独立新表,绝不复用被旧硬编码污染的 ado_smart_ops_kpi_atomic_day。
+/// </summary>
+[SugarTable("ado_smart_ops_kpi_dimension_value_day", "KPI 维度结果(配置化)")]
+[SugarIndex("uk_kpi_dim_val_rowhash", nameof(RowHash), OrderByType.Asc, true)]
+[SugarIndex("ix_kpi_dim_val_query", nameof(TenantId), OrderByType.Asc, nameof(MetricCode), OrderByType.Asc, nameof(DimensionConfigVersion), OrderByType.Asc, nameof(ValueDate), OrderByType.Asc)]
+public class AdoSmartOpsKpiDimensionValueDay : ITenantIdFilter
+{
+    [SugarColumn(ColumnDescription = "主键", IsPrimaryKey = true, IsIdentity = true, ColumnDataType = "bigint")]
+    public long Id { get; set; }
+
+    [SugarColumn(ColumnDescription = "租户 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? TenantId { get; set; }
+
+    [SugarColumn(ColumnDescription = "模块 S1~S9", Length = 20)]
+    public string ModuleCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "指标编码", Length = 50)]
+    public string MetricCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "绑定的汇总配置 Id")]
+    public long SummaryConfigId { get; set; }
+
+    [SugarColumn(ColumnDescription = "绑定的汇总配置版本号")]
+    public int SummaryConfigVersion { get; set; }
+
+    [SugarColumn(ColumnDescription = "维度配置 Id")]
+    public long DimensionConfigId { get; set; }
+
+    [SugarColumn(ColumnDescription = "维度配置版本号")]
+    public int DimensionConfigVersion { get; set; }
+
+    [SugarColumn(ColumnDescription = "数据源编码", Length = 100, IsNullable = true)]
+    public string? DataSourceCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "业务日期(日粒度取日、月粒度取月首日)")]
+    public DateTime ValueDate { get; set; }
+
+    [SugarColumn(ColumnDescription = "维度类型 如 ORDER / MATERIAL / CATEGORY", Length = 40)]
+    public string DimensionType { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "维度编码", Length = 200)]
+    public string DimensionCode { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "维度名称", Length = 300, IsNullable = true)]
+    public string? DimensionName { get; set; }
+
+    [SugarColumn(ColumnDescription = "组织 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? OrgId { get; set; }
+
+    [SugarColumn(ColumnDescription = "工厂 ID", ColumnDataType = "bigint", IsNullable = true)]
+    public long? FactoryId { get; set; }
+
+    [SugarColumn(ColumnDescription = "物料编码", Length = 100, IsNullable = true)]
+    public string? MaterialCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "工单号", Length = 100, IsNullable = true)]
+    public string? WorkOrderNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "品类编码", Length = 100, IsNullable = true)]
+    public string? CategoryCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "仓库编码", Length = 100, IsNullable = true)]
+    public string? WarehouseCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "订单号", Length = 100, IsNullable = true)]
+    public string? OrderNo { get; set; }
+
+    [SugarColumn(ColumnDescription = "客户编码", Length = 100, IsNullable = true)]
+    public string? CustomerCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "产品编码", Length = 100, IsNullable = true)]
+    public string? ProductCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "设备编码", Length = 100, IsNullable = true)]
+    public string? EquipmentCode { get; set; }
+
+    [SugarColumn(ColumnDescription = "指标值", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+    public decimal? MetricValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "分子", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+    public decimal? Numerator { get; set; }
+
+    [SugarColumn(ColumnDescription = "分母", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+    public decimal? Denominator { get; set; }
+
+    [SugarColumn(ColumnDescription = "求和值", ColumnDataType = "decimal(18,6)", IsNullable = true)]
+    public decimal? SumValue { get; set; }
+
+    [SugarColumn(ColumnDescription = "样本数", IsNullable = true)]
+    public int? SampleCount { get; set; }
+
+    [SugarColumn(ColumnDescription = "来源业务键", Length = 200, IsNullable = true)]
+    public string? SourceKey { get; set; }
+
+    [SugarColumn(ColumnDescription = "幂等行哈希(SHA256 hex)", Length = 64)]
+    public string RowHash { get; set; } = string.Empty;
+
+    [SugarColumn(ColumnDescription = "批次号", Length = 100, IsNullable = true)]
+    public string? BatchId { get; set; }
+
+    [SugarColumn(ColumnDescription = "计算时间")]
+    public DateTime CalcTime { get; set; }
+
+    [SugarColumn(ColumnDescription = "创建时间")]
+    public DateTime CreatedTime { get; set; }
+
+    [SugarColumn(ColumnDescription = "更新时间", IsNullable = true)]
+    public DateTime? UpdatedTime { get; set; }
+}

+ 369 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiDimensionConfigService.cs

@@ -0,0 +1,369 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>
+/// KPI 维度计算配置服务:CRUD + 校验 + 试算 + 发布/停用/激活(发布状态机,单生效版本事务保证)。
+/// 与当前生效汇总配置版本绑定;租户显式控制(ClearFilter),落在运行时 KPI 数据租户。
+/// SqlScript 属高危配置,接口须登录鉴权(Controller 层)。绝不改动 SUMMARY_SQL。
+/// </summary>
+public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
+{
+    private const string StatusDraft = "DRAFT";
+    private const string StatusPublished = "PUBLISHED";
+    private const string StatusRetired = "RETIRED";
+
+    private static readonly string[] AggregationTypes =
+        { "DIRECT_VALUE", "RATIO_OF_SUMS", "AVERAGE_OF_SUMS", "SUMMARY_ONLY" };
+
+    private readonly ISqlSugarClient _db;
+    private readonly KpiDimensionSqlExecutor _executor;
+    private readonly AdoSmartOpsKpiCalcConfigService _summaryConfig;
+
+    public AdoSmartOpsKpiDimensionConfigService(
+        ISqlSugarClient db, KpiDimensionSqlExecutor executor, AdoSmartOpsKpiCalcConfigService summaryConfig)
+    {
+        _db = db;
+        _executor = executor;
+        _summaryConfig = summaryConfig;
+    }
+
+    private ISugarQueryable<AdoSmartOpsKpiDimensionConfig> Query() =>
+        _db.Queryable<AdoSmartOpsKpiDimensionConfig>().ClearFilter<ITenantIdFilter>();
+
+    /// <summary>某 KPI 的全部维度配置版本(按版本号倒序)。</summary>
+    public async Task<List<KpiDimensionConfigDto>> GetByMetricAsync(string metricCode, string moduleCode)
+    {
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+        var list = await Query()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
+            .OrderBy(x => x.DimensionConfigVersion, OrderByType.Desc)
+            .ToListAsync();
+        return list.Select(ToDto).ToList();
+    }
+
+    /// <summary>当前生效维度配置(PUBLISHED + IsCurrent),无则 null。</summary>
+    public async Task<AdoSmartOpsKpiDimensionConfig?> GetActiveAsync(long tenantId, string metricCode)
+    {
+        return await Query()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId
+                        && x.IsCurrent && x.PublishStatus == StatusPublished)
+            .FirstAsync();
+    }
+
+    /// <summary>新增草稿:绑定当前生效汇总配置版本;DimensionConfigVersion = 该汇总版本下现有最大 + 1。</summary>
+    public async Task<KpiDimensionConfigDto> CreateDraftAsync(KpiDimensionConfigUpsertDto dto, string operatorName)
+    {
+        ValidateAggregation(dto.AggregationType);
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(dto.ModuleCode);
+
+        var summary = await _summaryConfig.GetActiveAsync(tenantId, dto.MetricCode)
+            ?? throw Oops.Bah("该 KPI 当前无生效的汇总配置(CONFIG_SQL),请先发布并激活汇总配置后再配置维度");
+
+        var maxVer = await Query()
+            .Where(x => x.MetricCode == dto.MetricCode && x.TenantId == tenantId && x.SummaryConfigId == summary.Id)
+            .MaxAsync(x => (int?)x.DimensionConfigVersion) ?? 0;
+
+        var entity = new AdoSmartOpsKpiDimensionConfig
+        {
+            TenantId = tenantId,
+            MetricCode = dto.MetricCode,
+            ModuleCode = dto.ModuleCode,
+            SummaryConfigId = summary.Id,
+            SummaryConfigVersion = summary.VersionNo,
+            DimensionConfigVersion = maxVer + 1,
+            DataSourceCode = dto.DataSourceCode,
+            SqlScript = dto.SqlScript,
+            AggregationType = dto.AggregationType,
+            SupportedDimensionsJson = dto.SupportedDimensionsJson,
+            OutputContractJson = dto.OutputContractJson,
+            ParameterContractJson = dto.ParameterContractJson,
+            TimeoutSeconds = NormalizeTimeout(dto.TimeoutSeconds),
+            PublishStatus = StatusDraft,
+            IsCurrent = false,
+            BusinessSqlSource = dto.BusinessSqlSource,
+            ChangeRemark = dto.ChangeRemark,
+            Remark = dto.Remark,
+            CreatedBy = operatorName,
+            CreatedAt = DateTime.Now,
+        };
+        entity.Id = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
+        return ToDto(entity);
+    }
+
+    /// <summary>编辑草稿(仅 DRAFT)。</summary>
+    public async Task UpdateDraftAsync(long id, KpiDimensionConfigUpsertDto dto, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("维度配置版本不存在");
+        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();
+    }
+
+    /// <summary>删除草稿(仅 DRAFT)。</summary>
+    public async Task DeleteAsync(long id)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("维度配置版本不存在");
+        if (entity.PublishStatus != StatusDraft)
+            throw Oops.Bah("仅草稿(DRAFT)可删除;已发布版本请用停用");
+        await _db.Deleteable<AdoSmartOpsKpiDimensionConfig>().Where(x => x.Id == id).ExecuteCommandAsync();
+    }
+
+    /// <summary>SQL 安全校验(复用汇总配置的多层非正则校验器)。</summary>
+    public KpiSqlValidateResultDto Validate(string? sql)
+    {
+        var r = KpiSqlSecurityValidator.Validate(sql);
+        return new KpiSqlValidateResultDto
+        {
+            Ok = r.Ok,
+            ValidationStatus = r.Ok ? "VALID" : "INVALID",
+            ErrorCode = r.ErrorCode,
+            ErrorMessage = r.ErrorMessage,
+            ReferencedTables = r.ReferencedTables,
+        };
+    }
+
+    /// <summary>试算(服务端重校验 + 只读多行执行;不写维度结果)。返回前 20 行样本 + 输出列 + 可识别维度。</summary>
+    public async Task<KpiDimensionPreviewResultDto> PreviewAsync(KpiDimensionPreviewDto dto, CancellationToken ct)
+    {
+        var bizDate = (dto.BizDate ?? DateTime.Today.AddDays(-1)).Date;
+        var res = new KpiDimensionPreviewResultDto
+        {
+            MetricCode = dto.MetricCode,
+            DataSourceCode = dto.DataSourceCode,
+            BizDate = bizDate.ToString("yyyy-MM-dd"),
+        };
+
+        var val = KpiSqlSecurityValidator.Validate(dto.SqlScript);
+        res.ValidationStatus = val.Ok ? "VALID" : "INVALID";
+        if (!val.Ok)
+        {
+            res.Ok = false;
+            res.ResultStatus = "FAILED";
+            res.ErrorCode = val.ErrorCode;
+            res.ErrorMessage = val.ErrorMessage;
+            return res;
+        }
+
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(dto.ModuleCode);
+        var pars = BuildRunParams(tenantId, dto.ModuleCode, dto.MetricCode, bizDate);
+        var exec = await _executor.ExecuteAsync(dto.DataSourceCode, dto.SqlScript ?? "", dto.TimeoutSeconds, true, pars, ct);
+
+        res.Ok = exec.Status != "FAILED";
+        res.ResultStatus = exec.Status;
+        res.DurationMs = exec.DurationMs;
+        res.RowCount = exec.RowCount;
+        res.OutputColumns = exec.OutputColumns;
+        res.SupportedDimensions = DetectSupportedDimensions(exec.OutputColumns);
+        res.ErrorCode = exec.ErrorCode;
+        res.ErrorMessage = exec.ErrorMessage;
+        res.SampleRows = exec.Rows.Take(20).Select(ToSampleRow).ToList();
+        res.ExecutedParameters =
+            $"tenant_id={pars.TenantId}, factory_id={pars.FactoryId}, module_code={pars.ModuleCode}, " +
+            $"metric_code={pars.MetricCode}, biz_date={pars.BizDate:yyyy-MM-dd}, ztid={pars.SourceZtid}";
+        return res;
+    }
+
+    /// <summary>发布:绑定汇总版本一致性校验 + 安全校验 + 事务单生效版本。</summary>
+    public async Task PublishAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("维度配置版本不存在");
+        if (entity.PublishStatus == StatusRetired)
+            throw Oops.Bah("已停用版本不可发布,请新建版本");
+
+        ValidateAggregation(entity.AggregationType);
+        if (entity.AggregationType != "SUMMARY_ONLY")
+        {
+            var val = KpiSqlSecurityValidator.Validate(entity.SqlScript);
+            if (!val.Ok) throw Oops.Bah($"SQL 安全校验未通过:{val.ErrorCode} {val.ErrorMessage}");
+            if (string.IsNullOrWhiteSpace(entity.DataSourceCode))
+                throw Oops.Bah("非 SUMMARY_ONLY 的维度配置必须指定数据源");
+            if (string.IsNullOrWhiteSpace(entity.SupportedDimensionsJson))
+                throw Oops.Bah("必须声明支持维度(SupportedDimensions)");
+        }
+
+        // 绑定的汇总配置必须仍存在且为当前生效版本
+        var summary = await _summaryConfig.GetActiveAsync(entity.TenantId ?? 0, entity.MetricCode)
+            ?? throw Oops.Bah("绑定的汇总配置已不存在或未生效,请基于当前汇总版本新建维度配置");
+        if (summary.Id != entity.SummaryConfigId || summary.VersionNo != entity.SummaryConfigVersion)
+            throw Oops.Bah($"汇总配置已切换到新版本(v{summary.VersionNo}),本维度配置绑定的是 v{entity.SummaryConfigVersion},请基于当前汇总版本新建维度配置");
+
+        var tenantId = entity.TenantId;
+        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();
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException;
+    }
+
+    /// <summary>激活/回滚:把某已发布维度版本设为当前生效。</summary>
+    public async Task ActivateAsync(long id, string operatorName)
+    {
+        var entity = await Query().Where(x => x.Id == id).FirstAsync()
+            ?? throw Oops.Bah("维度配置版本不存在");
+        if (entity.PublishStatus != StatusPublished)
+            throw Oops.Bah("只能激活已发布(PUBLISHED)版本");
+
+        var tenantId = entity.TenantId;
+        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();
+        });
+        if (!tran.IsSuccess) throw tran.ErrorException;
+    }
+
+    /// <summary>停用(RETIRED,IsCurrent=0)。停用当前生效版本后无 current → 筛选/下钻显示未配置。</summary>
+    public async Task RetireAsync(long id, string operatorName)
+    {
+        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();
+    }
+
+    /// <summary>KPI 维度能力:无生效维度配置 → PENDING_DIMENSION_SQL;SUMMARY_ONLY → SUMMARY_ONLY;否则 CONFIGURED。</summary>
+    public async Task<KpiDimensionCapabilityDto> GetCapabilityAsync(string metricCode, string moduleCode)
+    {
+        var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+        var active = await GetActiveAsync(tenantId, metricCode);
+        var cap = new KpiDimensionCapabilityDto { MetricCode = metricCode, ModuleCode = moduleCode };
+        if (active == null)
+        {
+            cap.Supported = false;
+            cap.CapabilityStatus = "PENDING_DIMENSION_SQL";
+            return cap;
+        }
+        cap.SummaryConfigVersion = active.SummaryConfigVersion;
+        cap.DimensionConfigVersion = active.DimensionConfigVersion;
+        cap.AggregationType = active.AggregationType;
+        cap.SupportedDimensions = ParseDimensions(active.SupportedDimensionsJson);
+        cap.Supported = active.AggregationType != "SUMMARY_ONLY";
+        cap.CapabilityStatus = active.AggregationType == "SUMMARY_ONLY" ? "SUMMARY_ONLY" : "CONFIGURED";
+
+        var lastRun = await _db.Queryable<AdoSmartOpsKpiDimensionRunLog>().ClearFilter<ITenantIdFilter>()
+            .Where(x => x.MetricCode == metricCode && x.TenantId == tenantId)
+            .OrderBy(x => x.StartedAt, OrderByType.Desc)
+            .FirstAsync();
+        if (lastRun != null)
+        {
+            cap.LastRunStatus = lastRun.Status;
+            cap.LastRunAt = lastRun.StartedAt;
+        }
+        return cap;
+    }
+
+    /// <summary>已登记数据源列表(第一版仅本地中台库)。</summary>
+    public List<object> ListDataSources() => new()
+    {
+        new { code = KpiDimensionSqlExecutor.LocalDataSourceCode, name = "本地中台库(aidopdev / mdp_std_* / dwd_*)", readOnly = true },
+    };
+
+    internal static KpiSqlRunParams BuildRunParams(long tenantId, string moduleCode, string metricCode, DateTime bizDate) => new()
+    {
+        TenantId = tenantId,
+        FactoryId = 1,
+        ModuleCode = moduleCode,
+        MetricCode = metricCode,
+        BizDate = bizDate,
+        PeriodStart = bizDate,
+        PeriodEnd = bizDate.AddDays(1).AddSeconds(-1),
+        SourceZtid = "pbxfxp",
+    };
+
+    private static readonly string[] KnownDimensionColumns =
+    {
+        "value_date", "org_id", "factory_id", "material_code", "work_order_no", "category_code",
+        "warehouse_code", "order_no", "customer_code", "product_code", "equipment_code",
+    };
+
+    private static List<string> DetectSupportedDimensions(List<string> outputColumns)
+    {
+        var set = new HashSet<string>(outputColumns, StringComparer.OrdinalIgnoreCase);
+        return KnownDimensionColumns.Where(set.Contains).ToList();
+    }
+
+    private static List<string> ParseDimensions(string? json)
+    {
+        if (string.IsNullOrWhiteSpace(json)) return new();
+        try { return System.Text.Json.JsonSerializer.Deserialize<List<string>>(json) ?? new(); }
+        catch { return new(); }
+    }
+
+    private static Dictionary<string, object?> ToSampleRow(KpiDimensionRow r) => new()
+    {
+        ["value_date"] = r.ValueDate == DateTime.MinValue ? null : r.ValueDate.ToString("yyyy-MM-dd"),
+        ["dimension_type"] = r.DimensionType,
+        ["dimension_code"] = r.DimensionCode,
+        ["dimension_name"] = r.DimensionName,
+        ["metric_value"] = r.MetricValue,
+        ["numerator"] = r.Numerator,
+        ["denominator"] = r.Denominator,
+        ["sum_value"] = r.SumValue,
+        ["sample_count"] = r.SampleCount,
+    };
+
+    private static void ValidateAggregation(string agg)
+    {
+        if (!AggregationTypes.Contains(agg))
+            throw Oops.Bah($"非法聚合类型:{agg}(DIRECT_VALUE/RATIO_OF_SUMS/AVERAGE_OF_SUMS/SUMMARY_ONLY)");
+    }
+
+    private static int NormalizeTimeout(int t) =>
+        t <= 0 ? 60 : Math.Min(t, KpiDimensionSqlExecutor.SystemMaxTimeoutSeconds);
+
+    private static KpiDimensionConfigDto ToDto(AdoSmartOpsKpiDimensionConfig e) => new()
+    {
+        Id = e.Id, TenantId = e.TenantId, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
+        SummaryConfigId = e.SummaryConfigId, SummaryConfigVersion = e.SummaryConfigVersion,
+        DimensionConfigVersion = e.DimensionConfigVersion, DataSourceCode = e.DataSourceCode,
+        SqlScript = e.SqlScript, AggregationType = e.AggregationType,
+        SupportedDimensionsJson = e.SupportedDimensionsJson, OutputContractJson = e.OutputContractJson,
+        ParameterContractJson = e.ParameterContractJson, TimeoutSeconds = e.TimeoutSeconds,
+        PublishStatus = e.PublishStatus, IsCurrent = e.IsCurrent, BusinessSqlSource = e.BusinessSqlSource,
+        ChangeRemark = e.ChangeRemark, Remark = e.Remark, CreatedBy = e.CreatedBy, CreatedAt = e.CreatedAt,
+        PublishedBy = e.PublishedBy, PublishedAt = e.PublishedAt,
+    };
+}

+ 208 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiDimensionRunService.cs

@@ -0,0 +1,208 @@
+using System.Security.Cryptography;
+using System.Text;
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>维度执行结果概要(供调度/接口回执)。</summary>
+public sealed class KpiDimensionRunResult
+{
+    public string Status { get; set; } = string.Empty;
+    public int RowCount { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+}
+
+/// <summary>
+/// KPI 维度执行通用入口:读当前生效维度配置 → 校验绑定汇总版本一致 → 只读执行 DIMENSION_SQL
+/// → 事务 FULL REPLACE 写维度结果表 → 写运行日志。禁止 per-KPI 分支。
+/// </summary>
+public sealed class KpiDimensionRunService : ITransient
+{
+    private readonly ISqlSugarClient _db;
+    private readonly KpiDimensionSqlExecutor _executor;
+    private readonly AdoSmartOpsKpiDimensionConfigService _dimensionConfig;
+    private readonly AdoSmartOpsKpiCalcConfigService _summaryConfig;
+
+    public KpiDimensionRunService(
+        ISqlSugarClient db, KpiDimensionSqlExecutor executor,
+        AdoSmartOpsKpiDimensionConfigService dimensionConfig, AdoSmartOpsKpiCalcConfigService summaryConfig)
+    {
+        _db = db;
+        _executor = executor;
+        _dimensionConfig = dimensionConfig;
+        _summaryConfig = summaryConfig;
+    }
+
+    /// <summary>
+    /// 执行某 KPI 当前生效维度配置。SUMMARY_ONLY / 无配置 / 版本不匹配均只记日志、不写结果。
+    /// </summary>
+    public async Task<KpiDimensionRunResult> RunDimensionAsync(
+        string metricCode, string moduleCode, long tenantId, DateTime valueDate,
+        string batchId, string triggerType, CancellationToken ct)
+    {
+        var startedAt = DateTime.Now;
+        var bizDate = valueDate.Date;
+
+        var active = await _dimensionConfig.GetActiveAsync(tenantId, metricCode);
+        if (active == null)
+        {
+            await WriteRunLogAsync(null, metricCode, moduleCode, tenantId, batchId, triggerType, bizDate, startedAt,
+                "NOT_CONFIGURED", 0, "NOT_CONFIGURED", "无生效维度配置", null);
+            return new KpiDimensionRunResult { Status = "NOT_CONFIGURED", ErrorCode = "NOT_CONFIGURED" };
+        }
+        if (active.AggregationType == "SUMMARY_ONLY")
+        {
+            await WriteRunLogAsync(active, metricCode, moduleCode, tenantId, batchId, triggerType, bizDate, startedAt,
+                "NOT_CONFIGURED", 0, "SUMMARY_ONLY", "维度配置为 SUMMARY_ONLY,不产生维度明细", null);
+            return new KpiDimensionRunResult { Status = "NOT_CONFIGURED", ErrorCode = "SUMMARY_ONLY" };
+        }
+
+        // 绑定汇总版本一致性校验
+        var summary = await _summaryConfig.GetActiveAsync(tenantId, metricCode);
+        if (summary == null || summary.Id != active.SummaryConfigId || summary.VersionNo != active.SummaryConfigVersion)
+        {
+            await WriteRunLogAsync(active, metricCode, moduleCode, tenantId, batchId, triggerType, bizDate, startedAt,
+                "VERSION_MISMATCH", 0, "VERSION_MISMATCH", "维度配置绑定的汇总版本与当前生效汇总版本不一致", null);
+            return new KpiDimensionRunResult { Status = "VERSION_MISMATCH", ErrorCode = "VERSION_MISMATCH" };
+        }
+
+        var pars = AdoSmartOpsKpiDimensionConfigService.BuildRunParams(tenantId, moduleCode, metricCode, bizDate);
+        var exec = await _executor.ExecuteAsync(active.DataSourceCode, active.SqlScript ?? "", active.TimeoutSeconds, false, pars, ct);
+
+        if (exec.Status == "FAILED")
+        {
+            await WriteRunLogAsync(active, metricCode, moduleCode, tenantId, batchId, triggerType, bizDate, startedAt,
+                "FAILED", exec.RowCount, exec.ErrorCode, exec.ErrorMessage, exec.SqlHash);
+            return new KpiDimensionRunResult { Status = "FAILED", RowCount = exec.RowCount, ErrorCode = exec.ErrorCode, ErrorMessage = exec.ErrorMessage };
+        }
+
+        // FULL REPLACE:同 (tenant, metric, dimVersion) + 本次返回的各 value_date 范围内先删后插,事务提交。
+        var now = DateTime.Now;
+        var rows = exec.Rows.Select(r => ToEntity(r, active, tenantId, metricCode, moduleCode, batchId, now)).ToList();
+        var dates = rows.Select(x => x.ValueDate.Date).Distinct().ToList();
+        if (dates.Count == 0) dates.Add(bizDate); // NO_DATA 也清空当日该版本旧数据
+
+        var tran = await _db.AsTenant().UseTranAsync(async () =>
+        {
+            await _db.Deleteable<AdoSmartOpsKpiDimensionValueDay>()
+                .Where(x => x.TenantId == tenantId && x.MetricCode == metricCode
+                            && x.DimensionConfigVersion == active.DimensionConfigVersion
+                            && dates.Contains(x.ValueDate.Date))
+                .ExecuteCommandAsync();
+            if (rows.Count > 0)
+                await _db.Insertable(rows).ExecuteCommandAsync();
+        });
+        if (!tran.IsSuccess)
+        {
+            await WriteRunLogAsync(active, metricCode, moduleCode, tenantId, batchId, triggerType, bizDate, startedAt,
+                "FAILED", rows.Count, "WRITE_FAILED", tran.ErrorException?.Message, exec.SqlHash);
+            return new KpiDimensionRunResult { Status = "FAILED", ErrorCode = "WRITE_FAILED", ErrorMessage = tran.ErrorException?.Message };
+        }
+
+        var status = rows.Count == 0 ? "NO_DATA" : "SUCCESS";
+        await WriteRunLogAsync(active, metricCode, moduleCode, tenantId, batchId, triggerType, bizDate, startedAt,
+            status, rows.Count, null, null, exec.SqlHash);
+        return new KpiDimensionRunResult { Status = status, RowCount = rows.Count };
+    }
+
+    private AdoSmartOpsKpiDimensionValueDay ToEntity(
+        KpiDimensionRow r, AdoSmartOpsKpiDimensionConfig cfg, long tenantId, string metricCode, string moduleCode,
+        string batchId, DateTime now)
+    {
+        var valueDate = r.ValueDate == DateTime.MinValue ? DateTime.Today : r.ValueDate.Date;
+        var e = new AdoSmartOpsKpiDimensionValueDay
+        {
+            TenantId = tenantId,
+            ModuleCode = moduleCode,
+            MetricCode = metricCode,
+            SummaryConfigId = cfg.SummaryConfigId,
+            SummaryConfigVersion = cfg.SummaryConfigVersion,
+            DimensionConfigId = cfg.Id,
+            DimensionConfigVersion = cfg.DimensionConfigVersion,
+            DataSourceCode = cfg.DataSourceCode,
+            ValueDate = valueDate,
+            DimensionType = r.DimensionType,
+            DimensionCode = r.DimensionCode,
+            DimensionName = r.DimensionName,
+            OrgId = r.OrgId,
+            FactoryId = r.FactoryId ?? 1,
+            MaterialCode = r.MaterialCode,
+            WorkOrderNo = r.WorkOrderNo,
+            CategoryCode = r.CategoryCode,
+            WarehouseCode = r.WarehouseCode,
+            OrderNo = r.OrderNo,
+            CustomerCode = r.CustomerCode,
+            ProductCode = r.ProductCode,
+            EquipmentCode = r.EquipmentCode,
+            MetricValue = r.MetricValue,
+            Numerator = r.Numerator,
+            Denominator = r.Denominator,
+            SumValue = r.SumValue,
+            SampleCount = r.SampleCount,
+            SourceKey = r.SourceKey,
+            BatchId = batchId,
+            CalcTime = now,
+            CreatedTime = now,
+        };
+        e.RowHash = ComputeRowHash(tenantId, metricCode, cfg.DimensionConfigVersion, valueDate, r.DimensionType, r.DimensionCode, r.SourceKey);
+        return e;
+    }
+
+    private static string ComputeRowHash(long tenantId, string metricCode, int dimVersion, DateTime valueDate,
+        string dimType, string dimCode, string? sourceKey)
+    {
+        var raw = $"{tenantId}|{metricCode}|{dimVersion}|{valueDate:yyyyMMdd}|{dimType}|{dimCode}|{sourceKey}";
+        return Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(raw)));
+    }
+
+    private async Task WriteRunLogAsync(
+        AdoSmartOpsKpiDimensionConfig? cfg, string metricCode, string moduleCode, long tenantId,
+        string batchId, string triggerType, DateTime bizDate, DateTime startedAt,
+        string status, int rowCount, string? errorCode, string? errorMessage, string? sqlHash)
+    {
+        try
+        {
+            var finishedAt = DateTime.Now;
+            var log = new AdoSmartOpsKpiDimensionRunLog
+            {
+                TenantId = tenantId,
+                BatchId = batchId,
+                ModuleCode = moduleCode,
+                MetricCode = metricCode,
+                DimensionConfigId = cfg?.Id,
+                DimensionConfigVersion = cfg?.DimensionConfigVersion,
+                SummaryConfigVersion = cfg?.SummaryConfigVersion,
+                DataSourceCode = cfg?.DataSourceCode,
+                BizDate = bizDate.Date,
+                StartedAt = startedAt,
+                FinishedAt = finishedAt,
+                DurationMs = (long)(finishedAt - startedAt).TotalMilliseconds,
+                Status = status,
+                RowCount = rowCount,
+                ErrorCode = errorCode,
+                ErrorMessage = errorMessage == null ? null : (errorMessage.Length > 480 ? errorMessage.Substring(0, 480) : errorMessage),
+                SqlHash = sqlHash,
+                TriggerType = triggerType,
+                CreateTime = finishedAt,
+            };
+            var existing = await _db.Queryable<AdoSmartOpsKpiDimensionRunLog>().ClearFilter<ITenantIdFilter>()
+                .Where(x => x.MetricCode == metricCode && x.BatchId == batchId).FirstAsync();
+            if (existing != null)
+            {
+                log.Id = existing.Id;
+                await _db.Updateable(log).ExecuteCommandAsync();
+            }
+            else
+            {
+                await _db.Insertable(log).ExecuteCommandAsync();
+            }
+        }
+        catch
+        {
+            // 运行日志失败不影响主执行链路
+        }
+    }
+}

+ 278 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiDimensionSqlExecutor.cs

@@ -0,0 +1,278 @@
+using System.Data;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>解析后的单条维度结果行。</summary>
+public sealed class KpiDimensionRow
+{
+    public DateTime ValueDate { get; set; }
+    public string DimensionType { get; set; } = string.Empty;
+    public string DimensionCode { get; set; } = string.Empty;
+    public string? DimensionName { get; set; }
+    public long? OrgId { get; set; }
+    public long? FactoryId { get; set; }
+    public string? MaterialCode { get; set; }
+    public string? WorkOrderNo { get; set; }
+    public string? CategoryCode { get; set; }
+    public string? WarehouseCode { get; set; }
+    public string? OrderNo { get; set; }
+    public string? CustomerCode { get; set; }
+    public string? ProductCode { get; set; }
+    public string? EquipmentCode { get; set; }
+    public decimal? MetricValue { get; set; }
+    public decimal? Numerator { get; set; }
+    public decimal? Denominator { get; set; }
+    public decimal? SumValue { get; set; }
+    public int? SampleCount { get; set; }
+    public string? SourceKey { get; set; }
+}
+
+/// <summary>维度 SQL 执行结果契约(多行)。</summary>
+public sealed class KpiDimensionExecutionResult
+{
+    /// <summary>SUCCESS / NO_DATA / FAILED。</summary>
+    public string Status { get; set; } = "FAILED";
+    public int RowCount { get; set; }
+    public long DurationMs { get; set; }
+    public string? ErrorCode { get; set; }
+    public string? ErrorMessage { get; set; }
+    public string SqlHash { get; set; } = string.Empty;
+    public List<string> OutputColumns { get; set; } = new();
+    public List<KpiDimensionRow> Rows { get; set; } = new();
+}
+
+/// <summary>
+/// KPI 维度 SQL 只读执行器。与 <see cref="KpiSqlReadOnlyExecutor"/> 同构的只读隔离连接
+/// + START TRANSACTION READ ONLY + 参数化,但解析**多行维度结果契约**(用于筛选/下钻)。
+/// 必填列:value_date / dimension_type / dimension_code。绝不写业务数据。
+/// </summary>
+public sealed class KpiDimensionSqlExecutor : ITransient
+{
+    public const int SystemMaxTimeoutSeconds = 120;
+    public const int PreviewMaxTimeoutSeconds = 30;
+
+    /// <summary>维度结果最大行数(超过即 FAILED,避免大结果占用连接)。</summary>
+    public const int MaxResultRows = 5000;
+
+    /// <summary>本地中台库数据源编码(解析为主库 aidopdev)。</summary>
+    public const string LocalDataSourceCode = "MDP_LOCAL";
+
+    private readonly ISqlSugarClient _db;
+
+    public KpiDimensionSqlExecutor(ISqlSugarClient db)
+    {
+        _db = db;
+    }
+
+    public static string ComputeSqlHash(string sql)
+    {
+        var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(sql ?? string.Empty));
+        return Convert.ToHexString(bytes);
+    }
+
+    public async Task<KpiDimensionExecutionResult> ExecuteAsync(
+        string? dataSourceCode, string sql, int timeoutSeconds, bool isPreview,
+        KpiSqlRunParams pars, CancellationToken ct)
+    {
+        var startedAt = DateTime.Now;
+        var res = new KpiDimensionExecutionResult { SqlHash = ComputeSqlHash(sql) };
+
+        var cap = isPreview ? PreviewMaxTimeoutSeconds : SystemMaxTimeoutSeconds;
+        var timeout = timeoutSeconds <= 0 ? cap : Math.Min(timeoutSeconds, cap);
+
+        var code = string.IsNullOrWhiteSpace(dataSourceCode) ? LocalDataSourceCode : dataSourceCode.Trim();
+        if (!string.Equals(code, LocalDataSourceCode, StringComparison.OrdinalIgnoreCase))
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "DATASOURCE_NOT_SUPPORTED";
+            res.ErrorMessage = $"数据源 {code} 暂不支持(仅 {LocalDataSourceCode})";
+            res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
+            return res;
+        }
+
+        var main = _db.CurrentConnectionConfig;
+        var roConfig = new ConnectionConfig
+        {
+            ConnectionString = main.ConnectionString,
+            DbType = main.DbType,
+            IsAutoCloseConnection = false,
+        };
+
+        using var roDb = new SqlSugarClient(roConfig);
+        try
+        {
+            roDb.Ado.CommandTimeOut = timeout;
+            await roDb.Ado.ExecuteCommandAsync("START TRANSACTION READ ONLY");
+            try
+            {
+                ct.ThrowIfCancellationRequested();
+                var sugarPars = BuildParameters(pars);
+                var dt = await roDb.Ado.GetDataTableAsync(sql, sugarPars);
+                ParseContract(dt, res);
+            }
+            finally
+            {
+                try { await roDb.Ado.ExecuteCommandAsync("ROLLBACK"); } catch { /* 专用连接随 Dispose 关闭 */ }
+            }
+        }
+        catch (OperationCanceledException) when (ct.IsCancellationRequested)
+        {
+            throw;
+        }
+        catch (Exception ex)
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = ex.Message.IndexOf("timeout", StringComparison.OrdinalIgnoreCase) >= 0
+                            || ex.Message.IndexOf("Command Timeout", StringComparison.OrdinalIgnoreCase) >= 0
+                ? "TIMEOUT" : "SQL_ERROR";
+            res.ErrorMessage = Truncate(ex.Message, 480);
+        }
+
+        res.DurationMs = (long)(DateTime.Now - startedAt).TotalMilliseconds;
+        return res;
+    }
+
+    private static SugarParameter[] BuildParameters(KpiSqlRunParams p) => new[]
+    {
+        new SugarParameter("@tenant_id", p.TenantId),
+        new SugarParameter("@factory_id", p.FactoryId),
+        new SugarParameter("@module_code", p.ModuleCode),
+        new SugarParameter("@metric_code", p.MetricCode),
+        new SugarParameter("@biz_date", p.BizDate.Date),
+        new SugarParameter("@period_start", p.PeriodStart),
+        new SugarParameter("@period_end", p.PeriodEnd),
+        new SugarParameter("@ztid", p.SourceZtid),
+    };
+
+    private static readonly string[] RequiredColumns = { "value_date", "dimension_type", "dimension_code" };
+
+    private static void ParseContract(DataTable dt, KpiDimensionExecutionResult res)
+    {
+        foreach (DataColumn c in dt.Columns) res.OutputColumns.Add(c.ColumnName);
+        res.RowCount = dt.Rows.Count;
+
+        if (dt.Rows.Count == 0)
+        {
+            res.Status = "NO_DATA";
+            return;
+        }
+        if (dt.Rows.Count > MaxResultRows)
+        {
+            res.Status = "FAILED";
+            res.ErrorCode = "TOO_MANY_ROWS";
+            res.ErrorMessage = $"维度结果超过上限 {MaxResultRows} 行(实际 {dt.Rows.Count}),请在 SQL 内聚合或限定范围";
+            return;
+        }
+        foreach (var col in RequiredColumns)
+        {
+            if (GetColumn(dt, col) == null)
+            {
+                res.Status = "FAILED";
+                res.ErrorCode = "MISSING_REQUIRED_COLUMN";
+                res.ErrorMessage = $"维度结果必须含列 {col}";
+                return;
+            }
+        }
+
+        foreach (DataRow row in dt.Rows)
+        {
+            var dimCode = TryString(row, dt, "dimension_code");
+            if (string.IsNullOrWhiteSpace(dimCode)) continue; // 维度编码空的行跳过(无法作为下钻键)
+            res.Rows.Add(new KpiDimensionRow
+            {
+                ValueDate = TryDate(row, dt, "value_date") ?? DateTime.MinValue,
+                DimensionType = TryString(row, dt, "dimension_type") ?? string.Empty,
+                DimensionCode = dimCode!,
+                DimensionName = TryString(row, dt, "dimension_name") ?? dimCode,
+                OrgId = TryLong(row, dt, "org_id"),
+                FactoryId = TryLong(row, dt, "factory_id"),
+                MaterialCode = TryString(row, dt, "material_code"),
+                WorkOrderNo = TryString(row, dt, "work_order_no"),
+                CategoryCode = TryString(row, dt, "category_code"),
+                WarehouseCode = TryString(row, dt, "warehouse_code"),
+                OrderNo = TryString(row, dt, "order_no"),
+                CustomerCode = TryString(row, dt, "customer_code"),
+                ProductCode = TryString(row, dt, "product_code"),
+                EquipmentCode = TryString(row, dt, "equipment_code"),
+                MetricValue = TryDecimal(row, dt, "metric_value"),
+                Numerator = TryDecimal(row, dt, "numerator") ?? TryDecimal(row, dt, "numerator_value"),
+                Denominator = TryDecimal(row, dt, "denominator") ?? TryDecimal(row, dt, "denominator_value"),
+                SumValue = TryDecimal(row, dt, "sum_value"),
+                SampleCount = (int?)TryLong(row, dt, "sample_count"),
+                SourceKey = TryString(row, dt, "source_key"),
+            });
+        }
+
+        res.RowCount = res.Rows.Count;
+        res.Status = res.Rows.Count == 0 ? "NO_DATA" : "SUCCESS";
+    }
+
+    private static string? GetColumn(DataTable dt, string name)
+    {
+        foreach (DataColumn c in dt.Columns)
+            if (string.Equals(c.ColumnName, name, StringComparison.OrdinalIgnoreCase)) return c.ColumnName;
+        return null;
+    }
+
+    private static string? TryString(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        return v == null || v == DBNull.Value ? null : v.ToString();
+    }
+
+    private static DateTime? TryDate(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        if (v == null || v == DBNull.Value) return null;
+        if (v is DateTime d) return d;
+        return DateTime.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), CultureInfo.InvariantCulture,
+            DateTimeStyles.None, out var parsed) ? parsed : (DateTime?)null;
+    }
+
+    private static long? TryLong(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        if (v == null || v == DBNull.Value) return null;
+        return long.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture), NumberStyles.Any,
+            CultureInfo.InvariantCulture, out var l) ? l : (long?)null;
+    }
+
+    private static decimal? TryDecimal(DataRow row, DataTable dt, string name)
+    {
+        var col = GetColumn(dt, name);
+        if (col == null) return null;
+        var v = row[col];
+        if (v == null || v == DBNull.Value) return null;
+        return TryToDecimal(v, out var d) ? d : (decimal?)null;
+    }
+
+    private static bool TryToDecimal(object v, out decimal d)
+    {
+        switch (v)
+        {
+            case decimal dec: d = dec; return true;
+            case double db: d = (decimal)db; return true;
+            case float f: d = (decimal)f; return true;
+            case long l: d = l; return true;
+            case int i: d = i; return true;
+            case short s: d = s; return true;
+            case byte b: d = b; return true;
+            default:
+                return decimal.TryParse(Convert.ToString(v, CultureInfo.InvariantCulture),
+                    NumberStyles.Any, CultureInfo.InvariantCulture, out d);
+        }
+    }
+
+    private static string Truncate(string s, int max) =>
+        string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s.Substring(0, max));
+}