Przeglądaj źródła

feat(kpi): route S7_L1_001 filters to configurable dimension results

Backend phase of S7_L1_001 user-side closeout. Only S7_L1_001 is switched;
other 8 codes unchanged. Card (no filter) still reads SUMMARY CONFIG_SQL.

- seed 1.0.287.sql: idempotent固化 of S7_L1_001 dimension config, dynamic
  SUMMARY binding, guards against double-current (0 rows on existing)
- KpiDimensionDashboardService: resolves managed-code filtered value from
  ado_smart_ops_kpi_dimension_value_day (current version), AggregationType
  recompute + per-day trend; version-consistent; NO_DATA/NOT_CONFIGURED/
  VERSION_MISMATCH never fall back to atomic; KPI-tenant (pbxfxp) resolved
- SmartOpsFilter routing: DimensionManagedMetricCodes={S7_L1_001} excluded
  from atomic query, resolved via dimension service; per-metric filterMode
- scheduling: S7 SUMMARY run success/no_data triggers dimension run (shared
  batchId), defensive
- detail: +orderNo/sortField(whitelist)/sortOrder, pageSize cap 200
- bump version server 1.0.287
YY968XX 1 tydzień temu
rodzic
commit
0dd6fb17a4

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

@@ -11,9 +11,9 @@
     <GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
     <Copyright>Admin.NET</Copyright>
     <Description>Admin.NET ͨ��Ȩ�޿���ƽ̨</Description>
-    <AssemblyVersion>1.0.286</AssemblyVersion>
-    <FileVersion>1.0.286</FileVersion>
-    <Version>1.0.286</Version>
+    <AssemblyVersion>1.0.287</AssemblyVersion>
+    <FileVersion>1.0.287</FileVersion>
+    <Version>1.0.287</Version>
   </PropertyGroup>
 
   <ItemGroup>
@@ -262,6 +262,9 @@
     <None Update="UpdateScripts\1.0.282.sql">
       <CopyToOutputDirectory>Always</CopyToOutputDirectory>
     </None>
+    <None Update="UpdateScripts\1.0.287.sql">
+      <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+    </None>
   </ItemGroup>
 
   <ItemGroup>

+ 27 - 0
server/Admin.NET.Web.Entry/UpdateScripts/1.0.287.sql

@@ -0,0 +1,27 @@
+-- AIDOP-S7-L1-001-DIMENSION-UI-DASHBOARD-CLOSEOUT-1:S7_L1_001 维度配置生产固化(幂等)。
+-- 动态绑定当前生效 SUMMARY 配置(不写死 SummaryConfigId/dev Id);已存在当前生效维度配置则整体跳过(杜绝双 Current)。
+-- 若正式 SUMMARY 版本与预期不一致,不强行插入错误绑定:无当前维度配置才插,插入即绑定彼时真实 SUMMARY 版本。
+-- 表列 PascalCase(EnableUnderLine=false);SQL 来自已通过试点验证的订单维度实现,未改业务公式。
+-- 幂等:NOT EXISTS(任一 IsCurrent=1 的 S7_L1_001 维度配置) 才插;重复执行影响行 0。
+
+INSERT INTO ado_smart_ops_kpi_dimension_config
+  (`TenantId`,`MetricCode`,`ModuleCode`,`SummaryConfigId`,`SummaryConfigVersion`,`DimensionConfigVersion`,
+   `DataSourceCode`,`SqlScript`,`AggregationType`,`SupportedDimensionsJson`,`TimeoutSeconds`,
+   `PublishStatus`,`IsCurrent`,`BusinessSqlSource`,`ChangeRemark`,`Remark`,`CreatedBy`,`CreatedAt`)
+SELECT s.`TenantId`, 'S7_L1_001', 'S7', s.`Id`, s.`VersionNo`, 1,
+   'MDP_LOCAL',
+   'SELECT biz_date AS value_date, ''ORDER'' AS dimension_type, order_no AS dimension_code, order_no AS dimension_name, cycle_days AS metric_value, cycle_days AS sum_value, 1 AS sample_count, order_no AS source_key
+FROM dwd_t8_order_shipment_cycle
+WHERE tenant_id = @tenant_id AND factory_id = @factory_id AND biz_date = @biz_date AND source_ztid = @ztid
+  AND cycle_days IS NOT NULL',
+   'AVERAGE_OF_SUMS', '["value_date","order_no"]', 60,
+   'PUBLISHED', 1,
+   'SUMMARY S7_L1_001 同源 dwd_t8_order_shipment_cycle 订单级 cycle_days',
+   '生产固化:订单发货周期订单维度,AVERAGE_OF_SUMS 与汇总 AVG(cycle_days) 1:1', 'S7_L1_001 维度配置生产初始化', 'system', NOW()
+FROM ado_smart_ops_kpi_calc_config s
+WHERE s.`MetricCode` = 'S7_L1_001' AND s.`IsCurrent` = 1 AND s.`PublishStatus` = 'PUBLISHED'
+  AND NOT EXISTS (
+    SELECT 1 FROM ado_smart_ops_kpi_dimension_config d
+    WHERE d.`MetricCode` = 'S7_L1_001' AND d.`TenantId` = s.`TenantId` AND d.`IsCurrent` = 1
+  )
+LIMIT 1;

+ 5 - 2
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AdoSmartOpsKpiDimensionConfigController.cs

@@ -136,12 +136,15 @@ public class AdoSmartOpsKpiDimensionConfigController : ControllerBase
     public async Task<IActionResult> Detail(
         string metricCode, [FromQuery] string moduleCode,
         [FromQuery] DateTime? startDate, [FromQuery] DateTime? endDate,
-        [FromQuery] string? dimensionType, [FromQuery] string? dimensionCode,
+        [FromQuery] string? dimensionType, [FromQuery] string? dimensionCode, [FromQuery] string? orderNo,
+        [FromQuery] string? sortField, [FromQuery] string? sortOrder,
         [FromQuery] int page = 1, [FromQuery] int pageSize = 20)
     {
         if (string.IsNullOrWhiteSpace(metricCode) || string.IsNullOrWhiteSpace(moduleCode))
             return BadRequest(new { message = "metricCode / moduleCode 必填" });
-        return Ok(await _service.QueryDetailAsync(metricCode, moduleCode, startDate, endDate, dimensionType, dimensionCode, page, pageSize));
+        if (pageSize > 200) pageSize = 200; // 分页上限,防大结果
+        return Ok(await _service.QueryDetailAsync(metricCode, moduleCode, startDate, endDate,
+            dimensionType, dimensionCode, page, pageSize, orderNo, sortField, sortOrder));
     }
 
     /// <summary>某 KPI 的维度运行日志(分页,脱敏,租户后端解析)。</summary>

+ 36 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/Controllers/AidopKanbanController.SmartOpsFilter.cs

@@ -5,6 +5,13 @@ namespace Admin.NET.Plugin.AiDOP.Controllers;
 
 public partial class AidopKanbanController
 {
+    /// <summary>
+    /// 受配置化维度托管的 KPI:有筛选时改读 ado_smart_ops_kpi_dimension_value_day(当前激活版本),
+    /// 绝不再走旧 Atomic 旁路。本批只纳管 S7_L1_001,其余 8 码保持现状。
+    /// </summary>
+    private static readonly HashSet<string> DimensionManagedMetricCodes =
+        new(StringComparer.OrdinalIgnoreCase) { "S7_L1_001" };
+
     private sealed record DashboardValuesBundle(
         Dictionary<string, DashboardMetricValueModel> Values,
         string Scope,
@@ -48,7 +55,9 @@ public partial class AidopKanbanController
                 new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase));
         }
 
-        var filtered = await _atomicQuery.QueryAsync(tenantId, factoryId, moduleCode, filter, kpis);
+        // 受管码从旧 Atomic 查询中排除,改走配置化维度结果(权威单入口,不回落)
+        var atomicKpis = kpis.Where(k => !DimensionManagedMetricCodes.Contains(k.MetricCode)).ToList();
+        var filtered = await _atomicQuery.QueryAsync(tenantId, factoryId, moduleCode, filter, atomicKpis);
         var merged = new Dictionary<string, DashboardMetricValueModel>(summaryValues, StringComparer.OrdinalIgnoreCase);
 
         foreach (var (metricCode, atomicValue) in filtered.Values)
@@ -65,11 +74,35 @@ public partial class AidopKanbanController
                     .ToList());
         }
 
+        var metricModes = new Dictionary<string, string>(filtered.MetricFilterModes, StringComparer.OrdinalIgnoreCase);
+
+        // 受管码走配置化维度结果(版本一致校验;NO_DATA/NOT_CONFIGURED/VERSION_MISMATCH 均不回落 Atomic)
+        var managedKpis = kpis.Where(k => DimensionManagedMetricCodes.Contains(k.MetricCode)).ToList();
+        if (managedKpis.Count > 0)
+        {
+            var dimensionService = App.GetRequiredService<KpiDimensionDashboardService>();
+            foreach (var k in managedKpis)
+            {
+                var dim = await dimensionService.ResolveAsync(tenantId, factoryId, k.MetricCode, moduleCode, filter);
+                summaryValues.TryGetValue(k.MetricCode, out var sv);
+                merged[k.MetricCode] = new DashboardMetricValueModel(
+                    sv?.Level ?? 1,
+                    dim.MetricValue,
+                    sv?.TargetValue,
+                    sv?.StatusColor,
+                    sv?.TrendFlag,
+                    dim.Trend.Select(t => new DashboardTrendPointModel(t.Date, t.Value)).ToList());
+                metricModes[k.MetricCode] = dim.FilterMode;
+            }
+        }
+
+        // 仅受管码被筛选时标 dimension_filtered;与旧码混合时保留 atomic scope,权威以每指标 filterMode 为准
+        var scope = atomicKpis.Count == 0 && managedKpis.Count > 0 ? "dimension_filtered" : filtered.Scope;
         return new DashboardValuesBundle(
             merged,
-            filtered.Scope,
+            scope,
             filtered.FilterContext,
-            filtered.MetricFilterModes,
+            metricModes,
             filtered.UnsupportedFiltersByMetric);
     }
 

+ 19 - 1
server/Plugins/Admin.NET.Plugin.AiDOP/FinishedWarehouse/S7MdpSyncTransformService.cs

@@ -17,6 +17,7 @@ public class S7MdpSyncTransformService : ITransient
     private readonly SysNoticeService _sysNoticeService;
     private readonly ILogger<S7MdpSyncTransformService> _logger;
     private readonly SmartOps.KpiCalcDispatcher _kpiCalcDispatcher;
+    private readonly SmartOps.KpiDimensionRunService _dimensionRun;
     private const string JobCode = "S7_MDP_SYNC_TRANSFORM";
     private const string JobName = "S7 成品仓储 MDP 同步与转换";
     private const string ModuleCode = "S7";
@@ -28,12 +29,14 @@ public class S7MdpSyncTransformService : ITransient
         ISqlSugarClient db,
         SysNoticeService sysNoticeService,
         ILogger<S7MdpSyncTransformService> logger,
-        SmartOps.KpiCalcDispatcher kpiCalcDispatcher)
+        SmartOps.KpiCalcDispatcher kpiCalcDispatcher,
+        SmartOps.KpiDimensionRunService dimensionRun)
     {
         _db = db;
         _sysNoticeService = sysNoticeService;
         _logger = logger;
         _kpiCalcDispatcher = kpiCalcDispatcher;
+        _dimensionRun = dimensionRun;
     }
 
     public async Task<S7MdpSyncTransformResult> RunFullAsync(
@@ -164,6 +167,21 @@ ON DUPLICATE KEY UPDATE
             ? await UpsertKpiValueAsync("S7_L1_001", option.BizDate, dispatch.MetricValue, now, option)
             : 0;
         sub.DenominatorStatus = dispatch.DenominatorStatus;
+
+        // 调度:SUMMARY 成功/NO_DATA 后触发对应 DIMENSION 跑批(共享 BatchId;SUMMARY FAILED 不触发)。
+        // 维度失败不影响汇总链路(内部已落 dimension_run_log)。
+        if (dispatch.ShouldUpsert)
+        {
+            try
+            {
+                await _dimensionRun.RunDimensionAsync(
+                    "S7_L1_001", ModuleCode, option.TargetTenantId, option.BizDate, batchId, triggerType, ct);
+            }
+            catch (Exception ex)
+            {
+                _logger.LogWarning(ex, "S7_L1_001 维度跑批异常(不影响汇总链路)");
+            }
+        }
         return sub;
     }
 

+ 13 - 3
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/AdoSmartOpsKpiDimensionConfigService.cs

@@ -301,7 +301,8 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
     /// </summary>
     public async Task<KpiDimensionDetailResultDto> QueryDetailAsync(
         string metricCode, string moduleCode, DateTime? startDate, DateTime? endDate,
-        string? dimensionType, string? dimensionCode, int page, int pageSize)
+        string? dimensionType, string? dimensionCode, int page, int pageSize,
+        string? orderNo = null, string? sortField = null, string? sortOrder = null)
     {
         var tenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
         var active = await GetActiveAsync(tenantId, metricCode);
@@ -317,6 +318,7 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
 
         var sd = startDate?.Date;
         var ed = endDate?.Date;
+        var ordNo = string.IsNullOrWhiteSpace(orderNo) ? null : orderNo!.Trim();
         ISugarQueryable<AdoSmartOpsKpiDimensionValueDay> Build() =>
             _db.Queryable<AdoSmartOpsKpiDimensionValueDay>().ClearFilter<ITenantIdFilter>()
                 .Where(x => x.TenantId == tenantId && x.MetricCode == metricCode
@@ -324,7 +326,8 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
                 .WhereIF(sd.HasValue, x => x.ValueDate >= sd!.Value)
                 .WhereIF(ed.HasValue, x => x.ValueDate <= ed!.Value)
                 .WhereIF(!string.IsNullOrWhiteSpace(dimensionType), x => x.DimensionType == dimensionType)
-                .WhereIF(!string.IsNullOrWhiteSpace(dimensionCode), x => x.DimensionCode == dimensionCode);
+                .WhereIF(!string.IsNullOrWhiteSpace(dimensionCode), x => x.DimensionCode == dimensionCode)
+                .WhereIF(ordNo != null, x => x.OrderNo == ordNo || x.DimensionCode == ordNo);
 
         result.AggregateValue = active.AggregationType switch
         {
@@ -334,8 +337,15 @@ public sealed class AdoSmartOpsKpiDimensionConfigService : ITransient
             _ => null,
         };
 
+        // 排序字段白名单,禁止任意列注入
+        var ot = string.Equals(sortOrder, "asc", StringComparison.OrdinalIgnoreCase) ? OrderByType.Asc : OrderByType.Desc;
+        var sorted = (sortField ?? "").Trim().ToLowerInvariant() switch
+        {
+            "metric_value" or "metricvalue" => Build().OrderBy(x => x.MetricValue, ot).OrderBy(x => x.DimensionCode),
+            _ => Build().OrderBy(x => x.ValueDate, ot).OrderBy(x => x.DimensionCode),
+        };
         RefAsync<int> total = 0;
-        var list = await Build().OrderBy(x => x.ValueDate, OrderByType.Desc).OrderBy(x => x.DimensionCode)
+        var list = await sorted
             .ToPageListAsync(page <= 0 ? 1 : page, pageSize <= 0 ? 20 : pageSize, total);
         result.Total = total.Value;
         result.List = list.Select(x => new KpiDimensionDetailRowDto

+ 140 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/SmartOps/KpiDimensionDashboardService.cs

@@ -0,0 +1,140 @@
+using Admin.NET.Core;
+using Admin.NET.Plugin.AiDOP.Entity;
+using SqlSugar;
+
+namespace Admin.NET.Plugin.AiDOP.SmartOps;
+
+/// <summary>维度看板解析结果(供 Dashboard 受管码筛选取数;绝不回落旧 Atomic)。</summary>
+public sealed class KpiDimensionDashboardResult
+{
+    /// <summary>SUCCESS / NO_DATA / NOT_CONFIGURED / VERSION_MISMATCH。</summary>
+    public string Status { get; set; } = "NO_DATA";
+    /// <summary>前端每指标 filterMode:configurable_dimension / dimension_no_data / filter_not_configured / version_mismatch。</summary>
+    public string FilterMode { get; set; } = "dimension_no_data";
+    public decimal? MetricValue { get; set; }
+    public List<(string Date, decimal? Value)> Trend { get; set; } = new();
+    public int? SummaryConfigVersion { get; set; }
+    public int? DimensionConfigVersion { get; set; }
+}
+
+/// <summary>
+/// 维度看板取数服务:为受管 KPI(本批 S7_L1_001)在有筛选时从 ado_smart_ops_kpi_dimension_value_day
+/// 读当前激活维度版本结果,按 AggregationType 复算筛选值 + 逐日趋势。校验绑定汇总版本一致;
+/// 无配置/版本不匹配/无数据均返回明确状态,**不回落旧 Atomic**。
+/// </summary>
+public sealed class KpiDimensionDashboardService : ITransient
+{
+    public const string FilterModeConfigured = "configurable_dimension";
+    public const string FilterModeNoData = "dimension_no_data";
+    public const string FilterModeNotConfigured = "filter_not_configured";
+    public const string FilterModeVersionMismatch = "version_mismatch";
+
+    private readonly ISqlSugarClient _db;
+    private readonly AdoSmartOpsKpiDimensionConfigService _dimensionConfig;
+    private readonly AdoSmartOpsKpiCalcConfigService _summaryConfig;
+
+    public KpiDimensionDashboardService(
+        ISqlSugarClient db, AdoSmartOpsKpiDimensionConfigService dimensionConfig, AdoSmartOpsKpiCalcConfigService summaryConfig)
+    {
+        _db = db;
+        _dimensionConfig = dimensionConfig;
+        _summaryConfig = summaryConfig;
+    }
+
+    public async Task<KpiDimensionDashboardResult> ResolveAsync(
+        long tenantId, long factoryId, string metricCode, string moduleCode, SmartOpsDashboardFilter filter)
+    {
+        // 维度配置/结果落在 KPI 数据租户(S5/S6/S7→pbxfxp/AIDOP),与看板会话租户可能不同,须显式解析
+        var kpiTenantId = AdoSmartOpsKpiCalcConfigService.ResolveKpiTenantId(moduleCode);
+        var active = await _dimensionConfig.GetActiveAsync(kpiTenantId, metricCode);
+        if (active == null)
+            return new KpiDimensionDashboardResult { Status = "NOT_CONFIGURED", FilterMode = FilterModeNotConfigured };
+
+        var summary = await _summaryConfig.GetActiveAsync(kpiTenantId, metricCode);
+        if (summary == null || summary.Id != active.SummaryConfigId || summary.VersionNo != active.SummaryConfigVersion)
+            return new KpiDimensionDashboardResult
+            {
+                Status = "VERSION_MISMATCH", FilterMode = FilterModeVersionMismatch,
+                SummaryConfigVersion = active.SummaryConfigVersion, DimensionConfigVersion = active.DimensionConfigVersion,
+            };
+
+        var result = new KpiDimensionDashboardResult
+        {
+            SummaryConfigVersion = active.SummaryConfigVersion,
+            DimensionConfigVersion = active.DimensionConfigVersion,
+        };
+
+        var ds = filter.DateStart?.Date;
+        var de = filter.DateEnd?.Date;
+        var orderNo = string.IsNullOrWhiteSpace(filter.OrderNo) ? null : filter.OrderNo!.Trim();
+        ISugarQueryable<AdoSmartOpsKpiDimensionValueDay> Build() =>
+            _db.Queryable<AdoSmartOpsKpiDimensionValueDay>().ClearFilter<ITenantIdFilter>()
+                .Where(x => x.TenantId == kpiTenantId && x.MetricCode == metricCode
+                            && x.DimensionConfigVersion == active.DimensionConfigVersion)
+                .WhereIF(ds.HasValue, x => x.ValueDate >= ds!.Value)
+                .WhereIF(de.HasValue, x => x.ValueDate <= de!.Value)
+                .WhereIF(orderNo != null, x => x.OrderNo == orderNo || x.DimensionCode == orderNo);
+
+        result.MetricValue = await AggregateAsync(active.AggregationType, Build);
+        // 逐日趋势(同 AggregationType 复算)
+        var grouped = await Build().GroupBy(x => x.ValueDate)
+            .Select(x => new DimTrendRow
+            {
+                D = x.ValueDate,
+                SumSum = SqlFunc.AggregateSum(x.SumValue),
+                SumCnt = SqlFunc.AggregateSum(x.SampleCount),
+                SumNum = SqlFunc.AggregateSum(x.Numerator),
+                SumDen = SqlFunc.AggregateSum(x.Denominator),
+                AvgMv = SqlFunc.AggregateAvg(x.MetricValue),
+            })
+            .OrderBy(x => x.D).ToListAsync();
+        result.Trend = grouped
+            .Select(g => (g.D.ToString("yyyy-MM-dd"), ComputeAggregate(active.AggregationType, g.SumSum, g.SumCnt, g.SumNum, g.SumDen, g.AvgMv)))
+            .ToList();
+
+        if (result.MetricValue == null)
+        {
+            result.Status = "NO_DATA";
+            result.FilterMode = FilterModeNoData;
+        }
+        else
+        {
+            result.Status = "SUCCESS";
+            result.FilterMode = FilterModeConfigured;
+        }
+        return result;
+    }
+
+    private static async Task<decimal?> AggregateAsync(string aggregationType, Func<ISugarQueryable<AdoSmartOpsKpiDimensionValueDay>> build)
+    {
+        return aggregationType switch
+        {
+            "AVERAGE_OF_SUMS" => Div(await build().SumAsync(x => x.SumValue), (decimal?)await build().SumAsync(x => x.SampleCount)),
+            "RATIO_OF_SUMS" => Div(await build().SumAsync(x => x.Numerator), await build().SumAsync(x => x.Denominator)),
+            "DIRECT_VALUE" => await build().AvgAsync(x => x.MetricValue),
+            _ => null,
+        };
+    }
+
+    private static decimal? ComputeAggregate(string aggregationType, decimal? sumSum, decimal? sumCnt, decimal? sumNum, decimal? sumDen, decimal? avgMv)
+        => aggregationType switch
+        {
+            "AVERAGE_OF_SUMS" => Div(sumSum, sumCnt),
+            "RATIO_OF_SUMS" => Div(sumNum, sumDen),
+            "DIRECT_VALUE" => avgMv,
+            _ => null,
+        };
+
+    private static decimal? Div(decimal? a, decimal? b) =>
+        b.HasValue && b.Value != 0 ? (a ?? 0m) / b.Value : null;
+
+    private sealed class DimTrendRow
+    {
+        public DateTime D { get; set; }
+        public decimal? SumSum { get; set; }
+        public decimal? SumCnt { get; set; }
+        public decimal? SumNum { get; set; }
+        public decimal? SumDen { get; set; }
+        public decimal? AvgMv { get; set; }
+    }
+}