S1KpiTargetSnapshotRefreshServiceTests.cs 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.SmartOps;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.SmartOps;
  5. public class S1KpiTargetSnapshotRefreshServiceTests
  6. {
  7. private static S1KpiTargetSnapshotRefreshService Svc(FakeS1KpiDailySnapshotStore daily, FakeKpiTargetStore configs) =>
  8. new(daily, new KpiTargetResolver(configs, new LegacyKpiTargetProvider(new FakeDailyReader(), Microsoft.Extensions.Logging.Abstractions.NullLogger<LegacyKpiTargetProvider>.Instance), Microsoft.Extensions.Logging.Abstractions.NullLogger<KpiTargetResolver>.Instance));
  9. [Fact]
  10. public async Task FormalFactory_OverwritesLegacySnapshot_OnMaxBizDateOnly()
  11. {
  12. var daily = new FakeS1KpiDailySnapshotStore();
  13. daily.Add(1, 9, 1, new DateTime(2026, 8, 13), "S1_L1_001", 20.3m, 20m);
  14. daily.Add(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001", 20.3m, 20m);
  15. daily.Add(2, 9, 1, new DateTime(2026, 8, 14), "S1_L2_001", 1m, 3m);
  16. var configs = new FakeKpiTargetStore();
  17. configs.Configs.Add(Cfg(11, 1, 10m, new DateTime(2026, 8, 14), metricCode: "S1_L1_001"));
  18. configs.Configs.Add(Cfg(12, 1, 10m, new DateTime(2026, 8, 14), metricCode: "S1_L2_001"));
  19. var result = await Svc(daily, configs).RefreshAsync(9, 1);
  20. Assert.True(result.Ok);
  21. Assert.Equal(2, result.UpdatedRows);
  22. Assert.Equal(10m, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").TargetValue);
  23. Assert.Equal(KpiTargetSnapshotSource.FormalFactory, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").TargetSource);
  24. Assert.Equal(11, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").TargetConfigId);
  25. Assert.Equal(20m, daily.Get(1, 9, 1, new DateTime(2026, 8, 13), "S1_L1_001").TargetValue);
  26. Assert.Equal(20.3m, daily.Get(1, 9, 1, new DateTime(2026, 8, 14), "S1_L1_001").MetricValue);
  27. Assert.Equal(10m, daily.Get(2, 9, 1, new DateTime(2026, 8, 14), "S1_L2_001").TargetValue);
  28. }
  29. [Fact]
  30. public async Task TenantDefault_UsedWhenFactoryMissing()
  31. {
  32. var daily = new FakeS1KpiDailySnapshotStore();
  33. daily.Add(1, 9, 1, DateTime.Today, "S1_L1_001", 8m, 20m);
  34. var configs = new FakeKpiTargetStore();
  35. configs.Configs.Add(Cfg(7, 0, 5m, DateTime.Today.AddDays(-10), metricCode: "S1_L1_001"));
  36. var result = await Svc(daily, configs).RefreshAsync(9, 1);
  37. Assert.Equal(1, result.UpdatedRows);
  38. Assert.Equal(5m, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetValue);
  39. Assert.Equal(KpiTargetSnapshotSource.FormalTenantDefault, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetSource);
  40. }
  41. [Fact]
  42. public async Task Missing_DoesNotOverwrite()
  43. {
  44. var daily = new FakeS1KpiDailySnapshotStore();
  45. daily.Add(1, 9, 1, DateTime.Today, "S5_L1_001", 8m, 20m);
  46. var result = await Svc(daily, new FakeKpiTargetStore()).RefreshAsync(9, 1);
  47. Assert.Equal(0, result.UpdatedRows);
  48. Assert.True(result.MissingRows >= 1);
  49. Assert.Equal(20m, daily.Get(1, 9, 1, DateTime.Today, "S5_L1_001").TargetValue);
  50. Assert.Null(daily.Get(1, 9, 1, DateTime.Today, "S5_L1_001").TargetSource);
  51. }
  52. [Fact]
  53. public async Task DoesNotTouchOtherTenantOrFactory()
  54. {
  55. var daily = new FakeS1KpiDailySnapshotStore();
  56. daily.Add(1, 9, 1, DateTime.Today, "S1_L1_001", 8m, 20m);
  57. daily.Add(1, 8, 1, DateTime.Today, "S1_L1_001", 8m, 20m);
  58. daily.Add(1, 9, 2, DateTime.Today, "S1_L1_001", 8m, 20m);
  59. var configs = new FakeKpiTargetStore();
  60. configs.Configs.Add(Cfg(1, 1, 10m, DateTime.Today.AddDays(-1), metricCode: "S1_L1_001"));
  61. await Svc(daily, configs).RefreshAsync(9, 1);
  62. Assert.Equal(10m, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetValue);
  63. Assert.Equal(20m, daily.Get(1, 8, 1, DateTime.Today, "S1_L1_001").TargetValue);
  64. Assert.Equal(20m, daily.Get(1, 9, 2, DateTime.Today, "S1_L1_001").TargetValue);
  65. }
  66. [Fact]
  67. public async Task LevelFailure_RollsBackAllLevels()
  68. {
  69. var daily = new FakeS1KpiDailySnapshotStore { FailOnLevel = 3 };
  70. daily.Add(1, 9, 1, DateTime.Today, "S1_L1_001", 8m, 20m);
  71. daily.Add(3, 9, 1, DateTime.Today, "S1_L3_001", 8m, 20m);
  72. var configs = new FakeKpiTargetStore();
  73. configs.Configs.Add(Cfg(1, 1, 10m, DateTime.Today.AddDays(-1), metricCode: "S1_L1_001"));
  74. configs.Configs.Add(Cfg(2, 1, 10m, DateTime.Today.AddDays(-1), metricCode: "S1_L3_001"));
  75. await Assert.ThrowsAsync<InvalidOperationException>(() => Svc(daily, configs).RefreshAsync(9, 1));
  76. Assert.Equal(20m, daily.Get(1, 9, 1, DateTime.Today, "S1_L1_001").TargetValue);
  77. }
  78. [Fact]
  79. public async Task NoDailyRows_ReturnsZero()
  80. {
  81. var result = await Svc(new FakeS1KpiDailySnapshotStore(), new FakeKpiTargetStore()).RefreshAsync(9, 1);
  82. Assert.True(result.Ok);
  83. Assert.Equal(0, result.UpdatedRows);
  84. Assert.Contains("暂无", result.Message);
  85. }
  86. private static AdoSmartOpsKpiTargetConfig Cfg(long id, long factoryId, decimal value, DateTime from, DateTime? to = null, string metricCode = "S1_L1_001") =>
  87. new()
  88. {
  89. Id = id,
  90. TenantId = 9,
  91. FactoryId = factoryId,
  92. MetricCode = metricCode,
  93. TargetValue = value,
  94. EffectiveFrom = from,
  95. EffectiveTo = to,
  96. SourceType = "MANUAL",
  97. Status = 1,
  98. CreateTime = DateTime.Now,
  99. UpdateTime = DateTime.Now
  100. };
  101. }
  102. internal sealed class FakeS1KpiDailySnapshotStore : IS1KpiDailySnapshotStore
  103. {
  104. public int? FailOnLevel { get; set; }
  105. private readonly List<Row> _rows = new();
  106. public void Add(int level, long tenantId, long factoryId, DateTime bizDate, string metric, decimal metricValue, decimal? target)
  107. => _rows.Add(new Row
  108. {
  109. Level = level,
  110. TenantId = tenantId,
  111. FactoryId = factoryId,
  112. BizDate = bizDate.Date,
  113. MetricCode = metric,
  114. MetricValue = metricValue,
  115. TargetValue = target
  116. });
  117. public Row Get(int level, long tenantId, long factoryId, DateTime bizDate, string metric) =>
  118. _rows.Single(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId && x.BizDate == bizDate.Date && x.MetricCode == metric);
  119. public async Task ExecuteInTransactionAsync(Func<Task> action, CancellationToken ct = default)
  120. {
  121. var backup = _rows.Select(x => x.Clone()).ToList();
  122. try
  123. {
  124. await action();
  125. }
  126. catch
  127. {
  128. _rows.Clear();
  129. _rows.AddRange(backup);
  130. throw;
  131. }
  132. }
  133. public Task<DateTime?> GetMaxBizDateAsync(int level, long tenantId, long factoryId, string moduleCode, CancellationToken ct = default)
  134. {
  135. _ = moduleCode;
  136. var dates = _rows.Where(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId).Select(x => x.BizDate).ToList();
  137. DateTime? max = dates.Count == 0 ? null : dates.Max();
  138. return Task.FromResult(max);
  139. }
  140. public Task<List<S1KpiDailySnapshotRow>> ListLatestAsync(int level, long tenantId, long factoryId, DateTime bizDate, string moduleCode, CancellationToken ct = default)
  141. {
  142. _ = moduleCode;
  143. return Task.FromResult(_rows.Where(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId && x.BizDate == bizDate.Date)
  144. .Select(x => new S1KpiDailySnapshotRow { MetricCode = x.MetricCode, BizDate = x.BizDate, MetricValue = x.MetricValue })
  145. .ToList());
  146. }
  147. public Task<int> UpdateTargetsAsync(int level, long tenantId, long factoryId, DateTime bizDate, string metricCode, KpiTargetSnapshot snap, string moduleCode, CancellationToken ct = default)
  148. {
  149. if (FailOnLevel == level)
  150. throw new InvalidOperationException($"fail level {level}");
  151. var hits = _rows.Where(x => x.Level == level && x.TenantId == tenantId && x.FactoryId == factoryId && x.BizDate == bizDate.Date && x.MetricCode == metricCode).ToList();
  152. foreach (var row in hits)
  153. {
  154. row.TargetValue = snap.TargetValue;
  155. row.TargetConfigId = snap.TargetConfigId;
  156. row.TargetSource = snap.TargetSource;
  157. row.TargetResolvedAt = snap.ResolvedAt;
  158. }
  159. return Task.FromResult(hits.Count);
  160. }
  161. internal sealed class Row
  162. {
  163. public int Level { get; set; }
  164. public long TenantId { get; set; }
  165. public long FactoryId { get; set; }
  166. public DateTime BizDate { get; set; }
  167. public string MetricCode { get; set; } = string.Empty;
  168. public decimal? MetricValue { get; set; }
  169. public decimal? TargetValue { get; set; }
  170. public long? TargetConfigId { get; set; }
  171. public string TargetSource { get; set; }
  172. public DateTime? TargetResolvedAt { get; set; }
  173. public Row Clone() => (Row)MemberwiseClone();
  174. }
  175. }