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;
using Admin.NET.Plugin.AiDOP.Infrastructure;
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;
private readonly UserManager _userManager;
private readonly SqlSugarRepository _orgRep;
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,
UserManager userManager,
SqlSugarRepository orgRep)
{
_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;
_userManager = userManager;
_orgRep = orgRep;
}
private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
private async Task ResolveFactoryIdAsync(long tenantId)
{
var orgId = _userManager.OrgId;
if (orgId > 0)
{
var userOrg = await _orgRep.AsQueryable()
.Where(o => o.Id == orgId && o.TenantId == tenantId)
.FirstAsync();
if (userOrg != null) return orgId;
}
var factory = await _orgRep.AsQueryable()
.Where(o => o.TenantId == tenantId && o.Type == "501")
.OrderBy(o => o.OrderNo)
.FirstAsync();
if (factory != null) return factory.Id;
var root = await _orgRep.AsQueryable()
.Where(o => o.TenantId == tenantId && o.Pid == 0)
.FirstAsync();
if (root != null) return root.Id;
throw Oops.Oh("无法确定当前工厂,请重新登录或选择目标组织");
}
private async Task<(long TenantId, long FactoryId)> ResolveScopeAsync()
{
var tenantId = ResolveTenantId();
var factoryId = await ResolveFactoryIdAsync(tenantId);
return (tenantId, factoryId);
}
/// 订单档案列表(无分页)。当前 baseline 20 单。
public async Task> GetOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
{
var (tenantId, factoryId) = await ResolveScopeAsync();
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)
{
if (string.IsNullOrWhiteSpace(orderCode)) return null;
var (tenantId, factoryId) = await ResolveScopeAsync();
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)
{
if (string.IsNullOrWhiteSpace(orderCode)) return null;
var (tenantId, factoryId) = await ResolveScopeAsync();
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, factoryId) = await ResolveScopeAsync();
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, factoryId) = await ResolveScopeAsync();
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, factoryId) = await ResolveScopeAsync();
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, factoryId) = await ResolveScopeAsync();
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, factoryId) = await ResolveScopeAsync();
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";
}
}