|
|
@@ -0,0 +1,172 @@
|
|
|
+using Admin.NET.Plugin.AiDOP.Dto.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Entity.S8;
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Service.S8;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S9 KPI 目标值配置服务(S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1)。
|
|
|
+///
|
|
|
+/// 复用 <c>ado_s8_monitor_metric</c>,不新建配置表;只维护 <c>default_target_ratio</c>(目标值)。
|
|
|
+/// 作用域语义与步骤 1 完全一致:平台默认(0/0)租户侧只读,需要调整走「自定义本工厂」覆盖,
|
|
|
+/// 「恢复平台默认」只删覆盖行。生效读取:工厂覆盖 > 平台默认。
|
|
|
+///
|
|
|
+/// 边界:
|
|
|
+/// - 只暴露 Result KPI(mechanism='RATIO' AND is_result_kpi=1),当前 5 项;
|
|
|
+/// 表里其余 8 行是监控指标字典,不属于业务目标值配置,不向业务前端暴露。
|
|
|
+/// - <b>只配置 TargetRatio。</b>KPI 当前值(CurrentValue)不是配置项,由业务计算/接口提供,本服务不涉及。
|
|
|
+/// </summary>
|
|
|
+public class S8KpiTargetConfigService : ITransient
|
|
|
+{
|
|
|
+ /// <summary>目标值为百分比,合法区间 [0, 100]。</summary>
|
|
|
+ public const decimal MinTargetRatio = 0m;
|
|
|
+ public const decimal MaxTargetRatio = 100m;
|
|
|
+
|
|
|
+ private readonly SqlSugarRepository<AdoS8MonitorMetric> _rep;
|
|
|
+
|
|
|
+ public S8KpiTargetConfigService(SqlSugarRepository<AdoS8MonitorMetric> rep) => _rep = rep;
|
|
|
+
|
|
|
+ /// <summary>Result KPI 判定:与 <see cref="S8MonitoringService.GetResultKpiSummaryAsync"/> 同口径。</summary>
|
|
|
+ private ISugarQueryable<AdoS8MonitorMetric> ResultKpiQuery() =>
|
|
|
+ _rep.AsQueryable().Where(x => x.Mechanism == "RATIO" && x.IsResultKpi);
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 列表:返回 5 项 Result KPI 的「生效目标值」视图。
|
|
|
+ /// 有工厂覆盖 → Scope=FACTORY 且 TargetRatio 取覆盖值;否则 Scope=GLOBAL 取平台默认值。
|
|
|
+ /// 不返回 TenantId / FactoryId / data_source 等技术字段。
|
|
|
+ /// </summary>
|
|
|
+ public async Task<List<AdoS8KpiTargetItemDto>> ListAsync(S8TrustedScope scope)
|
|
|
+ {
|
|
|
+ var rows = await ResultKpiQuery()
|
|
|
+ .Where(x => (x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId)
|
|
|
+ || (x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId))
|
|
|
+ .ToListAsync();
|
|
|
+
|
|
|
+ var globals = rows
|
|
|
+ .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
|
|
|
+ .ToDictionary(x => x.MetricCode);
|
|
|
+ var overrides = rows
|
|
|
+ .Where(x => !S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
|
|
|
+ .ToDictionary(x => x.MetricCode);
|
|
|
+
|
|
|
+ // 以平台默认行为骨架:平台没有定义的指标不应凭工厂覆盖凭空出现。
|
|
|
+ return globals.Values
|
|
|
+ .OrderBy(x => x.SortNo).ThenBy(x => x.MetricCode)
|
|
|
+ .Select(g =>
|
|
|
+ {
|
|
|
+ overrides.TryGetValue(g.MetricCode, out var ov);
|
|
|
+ return new AdoS8KpiTargetItemDto
|
|
|
+ {
|
|
|
+ MetricCode = g.MetricCode,
|
|
|
+ MetricName = g.MetricName,
|
|
|
+ Unit = string.IsNullOrWhiteSpace(g.Unit) ? "%" : g.Unit!,
|
|
|
+ TargetRatio = ov?.DefaultTargetRatio ?? g.DefaultTargetRatio,
|
|
|
+ GlobalTargetRatio = g.DefaultTargetRatio,
|
|
|
+ Remark = ov?.Remark ?? g.Remark,
|
|
|
+ Scope = ov != null ? S8ConfigScope.Factory : S8ConfigScope.Global,
|
|
|
+ HasFactoryOverride = ov != null,
|
|
|
+ };
|
|
|
+ })
|
|
|
+ .ToList();
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 自定义本工厂 / 编辑本工厂目标值(同一入口,按业务键 metricCode 定位,不暴露 DB Id)。
|
|
|
+ /// 无覆盖行 → 复制平台默认并盖章可信作用域后新建;已有覆盖行 → 就地更新目标值。
|
|
|
+ /// 归属列一律由服务端盖章,不接受客户端传入。
|
|
|
+ /// </summary>
|
|
|
+ public async Task<AdoS8KpiTargetItemDto> UpsertFactoryOverrideAsync(
|
|
|
+ string metricCode, decimal targetRatio, string? remark, S8TrustedScope scope)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(metricCode))
|
|
|
+ throw new S8BizException("指标编码必填");
|
|
|
+ ValidateTarget(targetRatio);
|
|
|
+
|
|
|
+ var code = metricCode.Trim();
|
|
|
+ var global = await ResultKpiQuery()
|
|
|
+ .Where(x => x.MetricCode == code
|
|
|
+ && x.TenantId == S8ConfigScope.GlobalTenantId
|
|
|
+ && x.FactoryId == S8ConfigScope.GlobalFactoryId)
|
|
|
+ .FirstAsync() ?? throw new S8NotFoundException("平台默认指标不存在");
|
|
|
+
|
|
|
+ var existing = await _rep.AsQueryable()
|
|
|
+ .Where(x => x.MetricCode == code
|
|
|
+ && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
|
|
|
+ .FirstAsync();
|
|
|
+
|
|
|
+ if (existing != null)
|
|
|
+ {
|
|
|
+ existing.DefaultTargetRatio = targetRatio;
|
|
|
+ if (remark != null) existing.Remark = NormalizeOrNull(remark);
|
|
|
+ existing.UpdatedAt = DateTime.Now;
|
|
|
+ await _rep.UpdateAsync(existing);
|
|
|
+ }
|
|
|
+ else
|
|
|
+ {
|
|
|
+ var copy = CloneForFactory(global, scope);
|
|
|
+ copy.DefaultTargetRatio = targetRatio;
|
|
|
+ if (remark != null) copy.Remark = NormalizeOrNull(remark);
|
|
|
+ copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync();
|
|
|
+ }
|
|
|
+
|
|
|
+ return (await ListAsync(scope)).First(x => x.MetricCode == code);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 恢复平台默认:只删除当前工厂覆盖行,平台默认行不受影响;删除后读取自动回落平台默认。
|
|
|
+ /// 本就没有覆盖行时按「不存在」处理(幂等语义由调用方 404 表达)。
|
|
|
+ /// </summary>
|
|
|
+ public async Task<AdoS8KpiTargetItemDto> ResetToGlobalDefaultAsync(string metricCode, S8TrustedScope scope)
|
|
|
+ {
|
|
|
+ if (string.IsNullOrWhiteSpace(metricCode))
|
|
|
+ throw new S8BizException("指标编码必填");
|
|
|
+ var code = metricCode.Trim();
|
|
|
+
|
|
|
+ var existing = await _rep.AsQueryable()
|
|
|
+ .Where(x => x.MetricCode == code
|
|
|
+ && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId)
|
|
|
+ .FirstAsync() ?? throw new S8NotFoundException("当前工厂没有覆盖配置,无需恢复");
|
|
|
+
|
|
|
+ await _rep.DeleteByIdAsync(existing.Id);
|
|
|
+ return (await ListAsync(scope)).First(x => x.MetricCode == code);
|
|
|
+ }
|
|
|
+
|
|
|
+ private static void ValidateTarget(decimal targetRatio)
|
|
|
+ {
|
|
|
+ if (targetRatio < MinTargetRatio || targetRatio > MaxTargetRatio)
|
|
|
+ throw new S8BizException($"目标值必须在 {MinTargetRatio:0}–{MaxTargetRatio:0} 之间");
|
|
|
+ }
|
|
|
+
|
|
|
+ private static string? NormalizeOrNull(string? v) =>
|
|
|
+ string.IsNullOrWhiteSpace(v) ? null : v.Trim();
|
|
|
+
|
|
|
+ /// <summary>复制平台默认行的字典字段,归属由服务端盖章;不复制 Id / 时间戳。</summary>
|
|
|
+ private static AdoS8MonitorMetric CloneForFactory(AdoS8MonitorMetric g, S8TrustedScope scope) => new()
|
|
|
+ {
|
|
|
+ Id = 0,
|
|
|
+ TenantId = scope.TenantId,
|
|
|
+ FactoryId = scope.FactoryId,
|
|
|
+ ObjectCode = g.ObjectCode,
|
|
|
+ MetricCode = g.MetricCode,
|
|
|
+ MetricName = g.MetricName,
|
|
|
+ Mechanism = g.Mechanism,
|
|
|
+ Unit = g.Unit,
|
|
|
+ DueAtField = g.DueAtField,
|
|
|
+ StatusField = g.StatusField,
|
|
|
+ MeasuredValueField = g.MeasuredValueField,
|
|
|
+ ObjectIdField = g.ObjectIdField,
|
|
|
+ ObjectCodeField = g.ObjectCodeField,
|
|
|
+ ObjectNameField = g.ObjectNameField,
|
|
|
+ DefaultGraceMinutes = g.DefaultGraceMinutes,
|
|
|
+ DefaultCompletedStates = g.DefaultCompletedStates,
|
|
|
+ DefaultTargetRatio = g.DefaultTargetRatio,
|
|
|
+ DefaultLowerBound = g.DefaultLowerBound,
|
|
|
+ DefaultUpperBound = g.DefaultUpperBound,
|
|
|
+ IsResultKpi = g.IsResultKpi,
|
|
|
+ Enabled = g.Enabled,
|
|
|
+ SortNo = g.SortNo,
|
|
|
+ Remark = g.Remark,
|
|
|
+ CreatedAt = DateTime.Now,
|
|
|
+ UpdatedAt = null,
|
|
|
+ };
|
|
|
+}
|