S9CompositeKpiWriter.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. using Microsoft.Extensions.Logging;
  2. namespace Admin.NET.Plugin.AiDOP.SmartOps;
  3. /// <summary>
  4. /// 四个运营 L1 写入 ado_s9_kpi_value_l1_day。
  5. /// 交付周期 = 评审 + 排程 + 物料计划 + 物料上线 + 制造 + 发货,六段同日都有值才写。
  6. /// 交付满足率 = 计划发货日当天、已发数量达到订单数量的订单行 / 有订单数量的订单行。
  7. /// 生产效率 = 产出工时 / (产出工时 + 停机),休息不计入投入。无法区分停机时不写。按产线状态的工厂过滤。
  8. /// 全库存周转 = 成品周转 + 在制周转 + 物料周转,三段同日都有值才写。
  9. /// </summary>
  10. public class S9CompositeKpiWriter : ITransient
  11. {
  12. private const string ValueTable = "ado_s9_kpi_value_l1_day";
  13. private const string ModuleCode = "S9";
  14. private readonly ISqlSugarClient _db;
  15. private readonly IKpiTargetResolver _kpiTargetResolver;
  16. private readonly KpiResultStatusService _kpiStatus;
  17. private readonly ILogger<S9CompositeKpiWriter> _logger;
  18. public S9CompositeKpiWriter(
  19. ISqlSugarClient db,
  20. IKpiTargetResolver kpiTargetResolver,
  21. KpiResultStatusService kpiStatus,
  22. ILogger<S9CompositeKpiWriter> logger)
  23. {
  24. _db = db;
  25. _kpiTargetResolver = kpiTargetResolver;
  26. _kpiStatus = kpiStatus;
  27. _logger = logger;
  28. }
  29. public async Task<int> WriteRecentAsync(
  30. long tenantId, long factoryId, DateTime anchorDate, CancellationToken cancellationToken = default)
  31. {
  32. if (tenantId <= 0 || factoryId <= 0) return 0;
  33. var rows = 0;
  34. for (var offset = 13; offset >= 0; offset--)
  35. {
  36. cancellationToken.ThrowIfCancellationRequested();
  37. rows += await WriteDayAsync(tenantId, factoryId, anchorDate.Date.AddDays(-offset), cancellationToken);
  38. }
  39. return rows;
  40. }
  41. private async Task<int> WriteDayAsync(long tenantId, long factoryId, DateTime bizDate, CancellationToken cancellationToken)
  42. {
  43. try
  44. {
  45. var now = DateTime.Now;
  46. var rows = 0;
  47. var cycle = await SumSegmentsAsync(tenantId, factoryId, bizDate,
  48. ["S1_L1_001", "S2_L1_001", "S3_L1_001", "S5_L1_001", "S6_L1_001", "S7_L1_001"], 6);
  49. if (cycle.HasValue)
  50. rows += await UpsertAsync("S9_L1_002", bizDate, cycle, now, tenantId, factoryId);
  51. var fulfillment = await FulfillmentAsync(tenantId, factoryId, bizDate);
  52. if (fulfillment.HasValue)
  53. rows += await UpsertAsync("S9_L1_003", bizDate, fulfillment, now, tenantId, factoryId);
  54. var efficiency = await EfficiencyAsync(tenantId, factoryId, bizDate);
  55. if (efficiency.HasValue)
  56. rows += await UpsertAsync("S9_L1_004", bizDate, efficiency, now, tenantId, factoryId);
  57. var turnover = await SumSegmentsAsync(tenantId, factoryId, bizDate,
  58. ["S1_L1_004", "S2_L1_004", "S3_L1_004"], 3);
  59. if (turnover.HasValue)
  60. rows += await UpsertAsync("S9_L1_005", bizDate, turnover, now, tenantId, factoryId);
  61. cancellationToken.ThrowIfCancellationRequested();
  62. return rows;
  63. }
  64. catch (Exception ex) when (ex is not OperationCanceledException)
  65. {
  66. _logger.LogWarning(ex, "S9 复合指标 {Date} 写入跳过,不打断模块重建", bizDate.ToString("yyyy-MM-dd"));
  67. return 0;
  68. }
  69. }
  70. private async Task<decimal?> SumSegmentsAsync(
  71. long tenantId, long factoryId, DateTime bizDate, string[] codes, int required)
  72. {
  73. var row = await _db.Ado.SqlQueryAsync<S9SegmentSumRow>(
  74. """
  75. SELECT COUNT(*) AS N, SUM(v) AS Total
  76. FROM (
  77. SELECT metric_code, MAX(metric_value) AS v
  78. FROM ado_s9_kpi_value_l1_day
  79. WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND biz_date=@BizDate
  80. AND is_deleted=0 AND metric_value IS NOT NULL
  81. AND FIND_IN_SET(metric_code, @Codes)
  82. AND module_code = SUBSTRING_INDEX(metric_code, '_', 1)
  83. GROUP BY metric_code
  84. ) s
  85. """,
  86. new { TenantId = tenantId, FactoryId = factoryId, BizDate = bizDate, Codes = string.Join(",", codes) });
  87. var hit = row.FirstOrDefault();
  88. if (hit == null || hit.N < required || hit.Total == null) return null;
  89. return Math.Round(hit.Total.Value, 4);
  90. }
  91. private async Task<decimal?> FulfillmentAsync(long tenantId, long factoryId, DateTime bizDate)
  92. {
  93. var row = await _db.Ado.SqlQueryAsync<S9RatioRow>(
  94. """
  95. SELECT
  96. SUM(CASE WHEN IFNULL(order_qty,0) > 0 AND IFNULL(delivered_qty,0) >= order_qty THEN 1 ELSE 0 END) AS Numer,
  97. SUM(CASE WHEN IFNULL(order_qty,0) > 0 THEN 1 ELSE 0 END) AS Denom
  98. FROM mdp_std_so
  99. WHERE tenant_id=@TenantId
  100. AND COALESCE(NULLIF(factory_id,0),1)=@FactoryId
  101. AND deleted_flag=0
  102. AND plan_delivery_date >= @DayStart AND plan_delivery_date < @DayEnd
  103. """,
  104. new
  105. {
  106. TenantId = tenantId,
  107. FactoryId = factoryId,
  108. DayStart = bizDate.Date,
  109. DayEnd = bizDate.Date.AddDays(1)
  110. });
  111. var hit = row.FirstOrDefault();
  112. if (hit?.Denom is not > 0 || hit.Numer == null) return null;
  113. return Math.Round(hit.Numer.Value / hit.Denom.Value * 100m, 4);
  114. }
  115. /// <summary>
  116. /// 产线状态贴源里的 ProdTime / RestTime / ProdDownTime。
  117. /// 休息或停机列缺失时无法区分有效工时,不写 100%。
  118. /// LineRunRestDet 的 TransType 取值未登记,不拿它猜开工或休息。
  119. /// </summary>
  120. private async Task<decimal?> EfficiencyAsync(long tenantId, long factoryId, DateTime bizDate)
  121. {
  122. try
  123. {
  124. var row = await _db.Ado.SqlQueryAsync<S9RatioRow>(
  125. """
  126. SELECT
  127. SUM(CASE WHEN JSON_EXTRACT(raw_data,'$.ProdTime') IS NULL THEN NULL
  128. ELSE CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdTime')) AS DECIMAL(18,4)) END) AS Numer,
  129. SUM(CASE WHEN JSON_EXTRACT(raw_data,'$.ProdDownTime') IS NULL THEN NULL
  130. ELSE IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdTime')) AS DECIMAL(18,4)),0)
  131. + IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdDownTime')) AS DECIMAL(18,4)),0)
  132. END) AS Denom
  133. FROM mdp_stg_line_status
  134. WHERE tenant_id=@TenantId
  135. AND factory_id=@FactoryId
  136. AND DATE(COALESCE(
  137. STR_TO_DATE(LEFT(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdDate')),10),'%Y-%m-%d'),
  138. sync_time)) = @BizDate
  139. """,
  140. new { TenantId = tenantId, FactoryId = factoryId.ToString(), BizDate = bizDate.Date });
  141. var hit = row.FirstOrDefault();
  142. if (hit?.Numer == null || hit.Denom is not > 0) return null;
  143. return Math.Round(hit.Numer.Value / hit.Denom.Value * 100m, 4);
  144. }
  145. catch (Exception ex) when (ex is not OperationCanceledException)
  146. {
  147. _logger.LogWarning(ex, "S9_L1_004 产线运行贴源不可用,生产效率保持空");
  148. return null;
  149. }
  150. }
  151. private async Task<int> UpsertAsync(
  152. string metricCode, DateTime bizDate, decimal? metricValue, DateTime now, long tenantId, long factoryId)
  153. {
  154. bizDate = bizDate.Date;
  155. var decision = await _kpiStatus.ResolveAsync(tenantId, metricCode, metricValue);
  156. metricValue = decision.Value;
  157. var snap = await _kpiTargetResolver.ResolveAsync(tenantId, factoryId, metricCode, ModuleCode, bizDate);
  158. var existingId = await _db.Ado.GetLongAsync(
  159. $"SELECT IFNULL((SELECT id FROM {ValueTable} WHERE tenant_id=@TenantId AND factory_id=@FactoryId " +
  160. "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " +
  161. "ORDER BY id LIMIT 1), 0)",
  162. new List<SugarParameter>
  163. {
  164. new("@TenantId", tenantId),
  165. new("@FactoryId", factoryId),
  166. new("@ModuleCode", ModuleCode),
  167. new("@MetricCode", metricCode),
  168. new("@BizDate", bizDate)
  169. });
  170. if (existingId > 0)
  171. {
  172. return await _db.Ado.ExecuteCommandAsync(
  173. $"UPDATE {ValueTable} SET {KpiResultWrite.SetClause}metric_value=@MetricValue, target_value=@TargetValue, " +
  174. "target_config_id=@TargetConfigId, target_source=@TargetSource, target_resolved_at=@TargetResolvedAt, " +
  175. "calc_time=@Now, update_time=@Now, is_deleted=0 WHERE id=@Id",
  176. new SugarParameter("@MetricValue", metricValue),
  177. KpiResultWrite.Status(decision.Status),
  178. KpiResultWrite.Reason(decision.Reason),
  179. new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  180. new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  181. new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  182. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt),
  183. new SugarParameter("@Now", now),
  184. new SugarParameter("@Id", existingId));
  185. }
  186. var nextId = Yitter.IdGenerator.YitIdHelper.NextId();
  187. return await _db.Ado.ExecuteCommandAsync($@"
  188. INSERT INTO {ValueTable}
  189. (id, tenant_id, org_id, company_id, factory_id, status, biz_date,
  190. create_time, update_time, is_deleted, is_active,
  191. module_code, metric_code, result_status, result_reason, metric_value, target_value, calc_time,
  192. target_config_id, target_source, target_resolved_at)
  193. VALUES
  194. (@Id, @TenantId, NULL, NULL, @FactoryId, NULL, @BizDate,
  195. @Now, @Now, 0, 1,
  196. @ModuleCode, @MetricCode, @ResultStatus, @ResultReason, @MetricValue, @TargetValue, @Now,
  197. @TargetConfigId, @TargetSource, @TargetResolvedAt)",
  198. new SugarParameter("@Id", nextId),
  199. new SugarParameter("@TenantId", tenantId),
  200. new SugarParameter("@FactoryId", factoryId),
  201. new SugarParameter("@BizDate", bizDate),
  202. new SugarParameter("@Now", now),
  203. new SugarParameter("@ModuleCode", ModuleCode),
  204. new SugarParameter("@MetricCode", metricCode),
  205. KpiResultWrite.Status(decision.Status),
  206. KpiResultWrite.Reason(decision.Reason),
  207. new SugarParameter("@MetricValue", metricValue),
  208. new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)),
  209. new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)),
  210. new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)),
  211. new SugarParameter("@TargetResolvedAt", snap.ResolvedAt));
  212. }
  213. }
  214. internal sealed class S9SegmentSumRow
  215. {
  216. public int N { get; set; }
  217. public decimal? Total { get; set; }
  218. }
  219. internal sealed class S9RatioRow
  220. {
  221. public decimal? Numer { get; set; }
  222. public decimal? Denom { get; set; }
  223. }