KpiTargetService.cs 9.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
  3. using Admin.NET.Plugin.AiDOP.Entity;
  4. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  5. public sealed class KpiTargetService : ITransient
  6. {
  7. public const int MaxImportRows = 10000;
  8. public const long MaxImportBytes = 5 * 1024 * 1024;
  9. private readonly IKpiTargetConfigStore _store;
  10. private readonly IKpiTargetResolver _resolver;
  11. private readonly UserManager _userManager;
  12. public KpiTargetService(IKpiTargetConfigStore store, IKpiTargetResolver resolver, UserManager userManager)
  13. {
  14. _store = store;
  15. _resolver = resolver;
  16. _userManager = userManager;
  17. }
  18. public long TenantId => AidopTenantScope.ResolveOrThrow(_userManager);
  19. public async Task<List<KpiTargetDto>> ListAsync(string metricCode, long? factoryId, CancellationToken ct = default)
  20. {
  21. EnsureMetricCode(metricCode);
  22. var tenantId = TenantId;
  23. await EnsureKpiAsync(tenantId, metricCode, ct);
  24. var list = await _store.ListAsync(tenantId, metricCode.Trim(), factoryId, ct);
  25. return list.Select(KpiTargetRules.ToDto).ToList();
  26. }
  27. public async Task<KpiTargetResolveResult> ResolveCurrentAsync(string metricCode, long factoryId, DateTime? bizDate, CancellationToken ct = default)
  28. {
  29. EnsureMetricCode(metricCode);
  30. if (factoryId < 0)
  31. throw new KpiTargetValidationException("工厂无效");
  32. var tenantId = TenantId;
  33. var kpi = await EnsureKpiAsync(tenantId, metricCode, ct);
  34. var day = (bizDate ?? DateTime.Today).Date;
  35. var snap = await _resolver.ResolveAsync(tenantId, factoryId, metricCode.Trim(), kpi.ModuleCode, day, ct);
  36. return new KpiTargetResolveResult
  37. {
  38. MetricCode = metricCode.Trim(),
  39. FactoryId = factoryId,
  40. BizDate = day.ToString("yyyy-MM-dd"),
  41. TargetValue = snap.TargetValue,
  42. TargetConfigId = snap.TargetConfigId,
  43. TargetSource = snap.TargetSource == KpiTargetSnapshotSource.Missing
  44. ? KpiTargetSnapshotSource.Missing
  45. : snap.TargetSource,
  46. ConfigSourceType = snap.TargetConfigId == null ? null : await ConfigSourceAsync(tenantId, snap.TargetConfigId.Value, ct),
  47. EffectiveFrom = snap.EffectiveFrom?.ToString("yyyy-MM-dd"),
  48. EffectiveTo = snap.EffectiveTo?.ToString("yyyy-MM-dd"),
  49. ResolvedAt = snap.ResolvedAt
  50. };
  51. }
  52. public Task<KpiTargetDto> CreateAsync(KpiTargetCreateDto dto, string sourceType, string? batchId, CancellationToken ct = default) =>
  53. CreateCoreAsync(TenantId, dto, sourceType, batchId, _userManager.UserId, ct);
  54. public async Task<KpiTargetDto> CreateCoreAsync(
  55. long tenantId,
  56. KpiTargetCreateDto dto,
  57. string sourceType,
  58. string? batchId,
  59. long? userId,
  60. CancellationToken ct = default)
  61. {
  62. EnsureMetricCode(dto.MetricCode);
  63. if (dto.FactoryId < 0)
  64. throw new KpiTargetValidationException("工厂无效");
  65. KpiTargetRules.EnsureTargetPositive(dto.TargetValue);
  66. var from = KpiTargetRules.Normalize(dto.EffectiveFrom);
  67. KpiTargetRules.EnsureRange(from, dto.EffectiveTo);
  68. await EnsureKpiAsync(tenantId, dto.MetricCode, ct);
  69. var existing = await _store.ListActiveScopeAsync(tenantId, dto.FactoryId, dto.MetricCode.Trim(), ct);
  70. if (dto.ClosePrevious)
  71. await ClosePreviousOpenAsync(existing, from, ct);
  72. var refreshed = await _store.ListActiveScopeAsync(tenantId, dto.FactoryId, dto.MetricCode.Trim(), ct);
  73. foreach (var row in refreshed.Where(x => x.Status == 1))
  74. {
  75. if (KpiTargetRules.Overlaps(row.EffectiveFrom, row.EffectiveTo, from, dto.EffectiveTo))
  76. throw new KpiTargetConflictException(
  77. $"与已有目标重叠:{row.EffectiveFrom:yyyy-MM-dd}~{row.EffectiveTo?.ToString("yyyy-MM-dd") ?? "开放"}");
  78. }
  79. var now = DateTime.Now;
  80. var created = await _store.InsertAsync(new AdoSmartOpsKpiTargetConfig
  81. {
  82. TenantId = tenantId,
  83. FactoryId = dto.FactoryId,
  84. MetricCode = dto.MetricCode.Trim(),
  85. TargetValue = dto.TargetValue,
  86. EffectiveFrom = from,
  87. EffectiveTo = dto.EffectiveTo?.Date,
  88. SourceType = sourceType,
  89. SourceBatchId = batchId,
  90. Status = 1,
  91. Remark = dto.Remark,
  92. CreatedBy = userId,
  93. UpdatedBy = userId,
  94. CreateTime = now,
  95. UpdateTime = now
  96. }, ct);
  97. return KpiTargetRules.ToDto(created);
  98. }
  99. public Task<KpiTargetDto> UpdateAsync(long id, KpiTargetUpdateDto dto, CancellationToken ct = default) =>
  100. UpdateCoreAsync(TenantId, id, dto, ct);
  101. public async Task<KpiTargetDto> UpdateCoreAsync(long tenantId, long id, KpiTargetUpdateDto dto, CancellationToken ct = default)
  102. {
  103. var row = await _store.GetByIdAsync(tenantId, id, ct)
  104. ?? throw new KpiTargetNotFoundException("目标配置不存在");
  105. var today = DateTime.Today;
  106. var alreadyEffective = row.Status == 1 && row.EffectiveFrom.Date <= today;
  107. if (dto.TargetValue.HasValue)
  108. {
  109. if (alreadyEffective && dto.TargetValue.Value != row.TargetValue)
  110. throw new KpiTargetConflictException("已生效目标不可原地改值,请新增未来版本");
  111. KpiTargetRules.EnsureTargetPositive(dto.TargetValue.Value);
  112. row.TargetValue = dto.TargetValue.Value;
  113. }
  114. if (dto.EffectiveTo.HasValue)
  115. {
  116. KpiTargetRules.EnsureRange(row.EffectiveFrom, dto.EffectiveTo);
  117. var others = (await _store.ListActiveScopeAsync(tenantId, row.FactoryId, row.MetricCode, ct))
  118. .Where(x => x.Id != row.Id && x.Status == 1);
  119. foreach (var other in others)
  120. {
  121. if (KpiTargetRules.Overlaps(row.EffectiveFrom, dto.EffectiveTo, other.EffectiveFrom, other.EffectiveTo))
  122. throw new KpiTargetConflictException("调整失效日后与其它目标重叠");
  123. }
  124. row.EffectiveTo = dto.EffectiveTo.Value.Date;
  125. }
  126. if (dto.Remark != null)
  127. row.Remark = dto.Remark;
  128. row.UpdatedBy = _userManager?.UserId;
  129. row.UpdateTime = DateTime.Now;
  130. await _store.UpdateAsync(row, ct);
  131. return KpiTargetRules.ToDto(row);
  132. }
  133. public async Task DisableAsync(long id, CancellationToken ct = default)
  134. {
  135. var tenantId = TenantId;
  136. var row = await _store.GetByIdAsync(tenantId, id, ct)
  137. ?? throw new KpiTargetNotFoundException("目标配置不存在");
  138. if (await _store.IsReferencedByDailyAsync(tenantId, id, ct))
  139. {
  140. row.Status = 0;
  141. row.EffectiveTo ??= DateTime.Today;
  142. row.UpdatedBy = _userManager.UserId;
  143. row.UpdateTime = DateTime.Now;
  144. await _store.UpdateAsync(row, ct);
  145. return;
  146. }
  147. row.Status = 0;
  148. row.UpdatedBy = _userManager.UserId;
  149. row.UpdateTime = DateTime.Now;
  150. await _store.UpdateAsync(row, ct);
  151. }
  152. public async Task<List<KpiTargetLegacyStatDto>> LegacyStatsAsync(CancellationToken ct = default)
  153. {
  154. _ = TenantId;
  155. _ = ct;
  156. return new List<KpiTargetLegacyStatDto>
  157. {
  158. new() { TargetSource = KpiTargetSnapshotSource.FormalFactory },
  159. new() { TargetSource = KpiTargetSnapshotSource.FormalTenantDefault },
  160. new() { TargetSource = KpiTargetSnapshotSource.LegacyDailyCurrent },
  161. new() { TargetSource = KpiTargetSnapshotSource.LegacyDailyPrior },
  162. new() { TargetSource = KpiTargetSnapshotSource.LegacyCode },
  163. new() { TargetSource = KpiTargetSnapshotSource.Missing }
  164. };
  165. }
  166. private async Task ClosePreviousOpenAsync(List<AdoSmartOpsKpiTargetConfig> existing, DateTime newFrom, CancellationToken ct)
  167. {
  168. var open = existing.Where(x => x.Status == 1 && x.EffectiveTo == null).OrderByDescending(x => x.EffectiveFrom).ToList();
  169. if (open.Count == 0)
  170. return;
  171. if (open.Count > 1)
  172. throw new KpiTargetDuplicateScopeException("同一工厂存在多条开放区间,拒绝静默关闭");
  173. var prev = open[0];
  174. if (prev.EffectiveFrom.Date >= newFrom)
  175. throw new KpiTargetConflictException("不允许自动截断未来目标");
  176. var closeTo = newFrom.AddDays(-1);
  177. if (closeTo < prev.EffectiveFrom.Date)
  178. throw new KpiTargetConflictException("关闭上一目标后区间无效");
  179. prev.EffectiveTo = closeTo;
  180. prev.UpdateTime = DateTime.Now;
  181. await _store.UpdateAsync(prev, ct);
  182. }
  183. private async Task<AdoSmartOpsKpiMaster> EnsureKpiAsync(long tenantId, string metricCode, CancellationToken ct)
  184. {
  185. var kpi = await _store.FindKpiAsync(tenantId, metricCode.Trim(), ct);
  186. if (kpi == null)
  187. throw new KpiTargetNotFoundException("指标不存在或不属于当前租户");
  188. return kpi;
  189. }
  190. private static void EnsureMetricCode(string? metricCode)
  191. {
  192. if (string.IsNullOrWhiteSpace(metricCode))
  193. throw new KpiTargetValidationException("指标编码必填");
  194. }
  195. private async Task<string?> ConfigSourceAsync(long tenantId, long configId, CancellationToken ct)
  196. {
  197. var cfg = await _store.GetByIdAsync(tenantId, configId, ct);
  198. return cfg?.SourceType;
  199. }
  200. public static string SerializePayload(IEnumerable<KpiTargetImportRow> rows) =>
  201. JsonSerializer.Serialize(rows.ToList());
  202. public static List<KpiTargetImportRow> DeserializePayload(string? json) =>
  203. string.IsNullOrWhiteSpace(json)
  204. ? new List<KpiTargetImportRow>()
  205. : JsonSerializer.Deserialize<List<KpiTargetImportRow>>(json) ?? new List<KpiTargetImportRow>();
  206. }