Explorar el Código

feat(s8): t3c add order flow aggregate and procurement pivot service

YY968XX hace 2 semanas
padre
commit
f7798d95e1

+ 286 - 0
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/OrderFlow/S8OrderFlowService.cs

@@ -1,3 +1,4 @@
+using System.Text.Json;
 using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
 using Admin.NET.Plugin.AiDOP.Entity.S8;
 using Admin.NET.Plugin.AiDOP.Entity.S8.OrderFlow;
@@ -13,11 +14,22 @@ namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
 public class S8OrderFlowService : ITransient
 {
     private const string ExceptionSourceObjectType = "SALES_ORDER";
+    private const string ScopeBaselinePpt = "BASELINE_PPT";
+    private const string ScopeCurrentFiltered = "CURRENT_FILTERED";
+    private const string BaselineSnapshotCode = "CHAIN_AGGREGATE_BASELINE";
+    private const string PivotTotalCode = "TOTAL";
+
+    private static readonly JsonSerializerOptions JsonOpts = new()
+    {
+        PropertyNameCaseInsensitive = true,
+    };
 
     private readonly SqlSugarRepository<AdoS8OrderFlowOrder> _orderRep;
     private readonly SqlSugarRepository<AdoS8OrderFlowStage> _stageRep;
     private readonly SqlSugarRepository<AdoS8OrderFlowSubstep> _substepRep;
     private readonly SqlSugarRepository<AdoS8OrderFlowSubstepUnit> _unitRep;
+    private readonly SqlSugarRepository<AdoS8OrderFlowSnapshot> _snapshotRep;
+    private readonly SqlSugarRepository<AdoS8OrderFlowProcurementPivot> _pivotRep;
     private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
 
     public S8OrderFlowService(
@@ -25,12 +37,16 @@ public class S8OrderFlowService : ITransient
         SqlSugarRepository<AdoS8OrderFlowStage> stageRep,
         SqlSugarRepository<AdoS8OrderFlowSubstep> substepRep,
         SqlSugarRepository<AdoS8OrderFlowSubstepUnit> unitRep,
+        SqlSugarRepository<AdoS8OrderFlowSnapshot> snapshotRep,
+        SqlSugarRepository<AdoS8OrderFlowProcurementPivot> pivotRep,
         SqlSugarRepository<AdoS8Exception> exceptionRep)
     {
         _orderRep = orderRep;
         _stageRep = stageRep;
         _substepRep = substepRep;
         _unitRep = unitRep;
+        _snapshotRep = snapshotRep;
+        _pivotRep = pivotRep;
         _exceptionRep = exceptionRep;
     }
 
@@ -314,4 +330,274 @@ public class S8OrderFlowService : ITransient
             Status = u.Status,
             SortNo = u.SortNo,
         };
+
+    // ──────────────────────────────────────────────────────────────────────
+    // t3c:Aggregate + ProcurementPivot
+    // ──────────────────────────────────────────────────────────────────────
+
+    /// <summary>
+    /// 链路聚合视图:
+    ///   scope=BASELINE_PPT      → 只读 ado_s8_order_flow_snapshot.stage_snapshots_json
+    ///   scope=CURRENT_FILTERED  → 从 order + stage 实时聚合
+    /// snapshot 缺失时返回空骨架(不做 fallback 硬编码)。
+    /// </summary>
+    public async Task<AdoS8OrderFlowAggregateDto> GetAggregateAsync(AdoS8OrderFlowAggregateQueryDto query)
+    {
+        var tenantId = query.TenantId ?? 1;
+        var factoryId = query.FactoryId ?? 1;
+        var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
+
+        if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
+            return await BuildBaselineAggregateAsync(tenantId, factoryId, scope);
+
+        if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
+            return await BuildCurrentAggregateAsync(tenantId, factoryId, scope, query.OrderCodes);
+
+        return EmptyAggregate(scope);
+    }
+
+    private async Task<AdoS8OrderFlowAggregateDto> BuildBaselineAggregateAsync(long tenantId, long factoryId, string scope)
+    {
+        var snapshot = await _snapshotRep.AsQueryable()
+            .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
+            .Where(s => s.ScopeCode == ScopeBaselinePpt && s.SnapshotCode == BaselineSnapshotCode)
+            .FirstAsync();
+
+        if (snapshot == null) return EmptyAggregate(scope);
+
+        var stages = ParseStageSnapshots(snapshot.StageSnapshotsJson);
+        return new AdoS8OrderFlowAggregateDto
+        {
+            Scope = scope,
+            TotalOrders = snapshot.TotalOrders,
+            TotalCustomers = snapshot.TotalCustomers,
+            AvgResponseMinutes = snapshot.AvgResponseMinutes,
+            AvgProcessingMinutes = snapshot.AvgProcessingMinutes,
+            AvgLossMinutes = snapshot.AvgLossMinutes,
+            StageAggregates = ReorderByCanonical(stages),
+        };
+    }
+
+    private async Task<AdoS8OrderFlowAggregateDto> BuildCurrentAggregateAsync(
+        long tenantId, long factoryId, string scope, List<string>? orderCodes)
+    {
+        var orders = await _orderRep.AsQueryable()
+            .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
+            .WhereIF(orderCodes != null && orderCodes.Count > 0, o => orderCodes!.Contains(o.OrderCode))
+            .ToListAsync();
+
+        if (orders.Count == 0) return EmptyAggregate(scope);
+
+        var orderIds = orders.Select(o => o.Id).ToList();
+        var stages = await _stageRep.AsQueryable()
+            .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
+            .Where(s => orderIds.Contains(s.OrderId))
+            .ToListAsync();
+
+        var responseList = orders.Where(o => o.ResponseMinutes.HasValue).Select(o => (decimal)o.ResponseMinutes!.Value).ToList();
+        var processingList = orders.Where(o => o.ProcessingMinutes.HasValue).Select(o => (decimal)o.ProcessingMinutes!.Value).ToList();
+        var lossList = orders.Where(o => o.TotalLossMinutes.HasValue).Select(o => (decimal)o.TotalLossMinutes!.Value).ToList();
+
+        var stagesByFlow = stages.GroupBy(s => s.OrderFlowCode).ToDictionary(g => g.Key, g => g.ToList());
+        var stageAggregates = new List<AdoS8OrderFlowStageAggregateDto>();
+        foreach (var code in OrderFlowConstants.All)
+        {
+            stagesByFlow.TryGetValue(code, out var rows);
+            stageAggregates.Add(BuildStageAggregateFromRows(code, rows ?? new List<AdoS8OrderFlowStage>()));
+        }
+
+        return new AdoS8OrderFlowAggregateDto
+        {
+            Scope = scope,
+            TotalOrders = orders.Count,
+            TotalCustomers = orders.Select(o => o.CustomerCode).Where(c => !string.IsNullOrEmpty(c)).Distinct().Count(),
+            AvgResponseMinutes = AvgOrZero(responseList),
+            AvgProcessingMinutes = AvgOrZero(processingList),
+            AvgLossMinutes = AvgOrZero(lossList),
+            StageAggregates = stageAggregates,
+        };
+    }
+
+    private static AdoS8OrderFlowStageAggregateDto BuildStageAggregateFromRows(string flowCode, List<AdoS8OrderFlowStage> rows)
+    {
+        var dto = new AdoS8OrderFlowStageAggregateDto
+        {
+            OrderFlowCode = flowCode,
+            OrderFlowName = OrderFlowConstants.ResolveName(flowCode),
+        };
+        if (rows.Count == 0) return dto;
+
+        var plannedList = rows.Select(r => r.PlannedDays).ToList();
+        var actualList = rows.Where(r => r.ActualDays.HasValue && !string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase))
+                             .Select(r => r.ActualDays!.Value).ToList();
+        var green = rows.Count(r => string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase));
+        var yellow = rows.Count(r => string.Equals(r.Status, "yellow", StringComparison.OrdinalIgnoreCase));
+        var red = rows.Count(r => string.Equals(r.Status, "red", StringComparison.OrdinalIgnoreCase));
+        var pending = rows.Count(r => string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase));
+        var total = rows.Count;
+
+        dto.KpiAvgDays = AvgOrZero(plannedList);
+        dto.ActualAvgDays = AvgOrZero(actualList);
+        dto.Green = green;
+        dto.Yellow = yellow;
+        dto.Red = red;
+        dto.Pending = pending;
+        // 整数百分比;total 不含 0 因为上面已 short-circuit。 percent 因子 100 是公式系数,非业务真值。
+        dto.OnTimeRate = total > 0 ? (int)Math.Round(green * 100.0 / total) : 0;
+        return dto;
+    }
+
+    private static List<AdoS8OrderFlowStageAggregateDto> ParseStageSnapshots(string? json)
+    {
+        if (string.IsNullOrWhiteSpace(json)) return new List<AdoS8OrderFlowStageAggregateDto>();
+        try
+        {
+            var parsed = JsonSerializer.Deserialize<List<AdoS8OrderFlowStageAggregateDto>>(json, JsonOpts);
+            return parsed ?? new List<AdoS8OrderFlowStageAggregateDto>();
+        }
+        catch (JsonException)
+        {
+            return new List<AdoS8OrderFlowStageAggregateDto>();
+        }
+    }
+
+    /// <summary>按 OrderFlowConstants.All 重排;缺失 code 补空骨架;多余 code 追加在尾部以避免静默丢数据。</summary>
+    private static List<AdoS8OrderFlowStageAggregateDto> ReorderByCanonical(List<AdoS8OrderFlowStageAggregateDto> input)
+    {
+        var byCode = input.Where(x => !string.IsNullOrEmpty(x.OrderFlowCode))
+                          .GroupBy(x => x.OrderFlowCode)
+                          .ToDictionary(g => g.Key, g => g.First());
+        var ordered = new List<AdoS8OrderFlowStageAggregateDto>();
+        foreach (var code in OrderFlowConstants.All)
+        {
+            if (byCode.TryGetValue(code, out var hit))
+            {
+                if (string.IsNullOrEmpty(hit.OrderFlowName))
+                    hit.OrderFlowName = OrderFlowConstants.ResolveName(code);
+                ordered.Add(hit);
+                byCode.Remove(code);
+            }
+            else
+            {
+                ordered.Add(new AdoS8OrderFlowStageAggregateDto
+                {
+                    OrderFlowCode = code,
+                    OrderFlowName = OrderFlowConstants.ResolveName(code),
+                });
+            }
+        }
+        ordered.AddRange(byCode.Values);
+        return ordered;
+    }
+
+    private static AdoS8OrderFlowAggregateDto EmptyAggregate(string scope)
+    {
+        var stages = OrderFlowConstants.All
+            .Select(code => new AdoS8OrderFlowStageAggregateDto
+            {
+                OrderFlowCode = code,
+                OrderFlowName = OrderFlowConstants.ResolveName(code),
+            })
+            .ToList();
+        return new AdoS8OrderFlowAggregateDto { Scope = scope, StageAggregates = stages };
+    }
+
+    private static decimal AvgOrZero(List<decimal> values)
+        => values.Count == 0 ? 0m : values.Average();
+
+    /// <summary>
+    /// 采购透视视图:
+    ///   scope=BASELINE_PPT → 读 ado_s8_order_flow_procurement_pivot baseline 行(order_id=NULL)
+    /// status 严格只读 DB;cycle_days 保持 decimal(6,3) 不截断。
+    /// 本切片正式只支持 BASELINE_PPT;其他 scope 返回空骨架并标注 scope。
+    /// </summary>
+    public async Task<AdoS8OrderFlowProcurementPivotDto> GetProcurementPivotAsync(AdoS8OrderFlowProcurementPivotQueryDto query)
+    {
+        var tenantId = query.TenantId ?? 1;
+        var factoryId = query.FactoryId ?? 1;
+        var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
+
+        if (!string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
+            return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
+
+        var rows = await _pivotRep.AsQueryable()
+            .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
+            .Where(p => p.OrderId == null) // baseline 行
+            .ToListAsync();
+
+        return BuildPivot(scope, rows);
+    }
+
+    private static AdoS8OrderFlowProcurementPivotDto BuildPivot(string scope, List<AdoS8OrderFlowProcurementPivot> rows)
+    {
+        var result = new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
+        if (rows.Count == 0) return result;
+
+        var byMaterial = rows.GroupBy(r => r.MaterialCode)
+                             .ToDictionary(g => g.Key, g => g.ToList());
+
+        foreach (var (material, list) in byMaterial.OrderBy(kv => kv.Key, StringComparer.Ordinal))
+        {
+            // KeyMaterials:取 (supplier=TOTAL, spec=TOTAL) 单元作为该 material 的概览
+            var grand = list.FirstOrDefault(r => r.SupplierCode == PivotTotalCode && r.SpecCode == PivotTotalCode);
+            if (grand != null)
+            {
+                result.KeyMaterials.Add(new AdoS8OrderFlowKeyMaterialDto
+                {
+                    MaterialCode = material,
+                    AvgCycleDays = grand.CycleDays,
+                    CycleStatus = grand.Status,
+                    ResultStatus = grand.Status,
+                });
+            }
+
+            // SupplierBreakdown:spec=TOTAL 的所有 supplier 行(含 supplier=TOTAL)
+            foreach (var row in list.Where(r => r.SpecCode == PivotTotalCode)
+                                    .OrderBy(r => r.SupplierCode == PivotTotalCode ? 1 : 0)
+                                    .ThenBy(r => r.SupplierCode, StringComparer.Ordinal))
+            {
+                result.SupplierBreakdown.Add(new AdoS8OrderFlowSupplierBreakdownDto
+                {
+                    MaterialCode = material,
+                    SupplierCode = row.SupplierCode,
+                    AvgCycleDays = row.CycleDays,
+                    Status = row.Status,
+                });
+            }
+
+            // SpecBreakdown:supplier=TOTAL 的所有 spec 行(含 spec=TOTAL)
+            foreach (var row in list.Where(r => r.SupplierCode == PivotTotalCode)
+                                    .OrderBy(r => r.SpecCode == PivotTotalCode ? 1 : 0)
+                                    .ThenBy(r => r.SpecCode, StringComparer.Ordinal))
+            {
+                result.SpecBreakdown.Add(new AdoS8OrderFlowSpecBreakdownDto
+                {
+                    MaterialCode = material,
+                    SpecCode = row.SpecCode,
+                    AvgCycleDays = row.CycleDays,
+                    Status = row.Status,
+                });
+            }
+
+            // MatrixByMaterial:每个 supplier 一行,cells keyed by spec_code(保留全部 cycle_days 精度)
+            var matrix = list.GroupBy(r => r.SupplierCode)
+                             .OrderBy(g => g.Key == PivotTotalCode ? 1 : 0)
+                             .ThenBy(g => g.Key, StringComparer.Ordinal)
+                             .Select(g => new AdoS8OrderFlowProcurementMatrixRowDto
+                             {
+                                 SupplierCode = g.Key,
+                                 Cells = g.ToDictionary(
+                                     r => r.SpecCode,
+                                     r => new AdoS8OrderFlowProcurementMatrixCellDto
+                                     {
+                                         CycleDays = r.CycleDays,
+                                         Status = r.Status,
+                                     }),
+                             })
+                             .ToList();
+            result.MatrixByMaterial[material] = matrix;
+        }
+
+        return result;
+    }
 }