S8OrderFlowService.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603
  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. private static readonly JsonSerializerOptions JsonOpts = new()
  20. {
  21. PropertyNameCaseInsensitive = true,
  22. };
  23. private readonly SqlSugarRepository<AdoS8OrderFlowOrder> _orderRep;
  24. private readonly SqlSugarRepository<AdoS8OrderFlowStage> _stageRep;
  25. private readonly SqlSugarRepository<AdoS8OrderFlowSubstep> _substepRep;
  26. private readonly SqlSugarRepository<AdoS8OrderFlowSubstepUnit> _unitRep;
  27. private readonly SqlSugarRepository<AdoS8OrderFlowSnapshot> _snapshotRep;
  28. private readonly SqlSugarRepository<AdoS8OrderFlowProcurementPivot> _pivotRep;
  29. private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
  30. public S8OrderFlowService(
  31. SqlSugarRepository<AdoS8OrderFlowOrder> orderRep,
  32. SqlSugarRepository<AdoS8OrderFlowStage> stageRep,
  33. SqlSugarRepository<AdoS8OrderFlowSubstep> substepRep,
  34. SqlSugarRepository<AdoS8OrderFlowSubstepUnit> unitRep,
  35. SqlSugarRepository<AdoS8OrderFlowSnapshot> snapshotRep,
  36. SqlSugarRepository<AdoS8OrderFlowProcurementPivot> pivotRep,
  37. SqlSugarRepository<AdoS8Exception> exceptionRep)
  38. {
  39. _orderRep = orderRep;
  40. _stageRep = stageRep;
  41. _substepRep = substepRep;
  42. _unitRep = unitRep;
  43. _snapshotRep = snapshotRep;
  44. _pivotRep = pivotRep;
  45. _exceptionRep = exceptionRep;
  46. }
  47. /// <summary>订单档案列表(无分页)。当前 baseline 20 单。</summary>
  48. public async Task<List<AdoS8OrderFlowOrderListItemDto>> GetOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
  49. {
  50. var tenantId = query.TenantId ?? 1;
  51. var factoryId = query.FactoryId ?? 1;
  52. var orders = await _orderRep.AsQueryable()
  53. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
  54. .WhereIF(!string.IsNullOrWhiteSpace(query.Keyword),
  55. o => o.OrderCode.Contains(query.Keyword!)
  56. || o.ProductName.Contains(query.Keyword!)
  57. || o.CustomerName.Contains(query.Keyword!))
  58. .WhereIF(!string.IsNullOrWhiteSpace(query.CurrentFlowCode),
  59. o => o.CurrentOrderFlowCode == query.CurrentFlowCode!)
  60. .WhereIF(!string.IsNullOrWhiteSpace(query.CustomerCode),
  61. o => o.CustomerCode == query.CustomerCode!)
  62. .WhereIF(!string.IsNullOrWhiteSpace(query.ProductLine),
  63. o => o.ProductLine == query.ProductLine!)
  64. .WhereIF(!string.IsNullOrWhiteSpace(query.Region),
  65. o => o.Region == query.Region!)
  66. .WhereIF(!string.IsNullOrWhiteSpace(query.WorkflowStatus),
  67. o => o.WorkflowStatus == query.WorkflowStatus!)
  68. .WhereIF(!string.IsNullOrWhiteSpace(query.ScenarioCode),
  69. o => o.ScenarioCode == query.ScenarioCode!)
  70. .OrderBy(o => o.OrderCode)
  71. .ToListAsync();
  72. if (orders.Count == 0) return new List<AdoS8OrderFlowOrderListItemDto>();
  73. var orderCodes = orders.Select(o => o.OrderCode).Distinct().ToList();
  74. var orderIds = orders.Select(o => o.Id).ToList();
  75. var currentStages = await _stageRep.AsQueryable()
  76. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  77. .Where(s => orderIds.Contains(s.OrderId))
  78. .ToListAsync();
  79. var currentStageMap = currentStages
  80. .GroupBy(s => s.OrderId)
  81. .ToDictionary(g => g.Key, g => g.ToList());
  82. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, orderCodes);
  83. return orders.Select(o => MapOrderListItem(o, currentStageMap.GetValueOrDefault(o.Id), exceptionCounts)).ToList();
  84. }
  85. /// <summary>单订单详情(不含 substep/unit)。</summary>
  86. public async Task<AdoS8OrderFlowOrderDetailDto?> GetOrderAsync(string orderCode, long tenantId = 1, long factoryId = 1)
  87. {
  88. if (string.IsNullOrWhiteSpace(orderCode)) return null;
  89. var order = await _orderRep.AsQueryable()
  90. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
  91. && !o.IsDeleted && o.OrderCode == orderCode)
  92. .FirstAsync();
  93. if (order == null) return null;
  94. var stages = await _stageRep.AsQueryable()
  95. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
  96. && !s.IsDeleted && s.OrderId == order.Id)
  97. .OrderBy(s => s.SortNo)
  98. .ToListAsync();
  99. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List<string> { orderCode });
  100. var listItem = MapOrderListItem(order, stages, exceptionCounts);
  101. return BuildDetail(listItem, stages, substepsByFlow: null, unitsBySubstepId: null);
  102. }
  103. /// <summary>订单链路视图:详情 + lifecycle + L2 substeps + L3 units。procurementPivot 留到 t3c。</summary>
  104. public async Task<AdoS8OrderFlowChainDto?> GetChainAsync(string orderCode, long tenantId = 1, long factoryId = 1)
  105. {
  106. if (string.IsNullOrWhiteSpace(orderCode)) return null;
  107. var order = await _orderRep.AsQueryable()
  108. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
  109. && !o.IsDeleted && o.OrderCode == orderCode)
  110. .FirstAsync();
  111. if (order == null) return null;
  112. var stages = await _stageRep.AsQueryable()
  113. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
  114. && !s.IsDeleted && s.OrderId == order.Id)
  115. .OrderBy(s => s.SortNo)
  116. .ToListAsync();
  117. var substeps = await _substepRep.AsQueryable()
  118. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId
  119. && !x.IsDeleted && x.OrderId == order.Id)
  120. .OrderBy(x => x.SortNo)
  121. .ToListAsync();
  122. var substepsByFlow = substeps
  123. .GroupBy(x => x.OrderFlowCode)
  124. .ToDictionary(g => g.Key, g => g.ToList());
  125. var substepIds = substeps.Select(x => x.Id).ToList();
  126. var units = substepIds.Count == 0
  127. ? new List<AdoS8OrderFlowSubstepUnit>()
  128. : await _unitRep.AsQueryable()
  129. .Where(u => u.TenantId == tenantId && u.FactoryId == factoryId
  130. && !u.IsDeleted && substepIds.Contains(u.SubstepId))
  131. .OrderBy(u => u.SortNo)
  132. .ToListAsync();
  133. var unitsBySubstepId = units
  134. .GroupBy(u => u.SubstepId)
  135. .ToDictionary(g => g.Key, g => g.ToList());
  136. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List<string> { orderCode });
  137. var listItem = MapOrderListItem(order, stages, exceptionCounts);
  138. var detail = BuildDetail(listItem, stages, substepsByFlow, unitsBySubstepId);
  139. return new AdoS8OrderFlowChainDto
  140. {
  141. Order = detail,
  142. ProcurementPivot = null, // t3c 切片接入
  143. };
  144. }
  145. // ─────────────── private mappers ───────────────
  146. /// <summary>从 ado_s8_exception 实时聚合每个 order_code 的异常计数 + 状态。</summary>
  147. private async Task<Dictionary<string, (int Count, string Status)>> MapExceptionCountsAsync(
  148. long tenantId, long factoryId, List<string> orderCodes)
  149. {
  150. var result = orderCodes.ToDictionary(c => c, _ => (Count: 0, Status: string.Empty));
  151. if (orderCodes.Count == 0) return result;
  152. var rows = await _exceptionRep.AsQueryable()
  153. .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  154. .Where(e => e.SourceObjectType == ExceptionSourceObjectType)
  155. .Where(e => orderCodes.Contains(e.RelatedObjectCode!))
  156. .Select(e => new { e.RelatedObjectCode, e.Severity })
  157. .ToListAsync();
  158. foreach (var grp in rows.Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
  159. .GroupBy(r => r.RelatedObjectCode!))
  160. {
  161. var items = grp.ToList();
  162. var status = items.Any(x => string.Equals(x.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase))
  163. ? "SERIOUS"
  164. : (items.Count > 0 ? "FOLLOW" : string.Empty);
  165. result[grp.Key] = (items.Count, status);
  166. }
  167. return result;
  168. }
  169. private static AdoS8OrderFlowOrderListItemDto MapOrderListItem(
  170. AdoS8OrderFlowOrder o,
  171. List<AdoS8OrderFlowStage>? lifecycle,
  172. Dictionary<string, (int Count, string Status)> exceptionCounts)
  173. {
  174. var current = lifecycle?.FirstOrDefault(s => s.OrderFlowCode == o.CurrentOrderFlowCode);
  175. var counts = exceptionCounts.TryGetValue(o.OrderCode, out var c) ? c : (Count: 0, Status: string.Empty);
  176. return new AdoS8OrderFlowOrderListItemDto
  177. {
  178. OrderCode = o.OrderCode,
  179. ProductName = o.ProductName,
  180. ProductLine = o.ProductLine,
  181. CustomerCode = o.CustomerCode,
  182. CustomerName = o.CustomerName,
  183. CustomerType = o.CustomerType,
  184. Region = o.Region,
  185. Priority = o.Priority,
  186. WorkflowStatus = o.WorkflowStatus,
  187. CurrentOrderFlowCode = o.CurrentOrderFlowCode,
  188. CurrentOrderFlowName = OrderFlowConstants.ResolveName(o.CurrentOrderFlowCode),
  189. CurrentStatus = current?.Status ?? string.Empty,
  190. ReleaseAt = o.ReleaseAt,
  191. TargetCycleDays = o.TargetCycleDays,
  192. ActualCycleDays = o.ActualCycleDays,
  193. CurrentCycleDays = current?.ActualDays,
  194. NodeVarianceDays = current?.NodeVarianceDays,
  195. CumulativeVarianceDays = current?.CumulativeVarianceDays,
  196. ExceptionCount = counts.Count,
  197. ExceptionStatus = counts.Status,
  198. ResponseMinutes = o.ResponseMinutes,
  199. ProcessingMinutes = o.ProcessingMinutes,
  200. TotalLossMinutes = o.TotalLossMinutes,
  201. ScenarioCode = o.ScenarioCode,
  202. DataSource = o.DataSource,
  203. };
  204. }
  205. private static AdoS8OrderFlowOrderDetailDto BuildDetail(
  206. AdoS8OrderFlowOrderListItemDto listItem,
  207. List<AdoS8OrderFlowStage> stages,
  208. Dictionary<string, List<AdoS8OrderFlowSubstep>>? substepsByFlow,
  209. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  210. {
  211. var detail = new AdoS8OrderFlowOrderDetailDto
  212. {
  213. OrderCode = listItem.OrderCode,
  214. ProductName = listItem.ProductName,
  215. ProductLine = listItem.ProductLine,
  216. CustomerCode = listItem.CustomerCode,
  217. CustomerName = listItem.CustomerName,
  218. CustomerType = listItem.CustomerType,
  219. Region = listItem.Region,
  220. Priority = listItem.Priority,
  221. WorkflowStatus = listItem.WorkflowStatus,
  222. CurrentOrderFlowCode = listItem.CurrentOrderFlowCode,
  223. CurrentOrderFlowName = listItem.CurrentOrderFlowName,
  224. CurrentStatus = listItem.CurrentStatus,
  225. ReleaseAt = listItem.ReleaseAt,
  226. TargetCycleDays = listItem.TargetCycleDays,
  227. ActualCycleDays = listItem.ActualCycleDays,
  228. CurrentCycleDays = listItem.CurrentCycleDays,
  229. NodeVarianceDays = listItem.NodeVarianceDays,
  230. CumulativeVarianceDays = listItem.CumulativeVarianceDays,
  231. ExceptionCount = listItem.ExceptionCount,
  232. ExceptionStatus = listItem.ExceptionStatus,
  233. ResponseMinutes = listItem.ResponseMinutes,
  234. ProcessingMinutes = listItem.ProcessingMinutes,
  235. TotalLossMinutes = listItem.TotalLossMinutes,
  236. ScenarioCode = listItem.ScenarioCode,
  237. DataSource = listItem.DataSource,
  238. Lifecycle = stages.Select(s => MapStage(s, substepsByFlow, unitsBySubstepId)).ToList(),
  239. };
  240. return detail;
  241. }
  242. private static AdoS8OrderFlowStageDto MapStage(
  243. AdoS8OrderFlowStage s,
  244. Dictionary<string, List<AdoS8OrderFlowSubstep>>? substepsByFlow,
  245. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  246. {
  247. var substeps = substepsByFlow != null && substepsByFlow.TryGetValue(s.OrderFlowCode, out var list)
  248. ? list.Select(x => MapSubstep(x, unitsBySubstepId)).ToList()
  249. : new List<AdoS8OrderFlowSubstepDto>();
  250. return new AdoS8OrderFlowStageDto
  251. {
  252. OrderFlowCode = s.OrderFlowCode,
  253. OrderFlowName = string.IsNullOrEmpty(s.OrderFlowName)
  254. ? OrderFlowConstants.ResolveName(s.OrderFlowCode)
  255. : s.OrderFlowName,
  256. OwnerDept = s.OwnerDept,
  257. SortNo = s.SortNo,
  258. PlannedDays = s.PlannedDays,
  259. ActualDays = s.ActualDays,
  260. TargetAt = s.TargetAt,
  261. ActualStartAt = s.ActualStartAt,
  262. ActualEndAt = s.ActualEndAt,
  263. Status = s.Status,
  264. NodeVarianceDays = s.NodeVarianceDays,
  265. CumulativeVarianceDays = s.CumulativeVarianceDays,
  266. Substeps = substeps,
  267. };
  268. }
  269. private static AdoS8OrderFlowSubstepDto MapSubstep(
  270. AdoS8OrderFlowSubstep x,
  271. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  272. {
  273. var units = unitsBySubstepId != null && unitsBySubstepId.TryGetValue(x.Id, out var list)
  274. ? list.Select(MapUnit).ToList()
  275. : new List<AdoS8OrderFlowSubstepUnitDto>();
  276. return new AdoS8OrderFlowSubstepDto
  277. {
  278. SubstepCode = x.SubstepCode,
  279. SubstepName = x.SubstepName,
  280. PiHours = x.PiHours,
  281. ActualHours = x.ActualHours,
  282. Status = x.Status,
  283. SortNo = x.SortNo,
  284. Units = units,
  285. };
  286. }
  287. private static AdoS8OrderFlowSubstepUnitDto MapUnit(AdoS8OrderFlowSubstepUnit u)
  288. => new()
  289. {
  290. UnitCode = u.UnitCode,
  291. UnitName = u.UnitName,
  292. PiHours = u.PiHours,
  293. ActualHours = u.ActualHours,
  294. Status = u.Status,
  295. SortNo = u.SortNo,
  296. };
  297. // ──────────────────────────────────────────────────────────────────────
  298. // t3c:Aggregate + ProcurementPivot
  299. // ──────────────────────────────────────────────────────────────────────
  300. /// <summary>
  301. /// 链路聚合视图:
  302. /// scope=BASELINE_PPT → 只读 ado_s8_order_flow_snapshot.stage_snapshots_json
  303. /// scope=CURRENT_FILTERED → 从 order + stage 实时聚合
  304. /// snapshot 缺失时返回空骨架(不做 fallback 硬编码)。
  305. /// </summary>
  306. public async Task<AdoS8OrderFlowAggregateDto> GetAggregateAsync(AdoS8OrderFlowAggregateQueryDto query)
  307. {
  308. var tenantId = query.TenantId ?? 1;
  309. var factoryId = query.FactoryId ?? 1;
  310. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  311. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  312. return await BuildBaselineAggregateAsync(tenantId, factoryId, scope);
  313. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  314. return await BuildCurrentAggregateAsync(tenantId, factoryId, scope, query.OrderCodes);
  315. return EmptyAggregate(scope);
  316. }
  317. private async Task<AdoS8OrderFlowAggregateDto> BuildBaselineAggregateAsync(long tenantId, long factoryId, string scope)
  318. {
  319. var snapshot = await _snapshotRep.AsQueryable()
  320. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  321. .Where(s => s.ScopeCode == ScopeBaselinePpt && s.SnapshotCode == BaselineSnapshotCode)
  322. .FirstAsync();
  323. if (snapshot == null) return EmptyAggregate(scope);
  324. var stages = ParseStageSnapshots(snapshot.StageSnapshotsJson);
  325. return new AdoS8OrderFlowAggregateDto
  326. {
  327. Scope = scope,
  328. TotalOrders = snapshot.TotalOrders,
  329. TotalCustomers = snapshot.TotalCustomers,
  330. AvgResponseMinutes = snapshot.AvgResponseMinutes,
  331. AvgProcessingMinutes = snapshot.AvgProcessingMinutes,
  332. AvgLossMinutes = snapshot.AvgLossMinutes,
  333. StageAggregates = ReorderByCanonical(stages),
  334. };
  335. }
  336. private async Task<AdoS8OrderFlowAggregateDto> BuildCurrentAggregateAsync(
  337. long tenantId, long factoryId, string scope, List<string>? orderCodes)
  338. {
  339. var orders = await _orderRep.AsQueryable()
  340. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
  341. .WhereIF(orderCodes != null && orderCodes.Count > 0, o => orderCodes!.Contains(o.OrderCode))
  342. .ToListAsync();
  343. if (orders.Count == 0) return EmptyAggregate(scope);
  344. var orderIds = orders.Select(o => o.Id).ToList();
  345. var stages = await _stageRep.AsQueryable()
  346. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  347. .Where(s => orderIds.Contains(s.OrderId))
  348. .ToListAsync();
  349. var responseList = orders.Where(o => o.ResponseMinutes.HasValue).Select(o => (decimal)o.ResponseMinutes!.Value).ToList();
  350. var processingList = orders.Where(o => o.ProcessingMinutes.HasValue).Select(o => (decimal)o.ProcessingMinutes!.Value).ToList();
  351. var lossList = orders.Where(o => o.TotalLossMinutes.HasValue).Select(o => (decimal)o.TotalLossMinutes!.Value).ToList();
  352. var stagesByFlow = stages.GroupBy(s => s.OrderFlowCode).ToDictionary(g => g.Key, g => g.ToList());
  353. var stageAggregates = new List<AdoS8OrderFlowStageAggregateDto>();
  354. foreach (var code in OrderFlowConstants.All)
  355. {
  356. stagesByFlow.TryGetValue(code, out var rows);
  357. stageAggregates.Add(BuildStageAggregateFromRows(code, rows ?? new List<AdoS8OrderFlowStage>()));
  358. }
  359. return new AdoS8OrderFlowAggregateDto
  360. {
  361. Scope = scope,
  362. TotalOrders = orders.Count,
  363. TotalCustomers = orders.Select(o => o.CustomerCode).Where(c => !string.IsNullOrEmpty(c)).Distinct().Count(),
  364. AvgResponseMinutes = AvgOrZero(responseList),
  365. AvgProcessingMinutes = AvgOrZero(processingList),
  366. AvgLossMinutes = AvgOrZero(lossList),
  367. StageAggregates = stageAggregates,
  368. };
  369. }
  370. private static AdoS8OrderFlowStageAggregateDto BuildStageAggregateFromRows(string flowCode, List<AdoS8OrderFlowStage> rows)
  371. {
  372. var dto = new AdoS8OrderFlowStageAggregateDto
  373. {
  374. OrderFlowCode = flowCode,
  375. OrderFlowName = OrderFlowConstants.ResolveName(flowCode),
  376. };
  377. if (rows.Count == 0) return dto;
  378. var plannedList = rows.Select(r => r.PlannedDays).ToList();
  379. var actualList = rows.Where(r => r.ActualDays.HasValue && !string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase))
  380. .Select(r => r.ActualDays!.Value).ToList();
  381. var green = rows.Count(r => string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase));
  382. var yellow = rows.Count(r => string.Equals(r.Status, "yellow", StringComparison.OrdinalIgnoreCase));
  383. var red = rows.Count(r => string.Equals(r.Status, "red", StringComparison.OrdinalIgnoreCase));
  384. var pending = rows.Count(r => string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase));
  385. var total = rows.Count;
  386. dto.KpiAvgDays = AvgOrZero(plannedList);
  387. dto.ActualAvgDays = AvgOrZero(actualList);
  388. dto.Green = green;
  389. dto.Yellow = yellow;
  390. dto.Red = red;
  391. dto.Pending = pending;
  392. // 整数百分比;total 不含 0 因为上面已 short-circuit。 percent 因子 100 是公式系数,非业务真值。
  393. dto.OnTimeRate = total > 0 ? (int)Math.Round(green * 100.0 / total) : 0;
  394. return dto;
  395. }
  396. private static List<AdoS8OrderFlowStageAggregateDto> ParseStageSnapshots(string? json)
  397. {
  398. if (string.IsNullOrWhiteSpace(json)) return new List<AdoS8OrderFlowStageAggregateDto>();
  399. try
  400. {
  401. var parsed = JsonSerializer.Deserialize<List<AdoS8OrderFlowStageAggregateDto>>(json, JsonOpts);
  402. return parsed ?? new List<AdoS8OrderFlowStageAggregateDto>();
  403. }
  404. catch (JsonException)
  405. {
  406. return new List<AdoS8OrderFlowStageAggregateDto>();
  407. }
  408. }
  409. /// <summary>按 OrderFlowConstants.All 重排;缺失 code 补空骨架;多余 code 追加在尾部以避免静默丢数据。</summary>
  410. private static List<AdoS8OrderFlowStageAggregateDto> ReorderByCanonical(List<AdoS8OrderFlowStageAggregateDto> input)
  411. {
  412. var byCode = input.Where(x => !string.IsNullOrEmpty(x.OrderFlowCode))
  413. .GroupBy(x => x.OrderFlowCode)
  414. .ToDictionary(g => g.Key, g => g.First());
  415. var ordered = new List<AdoS8OrderFlowStageAggregateDto>();
  416. foreach (var code in OrderFlowConstants.All)
  417. {
  418. if (byCode.TryGetValue(code, out var hit))
  419. {
  420. if (string.IsNullOrEmpty(hit.OrderFlowName))
  421. hit.OrderFlowName = OrderFlowConstants.ResolveName(code);
  422. ordered.Add(hit);
  423. byCode.Remove(code);
  424. }
  425. else
  426. {
  427. ordered.Add(new AdoS8OrderFlowStageAggregateDto
  428. {
  429. OrderFlowCode = code,
  430. OrderFlowName = OrderFlowConstants.ResolveName(code),
  431. });
  432. }
  433. }
  434. ordered.AddRange(byCode.Values);
  435. return ordered;
  436. }
  437. private static AdoS8OrderFlowAggregateDto EmptyAggregate(string scope)
  438. {
  439. var stages = OrderFlowConstants.All
  440. .Select(code => new AdoS8OrderFlowStageAggregateDto
  441. {
  442. OrderFlowCode = code,
  443. OrderFlowName = OrderFlowConstants.ResolveName(code),
  444. })
  445. .ToList();
  446. return new AdoS8OrderFlowAggregateDto { Scope = scope, StageAggregates = stages };
  447. }
  448. private static decimal AvgOrZero(List<decimal> values)
  449. => values.Count == 0 ? 0m : values.Average();
  450. /// <summary>
  451. /// 采购透视视图:
  452. /// scope=BASELINE_PPT → 读 ado_s8_order_flow_procurement_pivot baseline 行(order_id=NULL)
  453. /// status 严格只读 DB;cycle_days 保持 decimal(6,3) 不截断。
  454. /// 本切片正式只支持 BASELINE_PPT;其他 scope 返回空骨架并标注 scope。
  455. /// </summary>
  456. public async Task<AdoS8OrderFlowProcurementPivotDto> GetProcurementPivotAsync(AdoS8OrderFlowProcurementPivotQueryDto query)
  457. {
  458. var tenantId = query.TenantId ?? 1;
  459. var factoryId = query.FactoryId ?? 1;
  460. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  461. if (!string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  462. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  463. var rows = await _pivotRep.AsQueryable()
  464. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  465. .Where(p => p.OrderId == null) // baseline 行
  466. .ToListAsync();
  467. return BuildPivot(scope, rows);
  468. }
  469. private static AdoS8OrderFlowProcurementPivotDto BuildPivot(string scope, List<AdoS8OrderFlowProcurementPivot> rows)
  470. {
  471. var result = new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  472. if (rows.Count == 0) return result;
  473. var byMaterial = rows.GroupBy(r => r.MaterialCode)
  474. .ToDictionary(g => g.Key, g => g.ToList());
  475. foreach (var (material, list) in byMaterial.OrderBy(kv => kv.Key, StringComparer.Ordinal))
  476. {
  477. // KeyMaterials:取 (supplier=TOTAL, spec=TOTAL) 单元作为该 material 的概览
  478. var grand = list.FirstOrDefault(r => r.SupplierCode == PivotTotalCode && r.SpecCode == PivotTotalCode);
  479. if (grand != null)
  480. {
  481. result.KeyMaterials.Add(new AdoS8OrderFlowKeyMaterialDto
  482. {
  483. MaterialCode = material,
  484. AvgCycleDays = grand.CycleDays,
  485. CycleStatus = grand.Status,
  486. ResultStatus = grand.Status,
  487. });
  488. }
  489. // SupplierBreakdown:spec=TOTAL 的所有 supplier 行(含 supplier=TOTAL)
  490. foreach (var row in list.Where(r => r.SpecCode == PivotTotalCode)
  491. .OrderBy(r => r.SupplierCode == PivotTotalCode ? 1 : 0)
  492. .ThenBy(r => r.SupplierCode, StringComparer.Ordinal))
  493. {
  494. result.SupplierBreakdown.Add(new AdoS8OrderFlowSupplierBreakdownDto
  495. {
  496. MaterialCode = material,
  497. SupplierCode = row.SupplierCode,
  498. AvgCycleDays = row.CycleDays,
  499. Status = row.Status,
  500. });
  501. }
  502. // SpecBreakdown:supplier=TOTAL 的所有 spec 行(含 spec=TOTAL)
  503. foreach (var row in list.Where(r => r.SupplierCode == PivotTotalCode)
  504. .OrderBy(r => r.SpecCode == PivotTotalCode ? 1 : 0)
  505. .ThenBy(r => r.SpecCode, StringComparer.Ordinal))
  506. {
  507. result.SpecBreakdown.Add(new AdoS8OrderFlowSpecBreakdownDto
  508. {
  509. MaterialCode = material,
  510. SpecCode = row.SpecCode,
  511. AvgCycleDays = row.CycleDays,
  512. Status = row.Status,
  513. });
  514. }
  515. // MatrixByMaterial:每个 supplier 一行,cells keyed by spec_code(保留全部 cycle_days 精度)
  516. var matrix = list.GroupBy(r => r.SupplierCode)
  517. .OrderBy(g => g.Key == PivotTotalCode ? 1 : 0)
  518. .ThenBy(g => g.Key, StringComparer.Ordinal)
  519. .Select(g => new AdoS8OrderFlowProcurementMatrixRowDto
  520. {
  521. SupplierCode = g.Key,
  522. Cells = g.ToDictionary(
  523. r => r.SpecCode,
  524. r => new AdoS8OrderFlowProcurementMatrixCellDto
  525. {
  526. CycleDays = r.CycleDays,
  527. Status = r.Status,
  528. }),
  529. })
  530. .ToList();
  531. result.MatrixByMaterial[material] = matrix;
  532. }
  533. return result;
  534. }
  535. }