S8OrderFlowService.cs 46 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8.OrderFlow;
  5. namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
  6. /// <summary>
  7. /// ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t3b:S8 订单执行链路只读 service skeleton。
  8. /// 仅 GetOrders / GetOrder / GetChain 三个方法;procurement-pivot / aggregate / related-exceptions 延后切片。
  9. /// 任何业务真值(订单总数 / 平均时长 / YY 真值矩阵等)只能来自 DB,本文件零硬编码。
  10. /// 异常计数:从 ado_s8_exception 实时聚合(SourceObjectType=SALES_ORDER + RelatedObjectCode in order_codes + IsDeleted=0)。
  11. /// </summary>
  12. public class S8OrderFlowService : ITransient
  13. {
  14. private const string ExceptionSourceObjectType = "SALES_ORDER";
  15. private const string ScopeBaselinePpt = "BASELINE_PPT";
  16. private const string ScopeCurrentFiltered = "CURRENT_FILTERED";
  17. private const string BaselineSnapshotCode = "CHAIN_AGGREGATE_BASELINE";
  18. private const string PivotTotalCode = "TOTAL";
  19. // ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:异常类型 → 末端协同对象 映射常量。
  20. // 这些 type_code 均来自 ado_s8_exception_type seed 中 enabled=true、monitoring_category_key=FINAL_ASSEMBLY_DELIVERY 的子集;
  21. // 不新增异常类型、不依赖业务执行表。空数组对象(如 ASSEMBLY_COMPLETION / 包装材料准备)在前端展示 "--"。
  22. private const string FinalAssemblyObjectGoodsHandover = "GOODS_HANDOVER";
  23. private const string FinalAssemblyObjectShipmentConfirmation = "SHIPMENT_CONFIRMATION";
  24. private const string FinalAssemblyObjectShippingPlan = "SHIPPING_PLAN";
  25. private static readonly string[] GoodsHandoverExceptionTypes = { "PENDING_SHIPMENT" };
  26. private static readonly string[] ShipmentConfirmationExceptionTypes =
  27. {
  28. "DELIVERY_DELAY", "SHIPMENT_ABNORMAL", "DELIVERY_DELAY_WARNING", "FINISHED_GOODS_PENDING_SHIPMENT",
  29. };
  30. private static readonly string[] ShippingPlanExceptionTypes = { "FINISHED_GOODS_PENDING_SHIPMENT" };
  31. private static readonly JsonSerializerOptions JsonOpts = new()
  32. {
  33. PropertyNameCaseInsensitive = true,
  34. };
  35. private readonly SqlSugarRepository<AdoS8OrderFlowOrder> _orderRep;
  36. private readonly SqlSugarRepository<AdoS8OrderFlowStage> _stageRep;
  37. private readonly SqlSugarRepository<AdoS8OrderFlowSubstep> _substepRep;
  38. private readonly SqlSugarRepository<AdoS8OrderFlowSubstepUnit> _unitRep;
  39. private readonly SqlSugarRepository<AdoS8OrderFlowSnapshot> _snapshotRep;
  40. private readonly SqlSugarRepository<AdoS8OrderFlowProcurementPivot> _pivotRep;
  41. private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
  42. private readonly SqlSugarRepository<AdoS8OrderFlowProductDesignDrawing> _pddRep;
  43. public S8OrderFlowService(
  44. SqlSugarRepository<AdoS8OrderFlowOrder> orderRep,
  45. SqlSugarRepository<AdoS8OrderFlowStage> stageRep,
  46. SqlSugarRepository<AdoS8OrderFlowSubstep> substepRep,
  47. SqlSugarRepository<AdoS8OrderFlowSubstepUnit> unitRep,
  48. SqlSugarRepository<AdoS8OrderFlowSnapshot> snapshotRep,
  49. SqlSugarRepository<AdoS8OrderFlowProcurementPivot> pivotRep,
  50. SqlSugarRepository<AdoS8Exception> exceptionRep,
  51. SqlSugarRepository<AdoS8OrderFlowProductDesignDrawing> pddRep)
  52. {
  53. _orderRep = orderRep;
  54. _stageRep = stageRep;
  55. _substepRep = substepRep;
  56. _unitRep = unitRep;
  57. _snapshotRep = snapshotRep;
  58. _pivotRep = pivotRep;
  59. _exceptionRep = exceptionRep;
  60. _pddRep = pddRep;
  61. }
  62. /// <summary>订单档案列表(无分页)。当前 baseline 20 单。</summary>
  63. public async Task<List<AdoS8OrderFlowOrderListItemDto>> GetOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
  64. {
  65. var tenantId = query.TenantId ?? 1;
  66. var factoryId = query.FactoryId ?? 1;
  67. var orders = await _orderRep.AsQueryable()
  68. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
  69. .WhereIF(!string.IsNullOrWhiteSpace(query.Keyword),
  70. o => o.OrderCode.Contains(query.Keyword!)
  71. || o.ProductName.Contains(query.Keyword!)
  72. || o.CustomerName.Contains(query.Keyword!))
  73. .WhereIF(!string.IsNullOrWhiteSpace(query.CurrentFlowCode),
  74. o => o.CurrentOrderFlowCode == query.CurrentFlowCode!)
  75. .WhereIF(!string.IsNullOrWhiteSpace(query.CustomerCode),
  76. o => o.CustomerCode == query.CustomerCode!)
  77. .WhereIF(!string.IsNullOrWhiteSpace(query.ProductLine),
  78. o => o.ProductLine == query.ProductLine!)
  79. .WhereIF(!string.IsNullOrWhiteSpace(query.Region),
  80. o => o.Region == query.Region!)
  81. .WhereIF(!string.IsNullOrWhiteSpace(query.WorkflowStatus),
  82. o => o.WorkflowStatus == query.WorkflowStatus!)
  83. .WhereIF(!string.IsNullOrWhiteSpace(query.ScenarioCode),
  84. o => o.ScenarioCode == query.ScenarioCode!)
  85. .OrderBy(o => o.OrderCode)
  86. .ToListAsync();
  87. if (orders.Count == 0) return new List<AdoS8OrderFlowOrderListItemDto>();
  88. var orderCodes = orders.Select(o => o.OrderCode).Distinct().ToList();
  89. var orderIds = orders.Select(o => o.Id).ToList();
  90. var currentStages = await _stageRep.AsQueryable()
  91. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  92. .Where(s => orderIds.Contains(s.OrderId))
  93. .ToListAsync();
  94. var currentStageMap = currentStages
  95. .GroupBy(s => s.OrderId)
  96. .ToDictionary(g => g.Key, g => g.ToList());
  97. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, orderCodes);
  98. return orders.Select(o => MapOrderListItem(o, currentStageMap.GetValueOrDefault(o.Id), exceptionCounts)).ToList();
  99. }
  100. /// <summary>单订单详情(不含 substep/unit)。</summary>
  101. public async Task<AdoS8OrderFlowOrderDetailDto?> GetOrderAsync(string orderCode, long tenantId = 1, long factoryId = 1)
  102. {
  103. if (string.IsNullOrWhiteSpace(orderCode)) return null;
  104. var order = await _orderRep.AsQueryable()
  105. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
  106. && !o.IsDeleted && o.OrderCode == orderCode)
  107. .FirstAsync();
  108. if (order == null) return null;
  109. var stages = await _stageRep.AsQueryable()
  110. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
  111. && !s.IsDeleted && s.OrderId == order.Id)
  112. .OrderBy(s => s.SortNo)
  113. .ToListAsync();
  114. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List<string> { orderCode });
  115. var listItem = MapOrderListItem(order, stages, exceptionCounts);
  116. return BuildDetail(listItem, stages, substepsByFlow: null, unitsBySubstepId: null);
  117. }
  118. /// <summary>订单链路视图:详情 + lifecycle + L2 substeps + L3 units。procurementPivot 留到 t3c。</summary>
  119. public async Task<AdoS8OrderFlowChainDto?> GetChainAsync(string orderCode, long tenantId = 1, long factoryId = 1)
  120. {
  121. if (string.IsNullOrWhiteSpace(orderCode)) return null;
  122. var order = await _orderRep.AsQueryable()
  123. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
  124. && !o.IsDeleted && o.OrderCode == orderCode)
  125. .FirstAsync();
  126. if (order == null) return null;
  127. var stages = await _stageRep.AsQueryable()
  128. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
  129. && !s.IsDeleted && s.OrderId == order.Id)
  130. .OrderBy(s => s.SortNo)
  131. .ToListAsync();
  132. var substeps = await _substepRep.AsQueryable()
  133. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId
  134. && !x.IsDeleted && x.OrderId == order.Id)
  135. .OrderBy(x => x.SortNo)
  136. .ToListAsync();
  137. var substepsByFlow = substeps
  138. .GroupBy(x => x.OrderFlowCode)
  139. .ToDictionary(g => g.Key, g => g.ToList());
  140. var substepIds = substeps.Select(x => x.Id).ToList();
  141. var units = substepIds.Count == 0
  142. ? new List<AdoS8OrderFlowSubstepUnit>()
  143. : await _unitRep.AsQueryable()
  144. .Where(u => u.TenantId == tenantId && u.FactoryId == factoryId
  145. && !u.IsDeleted && substepIds.Contains(u.SubstepId))
  146. .OrderBy(u => u.SortNo)
  147. .ToListAsync();
  148. var unitsBySubstepId = units
  149. .GroupBy(u => u.SubstepId)
  150. .ToDictionary(g => g.Key, g => g.ToList());
  151. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List<string> { orderCode });
  152. var listItem = MapOrderListItem(order, stages, exceptionCounts);
  153. var detail = BuildDetail(listItem, stages, substepsByFlow, unitsBySubstepId);
  154. return new AdoS8OrderFlowChainDto
  155. {
  156. Order = detail,
  157. ProcurementPivot = null, // t3c 切片接入
  158. };
  159. }
  160. // ─────────────── private mappers ───────────────
  161. /// <summary>从 ado_s8_exception 实时聚合每个 order_code 的异常计数 + 状态。</summary>
  162. private async Task<Dictionary<string, (int Count, string Status)>> MapExceptionCountsAsync(
  163. long tenantId, long factoryId, List<string> orderCodes)
  164. {
  165. var result = orderCodes.ToDictionary(c => c, _ => (Count: 0, Status: string.Empty));
  166. if (orderCodes.Count == 0) return result;
  167. var rows = await _exceptionRep.AsQueryable()
  168. .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  169. .Where(e => e.SourceObjectType == ExceptionSourceObjectType)
  170. .Where(e => orderCodes.Contains(e.RelatedObjectCode!))
  171. .Select(e => new { e.RelatedObjectCode, e.Severity })
  172. .ToListAsync();
  173. foreach (var grp in rows.Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
  174. .GroupBy(r => r.RelatedObjectCode!))
  175. {
  176. var items = grp.ToList();
  177. var status = items.Any(x => string.Equals(x.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase))
  178. ? "SERIOUS"
  179. : (items.Count > 0 ? "FOLLOW" : string.Empty);
  180. result[grp.Key] = (items.Count, status);
  181. }
  182. return result;
  183. }
  184. private static AdoS8OrderFlowOrderListItemDto MapOrderListItem(
  185. AdoS8OrderFlowOrder o,
  186. List<AdoS8OrderFlowStage>? lifecycle,
  187. Dictionary<string, (int Count, string Status)> exceptionCounts)
  188. {
  189. var current = lifecycle?.FirstOrDefault(s => s.OrderFlowCode == o.CurrentOrderFlowCode);
  190. var counts = exceptionCounts.TryGetValue(o.OrderCode, out var c) ? c : (Count: 0, Status: string.Empty);
  191. return new AdoS8OrderFlowOrderListItemDto
  192. {
  193. OrderCode = o.OrderCode,
  194. ProductName = o.ProductName,
  195. ProductLine = o.ProductLine,
  196. CustomerCode = o.CustomerCode,
  197. CustomerName = o.CustomerName,
  198. CustomerType = o.CustomerType,
  199. Region = o.Region,
  200. Priority = o.Priority,
  201. WorkflowStatus = o.WorkflowStatus,
  202. CurrentOrderFlowCode = o.CurrentOrderFlowCode,
  203. CurrentOrderFlowName = OrderFlowConstants.ResolveName(o.CurrentOrderFlowCode),
  204. CurrentStatus = current?.Status ?? string.Empty,
  205. ReleaseAt = o.ReleaseAt,
  206. TargetCycleDays = o.TargetCycleDays,
  207. ActualCycleDays = o.ActualCycleDays,
  208. CurrentCycleDays = current?.ActualDays,
  209. NodeVarianceDays = current?.NodeVarianceDays,
  210. CumulativeVarianceDays = current?.CumulativeVarianceDays,
  211. ExceptionCount = counts.Count,
  212. ExceptionStatus = counts.Status,
  213. ResponseMinutes = o.ResponseMinutes,
  214. ProcessingMinutes = o.ProcessingMinutes,
  215. TotalLossMinutes = o.TotalLossMinutes,
  216. ScenarioCode = o.ScenarioCode,
  217. DataSource = o.DataSource,
  218. };
  219. }
  220. private static AdoS8OrderFlowOrderDetailDto BuildDetail(
  221. AdoS8OrderFlowOrderListItemDto listItem,
  222. List<AdoS8OrderFlowStage> stages,
  223. Dictionary<string, List<AdoS8OrderFlowSubstep>>? substepsByFlow,
  224. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  225. {
  226. var detail = new AdoS8OrderFlowOrderDetailDto
  227. {
  228. OrderCode = listItem.OrderCode,
  229. ProductName = listItem.ProductName,
  230. ProductLine = listItem.ProductLine,
  231. CustomerCode = listItem.CustomerCode,
  232. CustomerName = listItem.CustomerName,
  233. CustomerType = listItem.CustomerType,
  234. Region = listItem.Region,
  235. Priority = listItem.Priority,
  236. WorkflowStatus = listItem.WorkflowStatus,
  237. CurrentOrderFlowCode = listItem.CurrentOrderFlowCode,
  238. CurrentOrderFlowName = listItem.CurrentOrderFlowName,
  239. CurrentStatus = listItem.CurrentStatus,
  240. ReleaseAt = listItem.ReleaseAt,
  241. TargetCycleDays = listItem.TargetCycleDays,
  242. ActualCycleDays = listItem.ActualCycleDays,
  243. CurrentCycleDays = listItem.CurrentCycleDays,
  244. NodeVarianceDays = listItem.NodeVarianceDays,
  245. CumulativeVarianceDays = listItem.CumulativeVarianceDays,
  246. ExceptionCount = listItem.ExceptionCount,
  247. ExceptionStatus = listItem.ExceptionStatus,
  248. ResponseMinutes = listItem.ResponseMinutes,
  249. ProcessingMinutes = listItem.ProcessingMinutes,
  250. TotalLossMinutes = listItem.TotalLossMinutes,
  251. ScenarioCode = listItem.ScenarioCode,
  252. DataSource = listItem.DataSource,
  253. Lifecycle = stages.Select(s => MapStage(s, substepsByFlow, unitsBySubstepId)).ToList(),
  254. };
  255. return detail;
  256. }
  257. private static AdoS8OrderFlowStageDto MapStage(
  258. AdoS8OrderFlowStage s,
  259. Dictionary<string, List<AdoS8OrderFlowSubstep>>? substepsByFlow,
  260. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  261. {
  262. var substeps = substepsByFlow != null && substepsByFlow.TryGetValue(s.OrderFlowCode, out var list)
  263. ? list.Select(x => MapSubstep(x, unitsBySubstepId)).ToList()
  264. : new List<AdoS8OrderFlowSubstepDto>();
  265. return new AdoS8OrderFlowStageDto
  266. {
  267. OrderFlowCode = s.OrderFlowCode,
  268. OrderFlowName = string.IsNullOrEmpty(s.OrderFlowName)
  269. ? OrderFlowConstants.ResolveName(s.OrderFlowCode)
  270. : s.OrderFlowName,
  271. OwnerDept = s.OwnerDept,
  272. SortNo = s.SortNo,
  273. PlannedDays = s.PlannedDays,
  274. ActualDays = s.ActualDays,
  275. TargetAt = s.TargetAt,
  276. ActualStartAt = s.ActualStartAt,
  277. ActualEndAt = s.ActualEndAt,
  278. Status = s.Status,
  279. NodeVarianceDays = s.NodeVarianceDays,
  280. CumulativeVarianceDays = s.CumulativeVarianceDays,
  281. Substeps = substeps,
  282. };
  283. }
  284. private static AdoS8OrderFlowSubstepDto MapSubstep(
  285. AdoS8OrderFlowSubstep x,
  286. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  287. {
  288. var units = unitsBySubstepId != null && unitsBySubstepId.TryGetValue(x.Id, out var list)
  289. ? list.Select(MapUnit).ToList()
  290. : new List<AdoS8OrderFlowSubstepUnitDto>();
  291. return new AdoS8OrderFlowSubstepDto
  292. {
  293. SubstepCode = x.SubstepCode,
  294. SubstepName = x.SubstepName,
  295. PiHours = x.PiHours,
  296. ActualHours = x.ActualHours,
  297. Status = x.Status,
  298. SortNo = x.SortNo,
  299. Units = units,
  300. };
  301. }
  302. private static AdoS8OrderFlowSubstepUnitDto MapUnit(AdoS8OrderFlowSubstepUnit u)
  303. => new()
  304. {
  305. UnitCode = u.UnitCode,
  306. UnitName = u.UnitName,
  307. PiHours = u.PiHours,
  308. ActualHours = u.ActualHours,
  309. Status = u.Status,
  310. SortNo = u.SortNo,
  311. };
  312. // ──────────────────────────────────────────────────────────────────────
  313. // t3c:Aggregate + ProcurementPivot
  314. // ──────────────────────────────────────────────────────────────────────
  315. /// <summary>
  316. /// 链路聚合视图:
  317. /// scope=BASELINE_PPT → 只读 ado_s8_order_flow_snapshot.stage_snapshots_json
  318. /// scope=CURRENT_FILTERED → 从 order + stage 实时聚合
  319. /// snapshot 缺失时返回空骨架(不做 fallback 硬编码)。
  320. /// </summary>
  321. public async Task<AdoS8OrderFlowAggregateDto> GetAggregateAsync(AdoS8OrderFlowAggregateQueryDto query)
  322. {
  323. var tenantId = query.TenantId ?? 1;
  324. var factoryId = query.FactoryId ?? 1;
  325. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  326. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  327. return await BuildBaselineAggregateAsync(tenantId, factoryId, scope);
  328. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  329. return await BuildCurrentAggregateAsync(tenantId, factoryId, scope, query.OrderCodes);
  330. return EmptyAggregate(scope);
  331. }
  332. private async Task<AdoS8OrderFlowAggregateDto> BuildBaselineAggregateAsync(long tenantId, long factoryId, string scope)
  333. {
  334. var snapshot = await _snapshotRep.AsQueryable()
  335. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  336. .Where(s => s.ScopeCode == ScopeBaselinePpt && s.SnapshotCode == BaselineSnapshotCode)
  337. .FirstAsync();
  338. if (snapshot == null) return EmptyAggregate(scope);
  339. var stages = ParseStageSnapshots(snapshot.StageSnapshotsJson);
  340. var collab = await BuildFinalAssemblyCollabSummaryAsync(tenantId, factoryId, orderCodes: null);
  341. return new AdoS8OrderFlowAggregateDto
  342. {
  343. Scope = scope,
  344. TotalOrders = snapshot.TotalOrders,
  345. TotalCustomers = snapshot.TotalCustomers,
  346. AvgResponseMinutes = snapshot.AvgResponseMinutes,
  347. AvgProcessingMinutes = snapshot.AvgProcessingMinutes,
  348. AvgLossMinutes = snapshot.AvgLossMinutes,
  349. StageAggregates = ReorderByCanonical(stages),
  350. FinalAssemblyCollabSummary = collab,
  351. };
  352. }
  353. private async Task<AdoS8OrderFlowAggregateDto> BuildCurrentAggregateAsync(
  354. long tenantId, long factoryId, string scope, List<string>? orderCodes)
  355. {
  356. var orders = await _orderRep.AsQueryable()
  357. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
  358. .WhereIF(orderCodes != null && orderCodes.Count > 0, o => orderCodes!.Contains(o.OrderCode))
  359. .ToListAsync();
  360. if (orders.Count == 0) return EmptyAggregate(scope);
  361. var orderIds = orders.Select(o => o.Id).ToList();
  362. var stages = await _stageRep.AsQueryable()
  363. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  364. .Where(s => orderIds.Contains(s.OrderId))
  365. .ToListAsync();
  366. var responseList = orders.Where(o => o.ResponseMinutes.HasValue).Select(o => (decimal)o.ResponseMinutes!.Value).ToList();
  367. var processingList = orders.Where(o => o.ProcessingMinutes.HasValue).Select(o => (decimal)o.ProcessingMinutes!.Value).ToList();
  368. var lossList = orders.Where(o => o.TotalLossMinutes.HasValue).Select(o => (decimal)o.TotalLossMinutes!.Value).ToList();
  369. var stagesByFlow = stages.GroupBy(s => s.OrderFlowCode).ToDictionary(g => g.Key, g => g.ToList());
  370. var stageAggregates = new List<AdoS8OrderFlowStageAggregateDto>();
  371. foreach (var code in OrderFlowConstants.All)
  372. {
  373. stagesByFlow.TryGetValue(code, out var rows);
  374. stageAggregates.Add(BuildStageAggregateFromRows(code, rows ?? new List<AdoS8OrderFlowStage>()));
  375. }
  376. var collab = await BuildFinalAssemblyCollabSummaryAsync(tenantId, factoryId, orderCodes);
  377. return new AdoS8OrderFlowAggregateDto
  378. {
  379. Scope = scope,
  380. TotalOrders = orders.Count,
  381. TotalCustomers = orders.Select(o => o.CustomerCode).Where(c => !string.IsNullOrEmpty(c)).Distinct().Count(),
  382. AvgResponseMinutes = AvgOrZero(responseList),
  383. AvgProcessingMinutes = AvgOrZero(processingList),
  384. AvgLossMinutes = AvgOrZero(lossList),
  385. StageAggregates = stageAggregates,
  386. FinalAssemblyCollabSummary = collab,
  387. };
  388. }
  389. // ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:FINAL_ASSEMBLY_DELIVERY 末端协同派生。
  390. // 仅查 ado_s8_exception 现有数据;只覆盖有真实映射的 2 个主门禁 + 1 个并行准备,其它 6 个对象前端展示 "--"。
  391. // 不读取 S0|S4 业务表;不新增表 / seed / 异常类型。
  392. private async Task<AdoS8FinalAssemblyCollabSummaryDto> BuildFinalAssemblyCollabSummaryAsync(
  393. long tenantId, long factoryId, List<string>? orderCodes)
  394. {
  395. var allTypes = GoodsHandoverExceptionTypes
  396. .Concat(ShipmentConfirmationExceptionTypes)
  397. .Concat(ShippingPlanExceptionTypes)
  398. .Distinct()
  399. .ToList();
  400. var rows = await _exceptionRep.AsQueryable()
  401. .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  402. .Where(e => e.SourceObjectType == ExceptionSourceObjectType)
  403. .Where(e => e.ExceptionTypeCode != null && allTypes.Contains(e.ExceptionTypeCode!))
  404. .WhereIF(orderCodes != null && orderCodes.Count > 0,
  405. e => e.RelatedObjectCode != null && orderCodes!.Contains(e.RelatedObjectCode!))
  406. .Select(e => new ExceptionAttribution
  407. {
  408. ExceptionTypeCode = e.ExceptionTypeCode,
  409. RelatedObjectCode = e.RelatedObjectCode,
  410. Title = e.Title,
  411. Severity = e.Severity,
  412. CreatedAt = e.CreatedAt,
  413. })
  414. .ToListAsync();
  415. return new AdoS8FinalAssemblyCollabSummaryDto
  416. {
  417. Gates = new Dictionary<string, AdoS8FinalAssemblyObjectStatDto>
  418. {
  419. [FinalAssemblyObjectGoodsHandover] = BuildObjectStat(rows, GoodsHandoverExceptionTypes, isGate: true),
  420. [FinalAssemblyObjectShipmentConfirmation] = BuildObjectStat(rows, ShipmentConfirmationExceptionTypes, isGate: true),
  421. },
  422. Parallels = new Dictionary<string, AdoS8FinalAssemblyObjectStatDto>
  423. {
  424. [FinalAssemblyObjectShippingPlan] = BuildObjectStat(rows, ShippingPlanExceptionTypes, isGate: false),
  425. },
  426. };
  427. }
  428. private static AdoS8FinalAssemblyObjectStatDto BuildObjectStat(
  429. List<ExceptionAttribution> rows, string[] candidateTypes, bool isGate)
  430. {
  431. var subset = rows.Where(r => r.ExceptionTypeCode != null && candidateTypes.Contains(r.ExceptionTypeCode!)).ToList();
  432. var count = subset
  433. .Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
  434. .Select(r => r.RelatedObjectCode!)
  435. .Distinct()
  436. .Count();
  437. var top = subset
  438. .OrderByDescending(r => string.Equals(r.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
  439. .ThenByDescending(r => r.CreatedAt)
  440. .FirstOrDefault();
  441. return new AdoS8FinalAssemblyObjectStatDto
  442. {
  443. ImpactedOrderCount = isGate ? count : (int?)null,
  444. RiskOrderCount = isGate ? (int?)null : count,
  445. TopRiskTitle = top?.Title,
  446. TopRiskSeverity = top?.Severity,
  447. };
  448. }
  449. private sealed class ExceptionAttribution
  450. {
  451. public string? ExceptionTypeCode { get; set; }
  452. public string? RelatedObjectCode { get; set; }
  453. public string? Title { get; set; }
  454. public string? Severity { get; set; }
  455. public DateTime CreatedAt { get; set; }
  456. }
  457. private static AdoS8OrderFlowStageAggregateDto BuildStageAggregateFromRows(string flowCode, List<AdoS8OrderFlowStage> rows)
  458. {
  459. var dto = new AdoS8OrderFlowStageAggregateDto
  460. {
  461. OrderFlowCode = flowCode,
  462. OrderFlowName = OrderFlowConstants.ResolveName(flowCode),
  463. };
  464. if (rows.Count == 0) return dto;
  465. var plannedList = rows.Select(r => r.PlannedDays).ToList();
  466. var actualList = rows.Where(r => r.ActualDays.HasValue && !string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase))
  467. .Select(r => r.ActualDays!.Value).ToList();
  468. var green = rows.Count(r => string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase));
  469. var yellow = rows.Count(r => string.Equals(r.Status, "yellow", StringComparison.OrdinalIgnoreCase));
  470. var red = rows.Count(r => string.Equals(r.Status, "red", StringComparison.OrdinalIgnoreCase));
  471. var pending = rows.Count(r => string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase));
  472. var total = rows.Count;
  473. dto.KpiAvgDays = AvgOrZero(plannedList);
  474. dto.ActualAvgDays = AvgOrZero(actualList);
  475. dto.Green = green;
  476. dto.Yellow = yellow;
  477. dto.Red = red;
  478. dto.Pending = pending;
  479. // 整数百分比;total 不含 0 因为上面已 short-circuit。 percent 因子 100 是公式系数,非业务真值。
  480. dto.OnTimeRate = total > 0 ? (int)Math.Round(green * 100.0 / total) : 0;
  481. return dto;
  482. }
  483. private static List<AdoS8OrderFlowStageAggregateDto> ParseStageSnapshots(string? json)
  484. {
  485. if (string.IsNullOrWhiteSpace(json)) return new List<AdoS8OrderFlowStageAggregateDto>();
  486. try
  487. {
  488. var parsed = JsonSerializer.Deserialize<List<AdoS8OrderFlowStageAggregateDto>>(json, JsonOpts);
  489. return parsed ?? new List<AdoS8OrderFlowStageAggregateDto>();
  490. }
  491. catch (JsonException)
  492. {
  493. return new List<AdoS8OrderFlowStageAggregateDto>();
  494. }
  495. }
  496. /// <summary>按 OrderFlowConstants.All 重排;缺失 code 补空骨架;多余 code 追加在尾部以避免静默丢数据。</summary>
  497. private static List<AdoS8OrderFlowStageAggregateDto> ReorderByCanonical(List<AdoS8OrderFlowStageAggregateDto> input)
  498. {
  499. var byCode = input.Where(x => !string.IsNullOrEmpty(x.OrderFlowCode))
  500. .GroupBy(x => x.OrderFlowCode)
  501. .ToDictionary(g => g.Key, g => g.First());
  502. var ordered = new List<AdoS8OrderFlowStageAggregateDto>();
  503. foreach (var code in OrderFlowConstants.All)
  504. {
  505. if (byCode.TryGetValue(code, out var hit))
  506. {
  507. if (string.IsNullOrEmpty(hit.OrderFlowName))
  508. hit.OrderFlowName = OrderFlowConstants.ResolveName(code);
  509. ordered.Add(hit);
  510. byCode.Remove(code);
  511. }
  512. else
  513. {
  514. ordered.Add(new AdoS8OrderFlowStageAggregateDto
  515. {
  516. OrderFlowCode = code,
  517. OrderFlowName = OrderFlowConstants.ResolveName(code),
  518. });
  519. }
  520. }
  521. ordered.AddRange(byCode.Values);
  522. return ordered;
  523. }
  524. private static AdoS8OrderFlowAggregateDto EmptyAggregate(string scope)
  525. {
  526. var stages = OrderFlowConstants.All
  527. .Select(code => new AdoS8OrderFlowStageAggregateDto
  528. {
  529. OrderFlowCode = code,
  530. OrderFlowName = OrderFlowConstants.ResolveName(code),
  531. })
  532. .ToList();
  533. return new AdoS8OrderFlowAggregateDto { Scope = scope, StageAggregates = stages };
  534. }
  535. private static decimal AvgOrZero(List<decimal> values)
  536. => values.Count == 0 ? 0m : values.Average();
  537. /// <summary>
  538. /// 采购透视视图:
  539. /// scope=BASELINE_PPT → 读 ado_s8_order_flow_procurement_pivot baseline 行(order_id IS NULL)。
  540. /// scope=CURRENT_FILTERED → 读订单级行(order_id IS NOT NULL)并按 orderCodes 过滤后聚合:
  541. /// cycle_days 简单平均;impact_count 求和(语义:多订单合计影响台次);
  542. /// kit_rate 简单平均;status 按聚合后 cycle_days 三档阈值重判
  543. /// (与 baseline seed 一致:≤15 green / ≤20 yellow / &gt;20 red)。
  544. /// 其它 scope → 返回空骨架并回显 scope。
  545. /// orderCodes 为空时返回空骨架,不静默 fallback baseline(避免 baseline 泄漏到单订单态)。
  546. /// data_source 优先级:IMPORT / AGG &gt; SEED;同一 (order_code, material, supplier, spec) 上
  547. /// 若 IMPORT/AGG 行存在,只采用 IMPORT/AGG;SEED 仅作过渡兜底,便于真实数据源接入后无缝替换。
  548. /// status 来源:baseline 直接读 row.Status;CURRENT_FILTERED 聚合后按阈值重判(运行期重新分类,
  549. /// 不修改 seed 行)。cycle_days 保持 decimal(6,3) 不截断。
  550. /// </summary>
  551. public async Task<AdoS8OrderFlowProcurementPivotDto> GetProcurementPivotAsync(AdoS8OrderFlowProcurementPivotQueryDto query)
  552. {
  553. var tenantId = query.TenantId ?? 1;
  554. var factoryId = query.FactoryId ?? 1;
  555. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  556. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  557. {
  558. var baselineRows = await _pivotRep.AsQueryable()
  559. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  560. .Where(p => p.OrderId == null) // baseline 行
  561. .ToListAsync();
  562. return BuildPivot(scope, baselineRows);
  563. }
  564. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  565. {
  566. // 与产品设计 drawings API 一致:orderCodes 由 CSV 解析(trim / 去空 / Distinct)。
  567. var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
  568. if (orderCodes.Count == 0)
  569. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  570. var orderLevelRows = await _pivotRep.AsQueryable()
  571. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  572. .Where(p => p.OrderId != null && p.OrderCode != null)
  573. .Where(p => orderCodes.Contains(p.OrderCode!))
  574. .ToListAsync();
  575. if (orderLevelRows.Count == 0)
  576. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  577. var preferred = SelectPreferredDataSourceRows(orderLevelRows);
  578. var aggregated = AggregateOrderLevelRows(preferred);
  579. return BuildPivot(scope, aggregated);
  580. }
  581. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  582. }
  583. /// <summary>
  584. /// data_source 优先级:同一 (order_code, material, supplier, spec) 同时存在 IMPORT/AGG 与 SEED 时,
  585. /// 只保留 IMPORT/AGG 行;SEED 仅在 IMPORT/AGG 缺失时作为兜底。便于真实数据源接入后无缝替换 SEED。
  586. /// </summary>
  587. private static List<AdoS8OrderFlowProcurementPivot> SelectPreferredDataSourceRows(
  588. List<AdoS8OrderFlowProcurementPivot> rows)
  589. {
  590. var grouped = rows.GroupBy(r => (r.OrderCode, r.MaterialCode, r.SupplierCode, r.SpecCode));
  591. var result = new List<AdoS8OrderFlowProcurementPivot>(rows.Count);
  592. foreach (var g in grouped)
  593. {
  594. var preferred = g.FirstOrDefault(r =>
  595. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  596. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  597. result.Add(preferred ?? g.First());
  598. }
  599. return result;
  600. }
  601. /// <summary>
  602. /// 多订单聚合:按 (material, supplier, spec) 分组后简单平均;单订单时分组内只 1 行,聚合等于原值。
  603. /// cycle_days 平均;impact_count 求和;kit_rate 平均;status 按聚合后 cycle_days 阈值重判。
  604. /// 不补造缺位单元;聚合产物为非持久化 Pivot 对象供 BuildPivot 复用。
  605. /// </summary>
  606. private static List<AdoS8OrderFlowProcurementPivot> AggregateOrderLevelRows(
  607. List<AdoS8OrderFlowProcurementPivot> rows)
  608. {
  609. return rows
  610. .GroupBy(r => (r.MaterialCode, r.SupplierCode, r.SpecCode))
  611. .Select(g =>
  612. {
  613. var cycle = decimal.Round(g.Average(r => r.CycleDays), 3);
  614. var impactRows = g.Where(r => r.ImpactCount.HasValue).ToList();
  615. int? impactSum = impactRows.Count == 0 ? null : impactRows.Sum(r => r.ImpactCount!.Value);
  616. var kitRows = g.Where(r => r.KitRate.HasValue).ToList();
  617. decimal? kitAvg = kitRows.Count == 0
  618. ? null
  619. : decimal.Round(kitRows.Average(r => r.KitRate!.Value), 4);
  620. return new AdoS8OrderFlowProcurementPivot
  621. {
  622. Id = 0,
  623. OrderId = null,
  624. OrderCode = null,
  625. MaterialCode = g.Key.MaterialCode,
  626. SupplierCode = g.Key.SupplierCode,
  627. SpecCode = g.Key.SpecCode,
  628. CycleDays = cycle,
  629. Status = ClassifyAggregatedStatus(cycle),
  630. ImpactCount = impactSum,
  631. KitRate = kitAvg,
  632. ScenarioCode = string.Empty,
  633. DataSource = string.Empty,
  634. TenantId = 0,
  635. FactoryId = 0,
  636. IsDeleted = false,
  637. };
  638. })
  639. .ToList();
  640. }
  641. private static string ClassifyAggregatedStatus(decimal cycleDays)
  642. {
  643. if (cycleDays <= 15.0m) return "green";
  644. if (cycleDays <= 20.0m) return "yellow";
  645. return "red";
  646. }
  647. private static AdoS8OrderFlowProcurementPivotDto BuildPivot(string scope, List<AdoS8OrderFlowProcurementPivot> rows)
  648. {
  649. var result = new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  650. if (rows.Count == 0) return result;
  651. var byMaterial = rows.GroupBy(r => r.MaterialCode)
  652. .ToDictionary(g => g.Key, g => g.ToList());
  653. foreach (var (material, list) in byMaterial.OrderBy(kv => kv.Key, StringComparer.Ordinal))
  654. {
  655. // KeyMaterials:取 (supplier=TOTAL, spec=TOTAL) 单元作为该 material 的概览
  656. var grand = list.FirstOrDefault(r => r.SupplierCode == PivotTotalCode && r.SpecCode == PivotTotalCode);
  657. if (grand != null)
  658. {
  659. result.KeyMaterials.Add(new AdoS8OrderFlowKeyMaterialDto
  660. {
  661. MaterialCode = material,
  662. AvgCycleDays = grand.CycleDays,
  663. CycleStatus = grand.Status,
  664. ImpactCount = grand.ImpactCount,
  665. KitRate = grand.KitRate,
  666. ResultStatus = grand.Status,
  667. });
  668. }
  669. // SupplierBreakdown:spec=TOTAL 的所有 supplier 行(含 supplier=TOTAL)
  670. foreach (var row in list.Where(r => r.SpecCode == PivotTotalCode)
  671. .OrderBy(r => r.SupplierCode == PivotTotalCode ? 1 : 0)
  672. .ThenBy(r => r.SupplierCode, StringComparer.Ordinal))
  673. {
  674. result.SupplierBreakdown.Add(new AdoS8OrderFlowSupplierBreakdownDto
  675. {
  676. MaterialCode = material,
  677. SupplierCode = row.SupplierCode,
  678. AvgCycleDays = row.CycleDays,
  679. Status = row.Status,
  680. });
  681. }
  682. // SpecBreakdown:supplier=TOTAL 的所有 spec 行(含 spec=TOTAL)
  683. foreach (var row in list.Where(r => r.SupplierCode == PivotTotalCode)
  684. .OrderBy(r => r.SpecCode == PivotTotalCode ? 1 : 0)
  685. .ThenBy(r => r.SpecCode, StringComparer.Ordinal))
  686. {
  687. result.SpecBreakdown.Add(new AdoS8OrderFlowSpecBreakdownDto
  688. {
  689. MaterialCode = material,
  690. SpecCode = row.SpecCode,
  691. AvgCycleDays = row.CycleDays,
  692. Status = row.Status,
  693. });
  694. }
  695. // MatrixByMaterial:每个 supplier 一行,cells keyed by spec_code(保留全部 cycle_days 精度)
  696. var matrix = list.GroupBy(r => r.SupplierCode)
  697. .OrderBy(g => g.Key == PivotTotalCode ? 1 : 0)
  698. .ThenBy(g => g.Key, StringComparer.Ordinal)
  699. .Select(g => new AdoS8OrderFlowProcurementMatrixRowDto
  700. {
  701. SupplierCode = g.Key,
  702. Cells = g.ToDictionary(
  703. r => r.SpecCode,
  704. r => new AdoS8OrderFlowProcurementMatrixCellDto
  705. {
  706. CycleDays = r.CycleDays,
  707. Status = r.Status,
  708. }),
  709. })
  710. .ToList();
  711. result.MatrixByMaterial[material] = matrix;
  712. }
  713. return result;
  714. }
  715. // ──────────────────────────────────────────────────────────────────────
  716. // S8-ORDER-CHAIN-PRODUCT-DESIGN-DRAWING-PERSIST-1:产品设计图号粒度聚合。
  717. // 单一数据源 = ado_s8_order_flow_product_design_drawing 表;
  718. // 台数 / 占比 / 加权达成率 全部由 product_quantity 字段驱动;
  719. // 平均设计周期 = actual_days 算术平均(非 quantity 加权);
  720. // summary.categories 固定返回 STANDARD / NON_STANDARD / TOTAL 三档;
  721. // drawings 按 productType 过滤;未达标优先 + actualDays 降序 + orderCode/drawingNo 升序。
  722. // ──────────────────────────────────────────────────────────────────────
  723. private const string ProductDesignTypeStandard = "STANDARD";
  724. private const string ProductDesignTypeNonStandard = "NON_STANDARD";
  725. private const string ProductDesignTypeTotal = "TOTAL";
  726. private const decimal ProductDesignStageKpiDays = 3m;
  727. // S8-ORDER-CHAIN-PRODUCT-DESIGN-PPT-STATIC-AND-SINGLE-ORDER-ALIGN-1:
  728. // 非标产品 KPI 与常规产品同阶段标准 3 天;非标 actual_days 超 3 时记 red/未达标。
  729. private const decimal ProductDesignNonStandardKpiDays = 3m;
  730. public async Task<AdoS8OrderFlowProductDesignDrawingsDto> GetProductDesignDrawingsAsync(
  731. AdoS8OrderFlowProductDesignDrawingsQueryDto query)
  732. {
  733. var tenantId = query.TenantId ?? 1;
  734. var factoryId = query.FactoryId ?? 1;
  735. var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
  736. var productTypeFilter = NormalizeProductDesignType(query.ProductType);
  737. var scope = orderCodes.Count == 0 ? ScopeBaselinePpt : ScopeCurrentFiltered;
  738. var rows = await _pddRep.AsQueryable()
  739. .Where(d => d.TenantId == tenantId && d.FactoryId == factoryId && !d.IsDeleted)
  740. .WhereIF(orderCodes.Count > 0, d => orderCodes.Contains(d.OrderCode))
  741. .ToListAsync();
  742. // summary 始终基于命中订单范围的全分类汇总(不受 productType 过滤影响),让前端可同时渲染三档汇总。
  743. var summary = BuildProductDesignSummary(rows);
  744. var drawingItems = rows.AsEnumerable();
  745. if (productTypeFilter != null)
  746. drawingItems = drawingItems.Where(d => d.ProductType == productTypeFilter);
  747. var orderedDrawings = drawingItems
  748. .OrderBy(d => d.IsAchieved)
  749. .ThenByDescending(d => d.ActualDays ?? decimal.MinValue)
  750. .ThenBy(d => d.OrderCode, StringComparer.Ordinal)
  751. .ThenBy(d => d.DrawingNo, StringComparer.Ordinal)
  752. .Select(MapProductDesignDrawingItem)
  753. .ToList();
  754. return new AdoS8OrderFlowProductDesignDrawingsDto
  755. {
  756. Scope = scope,
  757. Filter = new AdoS8OrderFlowProductDesignFilterDto
  758. {
  759. OrderCodes = orderCodes,
  760. ProductType = productTypeFilter,
  761. },
  762. Summary = summary,
  763. Drawings = orderedDrawings,
  764. };
  765. }
  766. private static List<string> ParseOrderCodesCsv(string? raw)
  767. {
  768. if (string.IsNullOrWhiteSpace(raw)) return new List<string>();
  769. return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  770. .Where(s => !string.IsNullOrWhiteSpace(s))
  771. .Distinct(StringComparer.Ordinal)
  772. .ToList();
  773. }
  774. private static string? NormalizeProductDesignType(string? raw)
  775. {
  776. if (string.IsNullOrWhiteSpace(raw)) return null;
  777. var s = raw.Trim().ToUpperInvariant();
  778. return s == ProductDesignTypeStandard || s == ProductDesignTypeNonStandard ? s : null;
  779. }
  780. private static AdoS8OrderFlowProductDesignSummaryDto BuildProductDesignSummary(
  781. List<AdoS8OrderFlowProductDesignDrawing> rows)
  782. {
  783. var stdRows = rows.Where(r => r.ProductType == ProductDesignTypeStandard).ToList();
  784. var nonStdRows = rows.Where(r => r.ProductType == ProductDesignTypeNonStandard).ToList();
  785. var grandTotalQty = rows.Sum(r => r.ProductQuantity);
  786. return new AdoS8OrderFlowProductDesignSummaryDto
  787. {
  788. Overall = BuildProductDesignOverall(rows),
  789. Categories = new List<AdoS8OrderFlowProductDesignCategorySummaryDto>
  790. {
  791. BuildProductDesignCategory(ProductDesignTypeStandard, "常规产品", ProductDesignStageKpiDays, stdRows, grandTotalQty),
  792. BuildProductDesignCategory(ProductDesignTypeNonStandard, "非标产品", ProductDesignNonStandardKpiDays, nonStdRows, grandTotalQty),
  793. BuildProductDesignCategory(ProductDesignTypeTotal, "合计", ProductDesignStageKpiDays, rows, grandTotalQty),
  794. },
  795. };
  796. }
  797. private static AdoS8OrderFlowProductDesignOverallSummaryDto BuildProductDesignOverall(
  798. List<AdoS8OrderFlowProductDesignDrawing> rows)
  799. {
  800. var totalQty = rows.Sum(r => r.ProductQuantity);
  801. var achievedQty = rows.Where(r => r.IsAchieved).Sum(r => r.ProductQuantity);
  802. var actualDays = rows.Where(r => r.ActualDays.HasValue).Select(r => r.ActualDays!.Value).ToList();
  803. return new AdoS8OrderFlowProductDesignOverallSummaryDto
  804. {
  805. DrawingCount = rows.Select(r => r.DrawingNo).Distinct(StringComparer.Ordinal).Count(),
  806. TotalQuantity = totalQty,
  807. KpiDays = ProductDesignStageKpiDays,
  808. AvgActualDays = actualDays.Count == 0 ? 0m : actualDays.Average(),
  809. AchievementRate = totalQty == 0 ? 0m : (decimal)achievedQty / totalQty,
  810. };
  811. }
  812. private static AdoS8OrderFlowProductDesignCategorySummaryDto BuildProductDesignCategory(
  813. string productType,
  814. string name,
  815. decimal kpiDays,
  816. List<AdoS8OrderFlowProductDesignDrawing> rows,
  817. int grandTotalQty)
  818. {
  819. var totalQty = rows.Sum(r => r.ProductQuantity);
  820. var achievedQty = rows.Where(r => r.IsAchieved).Sum(r => r.ProductQuantity);
  821. var actualDays = rows.Where(r => r.ActualDays.HasValue).Select(r => r.ActualDays!.Value).ToList();
  822. var rate = totalQty == 0 ? 0m : (decimal)achievedQty / totalQty;
  823. // S8-ORDER-CHAIN-PRODUCT-DESIGN-20ORDER-GENERATION-CONSISTENCY-AUDIT-1:
  824. // STANDARD / NON_STANDARD / TOTAL 三档统一返回真实指标,不再用 null 掩盖。
  825. // - KpiDays:始终返回常量 KPI=3,方便前端展示阈值。
  826. // - AvgActualDays:actualDays 简单平均(pending 行已经被 HasValue 过滤)。TOTAL 平均 == 父 stage.actual_days(守恒)。
  827. // - AchievementRate:以 product_quantity 加权达成率(与 Overall 一致),保留业务台数权重直觉。
  828. // - Status:达成率 ≥0.95 green / ≥0.80 yellow / 否则 red;无完工行(all pending)时返回空串。
  829. return new AdoS8OrderFlowProductDesignCategorySummaryDto
  830. {
  831. ProductType = productType,
  832. Name = name,
  833. DrawingCount = rows.Select(r => r.DrawingNo).Distinct(StringComparer.Ordinal).Count(),
  834. TotalQuantity = totalQty,
  835. Ratio = grandTotalQty == 0 ? 0m : (decimal)totalQty / grandTotalQty,
  836. KpiDays = kpiDays,
  837. AvgActualDays = actualDays.Count == 0 ? (decimal?)null : actualDays.Average(),
  838. AchievementRate = rate,
  839. Status = actualDays.Count == 0
  840. ? string.Empty
  841. : ClassifyProductDesignCategoryStatus(rate, totalQty),
  842. };
  843. }
  844. /// <summary>达标率 ≥ 0.95 绿 / ≥ 0.80 黄 / 否则 红;无数据返回空串以表达未取数而非状态判定。</summary>
  845. private static string ClassifyProductDesignCategoryStatus(decimal achievementRate, int totalQty)
  846. {
  847. if (totalQty == 0) return string.Empty;
  848. if (achievementRate >= 0.95m) return "green";
  849. if (achievementRate >= 0.80m) return "yellow";
  850. return "red";
  851. }
  852. private static AdoS8OrderFlowProductDesignDrawingItemDto MapProductDesignDrawingItem(
  853. AdoS8OrderFlowProductDesignDrawing d)
  854. => new()
  855. {
  856. OrderCode = d.OrderCode,
  857. DrawingNo = d.DrawingNo,
  858. ProductType = d.ProductType,
  859. ResponsiblePerson = d.ResponsiblePerson,
  860. PlannedStartDate = d.PlannedStartDate,
  861. PlannedEndDate = d.PlannedEndDate,
  862. ActualStartDate = d.ActualStartDate,
  863. ActualEndDate = d.ActualEndDate,
  864. KpiDays = d.KpiDays,
  865. ActualDays = d.ActualDays,
  866. IsAchieved = d.IsAchieved,
  867. Status = d.Status,
  868. ProductQuantity = d.ProductQuantity,
  869. };
  870. }