S8KpiTargetConfigService.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  5. /// <summary>
  6. /// S9 KPI 目标值配置服务(S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1)。
  7. ///
  8. /// 复用 <c>ado_s8_monitor_metric</c>,不新建配置表;只维护 <c>default_target_ratio</c>(目标值)。
  9. /// 作用域语义与步骤 1 完全一致:平台默认(0/0)租户侧只读,需要调整走「自定义本工厂」覆盖,
  10. /// 「恢复平台默认」只删覆盖行。生效读取:工厂覆盖 &gt; 平台默认。
  11. ///
  12. /// 边界:
  13. /// - 只暴露 Result KPI(mechanism='RATIO' AND is_result_kpi=1),当前 5 项;
  14. /// 表里其余 8 行是监控指标字典,不属于业务目标值配置,不向业务前端暴露。
  15. /// - <b>只配置 TargetRatio。</b>KPI 当前值(CurrentValue)不是配置项,由业务计算/接口提供,本服务不涉及。
  16. /// </summary>
  17. public class S8KpiTargetConfigService : ITransient
  18. {
  19. /// <summary>目标值为百分比,合法区间 [0, 100]。</summary>
  20. public const decimal MinTargetRatio = 0m;
  21. public const decimal MaxTargetRatio = 100m;
  22. private readonly SqlSugarRepository<AdoS8MonitorMetric> _rep;
  23. public S8KpiTargetConfigService(SqlSugarRepository<AdoS8MonitorMetric> rep) => _rep = rep;
  24. /// <summary>Result KPI 判定:与 <see cref="S8MonitoringService.GetResultKpiSummaryAsync"/> 同口径。</summary>
  25. private ISugarQueryable<AdoS8MonitorMetric> ResultKpiQuery() =>
  26. _rep.AsQueryable().Where(x => x.Mechanism == "RATIO" && x.IsResultKpi);
  27. /// <summary>
  28. /// 列表:返回 5 项 Result KPI 的「生效目标值」视图。
  29. /// 有工厂覆盖 → Scope=FACTORY 且 TargetRatio 取覆盖值;否则 Scope=GLOBAL 取平台默认值。
  30. /// 不返回 TenantId / FactoryId / data_source 等技术字段。
  31. /// </summary>
  32. public async Task<List<AdoS8KpiTargetItemDto>> ListAsync(S8TrustedScope scope)
  33. {
  34. var rows = await ResultKpiQuery()
  35. .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
  36. || (x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId))
  37. .ToListAsync();
  38. var globals = rows
  39. .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
  40. .ToDictionary(x => x.MetricCode);
  41. var overrides = rows
  42. .Where(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
  43. .ToDictionary(x => x.MetricCode);
  44. // 以平台默认行为骨架:平台没有定义的指标不应凭工厂覆盖凭空出现。
  45. return globals.Values
  46. .OrderBy(x => x.SortNo).ThenBy(x => x.MetricCode)
  47. .Select(g =>
  48. {
  49. overrides.TryGetValue(g.MetricCode, out var ov);
  50. return new AdoS8KpiTargetItemDto
  51. {
  52. MetricCode = g.MetricCode,
  53. MetricName = g.MetricName,
  54. Unit = string.IsNullOrWhiteSpace(g.Unit) ? "%" : g.Unit!,
  55. TargetRatio = ov?.DefaultTargetRatio ?? g.DefaultTargetRatio,
  56. GlobalTargetRatio = g.DefaultTargetRatio,
  57. Remark = ov?.Remark ?? g.Remark,
  58. Scope = ov != null ? S8ConfigScope.Factory : S8ConfigScope.Global,
  59. HasFactoryOverride = ov != null,
  60. };
  61. })
  62. .ToList();
  63. }
  64. /// <summary>
  65. /// 自定义本工厂 / 编辑本工厂目标值(同一入口,按业务键 metricCode 定位,不暴露 DB Id)。
  66. /// 无覆盖行 → 复制平台默认并盖章可信作用域后新建;已有覆盖行 → 就地更新目标值。
  67. /// 归属列一律由服务端盖章,不接受客户端传入。
  68. /// </summary>
  69. public async Task<AdoS8KpiTargetItemDto> UpsertFactoryOverrideAsync(
  70. string metricCode, decimal targetRatio, string? remark, S8TrustedScope scope)
  71. {
  72. if (string.IsNullOrWhiteSpace(metricCode))
  73. throw new S8BizException("指标编码必填");
  74. ValidateTarget(targetRatio);
  75. var code = metricCode.Trim();
  76. var global = await ResultKpiQuery()
  77. .Where(x => x.MetricCode == code
  78. && x.TenantId == S8ConfigScope.GlobalTenantId
  79. && x.FactoryId == S8ConfigScope.GlobalFactoryId)
  80. .FirstAsync() ?? throw new S8NotFoundException("平台默认指标不存在");
  81. var existing = await _rep.AsQueryable()
  82. .Where(x => x.MetricCode == code
  83. && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
  84. .FirstAsync();
  85. if (existing != null)
  86. {
  87. existing.DefaultTargetRatio = targetRatio;
  88. if (remark != null) existing.Remark = NormalizeOrNull(remark);
  89. existing.UpdatedAt = DateTime.Now;
  90. await _rep.UpdateAsync(existing);
  91. }
  92. else
  93. {
  94. var copy = CloneForFactory(global, scope);
  95. copy.DefaultTargetRatio = targetRatio;
  96. if (remark != null) copy.Remark = NormalizeOrNull(remark);
  97. copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync();
  98. }
  99. return (await ListAsync(scope)).First(x => x.MetricCode == code);
  100. }
  101. /// <summary>
  102. /// 恢复平台默认:只删除当前工厂覆盖行,平台默认行不受影响;删除后读取自动回落平台默认。
  103. /// 本就没有覆盖行时按「不存在」处理(幂等语义由调用方 404 表达)。
  104. /// </summary>
  105. public async Task<AdoS8KpiTargetItemDto> ResetToGlobalDefaultAsync(string metricCode, S8TrustedScope scope)
  106. {
  107. if (string.IsNullOrWhiteSpace(metricCode))
  108. throw new S8BizException("指标编码必填");
  109. var code = metricCode.Trim();
  110. var existing = await _rep.AsQueryable()
  111. .Where(x => x.MetricCode == code
  112. && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
  113. .FirstAsync() ?? throw new S8NotFoundException("当前工厂没有覆盖配置,无需恢复");
  114. await _rep.DeleteByIdAsync(existing.Id);
  115. return (await ListAsync(scope)).First(x => x.MetricCode == code);
  116. }
  117. private static void ValidateTarget(decimal targetRatio)
  118. {
  119. if (targetRatio < MinTargetRatio || targetRatio > MaxTargetRatio)
  120. throw new S8BizException($"目标值必须在 {MinTargetRatio:0}–{MaxTargetRatio:0} 之间");
  121. }
  122. private static string? NormalizeOrNull(string? v) =>
  123. string.IsNullOrWhiteSpace(v) ? null : v.Trim();
  124. /// <summary>复制平台默认行的字典字段,归属由服务端盖章;不复制 Id / 时间戳。</summary>
  125. private static AdoS8MonitorMetric CloneForFactory(AdoS8MonitorMetric g, S8TrustedScope scope) => new()
  126. {
  127. Id = 0,
  128. TenantId = scope.TenantId,
  129. FactoryId = scope.FactoryId,
  130. ObjectCode = g.ObjectCode,
  131. MetricCode = g.MetricCode,
  132. MetricName = g.MetricName,
  133. Mechanism = g.Mechanism,
  134. Unit = g.Unit,
  135. DueAtField = g.DueAtField,
  136. StatusField = g.StatusField,
  137. MeasuredValueField = g.MeasuredValueField,
  138. ObjectIdField = g.ObjectIdField,
  139. ObjectCodeField = g.ObjectCodeField,
  140. ObjectNameField = g.ObjectNameField,
  141. DefaultGraceMinutes = g.DefaultGraceMinutes,
  142. DefaultCompletedStates = g.DefaultCompletedStates,
  143. DefaultTargetRatio = g.DefaultTargetRatio,
  144. DefaultLowerBound = g.DefaultLowerBound,
  145. DefaultUpperBound = g.DefaultUpperBound,
  146. IsResultKpi = g.IsResultKpi,
  147. Enabled = g.Enabled,
  148. SortNo = g.SortNo,
  149. Remark = g.Remark,
  150. CreatedAt = DateTime.Now,
  151. UpdatedAt = null,
  152. };
  153. }