using Microsoft.Extensions.Logging; namespace Admin.NET.Plugin.AiDOP.SmartOps; /// /// 四个运营 L1 写入 ado_s9_kpi_value_l1_day。 /// 交付周期 = 评审 + 排程 + 物料计划 + 物料上线 + 制造 + 发货,六段同日都有值才写。 /// 交付满足率 = 计划发货日当天、已发数量达到订单数量的订单行 / 有订单数量的订单行。 /// 生产效率 = 产出工时 / (产出工时 + 停机),休息不计入投入。无法区分停机时不写。按产线状态的工厂过滤。 /// 全库存周转 = 成品周转 + 在制周转 + 物料周转,三段同日都有值才写。 /// public class S9CompositeKpiWriter : ITransient { private const string ValueTable = "ado_s9_kpi_value_l1_day"; private const string ModuleCode = "S9"; private readonly ISqlSugarClient _db; private readonly IKpiTargetResolver _kpiTargetResolver; private readonly ILogger _logger; public S9CompositeKpiWriter( ISqlSugarClient db, IKpiTargetResolver kpiTargetResolver, ILogger logger) { _db = db; _kpiTargetResolver = kpiTargetResolver; _logger = logger; } public async Task WriteRecentAsync( long tenantId, long factoryId, DateTime anchorDate, CancellationToken cancellationToken = default) { if (tenantId <= 0 || factoryId <= 0) return 0; var rows = 0; for (var offset = 13; offset >= 0; offset--) { cancellationToken.ThrowIfCancellationRequested(); rows += await WriteDayAsync(tenantId, factoryId, anchorDate.Date.AddDays(-offset), cancellationToken); } return rows; } private async Task WriteDayAsync(long tenantId, long factoryId, DateTime bizDate, CancellationToken cancellationToken) { try { var now = DateTime.Now; var rows = 0; var cycle = await SumSegmentsAsync(tenantId, factoryId, bizDate, ["S1_L1_001", "S2_L1_001", "S3_L1_001", "S5_L1_001", "S6_L1_001", "S7_L1_001"], 6); if (cycle.HasValue) rows += await UpsertAsync("S9_L1_002", bizDate, cycle, now, tenantId, factoryId); var fulfillment = await FulfillmentAsync(tenantId, factoryId, bizDate); if (fulfillment.HasValue) rows += await UpsertAsync("S9_L1_003", bizDate, fulfillment, now, tenantId, factoryId); var efficiency = await EfficiencyAsync(tenantId, factoryId, bizDate); if (efficiency.HasValue) rows += await UpsertAsync("S9_L1_004", bizDate, efficiency, now, tenantId, factoryId); var turnover = await SumSegmentsAsync(tenantId, factoryId, bizDate, ["S1_L1_004", "S2_L1_004", "S3_L1_004"], 3); if (turnover.HasValue) rows += await UpsertAsync("S9_L1_005", bizDate, turnover, now, tenantId, factoryId); cancellationToken.ThrowIfCancellationRequested(); return rows; } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "S9 复合指标 {Date} 写入跳过,不打断模块重建", bizDate.ToString("yyyy-MM-dd")); return 0; } } private async Task SumSegmentsAsync( long tenantId, long factoryId, DateTime bizDate, string[] codes, int required) { var row = await _db.Ado.SqlQueryAsync( """ SELECT COUNT(*) AS N, SUM(v) AS Total FROM ( SELECT metric_code, MAX(metric_value) AS v FROM ado_s9_kpi_value_l1_day WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND biz_date=@BizDate AND is_deleted=0 AND metric_value IS NOT NULL AND FIND_IN_SET(metric_code, @Codes) AND module_code = SUBSTRING_INDEX(metric_code, '_', 1) GROUP BY metric_code ) s """, new { TenantId = tenantId, FactoryId = factoryId, BizDate = bizDate, Codes = string.Join(",", codes) }); var hit = row.FirstOrDefault(); if (hit == null || hit.N < required || hit.Total == null) return null; return Math.Round(hit.Total.Value, 4); } private async Task FulfillmentAsync(long tenantId, long factoryId, DateTime bizDate) { var row = await _db.Ado.SqlQueryAsync( """ SELECT SUM(CASE WHEN IFNULL(order_qty,0) > 0 AND IFNULL(delivered_qty,0) >= order_qty THEN 1 ELSE 0 END) AS Numer, SUM(CASE WHEN IFNULL(order_qty,0) > 0 THEN 1 ELSE 0 END) AS Denom FROM mdp_std_so WHERE tenant_id=@TenantId AND COALESCE(NULLIF(factory_id,0),1)=@FactoryId AND deleted_flag=0 AND plan_delivery_date >= @DayStart AND plan_delivery_date < @DayEnd """, new { TenantId = tenantId, FactoryId = factoryId, DayStart = bizDate.Date, DayEnd = bizDate.Date.AddDays(1) }); var hit = row.FirstOrDefault(); if (hit?.Denom is not > 0 || hit.Numer == null) return null; return Math.Round(hit.Numer.Value / hit.Denom.Value * 100m, 4); } /// /// 产线状态贴源里的 ProdTime / RestTime / ProdDownTime。 /// 休息或停机列缺失时无法区分有效工时,不写 100%。 /// LineRunRestDet 的 TransType 取值未登记,不拿它猜开工或休息。 /// private async Task EfficiencyAsync(long tenantId, long factoryId, DateTime bizDate) { try { var row = await _db.Ado.SqlQueryAsync( """ SELECT SUM(CASE WHEN JSON_EXTRACT(raw_data,'$.ProdTime') IS NULL THEN NULL ELSE CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdTime')) AS DECIMAL(18,4)) END) AS Numer, SUM(CASE WHEN JSON_EXTRACT(raw_data,'$.ProdDownTime') IS NULL THEN NULL ELSE IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdTime')) AS DECIMAL(18,4)),0) + IFNULL(CAST(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdDownTime')) AS DECIMAL(18,4)),0) END) AS Denom FROM mdp_stg_line_status WHERE tenant_id=@TenantId AND factory_id=@FactoryId AND DATE(COALESCE( STR_TO_DATE(LEFT(JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.ProdDate')),10),'%Y-%m-%d'), sync_time)) = @BizDate """, new { TenantId = tenantId, FactoryId = factoryId.ToString(), BizDate = bizDate.Date }); var hit = row.FirstOrDefault(); if (hit?.Numer == null || hit.Denom is not > 0) return null; return Math.Round(hit.Numer.Value / hit.Denom.Value * 100m, 4); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogWarning(ex, "S9_L1_004 产线运行贴源不可用,生产效率保持空"); return null; } } private async Task UpsertAsync( string metricCode, DateTime bizDate, decimal? metricValue, DateTime now, long tenantId, long factoryId) { bizDate = bizDate.Date; var snap = await _kpiTargetResolver.ResolveAsync(tenantId, factoryId, metricCode, ModuleCode, bizDate); var existingId = await _db.Ado.GetLongAsync( $"SELECT IFNULL((SELECT id FROM {ValueTable} WHERE tenant_id=@TenantId AND factory_id=@FactoryId " + "AND module_code=@ModuleCode AND metric_code=@MetricCode AND biz_date=@BizDate AND is_deleted=0 " + "ORDER BY id LIMIT 1), 0)", new List { new("@TenantId", tenantId), new("@FactoryId", factoryId), new("@ModuleCode", ModuleCode), new("@MetricCode", metricCode), new("@BizDate", bizDate) }); if (existingId > 0) { return await _db.Ado.ExecuteCommandAsync( $"UPDATE {ValueTable} SET metric_value=@MetricValue, target_value=@TargetValue, " + "target_config_id=@TargetConfigId, target_source=@TargetSource, target_resolved_at=@TargetResolvedAt, " + "calc_time=@Now, update_time=@Now, is_deleted=0 WHERE id=@Id", new SugarParameter("@MetricValue", metricValue), new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)), new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)), new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)), new SugarParameter("@TargetResolvedAt", snap.ResolvedAt), new SugarParameter("@Now", now), new SugarParameter("@Id", existingId)); } var nextId = Yitter.IdGenerator.YitIdHelper.NextId(); return await _db.Ado.ExecuteCommandAsync($@" INSERT INTO {ValueTable} (id, tenant_id, org_id, company_id, factory_id, status, biz_date, create_time, update_time, is_deleted, is_active, module_code, metric_code, metric_value, target_value, calc_time, target_config_id, target_source, target_resolved_at) VALUES (@Id, @TenantId, NULL, NULL, @FactoryId, NULL, @BizDate, @Now, @Now, 0, 1, @ModuleCode, @MetricCode, @MetricValue, @TargetValue, @Now, @TargetConfigId, @TargetSource, @TargetResolvedAt)", new SugarParameter("@Id", nextId), new SugarParameter("@TenantId", tenantId), new SugarParameter("@FactoryId", factoryId), new SugarParameter("@BizDate", bizDate), new SugarParameter("@Now", now), new SugarParameter("@ModuleCode", ModuleCode), new SugarParameter("@MetricCode", metricCode), new SugarParameter("@MetricValue", metricValue), new SugarParameter("@TargetValue", KpiTargetSnapshotSql.ValueOrDbNull(snap)), new SugarParameter("@TargetConfigId", KpiTargetSnapshotSql.ConfigIdOrDbNull(snap)), new SugarParameter("@TargetSource", KpiTargetSnapshotSql.SourceOrDbNull(snap)), new SugarParameter("@TargetResolvedAt", snap.ResolvedAt)); } } internal sealed class S9SegmentSumRow { public int N { get; set; } public decimal? Total { get; set; } } internal sealed class S9RatioRow { public decimal? Numer { get; set; } public decimal? Denom { get; set; } }