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;
namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
///
/// ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t3b:S8 订单执行链路只读 service skeleton。
/// 仅 GetOrders / GetOrder / GetChain 三个方法;procurement-pivot / aggregate / related-exceptions 延后切片。
/// 任何业务真值(订单总数 / 平均时长 / YY 真值矩阵等)只能来自 DB,本文件零硬编码。
/// 异常计数:从 ado_s8_exception 实时聚合(SourceObjectType=SALES_ORDER + RelatedObjectCode in order_codes + IsDeleted=0)。
///
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";
// ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:异常类型 → 末端协同对象 映射常量。
// 这些 type_code 均来自 ado_s8_exception_type seed 中 enabled=true、monitoring_category_key=FINAL_ASSEMBLY_DELIVERY 的子集;
// 不新增异常类型、不依赖业务执行表。空数组对象(如 ASSEMBLY_COMPLETION / 包装材料准备)在前端展示 "--"。
private const string FinalAssemblyObjectGoodsHandover = "GOODS_HANDOVER";
private const string FinalAssemblyObjectShipmentConfirmation = "SHIPMENT_CONFIRMATION";
private const string FinalAssemblyObjectShippingPlan = "SHIPPING_PLAN";
private static readonly string[] GoodsHandoverExceptionTypes = { "PENDING_SHIPMENT" };
private static readonly string[] ShipmentConfirmationExceptionTypes =
{
"DELIVERY_DELAY", "SHIPMENT_ABNORMAL", "DELIVERY_DELAY_WARNING", "FINISHED_GOODS_PENDING_SHIPMENT",
};
private static readonly string[] ShippingPlanExceptionTypes = { "FINISHED_GOODS_PENDING_SHIPMENT" };
private static readonly JsonSerializerOptions JsonOpts = new()
{
PropertyNameCaseInsensitive = true,
};
private readonly SqlSugarRepository _orderRep;
private readonly SqlSugarRepository _stageRep;
private readonly SqlSugarRepository _substepRep;
private readonly SqlSugarRepository _unitRep;
private readonly SqlSugarRepository _snapshotRep;
private readonly SqlSugarRepository _pivotRep;
private readonly SqlSugarRepository _exceptionRep;
private readonly SqlSugarRepository _pddRep;
// S8-ORDER-CHAIN-BODY-PRODUCTION-ORDER-LEVEL-SEED-FIX-1:本体生产 3 表 repository。
private readonly SqlSugarRepository _mfgProcessRep;
private readonly SqlSugarRepository _mfgLossRep;
private readonly SqlSugarRepository _mfgOperatorRep;
// S8-ORDER-CHAIN-ASSEMBLY-DELIVERY-DATA-LINEAGE-AUDIT-1:总装发货 2 表 repository。
private readonly SqlSugarRepository _faGateRep;
private readonly SqlSugarRepository _faParallelRep;
public S8OrderFlowService(
SqlSugarRepository orderRep,
SqlSugarRepository stageRep,
SqlSugarRepository substepRep,
SqlSugarRepository unitRep,
SqlSugarRepository snapshotRep,
SqlSugarRepository pivotRep,
SqlSugarRepository exceptionRep,
SqlSugarRepository pddRep,
SqlSugarRepository mfgProcessRep,
SqlSugarRepository mfgLossRep,
SqlSugarRepository mfgOperatorRep,
SqlSugarRepository faGateRep,
SqlSugarRepository faParallelRep)
{
_orderRep = orderRep;
_stageRep = stageRep;
_substepRep = substepRep;
_unitRep = unitRep;
_snapshotRep = snapshotRep;
_pivotRep = pivotRep;
_exceptionRep = exceptionRep;
_pddRep = pddRep;
_mfgProcessRep = mfgProcessRep;
_mfgLossRep = mfgLossRep;
_mfgOperatorRep = mfgOperatorRep;
_faGateRep = faGateRep;
_faParallelRep = faParallelRep;
}
/// 订单档案列表(无分页)。当前 baseline 20 单。
public async Task> GetOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
{
var tenantId = query.TenantId ?? 1;
var factoryId = query.FactoryId ?? 1;
var orders = await _orderRep.AsQueryable()
.Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
.WhereIF(!string.IsNullOrWhiteSpace(query.Keyword),
o => o.OrderCode.Contains(query.Keyword!)
|| o.ProductName.Contains(query.Keyword!)
|| o.CustomerName.Contains(query.Keyword!))
.WhereIF(!string.IsNullOrWhiteSpace(query.CurrentFlowCode),
o => o.CurrentOrderFlowCode == query.CurrentFlowCode!)
.WhereIF(!string.IsNullOrWhiteSpace(query.CustomerCode),
o => o.CustomerCode == query.CustomerCode!)
.WhereIF(!string.IsNullOrWhiteSpace(query.ProductLine),
o => o.ProductLine == query.ProductLine!)
.WhereIF(!string.IsNullOrWhiteSpace(query.Region),
o => o.Region == query.Region!)
.WhereIF(!string.IsNullOrWhiteSpace(query.WorkflowStatus),
o => o.WorkflowStatus == query.WorkflowStatus!)
.WhereIF(!string.IsNullOrWhiteSpace(query.ScenarioCode),
o => o.ScenarioCode == query.ScenarioCode!)
.OrderBy(o => o.OrderCode)
.ToListAsync();
if (orders.Count == 0) return new List();
var orderCodes = orders.Select(o => o.OrderCode).Distinct().ToList();
var orderIds = orders.Select(o => o.Id).ToList();
var currentStages = await _stageRep.AsQueryable()
.Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
.Where(s => orderIds.Contains(s.OrderId))
.ToListAsync();
var currentStageMap = currentStages
.GroupBy(s => s.OrderId)
.ToDictionary(g => g.Key, g => g.ToList());
var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, orderCodes);
return orders.Select(o => MapOrderListItem(o, currentStageMap.GetValueOrDefault(o.Id), exceptionCounts)).ToList();
}
/// 单订单详情(不含 substep/unit)。
public async Task GetOrderAsync(string orderCode, long tenantId = 1, long factoryId = 1)
{
if (string.IsNullOrWhiteSpace(orderCode)) return null;
var order = await _orderRep.AsQueryable()
.Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
&& !o.IsDeleted && o.OrderCode == orderCode)
.FirstAsync();
if (order == null) return null;
var stages = await _stageRep.AsQueryable()
.Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
&& !s.IsDeleted && s.OrderId == order.Id)
.OrderBy(s => s.SortNo)
.ToListAsync();
var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List { orderCode });
var listItem = MapOrderListItem(order, stages, exceptionCounts);
return BuildDetail(listItem, stages, substepsByFlow: null, unitsBySubstepId: null);
}
/// 订单链路视图:详情 + lifecycle + L2 substeps + L3 units。procurementPivot 留到 t3c。
public async Task GetChainAsync(string orderCode, long tenantId = 1, long factoryId = 1)
{
if (string.IsNullOrWhiteSpace(orderCode)) return null;
var order = await _orderRep.AsQueryable()
.Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
&& !o.IsDeleted && o.OrderCode == orderCode)
.FirstAsync();
if (order == null) return null;
var stages = await _stageRep.AsQueryable()
.Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
&& !s.IsDeleted && s.OrderId == order.Id)
.OrderBy(s => s.SortNo)
.ToListAsync();
var substeps = await _substepRep.AsQueryable()
.Where(x => x.TenantId == tenantId && x.FactoryId == factoryId
&& !x.IsDeleted && x.OrderId == order.Id)
.OrderBy(x => x.SortNo)
.ToListAsync();
var substepsByFlow = substeps
.GroupBy(x => x.OrderFlowCode)
.ToDictionary(g => g.Key, g => g.ToList());
var substepIds = substeps.Select(x => x.Id).ToList();
var units = substepIds.Count == 0
? new List()
: await _unitRep.AsQueryable()
.Where(u => u.TenantId == tenantId && u.FactoryId == factoryId
&& !u.IsDeleted && substepIds.Contains(u.SubstepId))
.OrderBy(u => u.SortNo)
.ToListAsync();
var unitsBySubstepId = units
.GroupBy(u => u.SubstepId)
.ToDictionary(g => g.Key, g => g.ToList());
var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List { orderCode });
var listItem = MapOrderListItem(order, stages, exceptionCounts);
var detail = BuildDetail(listItem, stages, substepsByFlow, unitsBySubstepId);
return new AdoS8OrderFlowChainDto
{
Order = detail,
ProcurementPivot = null, // t3c 切片接入
};
}
// ─────────────── private mappers ───────────────
/// 从 ado_s8_exception 实时聚合每个 order_code 的异常计数 + 状态。
private async Task> MapExceptionCountsAsync(
long tenantId, long factoryId, List orderCodes)
{
var result = orderCodes.ToDictionary(c => c, _ => (Count: 0, Status: string.Empty));
if (orderCodes.Count == 0) return result;
var rows = await _exceptionRep.AsQueryable()
.Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
.Where(e => e.SourceObjectType == ExceptionSourceObjectType)
.Where(e => orderCodes.Contains(e.RelatedObjectCode!))
.Select(e => new { e.RelatedObjectCode, e.Severity })
.ToListAsync();
foreach (var grp in rows.Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
.GroupBy(r => r.RelatedObjectCode!))
{
var items = grp.ToList();
var status = items.Any(x => string.Equals(x.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase))
? "SERIOUS"
: (items.Count > 0 ? "FOLLOW" : string.Empty);
result[grp.Key] = (items.Count, status);
}
return result;
}
private static AdoS8OrderFlowOrderListItemDto MapOrderListItem(
AdoS8OrderFlowOrder o,
List? lifecycle,
Dictionary exceptionCounts)
{
var current = lifecycle?.FirstOrDefault(s => s.OrderFlowCode == o.CurrentOrderFlowCode);
var counts = exceptionCounts.TryGetValue(o.OrderCode, out var c) ? c : (Count: 0, Status: string.Empty);
return new AdoS8OrderFlowOrderListItemDto
{
OrderCode = o.OrderCode,
ProductName = o.ProductName,
ProductLine = o.ProductLine,
CustomerCode = o.CustomerCode,
CustomerName = o.CustomerName,
CustomerType = o.CustomerType,
Region = o.Region,
Priority = o.Priority,
WorkflowStatus = o.WorkflowStatus,
CurrentOrderFlowCode = o.CurrentOrderFlowCode,
CurrentOrderFlowName = OrderFlowConstants.ResolveName(o.CurrentOrderFlowCode),
CurrentStatus = current?.Status ?? string.Empty,
ReleaseAt = o.ReleaseAt,
TargetCycleDays = o.TargetCycleDays,
ActualCycleDays = o.ActualCycleDays,
CurrentCycleDays = current?.ActualDays,
NodeVarianceDays = current?.NodeVarianceDays,
CumulativeVarianceDays = current?.CumulativeVarianceDays,
ExceptionCount = counts.Count,
ExceptionStatus = counts.Status,
ResponseMinutes = o.ResponseMinutes,
ProcessingMinutes = o.ProcessingMinutes,
TotalLossMinutes = o.TotalLossMinutes,
ScenarioCode = o.ScenarioCode,
DataSource = o.DataSource,
};
}
private static AdoS8OrderFlowOrderDetailDto BuildDetail(
AdoS8OrderFlowOrderListItemDto listItem,
List stages,
Dictionary>? substepsByFlow,
Dictionary>? unitsBySubstepId)
{
var detail = new AdoS8OrderFlowOrderDetailDto
{
OrderCode = listItem.OrderCode,
ProductName = listItem.ProductName,
ProductLine = listItem.ProductLine,
CustomerCode = listItem.CustomerCode,
CustomerName = listItem.CustomerName,
CustomerType = listItem.CustomerType,
Region = listItem.Region,
Priority = listItem.Priority,
WorkflowStatus = listItem.WorkflowStatus,
CurrentOrderFlowCode = listItem.CurrentOrderFlowCode,
CurrentOrderFlowName = listItem.CurrentOrderFlowName,
CurrentStatus = listItem.CurrentStatus,
ReleaseAt = listItem.ReleaseAt,
TargetCycleDays = listItem.TargetCycleDays,
ActualCycleDays = listItem.ActualCycleDays,
CurrentCycleDays = listItem.CurrentCycleDays,
NodeVarianceDays = listItem.NodeVarianceDays,
CumulativeVarianceDays = listItem.CumulativeVarianceDays,
ExceptionCount = listItem.ExceptionCount,
ExceptionStatus = listItem.ExceptionStatus,
ResponseMinutes = listItem.ResponseMinutes,
ProcessingMinutes = listItem.ProcessingMinutes,
TotalLossMinutes = listItem.TotalLossMinutes,
ScenarioCode = listItem.ScenarioCode,
DataSource = listItem.DataSource,
Lifecycle = stages.Select(s => MapStage(s, substepsByFlow, unitsBySubstepId)).ToList(),
};
return detail;
}
private static AdoS8OrderFlowStageDto MapStage(
AdoS8OrderFlowStage s,
Dictionary>? substepsByFlow,
Dictionary>? unitsBySubstepId)
{
var substeps = substepsByFlow != null && substepsByFlow.TryGetValue(s.OrderFlowCode, out var list)
? list.Select(x => MapSubstep(x, unitsBySubstepId)).ToList()
: new List();
return new AdoS8OrderFlowStageDto
{
OrderFlowCode = s.OrderFlowCode,
OrderFlowName = string.IsNullOrEmpty(s.OrderFlowName)
? OrderFlowConstants.ResolveName(s.OrderFlowCode)
: s.OrderFlowName,
OwnerDept = s.OwnerDept,
SortNo = s.SortNo,
PlannedDays = s.PlannedDays,
ActualDays = s.ActualDays,
TargetAt = s.TargetAt,
ActualStartAt = s.ActualStartAt,
ActualEndAt = s.ActualEndAt,
Status = s.Status,
NodeVarianceDays = s.NodeVarianceDays,
CumulativeVarianceDays = s.CumulativeVarianceDays,
Substeps = substeps,
};
}
private static AdoS8OrderFlowSubstepDto MapSubstep(
AdoS8OrderFlowSubstep x,
Dictionary>? unitsBySubstepId)
{
var units = unitsBySubstepId != null && unitsBySubstepId.TryGetValue(x.Id, out var list)
? list.Select(MapUnit).ToList()
: new List();
return new AdoS8OrderFlowSubstepDto
{
SubstepCode = x.SubstepCode,
SubstepName = x.SubstepName,
PiHours = x.PiHours,
ActualHours = x.ActualHours,
Status = x.Status,
SortNo = x.SortNo,
Units = units,
};
}
private static AdoS8OrderFlowSubstepUnitDto MapUnit(AdoS8OrderFlowSubstepUnit u)
=> new()
{
UnitCode = u.UnitCode,
UnitName = u.UnitName,
PiHours = u.PiHours,
ActualHours = u.ActualHours,
Status = u.Status,
SortNo = u.SortNo,
};
// ──────────────────────────────────────────────────────────────────────
// t3c:Aggregate + ProcurementPivot
// ──────────────────────────────────────────────────────────────────────
///
/// 链路聚合视图:
/// scope=BASELINE_PPT → 只读 ado_s8_order_flow_snapshot.stage_snapshots_json
/// scope=CURRENT_FILTERED → 从 order + stage 实时聚合
/// snapshot 缺失时返回空骨架(不做 fallback 硬编码)。
///
public async Task 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 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);
var collab = await BuildFinalAssemblyCollabSummaryAsync(tenantId, factoryId, orderCodes: null);
return new AdoS8OrderFlowAggregateDto
{
Scope = scope,
TotalOrders = snapshot.TotalOrders,
TotalCustomers = snapshot.TotalCustomers,
AvgResponseMinutes = snapshot.AvgResponseMinutes,
AvgProcessingMinutes = snapshot.AvgProcessingMinutes,
AvgLossMinutes = snapshot.AvgLossMinutes,
StageAggregates = ReorderByCanonical(stages),
FinalAssemblyCollabSummary = collab,
};
}
private async Task BuildCurrentAggregateAsync(
long tenantId, long factoryId, string scope, List? 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();
foreach (var code in OrderFlowConstants.All)
{
stagesByFlow.TryGetValue(code, out var rows);
stageAggregates.Add(BuildStageAggregateFromRows(code, rows ?? new List()));
}
var collab = await BuildFinalAssemblyCollabSummaryAsync(tenantId, factoryId, orderCodes);
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,
FinalAssemblyCollabSummary = collab,
};
}
// ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:FINAL_ASSEMBLY_DELIVERY 末端协同派生。
// 仅查 ado_s8_exception 现有数据;只覆盖有真实映射的 2 个主门禁 + 1 个并行准备,其它 6 个对象前端展示 "--"。
// 不读取 S0|S4 业务表;不新增表 / seed / 异常类型。
private async Task BuildFinalAssemblyCollabSummaryAsync(
long tenantId, long factoryId, List? orderCodes)
{
var allTypes = GoodsHandoverExceptionTypes
.Concat(ShipmentConfirmationExceptionTypes)
.Concat(ShippingPlanExceptionTypes)
.Distinct()
.ToList();
var rows = await _exceptionRep.AsQueryable()
.Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
.Where(e => e.SourceObjectType == ExceptionSourceObjectType)
.Where(e => e.ExceptionTypeCode != null && allTypes.Contains(e.ExceptionTypeCode!))
.WhereIF(orderCodes != null && orderCodes.Count > 0,
e => e.RelatedObjectCode != null && orderCodes!.Contains(e.RelatedObjectCode!))
.Select(e => new ExceptionAttribution
{
ExceptionTypeCode = e.ExceptionTypeCode,
RelatedObjectCode = e.RelatedObjectCode,
Title = e.Title,
Severity = e.Severity,
CreatedAt = e.CreatedAt,
})
.ToListAsync();
return new AdoS8FinalAssemblyCollabSummaryDto
{
Gates = new Dictionary
{
[FinalAssemblyObjectGoodsHandover] = BuildObjectStat(rows, GoodsHandoverExceptionTypes, isGate: true),
[FinalAssemblyObjectShipmentConfirmation] = BuildObjectStat(rows, ShipmentConfirmationExceptionTypes, isGate: true),
},
Parallels = new Dictionary
{
[FinalAssemblyObjectShippingPlan] = BuildObjectStat(rows, ShippingPlanExceptionTypes, isGate: false),
},
};
}
private static AdoS8FinalAssemblyObjectStatDto BuildObjectStat(
List rows, string[] candidateTypes, bool isGate)
{
var subset = rows.Where(r => r.ExceptionTypeCode != null && candidateTypes.Contains(r.ExceptionTypeCode!)).ToList();
var count = subset
.Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
.Select(r => r.RelatedObjectCode!)
.Distinct()
.Count();
var top = subset
.OrderByDescending(r => string.Equals(r.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
.ThenByDescending(r => r.CreatedAt)
.FirstOrDefault();
return new AdoS8FinalAssemblyObjectStatDto
{
ImpactedOrderCount = isGate ? count : (int?)null,
RiskOrderCount = isGate ? (int?)null : count,
TopRiskTitle = top?.Title,
TopRiskSeverity = top?.Severity,
};
}
private sealed class ExceptionAttribution
{
public string? ExceptionTypeCode { get; set; }
public string? RelatedObjectCode { get; set; }
public string? Title { get; set; }
public string? Severity { get; set; }
public DateTime CreatedAt { get; set; }
}
private static AdoS8OrderFlowStageAggregateDto BuildStageAggregateFromRows(string flowCode, List 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 ParseStageSnapshots(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return new List();
try
{
var parsed = JsonSerializer.Deserialize>(json, JsonOpts);
return parsed ?? new List();
}
catch (JsonException)
{
return new List();
}
}
/// 按 OrderFlowConstants.All 重排;缺失 code 补空骨架;多余 code 追加在尾部以避免静默丢数据。
private static List ReorderByCanonical(List 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();
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 values)
=> values.Count == 0 ? 0m : values.Average();
///
/// 采购透视视图:
/// scope=BASELINE_PPT → 读 ado_s8_order_flow_procurement_pivot baseline 行(order_id IS NULL)。
/// scope=CURRENT_FILTERED → 读订单级行(order_id IS NOT NULL)并按 orderCodes 过滤后聚合:
/// cycle_days 简单平均;impact_count 求和(语义:多订单合计影响台次);
/// kit_rate 简单平均;status 按聚合后 cycle_days 三档阈值重判
/// (与 baseline seed 一致:≤15 green / ≤20 yellow / >20 red)。
/// 其它 scope → 返回空骨架并回显 scope。
/// orderCodes 为空时返回空骨架,不静默 fallback baseline(避免 baseline 泄漏到单订单态)。
/// data_source 优先级:IMPORT / AGG > SEED;同一 (order_code, material, supplier, spec) 上
/// 若 IMPORT/AGG 行存在,只采用 IMPORT/AGG;SEED 仅作过渡兜底,便于真实数据源接入后无缝替换。
/// status 来源:baseline 直接读 row.Status;CURRENT_FILTERED 聚合后按阈值重判(运行期重新分类,
/// 不修改 seed 行)。cycle_days 保持 decimal(6,3) 不截断。
///
public async Task 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))
{
var baselineRows = await _pivotRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId == null) // baseline 行
.ToListAsync();
return BuildPivot(scope, baselineRows);
}
if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
{
// 与产品设计 drawings API 一致:orderCodes 由 CSV 解析(trim / 去空 / Distinct)。
var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
if (orderCodes.Count == 0)
return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
var orderLevelRows = await _pivotRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId != null && p.OrderCode != null)
.Where(p => orderCodes.Contains(p.OrderCode!))
.ToListAsync();
if (orderLevelRows.Count == 0)
return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
var preferred = SelectPreferredDataSourceRows(orderLevelRows);
var aggregated = AggregateOrderLevelRows(preferred);
return BuildPivot(scope, aggregated);
}
return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
}
///
/// data_source 优先级:同一 (order_code, material, supplier, spec) 同时存在 IMPORT/AGG 与 SEED 时,
/// 只保留 IMPORT/AGG 行;SEED 仅在 IMPORT/AGG 缺失时作为兜底。便于真实数据源接入后无缝替换 SEED。
///
private static List SelectPreferredDataSourceRows(
List rows)
{
var grouped = rows.GroupBy(r => (r.OrderCode, r.MaterialCode, r.SupplierCode, r.SpecCode));
var result = new List(rows.Count);
foreach (var g in grouped)
{
var preferred = g.FirstOrDefault(r =>
string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
result.Add(preferred ?? g.First());
}
return result;
}
///
/// 多订单聚合:按 (material, supplier, spec) 分组后简单平均;单订单时分组内只 1 行,聚合等于原值。
/// cycle_days 平均;impact_count 求和;kit_rate 平均;status 按聚合后 cycle_days 阈值重判。
/// 不补造缺位单元;聚合产物为非持久化 Pivot 对象供 BuildPivot 复用。
///
private static List AggregateOrderLevelRows(
List rows)
{
return rows
.GroupBy(r => (r.MaterialCode, r.SupplierCode, r.SpecCode))
.Select(g =>
{
var cycle = decimal.Round(g.Average(r => r.CycleDays), 3);
var impactRows = g.Where(r => r.ImpactCount.HasValue).ToList();
int? impactSum = impactRows.Count == 0 ? null : impactRows.Sum(r => r.ImpactCount!.Value);
var kitRows = g.Where(r => r.KitRate.HasValue).ToList();
decimal? kitAvg = kitRows.Count == 0
? null
: decimal.Round(kitRows.Average(r => r.KitRate!.Value), 4);
return new AdoS8OrderFlowProcurementPivot
{
Id = 0,
OrderId = null,
OrderCode = null,
MaterialCode = g.Key.MaterialCode,
SupplierCode = g.Key.SupplierCode,
SpecCode = g.Key.SpecCode,
CycleDays = cycle,
Status = ClassifyAggregatedStatus(cycle),
ImpactCount = impactSum,
KitRate = kitAvg,
ScenarioCode = string.Empty,
DataSource = string.Empty,
TenantId = 0,
FactoryId = 0,
IsDeleted = false,
};
})
.ToList();
}
private static string ClassifyAggregatedStatus(decimal cycleDays)
{
if (cycleDays <= 15.0m) return "green";
if (cycleDays <= 20.0m) return "yellow";
return "red";
}
private static AdoS8OrderFlowProcurementPivotDto BuildPivot(string scope, List 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,
ImpactCount = grand.ImpactCount,
KitRate = grand.KitRate,
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;
}
// ──────────────────────────────────────────────────────────────────────
// S8-ORDER-CHAIN-PRODUCT-DESIGN-DRAWING-PERSIST-1:产品设计图号粒度聚合。
// 单一数据源 = ado_s8_order_flow_product_design_drawing 表;
// 台数 / 占比 / 加权达成率 全部由 product_quantity 字段驱动;
// 平均设计周期 = actual_days 算术平均(非 quantity 加权);
// summary.categories 固定返回 STANDARD / NON_STANDARD / TOTAL 三档;
// drawings 按 productType 过滤;未达标优先 + actualDays 降序 + orderCode/drawingNo 升序。
// ──────────────────────────────────────────────────────────────────────
private const string ProductDesignTypeStandard = "STANDARD";
private const string ProductDesignTypeNonStandard = "NON_STANDARD";
private const string ProductDesignTypeTotal = "TOTAL";
private const decimal ProductDesignStageKpiDays = 3m;
// S8-ORDER-CHAIN-PRODUCT-DESIGN-PPT-STATIC-AND-SINGLE-ORDER-ALIGN-1:
// 非标产品 KPI 与常规产品同阶段标准 3 天;非标 actual_days 超 3 时记 red/未达标。
private const decimal ProductDesignNonStandardKpiDays = 3m;
public async Task GetProductDesignDrawingsAsync(
AdoS8OrderFlowProductDesignDrawingsQueryDto query)
{
var tenantId = query.TenantId ?? 1;
var factoryId = query.FactoryId ?? 1;
var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
var productTypeFilter = NormalizeProductDesignType(query.ProductType);
var scope = orderCodes.Count == 0 ? ScopeBaselinePpt : ScopeCurrentFiltered;
var rows = await _pddRep.AsQueryable()
.Where(d => d.TenantId == tenantId && d.FactoryId == factoryId && !d.IsDeleted)
.WhereIF(orderCodes.Count > 0, d => orderCodes.Contains(d.OrderCode))
.ToListAsync();
// summary 始终基于命中订单范围的全分类汇总(不受 productType 过滤影响),让前端可同时渲染三档汇总。
var summary = BuildProductDesignSummary(rows);
var drawingItems = rows.AsEnumerable();
if (productTypeFilter != null)
drawingItems = drawingItems.Where(d => d.ProductType == productTypeFilter);
var orderedDrawings = drawingItems
.OrderBy(d => d.IsAchieved)
.ThenByDescending(d => d.ActualDays ?? decimal.MinValue)
.ThenBy(d => d.OrderCode, StringComparer.Ordinal)
.ThenBy(d => d.DrawingNo, StringComparer.Ordinal)
.Select(MapProductDesignDrawingItem)
.ToList();
return new AdoS8OrderFlowProductDesignDrawingsDto
{
Scope = scope,
Filter = new AdoS8OrderFlowProductDesignFilterDto
{
OrderCodes = orderCodes,
ProductType = productTypeFilter,
},
Summary = summary,
Drawings = orderedDrawings,
};
}
private static List ParseOrderCodesCsv(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return new List();
return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.Ordinal)
.ToList();
}
private static string? NormalizeProductDesignType(string? raw)
{
if (string.IsNullOrWhiteSpace(raw)) return null;
var s = raw.Trim().ToUpperInvariant();
return s == ProductDesignTypeStandard || s == ProductDesignTypeNonStandard ? s : null;
}
private static AdoS8OrderFlowProductDesignSummaryDto BuildProductDesignSummary(
List rows)
{
var stdRows = rows.Where(r => r.ProductType == ProductDesignTypeStandard).ToList();
var nonStdRows = rows.Where(r => r.ProductType == ProductDesignTypeNonStandard).ToList();
var grandTotalQty = rows.Sum(r => r.ProductQuantity);
return new AdoS8OrderFlowProductDesignSummaryDto
{
Overall = BuildProductDesignOverall(rows),
Categories = new List
{
BuildProductDesignCategory(ProductDesignTypeStandard, "常规产品", ProductDesignStageKpiDays, stdRows, grandTotalQty),
BuildProductDesignCategory(ProductDesignTypeNonStandard, "非标产品", ProductDesignNonStandardKpiDays, nonStdRows, grandTotalQty),
BuildProductDesignCategory(ProductDesignTypeTotal, "合计", ProductDesignStageKpiDays, rows, grandTotalQty),
},
};
}
private static AdoS8OrderFlowProductDesignOverallSummaryDto BuildProductDesignOverall(
List rows)
{
var totalQty = rows.Sum(r => r.ProductQuantity);
var achievedQty = rows.Where(r => r.IsAchieved).Sum(r => r.ProductQuantity);
var actualDays = rows.Where(r => r.ActualDays.HasValue).Select(r => r.ActualDays!.Value).ToList();
return new AdoS8OrderFlowProductDesignOverallSummaryDto
{
DrawingCount = rows.Select(r => r.DrawingNo).Distinct(StringComparer.Ordinal).Count(),
TotalQuantity = totalQty,
KpiDays = ProductDesignStageKpiDays,
AvgActualDays = actualDays.Count == 0 ? 0m : actualDays.Average(),
AchievementRate = totalQty == 0 ? 0m : (decimal)achievedQty / totalQty,
};
}
private static AdoS8OrderFlowProductDesignCategorySummaryDto BuildProductDesignCategory(
string productType,
string name,
decimal kpiDays,
List rows,
int grandTotalQty)
{
var totalQty = rows.Sum(r => r.ProductQuantity);
var achievedQty = rows.Where(r => r.IsAchieved).Sum(r => r.ProductQuantity);
var actualDays = rows.Where(r => r.ActualDays.HasValue).Select(r => r.ActualDays!.Value).ToList();
var rate = totalQty == 0 ? 0m : (decimal)achievedQty / totalQty;
// S8-ORDER-CHAIN-PRODUCT-DESIGN-20ORDER-GENERATION-CONSISTENCY-AUDIT-1:
// STANDARD / NON_STANDARD / TOTAL 三档统一返回真实指标,不再用 null 掩盖。
// - KpiDays:始终返回常量 KPI=3,方便前端展示阈值。
// - AvgActualDays:actualDays 简单平均(pending 行已经被 HasValue 过滤)。TOTAL 平均 == 父 stage.actual_days(守恒)。
// - AchievementRate:以 product_quantity 加权达成率(与 Overall 一致),保留业务台数权重直觉。
// - Status:达成率 ≥0.95 green / ≥0.80 yellow / 否则 red;无完工行(all pending)时返回空串。
return new AdoS8OrderFlowProductDesignCategorySummaryDto
{
ProductType = productType,
Name = name,
DrawingCount = rows.Select(r => r.DrawingNo).Distinct(StringComparer.Ordinal).Count(),
TotalQuantity = totalQty,
Ratio = grandTotalQty == 0 ? 0m : (decimal)totalQty / grandTotalQty,
KpiDays = kpiDays,
AvgActualDays = actualDays.Count == 0 ? (decimal?)null : actualDays.Average(),
AchievementRate = rate,
Status = actualDays.Count == 0
? string.Empty
: ClassifyProductDesignCategoryStatus(rate, totalQty),
};
}
/// 达标率 ≥ 0.95 绿 / ≥ 0.80 黄 / 否则 红;无数据返回空串以表达未取数而非状态判定。
private static string ClassifyProductDesignCategoryStatus(decimal achievementRate, int totalQty)
{
if (totalQty == 0) return string.Empty;
if (achievementRate >= 0.95m) return "green";
if (achievementRate >= 0.80m) return "yellow";
return "red";
}
private static AdoS8OrderFlowProductDesignDrawingItemDto MapProductDesignDrawingItem(
AdoS8OrderFlowProductDesignDrawing d)
=> new()
{
OrderCode = d.OrderCode,
DrawingNo = d.DrawingNo,
ProductType = d.ProductType,
ResponsiblePerson = d.ResponsiblePerson,
PlannedStartDate = d.PlannedStartDate,
PlannedEndDate = d.PlannedEndDate,
ActualStartDate = d.ActualStartDate,
ActualEndDate = d.ActualEndDate,
KpiDays = d.KpiDays,
ActualDays = d.ActualDays,
IsAchieved = d.IsAchieved,
Status = d.Status,
ProductQuantity = d.ProductQuantity,
};
// ──────────────────────────────────────────────────────────────────────
// S8-ORDER-CHAIN-BODY-PRODUCTION-ORDER-LEVEL-SEED-FIX-1:本体生产透视。
// 单一数据源 = ado_s8_order_flow_manufacturing_{process,loss_factor,operator} 三表。
// BASELINE_PPT 读 (order_id IS NULL) baseline 行;CURRENT_FILTERED 读订单级行(order_id IS NOT NULL),
// 按 IMPORT/AGG > SEED 优先级去重后,按业务口径聚合:
// process actual_days:AVG(多订单按行平均)
// process plan_qty:SUM(多订单累计计划台次)
// process achievement_rate:AVG(多订单平均达成率),TOTAL 行特殊处理见 BuildManufacturingProcessAggregated。
// process cycle_status / achievement_status:聚合后按阈值重判(与 baseline 同口径,运行期不修改 seed 行)。
// loss factor count:SUM;ratio_pct / loss_hours:AVG。
// operator avg_hours:AVG;status:取首单的 status(baseline 每操作员固定,AVG 后语义不变)。
// orderCodes 为空时返回空骨架(不静默 fallback baseline,避免 baseline 泄漏到单订单态)。
// status 阈值与 seed 同步:cycle_status (≤pi green / ≤pi*1.2 yellow / 否则 red),
// achievement_status (≥0.95 green / ≥0.80 yellow / 否则 red)。
// ──────────────────────────────────────────────────────────────────────
private const string ManufacturingProcessTotalCode = "TOTAL";
// S8-...-FIX-1R:order-level TOTAL.achievement_rate / achievement_status 锚定 fixture,
// 与 baseline TOTAL(0.90 / yellow)保持一致,不再用 plan_qty 加权计算(避免 65% red 与 90% yellow 冲突)。
// SEED 过渡口径;真实 IMPORT/AGG 接入后由真实生产达成数据替换。
private const decimal ManufacturingTotalAchievementRate = 0.9000m;
private const string ManufacturingTotalAchievementStatus = "yellow";
public async Task GetManufacturingPivotAsync(
AdoS8OrderFlowManufacturingPivotQueryDto 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))
{
var processRows = await _mfgProcessRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId == null)
.ToListAsync();
var lossRows = await _mfgLossRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId == null)
.ToListAsync();
var operatorRows = await _mfgOperatorRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId == null)
.ToListAsync();
return BuildManufacturingPivotFromBaseline(scope, processRows, lossRows, operatorRows);
}
if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
{
var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
if (orderCodes.Count == 0)
return new AdoS8OrderFlowManufacturingPivotDto { Scope = scope };
var processRows = await _mfgProcessRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId != null && p.OrderCode != null)
.Where(p => orderCodes.Contains(p.OrderCode!))
.ToListAsync();
var lossRows = await _mfgLossRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId != null && p.OrderCode != null)
.Where(p => orderCodes.Contains(p.OrderCode!))
.ToListAsync();
var operatorRows = await _mfgOperatorRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId != null && p.OrderCode != null)
.Where(p => orderCodes.Contains(p.OrderCode!))
.ToListAsync();
if (processRows.Count == 0 && lossRows.Count == 0 && operatorRows.Count == 0)
return new AdoS8OrderFlowManufacturingPivotDto { Scope = scope };
// IMPORT/AGG > SEED 去重:同 (order_code, process_code/factor_code/operator_code) 上 IMPORT/AGG 优先。
var preferredProcess = SelectPreferredMfgProcess(processRows);
var preferredLoss = SelectPreferredMfgLossFactor(lossRows);
var preferredOperator = SelectPreferredMfgOperator(operatorRows);
return new AdoS8OrderFlowManufacturingPivotDto
{
Scope = scope,
Processes = BuildManufacturingProcessAggregated(preferredProcess),
LossFactors = BuildManufacturingLossFactorAggregated(preferredLoss),
Operators = BuildManufacturingOperatorAggregated(preferredOperator),
};
}
return new AdoS8OrderFlowManufacturingPivotDto { Scope = scope };
}
private static AdoS8OrderFlowManufacturingPivotDto BuildManufacturingPivotFromBaseline(
string scope,
List processRows,
List lossRows,
List operatorRows)
{
var processes = processRows
.OrderBy(r => r.SortNo)
.Select(r => new AdoS8OrderFlowManufacturingProcessDto
{
ProcessCode = r.ProcessCode,
ProcessName = r.ProcessName,
PiDays = r.PiDays,
ActualDays = r.ActualDays,
CycleStatus = r.CycleStatus,
PlanQty = r.PlanQty,
AchievementRate = r.AchievementRate,
AchievementStatus = r.AchievementStatus,
SortNo = r.SortNo,
})
.ToList();
var losses = lossRows
.OrderBy(r => r.SortNo)
.Select(r => new AdoS8OrderFlowManufacturingLossFactorDto
{
FactorCode = r.FactorCode,
FactorName = r.FactorName,
CountValue = r.CountValue,
RatioPct = r.RatioPct,
LossHours = r.LossHours,
SortNo = r.SortNo,
})
.ToList();
var operators = operatorRows
.OrderBy(r => r.SortNo)
.Select(r => new AdoS8OrderFlowManufacturingOperatorDto
{
OperatorCode = r.OperatorCode,
OperatorName = r.OperatorName,
AvgHours = r.AvgHours,
Status = r.Status,
SortNo = r.SortNo,
})
.ToList();
return new AdoS8OrderFlowManufacturingPivotDto
{
Scope = scope,
Processes = processes,
LossFactors = losses,
Operators = operators,
};
}
private static List SelectPreferredMfgProcess(
List rows)
{
var grouped = rows.GroupBy(r => (r.OrderCode, r.ProcessCode));
var result = new List(rows.Count);
foreach (var g in grouped)
{
var preferred = g.FirstOrDefault(r =>
string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
result.Add(preferred ?? g.First());
}
return result;
}
private static List SelectPreferredMfgLossFactor(
List rows)
{
var grouped = rows.GroupBy(r => (r.OrderCode, r.FactorCode));
var result = new List(rows.Count);
foreach (var g in grouped)
{
var preferred = g.FirstOrDefault(r =>
string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
result.Add(preferred ?? g.First());
}
return result;
}
private static List SelectPreferredMfgOperator(
List rows)
{
var grouped = rows.GroupBy(r => (r.OrderCode, r.OperatorCode));
var result = new List(rows.Count);
foreach (var g in grouped)
{
var preferred = g.FirstOrDefault(r =>
string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
result.Add(preferred ?? g.First());
}
return result;
}
///
/// 多订单工序聚合:
/// 非 TOTAL 行:actual_days = AVG;plan_qty = SUM;achievement_rate = AVG(保留 4 位小数);
/// cycle_status 按聚合 actual vs pi 重判;achievement_status 按聚合 rate 重判。
/// TOTAL 行:pi=6(取 baseline 常量);actual = AVG(与 4 工序 AVG 之和守恒,差异为 rounding 容差);
/// plan_qty = NULL;
/// S8-...-FIX-1R:achievement_rate / achievement_status 锚定 fixture(0.90 / yellow),
/// 不再做 plan_qty 加权计算(避免与 baseline TOTAL 视觉冲突);
/// cycle_status 按聚合 actual vs pi 重判。
///
private static List BuildManufacturingProcessAggregated(
List rows)
{
var grouped = rows
.GroupBy(r => r.ProcessCode)
.ToDictionary(g => g.Key, g => g.ToList());
// S8-...-FIX-1R:TOTAL 行 rate / status 锚定 fixture,不再做 4 工序 plan_qty 加权计算,
// 不需要维护中间 aggregatedRates 字典。
var processDtos = new List();
foreach (var kv in grouped.OrderBy(g => g.Value.First().SortNo))
{
var code = kv.Key;
var list = kv.Value;
var sample = list[0];
var isTotal = string.Equals(code, ManufacturingProcessTotalCode, StringComparison.OrdinalIgnoreCase);
var actualAvg = decimal.Round(list.Average(r => r.ActualDays), 3);
var pi = sample.PiDays;
decimal? aggregatedRate;
int? planQtySum;
string achStatus;
if (isTotal)
{
// S8-...-FIX-1R:TOTAL 行 rate / status 锚定 fixture(0.90 / yellow),
// 不再做 plan_qty 加权计算;非 TOTAL 行的 plan_qty 仍保留供 UI 显示,但不参与 TOTAL.rate。
planQtySum = null;
aggregatedRate = ManufacturingTotalAchievementRate;
achStatus = ManufacturingTotalAchievementStatus;
}
else
{
planQtySum = list.Where(r => r.PlanQty.HasValue).Sum(r => r.PlanQty!.Value);
var rateRows = list.Where(r => r.AchievementRate.HasValue).ToList();
aggregatedRate = rateRows.Count == 0
? null
: decimal.Round(rateRows.Average(r => r.AchievementRate!.Value), 4);
achStatus = aggregatedRate.HasValue
? ClassifyMfgAchievementStatus(aggregatedRate.Value)
: string.Empty;
}
var cycleStatus = ClassifyMfgCycleStatus(actualAvg, pi);
processDtos.Add(new AdoS8OrderFlowManufacturingProcessDto
{
ProcessCode = sample.ProcessCode,
ProcessName = sample.ProcessName,
PiDays = pi,
ActualDays = actualAvg,
CycleStatus = cycleStatus,
PlanQty = planQtySum,
AchievementRate = aggregatedRate,
AchievementStatus = achStatus,
SortNo = sample.SortNo,
});
}
return processDtos;
}
private static List BuildManufacturingLossFactorAggregated(
List rows)
{
return rows
.GroupBy(r => r.FactorCode)
.OrderBy(g => g.First().SortNo)
.Select(g =>
{
var sample = g.First();
var countList = g.Where(r => r.CountValue.HasValue).ToList();
var ratioList = g.Where(r => r.RatioPct.HasValue).ToList();
var hoursList = g.Where(r => r.LossHours.HasValue).ToList();
return new AdoS8OrderFlowManufacturingLossFactorDto
{
FactorCode = sample.FactorCode,
FactorName = sample.FactorName,
CountValue = countList.Count == 0 ? null : countList.Sum(r => r.CountValue!.Value),
RatioPct = ratioList.Count == 0
? null
: decimal.Round(ratioList.Average(r => r.RatioPct!.Value), 2),
LossHours = hoursList.Count == 0
? null
: decimal.Round(hoursList.Average(r => r.LossHours!.Value), 2),
SortNo = sample.SortNo,
};
})
.ToList();
}
private static List BuildManufacturingOperatorAggregated(
List rows)
{
return rows
.GroupBy(r => r.OperatorCode)
.OrderBy(g => g.First().SortNo)
.Select(g =>
{
var sample = g.First();
return new AdoS8OrderFlowManufacturingOperatorDto
{
OperatorCode = sample.OperatorCode,
OperatorName = sample.OperatorName,
AvgHours = decimal.Round(g.Average(r => r.AvgHours), 2),
// 每操作员 baseline status 固化,多订单聚合时各订单值一致;取首单不引入歧义。
Status = sample.Status,
SortNo = sample.SortNo,
};
})
.ToList();
}
private static string ClassifyMfgCycleStatus(decimal actual, decimal pi)
{
if (actual <= pi) return "green";
if (actual <= pi * 1.2m) return "yellow";
return "red";
}
private static string ClassifyMfgAchievementStatus(decimal rate)
{
if (rate >= 0.95m) return "green";
if (rate >= 0.80m) return "yellow";
return "red";
}
// ────────────────────────────────────────────────────────────
// 总装发货透视 — S8-ORDER-CHAIN-ASSEMBLY-DELIVERY-DATA-LINEAGE-AUDIT-1
//
// 与 aggregate.finalAssemblyCollabSummary(exception 派生)通道独立:
// - 本 API 提供门禁 KPI / 实际 / 偏差 / 齐套率(来自专属 pivot 表)。
// - 现有 BuildFinalAssemblyCollabSummaryAsync 保留,作为 collabSummary 补充。
// 数据源优先级:IMPORT > AGG > SEED(同 (order_code, gate_code/prep_code) 上去重)。
// 多订单聚合规则:
// - planned_offset_days:取首单值(baseline 固定 0.6/1.2/1.8/2.4/3.0,不随订单变)。
// - actual_offset_days:AVG。
// - variance_days:AVG(等于 avg_actual - planned,因 planned 固定)。
// - status:聚合后按 variance 重判(同 seed 阈值)。
// - impacted_order_count / risk_order_count:SUM。
// - top_risk_type / top_risk_label:稳定取首条 status != green 的样本(按 sort_no / order_code 升序)。
// - parallel kitting_rate:AVG(保留 4 位小数);status 按聚合 kitting_rate 重判。
// - orderCodes 为空 → 返回空骨架,不静默 fallback baseline;
// - 全 pending → 自然空骨架(SEED 不包含 pending 行)。
// ────────────────────────────────────────────────────────────
public async Task GetFinalAssemblyPivotAsync(
AdoS8FinalAssemblyPivotQueryDto 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))
{
var gateRows = await _faGateRep.AsQueryable()
.Where(g => g.TenantId == tenantId && g.FactoryId == factoryId && !g.IsDeleted)
.Where(g => g.OrderId == null)
.ToListAsync();
var parallelRows = await _faParallelRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId == null)
.ToListAsync();
return BuildFinalAssemblyPivotFromBaseline(scope, gateRows, parallelRows);
}
if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
{
var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
if (orderCodes.Count == 0)
return new AdoS8FinalAssemblyPivotDto { Scope = scope };
var gateRows = await _faGateRep.AsQueryable()
.Where(g => g.TenantId == tenantId && g.FactoryId == factoryId && !g.IsDeleted)
.Where(g => g.OrderId != null && g.OrderCode != null)
.Where(g => orderCodes.Contains(g.OrderCode!))
.ToListAsync();
var parallelRows = await _faParallelRep.AsQueryable()
.Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
.Where(p => p.OrderId != null && p.OrderCode != null)
.Where(p => orderCodes.Contains(p.OrderCode!))
.ToListAsync();
if (gateRows.Count == 0 && parallelRows.Count == 0)
return new AdoS8FinalAssemblyPivotDto { Scope = scope };
var preferredGates = SelectPreferredFaGate(gateRows);
var preferredParallels = SelectPreferredFaParallel(parallelRows);
return new AdoS8FinalAssemblyPivotDto
{
Scope = scope,
Gates = BuildFaGateAggregated(preferredGates),
ParallelPreps = BuildFaParallelAggregated(preferredParallels),
};
}
return new AdoS8FinalAssemblyPivotDto { Scope = scope };
}
private static AdoS8FinalAssemblyPivotDto BuildFinalAssemblyPivotFromBaseline(
string scope,
List gateRows,
List parallelRows)
{
var gates = gateRows
.OrderBy(g => g.SortNo)
.Select(g => new AdoS8FinalAssemblyGateDto
{
GateCode = g.GateCode,
GateName = g.GateName,
PlannedOffsetDays = g.PlannedOffsetDays,
ActualOffsetDays = g.ActualOffsetDays,
VarianceDays = g.VarianceDays,
Status = g.Status,
ImpactedOrderCount = g.ImpactedOrderCount,
RiskOrderCount = g.RiskOrderCount,
TopRiskType = g.TopRiskType,
TopRiskLabel = g.TopRiskLabel,
OwnerSideText = g.OwnerSideText,
SortNo = g.SortNo,
})
.ToList();
var parallels = parallelRows
.OrderBy(p => p.SortNo)
.Select(p => new AdoS8FinalAssemblyParallelDto
{
PrepCode = p.PrepCode,
PrepName = p.PrepName,
KittingRate = p.KittingRate,
Status = p.Status,
ImpactedOrderCount = p.ImpactedOrderCount,
RiskOrderCount = p.RiskOrderCount,
TopRiskType = p.TopRiskType,
TopRiskLabel = p.TopRiskLabel,
OwnerSideText = p.OwnerSideText,
SortNo = p.SortNo,
})
.ToList();
return new AdoS8FinalAssemblyPivotDto
{
Scope = scope,
Gates = gates,
ParallelPreps = parallels,
};
}
private static List SelectPreferredFaGate(
List rows)
{
var grouped = rows.GroupBy(r => (r.OrderCode, r.GateCode));
var result = new List(rows.Count);
foreach (var g in grouped)
{
var preferred = g.FirstOrDefault(r =>
string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
result.Add(preferred ?? g.First());
}
return result;
}
private static List SelectPreferredFaParallel(
List rows)
{
var grouped = rows.GroupBy(r => (r.OrderCode, r.PrepCode));
var result = new List(rows.Count);
foreach (var g in grouped)
{
var preferred = g.FirstOrDefault(r =>
string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
result.Add(preferred ?? g.First());
}
return result;
}
private static List BuildFaGateAggregated(
List rows)
{
return rows
.GroupBy(r => r.GateCode)
.OrderBy(g => g.First().SortNo)
.Select(g =>
{
var sample = g.First();
var actualList = g.Where(r => r.ActualOffsetDays.HasValue).ToList();
var varianceList = g.Where(r => r.VarianceDays.HasValue).ToList();
decimal? actualAvg = actualList.Count == 0
? null
: decimal.Round(actualList.Average(r => r.ActualOffsetDays!.Value), 3);
decimal? varianceAvg = varianceList.Count == 0
? null
: decimal.Round(varianceList.Average(r => r.VarianceDays!.Value), 3);
var aggregatedStatus = varianceAvg.HasValue
? ClassifyFaGateStatus(varianceAvg.Value)
: sample.Status;
var impactedSum = g.Sum(r => r.ImpactedOrderCount ?? 0);
var riskSum = g.Sum(r => r.RiskOrderCount ?? 0);
// 稳定取首条非 green 行的 top_risk_type / label(按 order_code 升序保证确定性)。
var riskSample = g
.Where(r => !string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase)
&& r.TopRiskType != null)
.OrderBy(r => r.OrderCode, StringComparer.Ordinal)
.FirstOrDefault();
return new AdoS8FinalAssemblyGateDto
{
GateCode = sample.GateCode,
GateName = sample.GateName,
PlannedOffsetDays = sample.PlannedOffsetDays,
ActualOffsetDays = actualAvg,
VarianceDays = varianceAvg,
Status = aggregatedStatus,
ImpactedOrderCount = impactedSum,
RiskOrderCount = riskSum,
TopRiskType = riskSample?.TopRiskType,
TopRiskLabel = riskSample?.TopRiskLabel,
OwnerSideText = sample.OwnerSideText,
SortNo = sample.SortNo,
};
})
.ToList();
}
private static List BuildFaParallelAggregated(
List rows)
{
return rows
.GroupBy(r => r.PrepCode)
.OrderBy(g => g.First().SortNo)
.Select(g =>
{
var sample = g.First();
var rateList = g.Where(r => r.KittingRate.HasValue).ToList();
decimal? kittingAvg = rateList.Count == 0
? null
: decimal.Round(rateList.Average(r => r.KittingRate!.Value), 4);
var aggregatedStatus = kittingAvg.HasValue
? ClassifyFaParallelStatus(kittingAvg.Value)
: sample.Status;
var impactedSum = g.Sum(r => r.ImpactedOrderCount ?? 0);
var riskSum = g.Sum(r => r.RiskOrderCount ?? 0);
var riskSample = g
.Where(r => !string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase)
&& r.TopRiskType != null)
.OrderBy(r => r.OrderCode, StringComparer.Ordinal)
.FirstOrDefault();
return new AdoS8FinalAssemblyParallelDto
{
PrepCode = sample.PrepCode,
PrepName = sample.PrepName,
KittingRate = kittingAvg,
Status = aggregatedStatus,
ImpactedOrderCount = impactedSum,
RiskOrderCount = riskSum,
TopRiskType = riskSample?.TopRiskType,
TopRiskLabel = riskSample?.TopRiskLabel,
OwnerSideText = sample.OwnerSideText,
SortNo = sample.SortNo,
};
})
.ToList();
}
/// gate status:variance ≤ 0 green / ≤ 0.3 yellow / 否则 red(与 seed 同口径)。
private static string ClassifyFaGateStatus(decimal variance)
{
if (variance <= 0m) return "green";
if (variance <= 0.3m) return "yellow";
return "red";
}
/// parallel status:kitting_rate ≥ 0.95 green / ≥ 0.85 yellow / 否则 red(与 seed 同口径)。
private static string ClassifyFaParallelStatus(decimal kittingRate)
{
if (kittingRate >= 0.95m) return "green";
if (kittingRate >= 0.85m) return "yellow";
return "red";
}
}