S8OrderFlowService.cs 76 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623
  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. using Admin.NET.Plugin.AiDOP.Infrastructure;
  6. namespace Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
  7. /// <summary>
  8. /// ORDER-FLOW-S8-INTEGRATED-DOMAIN-RESET-1 t3b:S8 订单执行链路只读 service skeleton。
  9. /// 仅 GetOrders / GetOrder / GetChain 三个方法;procurement-pivot / aggregate / related-exceptions 延后切片。
  10. /// 任何业务真值(订单总数 / 平均时长 / YY 真值矩阵等)只能来自 DB,本文件零硬编码。
  11. /// 异常计数:从 ado_s8_exception 实时聚合(SourceObjectType=SALES_ORDER + RelatedObjectCode in order_codes + IsDeleted=0)。
  12. /// </summary>
  13. public class S8OrderFlowService : ITransient
  14. {
  15. private const string ExceptionSourceObjectType = "SALES_ORDER";
  16. private const string ScopeBaselinePpt = "BASELINE_PPT";
  17. private const string ScopeCurrentFiltered = "CURRENT_FILTERED";
  18. private const string BaselineSnapshotCode = "CHAIN_AGGREGATE_BASELINE";
  19. private const string PivotTotalCode = "TOTAL";
  20. // ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:异常类型 → 末端协同对象 映射常量。
  21. // 这些 type_code 均来自 ado_s8_exception_type seed 中 enabled=true、monitoring_category_key=FINAL_ASSEMBLY_DELIVERY 的子集;
  22. // 不新增异常类型、不依赖业务执行表。空数组对象(如 ASSEMBLY_COMPLETION / 包装材料准备)在前端展示 "--"。
  23. private const string FinalAssemblyObjectGoodsHandover = "GOODS_HANDOVER";
  24. private const string FinalAssemblyObjectShipmentConfirmation = "SHIPMENT_CONFIRMATION";
  25. private const string FinalAssemblyObjectShippingPlan = "SHIPPING_PLAN";
  26. private static readonly string[] GoodsHandoverExceptionTypes = { "PENDING_SHIPMENT" };
  27. private static readonly string[] ShipmentConfirmationExceptionTypes =
  28. {
  29. "DELIVERY_DELAY", "SHIPMENT_ABNORMAL", "DELIVERY_DELAY_WARNING", "FINISHED_GOODS_PENDING_SHIPMENT",
  30. };
  31. private static readonly string[] ShippingPlanExceptionTypes = { "FINISHED_GOODS_PENDING_SHIPMENT" };
  32. private static readonly JsonSerializerOptions JsonOpts = new()
  33. {
  34. PropertyNameCaseInsensitive = true,
  35. };
  36. private readonly SqlSugarRepository<AdoS8OrderFlowOrder> _orderRep;
  37. private readonly SqlSugarRepository<AdoS8OrderFlowStage> _stageRep;
  38. private readonly SqlSugarRepository<AdoS8OrderFlowSubstep> _substepRep;
  39. private readonly SqlSugarRepository<AdoS8OrderFlowSubstepUnit> _unitRep;
  40. private readonly SqlSugarRepository<AdoS8OrderFlowSnapshot> _snapshotRep;
  41. private readonly SqlSugarRepository<AdoS8OrderFlowProcurementPivot> _pivotRep;
  42. private readonly SqlSugarRepository<AdoS8Exception> _exceptionRep;
  43. private readonly SqlSugarRepository<AdoS8OrderFlowProductDesignDrawing> _pddRep;
  44. // S8-ORDER-CHAIN-BODY-PRODUCTION-ORDER-LEVEL-SEED-FIX-1:本体生产 3 表 repository。
  45. private readonly SqlSugarRepository<AdoS8OrderFlowManufacturingProcess> _mfgProcessRep;
  46. private readonly SqlSugarRepository<AdoS8OrderFlowManufacturingLossFactor> _mfgLossRep;
  47. private readonly SqlSugarRepository<AdoS8OrderFlowManufacturingOperator> _mfgOperatorRep;
  48. // S8-ORDER-CHAIN-ASSEMBLY-DELIVERY-DATA-LINEAGE-AUDIT-1:总装发货 2 表 repository。
  49. private readonly SqlSugarRepository<AdoS8OrderFlowFinalAssemblyGate> _faGateRep;
  50. private readonly SqlSugarRepository<AdoS8OrderFlowFinalAssemblyParallel> _faParallelRep;
  51. private readonly UserManager _userManager;
  52. private readonly SqlSugarRepository<SysOrg> _orgRep;
  53. public S8OrderFlowService(
  54. SqlSugarRepository<AdoS8OrderFlowOrder> orderRep,
  55. SqlSugarRepository<AdoS8OrderFlowStage> stageRep,
  56. SqlSugarRepository<AdoS8OrderFlowSubstep> substepRep,
  57. SqlSugarRepository<AdoS8OrderFlowSubstepUnit> unitRep,
  58. SqlSugarRepository<AdoS8OrderFlowSnapshot> snapshotRep,
  59. SqlSugarRepository<AdoS8OrderFlowProcurementPivot> pivotRep,
  60. SqlSugarRepository<AdoS8Exception> exceptionRep,
  61. SqlSugarRepository<AdoS8OrderFlowProductDesignDrawing> pddRep,
  62. SqlSugarRepository<AdoS8OrderFlowManufacturingProcess> mfgProcessRep,
  63. SqlSugarRepository<AdoS8OrderFlowManufacturingLossFactor> mfgLossRep,
  64. SqlSugarRepository<AdoS8OrderFlowManufacturingOperator> mfgOperatorRep,
  65. SqlSugarRepository<AdoS8OrderFlowFinalAssemblyGate> faGateRep,
  66. SqlSugarRepository<AdoS8OrderFlowFinalAssemblyParallel> faParallelRep,
  67. UserManager userManager,
  68. SqlSugarRepository<SysOrg> orgRep)
  69. {
  70. _orderRep = orderRep;
  71. _stageRep = stageRep;
  72. _substepRep = substepRep;
  73. _unitRep = unitRep;
  74. _snapshotRep = snapshotRep;
  75. _pivotRep = pivotRep;
  76. _exceptionRep = exceptionRep;
  77. _pddRep = pddRep;
  78. _mfgProcessRep = mfgProcessRep;
  79. _mfgLossRep = mfgLossRep;
  80. _mfgOperatorRep = mfgOperatorRep;
  81. _faGateRep = faGateRep;
  82. _faParallelRep = faParallelRep;
  83. _userManager = userManager;
  84. _orgRep = orgRep;
  85. }
  86. private long ResolveTenantId() => AidopTenantScope.ResolveOrThrow(_userManager);
  87. private async Task<long> ResolveFactoryIdAsync(long tenantId)
  88. {
  89. var orgId = _userManager.OrgId;
  90. if (orgId > 0)
  91. {
  92. var userOrg = await _orgRep.AsQueryable()
  93. .Where(o => o.Id == orgId && o.TenantId == tenantId)
  94. .FirstAsync();
  95. if (userOrg != null) return orgId;
  96. }
  97. var factory = await _orgRep.AsQueryable()
  98. .Where(o => o.TenantId == tenantId && o.Type == "501")
  99. .OrderBy(o => o.OrderNo)
  100. .FirstAsync();
  101. if (factory != null) return factory.Id;
  102. var root = await _orgRep.AsQueryable()
  103. .Where(o => o.TenantId == tenantId && o.Pid == 0)
  104. .FirstAsync();
  105. if (root != null) return root.Id;
  106. throw Oops.Oh("无法确定当前工厂,请重新登录或选择目标组织");
  107. }
  108. private async Task<(long TenantId, long FactoryId)> ResolveScopeAsync()
  109. {
  110. var tenantId = ResolveTenantId();
  111. var factoryId = await ResolveFactoryIdAsync(tenantId);
  112. return (tenantId, factoryId);
  113. }
  114. /// <summary>订单档案列表(无分页)。当前 baseline 20 单。</summary>
  115. public async Task<List<AdoS8OrderFlowOrderListItemDto>> GetOrdersAsync(AdoS8OrderFlowOrderQueryDto query)
  116. {
  117. var (tenantId, factoryId) = await ResolveScopeAsync();
  118. var orders = await _orderRep.AsQueryable()
  119. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
  120. .WhereIF(!string.IsNullOrWhiteSpace(query.Keyword),
  121. o => o.OrderCode.Contains(query.Keyword!)
  122. || o.ProductName.Contains(query.Keyword!)
  123. || o.CustomerName.Contains(query.Keyword!))
  124. .WhereIF(!string.IsNullOrWhiteSpace(query.CurrentFlowCode),
  125. o => o.CurrentOrderFlowCode == query.CurrentFlowCode!)
  126. .WhereIF(!string.IsNullOrWhiteSpace(query.CustomerCode),
  127. o => o.CustomerCode == query.CustomerCode!)
  128. .WhereIF(!string.IsNullOrWhiteSpace(query.ProductLine),
  129. o => o.ProductLine == query.ProductLine!)
  130. .WhereIF(!string.IsNullOrWhiteSpace(query.Region),
  131. o => o.Region == query.Region!)
  132. .WhereIF(!string.IsNullOrWhiteSpace(query.WorkflowStatus),
  133. o => o.WorkflowStatus == query.WorkflowStatus!)
  134. .WhereIF(!string.IsNullOrWhiteSpace(query.ScenarioCode),
  135. o => o.ScenarioCode == query.ScenarioCode!)
  136. .OrderBy(o => o.OrderCode)
  137. .ToListAsync();
  138. if (orders.Count == 0) return new List<AdoS8OrderFlowOrderListItemDto>();
  139. var orderCodes = orders.Select(o => o.OrderCode).Distinct().ToList();
  140. var orderIds = orders.Select(o => o.Id).ToList();
  141. var currentStages = await _stageRep.AsQueryable()
  142. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  143. .Where(s => orderIds.Contains(s.OrderId))
  144. .ToListAsync();
  145. var currentStageMap = currentStages
  146. .GroupBy(s => s.OrderId)
  147. .ToDictionary(g => g.Key, g => g.ToList());
  148. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, orderCodes);
  149. return orders.Select(o => MapOrderListItem(o, currentStageMap.GetValueOrDefault(o.Id), exceptionCounts)).ToList();
  150. }
  151. /// <summary>单订单详情(不含 substep/unit)。</summary>
  152. public async Task<AdoS8OrderFlowOrderDetailDto?> GetOrderAsync(string orderCode)
  153. {
  154. if (string.IsNullOrWhiteSpace(orderCode)) return null;
  155. var (tenantId, factoryId) = await ResolveScopeAsync();
  156. var order = await _orderRep.AsQueryable()
  157. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
  158. && !o.IsDeleted && o.OrderCode == orderCode)
  159. .FirstAsync();
  160. if (order == null) return null;
  161. var stages = await _stageRep.AsQueryable()
  162. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
  163. && !s.IsDeleted && s.OrderId == order.Id)
  164. .OrderBy(s => s.SortNo)
  165. .ToListAsync();
  166. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List<string> { orderCode });
  167. var listItem = MapOrderListItem(order, stages, exceptionCounts);
  168. return BuildDetail(listItem, stages, substepsByFlow: null, unitsBySubstepId: null);
  169. }
  170. /// <summary>订单链路视图:详情 + lifecycle + L2 substeps + L3 units。procurementPivot 留到 t3c。</summary>
  171. public async Task<AdoS8OrderFlowChainDto?> GetChainAsync(string orderCode)
  172. {
  173. if (string.IsNullOrWhiteSpace(orderCode)) return null;
  174. var (tenantId, factoryId) = await ResolveScopeAsync();
  175. var order = await _orderRep.AsQueryable()
  176. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId
  177. && !o.IsDeleted && o.OrderCode == orderCode)
  178. .FirstAsync();
  179. if (order == null) return null;
  180. var stages = await _stageRep.AsQueryable()
  181. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId
  182. && !s.IsDeleted && s.OrderId == order.Id)
  183. .OrderBy(s => s.SortNo)
  184. .ToListAsync();
  185. var substeps = await _substepRep.AsQueryable()
  186. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId
  187. && !x.IsDeleted && x.OrderId == order.Id)
  188. .OrderBy(x => x.SortNo)
  189. .ToListAsync();
  190. var substepsByFlow = substeps
  191. .GroupBy(x => x.OrderFlowCode)
  192. .ToDictionary(g => g.Key, g => g.ToList());
  193. var substepIds = substeps.Select(x => x.Id).ToList();
  194. var units = substepIds.Count == 0
  195. ? new List<AdoS8OrderFlowSubstepUnit>()
  196. : await _unitRep.AsQueryable()
  197. .Where(u => u.TenantId == tenantId && u.FactoryId == factoryId
  198. && !u.IsDeleted && substepIds.Contains(u.SubstepId))
  199. .OrderBy(u => u.SortNo)
  200. .ToListAsync();
  201. var unitsBySubstepId = units
  202. .GroupBy(u => u.SubstepId)
  203. .ToDictionary(g => g.Key, g => g.ToList());
  204. var exceptionCounts = await MapExceptionCountsAsync(tenantId, factoryId, new List<string> { orderCode });
  205. var listItem = MapOrderListItem(order, stages, exceptionCounts);
  206. var detail = BuildDetail(listItem, stages, substepsByFlow, unitsBySubstepId);
  207. return new AdoS8OrderFlowChainDto
  208. {
  209. Order = detail,
  210. ProcurementPivot = null, // t3c 切片接入
  211. };
  212. }
  213. // ─────────────── private mappers ───────────────
  214. /// <summary>从 ado_s8_exception 实时聚合每个 order_code 的异常计数 + 状态。</summary>
  215. private async Task<Dictionary<string, (int Count, string Status)>> MapExceptionCountsAsync(
  216. long tenantId, long factoryId, List<string> orderCodes)
  217. {
  218. var result = orderCodes.ToDictionary(c => c, _ => (Count: 0, Status: string.Empty));
  219. if (orderCodes.Count == 0) return result;
  220. var rows = await _exceptionRep.AsQueryable()
  221. .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  222. .Where(e => e.SourceObjectType == ExceptionSourceObjectType)
  223. .Where(e => orderCodes.Contains(e.RelatedObjectCode!))
  224. .Select(e => new { e.RelatedObjectCode, e.Severity })
  225. .ToListAsync();
  226. foreach (var grp in rows.Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
  227. .GroupBy(r => r.RelatedObjectCode!))
  228. {
  229. var items = grp.ToList();
  230. var status = items.Any(x => string.Equals(x.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase))
  231. ? "SERIOUS"
  232. : (items.Count > 0 ? "FOLLOW" : string.Empty);
  233. result[grp.Key] = (items.Count, status);
  234. }
  235. return result;
  236. }
  237. private static AdoS8OrderFlowOrderListItemDto MapOrderListItem(
  238. AdoS8OrderFlowOrder o,
  239. List<AdoS8OrderFlowStage>? lifecycle,
  240. Dictionary<string, (int Count, string Status)> exceptionCounts)
  241. {
  242. var current = lifecycle?.FirstOrDefault(s => s.OrderFlowCode == o.CurrentOrderFlowCode);
  243. var counts = exceptionCounts.TryGetValue(o.OrderCode, out var c) ? c : (Count: 0, Status: string.Empty);
  244. return new AdoS8OrderFlowOrderListItemDto
  245. {
  246. OrderCode = o.OrderCode,
  247. ProductName = o.ProductName,
  248. ProductLine = o.ProductLine,
  249. CustomerCode = o.CustomerCode,
  250. CustomerName = o.CustomerName,
  251. CustomerType = o.CustomerType,
  252. Region = o.Region,
  253. Priority = o.Priority,
  254. WorkflowStatus = o.WorkflowStatus,
  255. CurrentOrderFlowCode = o.CurrentOrderFlowCode,
  256. CurrentOrderFlowName = OrderFlowConstants.ResolveName(o.CurrentOrderFlowCode),
  257. CurrentStatus = current?.Status ?? string.Empty,
  258. ReleaseAt = o.ReleaseAt,
  259. TargetCycleDays = o.TargetCycleDays,
  260. ActualCycleDays = o.ActualCycleDays,
  261. CurrentCycleDays = current?.ActualDays,
  262. NodeVarianceDays = current?.NodeVarianceDays,
  263. CumulativeVarianceDays = current?.CumulativeVarianceDays,
  264. ExceptionCount = counts.Count,
  265. ExceptionStatus = counts.Status,
  266. ResponseMinutes = o.ResponseMinutes,
  267. ProcessingMinutes = o.ProcessingMinutes,
  268. TotalLossMinutes = o.TotalLossMinutes,
  269. ScenarioCode = o.ScenarioCode,
  270. DataSource = o.DataSource,
  271. };
  272. }
  273. private static AdoS8OrderFlowOrderDetailDto BuildDetail(
  274. AdoS8OrderFlowOrderListItemDto listItem,
  275. List<AdoS8OrderFlowStage> stages,
  276. Dictionary<string, List<AdoS8OrderFlowSubstep>>? substepsByFlow,
  277. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  278. {
  279. var detail = new AdoS8OrderFlowOrderDetailDto
  280. {
  281. OrderCode = listItem.OrderCode,
  282. ProductName = listItem.ProductName,
  283. ProductLine = listItem.ProductLine,
  284. CustomerCode = listItem.CustomerCode,
  285. CustomerName = listItem.CustomerName,
  286. CustomerType = listItem.CustomerType,
  287. Region = listItem.Region,
  288. Priority = listItem.Priority,
  289. WorkflowStatus = listItem.WorkflowStatus,
  290. CurrentOrderFlowCode = listItem.CurrentOrderFlowCode,
  291. CurrentOrderFlowName = listItem.CurrentOrderFlowName,
  292. CurrentStatus = listItem.CurrentStatus,
  293. ReleaseAt = listItem.ReleaseAt,
  294. TargetCycleDays = listItem.TargetCycleDays,
  295. ActualCycleDays = listItem.ActualCycleDays,
  296. CurrentCycleDays = listItem.CurrentCycleDays,
  297. NodeVarianceDays = listItem.NodeVarianceDays,
  298. CumulativeVarianceDays = listItem.CumulativeVarianceDays,
  299. ExceptionCount = listItem.ExceptionCount,
  300. ExceptionStatus = listItem.ExceptionStatus,
  301. ResponseMinutes = listItem.ResponseMinutes,
  302. ProcessingMinutes = listItem.ProcessingMinutes,
  303. TotalLossMinutes = listItem.TotalLossMinutes,
  304. ScenarioCode = listItem.ScenarioCode,
  305. DataSource = listItem.DataSource,
  306. Lifecycle = stages.Select(s => MapStage(s, substepsByFlow, unitsBySubstepId)).ToList(),
  307. };
  308. return detail;
  309. }
  310. private static AdoS8OrderFlowStageDto MapStage(
  311. AdoS8OrderFlowStage s,
  312. Dictionary<string, List<AdoS8OrderFlowSubstep>>? substepsByFlow,
  313. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  314. {
  315. var substeps = substepsByFlow != null && substepsByFlow.TryGetValue(s.OrderFlowCode, out var list)
  316. ? list.Select(x => MapSubstep(x, unitsBySubstepId)).ToList()
  317. : new List<AdoS8OrderFlowSubstepDto>();
  318. return new AdoS8OrderFlowStageDto
  319. {
  320. OrderFlowCode = s.OrderFlowCode,
  321. OrderFlowName = string.IsNullOrEmpty(s.OrderFlowName)
  322. ? OrderFlowConstants.ResolveName(s.OrderFlowCode)
  323. : s.OrderFlowName,
  324. OwnerDept = s.OwnerDept,
  325. SortNo = s.SortNo,
  326. PlannedDays = s.PlannedDays,
  327. ActualDays = s.ActualDays,
  328. TargetAt = s.TargetAt,
  329. ActualStartAt = s.ActualStartAt,
  330. ActualEndAt = s.ActualEndAt,
  331. Status = s.Status,
  332. NodeVarianceDays = s.NodeVarianceDays,
  333. CumulativeVarianceDays = s.CumulativeVarianceDays,
  334. Substeps = substeps,
  335. };
  336. }
  337. private static AdoS8OrderFlowSubstepDto MapSubstep(
  338. AdoS8OrderFlowSubstep x,
  339. Dictionary<long, List<AdoS8OrderFlowSubstepUnit>>? unitsBySubstepId)
  340. {
  341. var units = unitsBySubstepId != null && unitsBySubstepId.TryGetValue(x.Id, out var list)
  342. ? list.Select(MapUnit).ToList()
  343. : new List<AdoS8OrderFlowSubstepUnitDto>();
  344. return new AdoS8OrderFlowSubstepDto
  345. {
  346. SubstepCode = x.SubstepCode,
  347. SubstepName = x.SubstepName,
  348. PiHours = x.PiHours,
  349. ActualHours = x.ActualHours,
  350. Status = x.Status,
  351. SortNo = x.SortNo,
  352. Units = units,
  353. };
  354. }
  355. private static AdoS8OrderFlowSubstepUnitDto MapUnit(AdoS8OrderFlowSubstepUnit u)
  356. => new()
  357. {
  358. UnitCode = u.UnitCode,
  359. UnitName = u.UnitName,
  360. PiHours = u.PiHours,
  361. ActualHours = u.ActualHours,
  362. Status = u.Status,
  363. SortNo = u.SortNo,
  364. };
  365. // ──────────────────────────────────────────────────────────────────────
  366. // t3c:Aggregate + ProcurementPivot
  367. // ──────────────────────────────────────────────────────────────────────
  368. /// <summary>
  369. /// 链路聚合视图:
  370. /// scope=BASELINE_PPT → 只读 ado_s8_order_flow_snapshot.stage_snapshots_json
  371. /// scope=CURRENT_FILTERED → 从 order + stage 实时聚合
  372. /// snapshot 缺失时返回空骨架(不做 fallback 硬编码)。
  373. /// </summary>
  374. public async Task<AdoS8OrderFlowAggregateDto> GetAggregateAsync(AdoS8OrderFlowAggregateQueryDto query)
  375. {
  376. var (tenantId, factoryId) = await ResolveScopeAsync();
  377. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  378. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  379. return await BuildBaselineAggregateAsync(tenantId, factoryId, scope);
  380. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  381. return await BuildCurrentAggregateAsync(tenantId, factoryId, scope, query.OrderCodes);
  382. return EmptyAggregate(scope);
  383. }
  384. private async Task<AdoS8OrderFlowAggregateDto> BuildBaselineAggregateAsync(long tenantId, long factoryId, string scope)
  385. {
  386. var snapshot = await _snapshotRep.AsQueryable()
  387. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  388. .Where(s => s.ScopeCode == ScopeBaselinePpt && s.SnapshotCode == BaselineSnapshotCode)
  389. .FirstAsync();
  390. if (snapshot == null) return EmptyAggregate(scope);
  391. var stages = ParseStageSnapshots(snapshot.StageSnapshotsJson);
  392. var collab = await BuildFinalAssemblyCollabSummaryAsync(tenantId, factoryId, orderCodes: null);
  393. return new AdoS8OrderFlowAggregateDto
  394. {
  395. Scope = scope,
  396. TotalOrders = snapshot.TotalOrders,
  397. TotalCustomers = snapshot.TotalCustomers,
  398. AvgResponseMinutes = snapshot.AvgResponseMinutes,
  399. AvgProcessingMinutes = snapshot.AvgProcessingMinutes,
  400. AvgLossMinutes = snapshot.AvgLossMinutes,
  401. StageAggregates = ReorderByCanonical(stages),
  402. FinalAssemblyCollabSummary = collab,
  403. };
  404. }
  405. private async Task<AdoS8OrderFlowAggregateDto> BuildCurrentAggregateAsync(
  406. long tenantId, long factoryId, string scope, List<string>? orderCodes)
  407. {
  408. var orders = await _orderRep.AsQueryable()
  409. .Where(o => o.TenantId == tenantId && o.FactoryId == factoryId && !o.IsDeleted)
  410. .WhereIF(orderCodes != null && orderCodes.Count > 0, o => orderCodes!.Contains(o.OrderCode))
  411. .ToListAsync();
  412. if (orders.Count == 0) return EmptyAggregate(scope);
  413. var orderIds = orders.Select(o => o.Id).ToList();
  414. var stages = await _stageRep.AsQueryable()
  415. .Where(s => s.TenantId == tenantId && s.FactoryId == factoryId && !s.IsDeleted)
  416. .Where(s => orderIds.Contains(s.OrderId))
  417. .ToListAsync();
  418. var responseList = orders.Where(o => o.ResponseMinutes.HasValue).Select(o => (decimal)o.ResponseMinutes!.Value).ToList();
  419. var processingList = orders.Where(o => o.ProcessingMinutes.HasValue).Select(o => (decimal)o.ProcessingMinutes!.Value).ToList();
  420. var lossList = orders.Where(o => o.TotalLossMinutes.HasValue).Select(o => (decimal)o.TotalLossMinutes!.Value).ToList();
  421. var stagesByFlow = stages.GroupBy(s => s.OrderFlowCode).ToDictionary(g => g.Key, g => g.ToList());
  422. var stageAggregates = new List<AdoS8OrderFlowStageAggregateDto>();
  423. foreach (var code in OrderFlowConstants.All)
  424. {
  425. stagesByFlow.TryGetValue(code, out var rows);
  426. stageAggregates.Add(BuildStageAggregateFromRows(code, rows ?? new List<AdoS8OrderFlowStage>()));
  427. }
  428. var collab = await BuildFinalAssemblyCollabSummaryAsync(tenantId, factoryId, orderCodes);
  429. return new AdoS8OrderFlowAggregateDto
  430. {
  431. Scope = scope,
  432. TotalOrders = orders.Count,
  433. TotalCustomers = orders.Select(o => o.CustomerCode).Where(c => !string.IsNullOrEmpty(c)).Distinct().Count(),
  434. AvgResponseMinutes = AvgOrZero(responseList),
  435. AvgProcessingMinutes = AvgOrZero(processingList),
  436. AvgLossMinutes = AvgOrZero(lossList),
  437. StageAggregates = stageAggregates,
  438. FinalAssemblyCollabSummary = collab,
  439. };
  440. }
  441. // ORDER-CHAIN-FINAL-ASSEMBLY-DEPT-COLLAB-MVP-1:FINAL_ASSEMBLY_DELIVERY 末端协同派生。
  442. // 仅查 ado_s8_exception 现有数据;只覆盖有真实映射的 2 个主门禁 + 1 个并行准备,其它 6 个对象前端展示 "--"。
  443. // 不读取 S0|S4 业务表;不新增表 / seed / 异常类型。
  444. private async Task<AdoS8FinalAssemblyCollabSummaryDto> BuildFinalAssemblyCollabSummaryAsync(
  445. long tenantId, long factoryId, List<string>? orderCodes)
  446. {
  447. var allTypes = GoodsHandoverExceptionTypes
  448. .Concat(ShipmentConfirmationExceptionTypes)
  449. .Concat(ShippingPlanExceptionTypes)
  450. .Distinct()
  451. .ToList();
  452. var rows = await _exceptionRep.AsQueryable()
  453. .Where(e => e.TenantId == tenantId && e.FactoryId == factoryId && !e.IsDeleted)
  454. .Where(e => e.SourceObjectType == ExceptionSourceObjectType)
  455. .Where(e => e.ExceptionTypeCode != null && allTypes.Contains(e.ExceptionTypeCode!))
  456. .WhereIF(orderCodes != null && orderCodes.Count > 0,
  457. e => e.RelatedObjectCode != null && orderCodes!.Contains(e.RelatedObjectCode!))
  458. .Select(e => new ExceptionAttribution
  459. {
  460. ExceptionTypeCode = e.ExceptionTypeCode,
  461. RelatedObjectCode = e.RelatedObjectCode,
  462. Title = e.Title,
  463. Severity = e.Severity,
  464. CreatedAt = e.CreatedAt,
  465. })
  466. .ToListAsync();
  467. return new AdoS8FinalAssemblyCollabSummaryDto
  468. {
  469. Gates = new Dictionary<string, AdoS8FinalAssemblyObjectStatDto>
  470. {
  471. [FinalAssemblyObjectGoodsHandover] = BuildObjectStat(rows, GoodsHandoverExceptionTypes, isGate: true),
  472. [FinalAssemblyObjectShipmentConfirmation] = BuildObjectStat(rows, ShipmentConfirmationExceptionTypes, isGate: true),
  473. },
  474. Parallels = new Dictionary<string, AdoS8FinalAssemblyObjectStatDto>
  475. {
  476. [FinalAssemblyObjectShippingPlan] = BuildObjectStat(rows, ShippingPlanExceptionTypes, isGate: false),
  477. },
  478. };
  479. }
  480. private static AdoS8FinalAssemblyObjectStatDto BuildObjectStat(
  481. List<ExceptionAttribution> rows, string[] candidateTypes, bool isGate)
  482. {
  483. var subset = rows.Where(r => r.ExceptionTypeCode != null && candidateTypes.Contains(r.ExceptionTypeCode!)).ToList();
  484. var count = subset
  485. .Where(r => !string.IsNullOrEmpty(r.RelatedObjectCode))
  486. .Select(r => r.RelatedObjectCode!)
  487. .Distinct()
  488. .Count();
  489. var top = subset
  490. .OrderByDescending(r => string.Equals(r.Severity, "SERIOUS", StringComparison.OrdinalIgnoreCase) ? 1 : 0)
  491. .ThenByDescending(r => r.CreatedAt)
  492. .FirstOrDefault();
  493. return new AdoS8FinalAssemblyObjectStatDto
  494. {
  495. ImpactedOrderCount = isGate ? count : (int?)null,
  496. RiskOrderCount = isGate ? (int?)null : count,
  497. TopRiskTitle = top?.Title,
  498. TopRiskSeverity = top?.Severity,
  499. };
  500. }
  501. private sealed class ExceptionAttribution
  502. {
  503. public string? ExceptionTypeCode { get; set; }
  504. public string? RelatedObjectCode { get; set; }
  505. public string? Title { get; set; }
  506. public string? Severity { get; set; }
  507. public DateTime CreatedAt { get; set; }
  508. }
  509. private static AdoS8OrderFlowStageAggregateDto BuildStageAggregateFromRows(string flowCode, List<AdoS8OrderFlowStage> rows)
  510. {
  511. var dto = new AdoS8OrderFlowStageAggregateDto
  512. {
  513. OrderFlowCode = flowCode,
  514. OrderFlowName = OrderFlowConstants.ResolveName(flowCode),
  515. };
  516. if (rows.Count == 0) return dto;
  517. var plannedList = rows.Select(r => r.PlannedDays).ToList();
  518. var actualList = rows.Where(r => r.ActualDays.HasValue && !string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase))
  519. .Select(r => r.ActualDays!.Value).ToList();
  520. var green = rows.Count(r => string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase));
  521. var yellow = rows.Count(r => string.Equals(r.Status, "yellow", StringComparison.OrdinalIgnoreCase));
  522. var red = rows.Count(r => string.Equals(r.Status, "red", StringComparison.OrdinalIgnoreCase));
  523. var pending = rows.Count(r => string.Equals(r.Status, "pending", StringComparison.OrdinalIgnoreCase));
  524. var total = rows.Count;
  525. dto.KpiAvgDays = AvgOrZero(plannedList);
  526. dto.ActualAvgDays = AvgOrZero(actualList);
  527. dto.Green = green;
  528. dto.Yellow = yellow;
  529. dto.Red = red;
  530. dto.Pending = pending;
  531. // 整数百分比;total 不含 0 因为上面已 short-circuit。 percent 因子 100 是公式系数,非业务真值。
  532. dto.OnTimeRate = total > 0 ? (int)Math.Round(green * 100.0 / total) : 0;
  533. return dto;
  534. }
  535. private static List<AdoS8OrderFlowStageAggregateDto> ParseStageSnapshots(string? json)
  536. {
  537. if (string.IsNullOrWhiteSpace(json)) return new List<AdoS8OrderFlowStageAggregateDto>();
  538. try
  539. {
  540. var parsed = JsonSerializer.Deserialize<List<AdoS8OrderFlowStageAggregateDto>>(json, JsonOpts);
  541. return parsed ?? new List<AdoS8OrderFlowStageAggregateDto>();
  542. }
  543. catch (JsonException)
  544. {
  545. return new List<AdoS8OrderFlowStageAggregateDto>();
  546. }
  547. }
  548. /// <summary>按 OrderFlowConstants.All 重排;缺失 code 补空骨架;多余 code 追加在尾部以避免静默丢数据。</summary>
  549. private static List<AdoS8OrderFlowStageAggregateDto> ReorderByCanonical(List<AdoS8OrderFlowStageAggregateDto> input)
  550. {
  551. var byCode = input.Where(x => !string.IsNullOrEmpty(x.OrderFlowCode))
  552. .GroupBy(x => x.OrderFlowCode)
  553. .ToDictionary(g => g.Key, g => g.First());
  554. var ordered = new List<AdoS8OrderFlowStageAggregateDto>();
  555. foreach (var code in OrderFlowConstants.All)
  556. {
  557. if (byCode.TryGetValue(code, out var hit))
  558. {
  559. if (string.IsNullOrEmpty(hit.OrderFlowName))
  560. hit.OrderFlowName = OrderFlowConstants.ResolveName(code);
  561. ordered.Add(hit);
  562. byCode.Remove(code);
  563. }
  564. else
  565. {
  566. ordered.Add(new AdoS8OrderFlowStageAggregateDto
  567. {
  568. OrderFlowCode = code,
  569. OrderFlowName = OrderFlowConstants.ResolveName(code),
  570. });
  571. }
  572. }
  573. ordered.AddRange(byCode.Values);
  574. return ordered;
  575. }
  576. private static AdoS8OrderFlowAggregateDto EmptyAggregate(string scope)
  577. {
  578. var stages = OrderFlowConstants.All
  579. .Select(code => new AdoS8OrderFlowStageAggregateDto
  580. {
  581. OrderFlowCode = code,
  582. OrderFlowName = OrderFlowConstants.ResolveName(code),
  583. })
  584. .ToList();
  585. return new AdoS8OrderFlowAggregateDto { Scope = scope, StageAggregates = stages };
  586. }
  587. private static decimal AvgOrZero(List<decimal> values)
  588. => values.Count == 0 ? 0m : values.Average();
  589. /// <summary>
  590. /// 采购透视视图:
  591. /// scope=BASELINE_PPT → 读 ado_s8_order_flow_procurement_pivot baseline 行(order_id IS NULL)。
  592. /// scope=CURRENT_FILTERED → 读订单级行(order_id IS NOT NULL)并按 orderCodes 过滤后聚合:
  593. /// cycle_days 简单平均;impact_count 求和(语义:多订单合计影响台次);
  594. /// kit_rate 简单平均;status 按聚合后 cycle_days 三档阈值重判
  595. /// (与 baseline seed 一致:≤15 green / ≤20 yellow / &gt;20 red)。
  596. /// 其它 scope → 返回空骨架并回显 scope。
  597. /// orderCodes 为空时返回空骨架,不静默 fallback baseline(避免 baseline 泄漏到单订单态)。
  598. /// data_source 优先级:IMPORT / AGG &gt; SEED;同一 (order_code, material, supplier, spec) 上
  599. /// 若 IMPORT/AGG 行存在,只采用 IMPORT/AGG;SEED 仅作过渡兜底,便于真实数据源接入后无缝替换。
  600. /// status 来源:baseline 直接读 row.Status;CURRENT_FILTERED 聚合后按阈值重判(运行期重新分类,
  601. /// 不修改 seed 行)。cycle_days 保持 decimal(6,3) 不截断。
  602. /// </summary>
  603. public async Task<AdoS8OrderFlowProcurementPivotDto> GetProcurementPivotAsync(AdoS8OrderFlowProcurementPivotQueryDto query)
  604. {
  605. var (tenantId, factoryId) = await ResolveScopeAsync();
  606. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  607. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  608. {
  609. var baselineRows = await _pivotRep.AsQueryable()
  610. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  611. .Where(p => p.OrderId == null) // baseline 行
  612. .ToListAsync();
  613. return BuildPivot(scope, baselineRows);
  614. }
  615. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  616. {
  617. // 与产品设计 drawings API 一致:orderCodes 由 CSV 解析(trim / 去空 / Distinct)。
  618. var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
  619. if (orderCodes.Count == 0)
  620. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  621. var orderLevelRows = await _pivotRep.AsQueryable()
  622. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  623. .Where(p => p.OrderId != null && p.OrderCode != null)
  624. .Where(p => orderCodes.Contains(p.OrderCode!))
  625. .ToListAsync();
  626. if (orderLevelRows.Count == 0)
  627. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  628. var preferred = SelectPreferredDataSourceRows(orderLevelRows);
  629. var aggregated = AggregateOrderLevelRows(preferred);
  630. return BuildPivot(scope, aggregated);
  631. }
  632. return new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  633. }
  634. /// <summary>
  635. /// data_source 优先级:同一 (order_code, material, supplier, spec) 同时存在 IMPORT/AGG 与 SEED 时,
  636. /// 只保留 IMPORT/AGG 行;SEED 仅在 IMPORT/AGG 缺失时作为兜底。便于真实数据源接入后无缝替换 SEED。
  637. /// </summary>
  638. private static List<AdoS8OrderFlowProcurementPivot> SelectPreferredDataSourceRows(
  639. List<AdoS8OrderFlowProcurementPivot> rows)
  640. {
  641. var grouped = rows.GroupBy(r => (r.OrderCode, r.MaterialCode, r.SupplierCode, r.SpecCode));
  642. var result = new List<AdoS8OrderFlowProcurementPivot>(rows.Count);
  643. foreach (var g in grouped)
  644. {
  645. var preferred = g.FirstOrDefault(r =>
  646. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  647. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  648. result.Add(preferred ?? g.First());
  649. }
  650. return result;
  651. }
  652. /// <summary>
  653. /// 多订单聚合:按 (material, supplier, spec) 分组后简单平均;单订单时分组内只 1 行,聚合等于原值。
  654. /// cycle_days 平均;impact_count 求和;kit_rate 平均;status 按聚合后 cycle_days 阈值重判。
  655. /// 不补造缺位单元;聚合产物为非持久化 Pivot 对象供 BuildPivot 复用。
  656. /// </summary>
  657. private static List<AdoS8OrderFlowProcurementPivot> AggregateOrderLevelRows(
  658. List<AdoS8OrderFlowProcurementPivot> rows)
  659. {
  660. return rows
  661. .GroupBy(r => (r.MaterialCode, r.SupplierCode, r.SpecCode))
  662. .Select(g =>
  663. {
  664. var cycle = decimal.Round(g.Average(r => r.CycleDays), 3);
  665. var impactRows = g.Where(r => r.ImpactCount.HasValue).ToList();
  666. int? impactSum = impactRows.Count == 0 ? null : impactRows.Sum(r => r.ImpactCount!.Value);
  667. var kitRows = g.Where(r => r.KitRate.HasValue).ToList();
  668. decimal? kitAvg = kitRows.Count == 0
  669. ? null
  670. : decimal.Round(kitRows.Average(r => r.KitRate!.Value), 4);
  671. return new AdoS8OrderFlowProcurementPivot
  672. {
  673. Id = 0,
  674. OrderId = null,
  675. OrderCode = null,
  676. MaterialCode = g.Key.MaterialCode,
  677. SupplierCode = g.Key.SupplierCode,
  678. SpecCode = g.Key.SpecCode,
  679. CycleDays = cycle,
  680. Status = ClassifyAggregatedStatus(cycle),
  681. ImpactCount = impactSum,
  682. KitRate = kitAvg,
  683. ScenarioCode = string.Empty,
  684. DataSource = string.Empty,
  685. TenantId = 0,
  686. FactoryId = 0,
  687. IsDeleted = false,
  688. };
  689. })
  690. .ToList();
  691. }
  692. private static string ClassifyAggregatedStatus(decimal cycleDays)
  693. {
  694. if (cycleDays <= 15.0m) return "green";
  695. if (cycleDays <= 20.0m) return "yellow";
  696. return "red";
  697. }
  698. private static AdoS8OrderFlowProcurementPivotDto BuildPivot(string scope, List<AdoS8OrderFlowProcurementPivot> rows)
  699. {
  700. var result = new AdoS8OrderFlowProcurementPivotDto { Scope = scope };
  701. if (rows.Count == 0) return result;
  702. var byMaterial = rows.GroupBy(r => r.MaterialCode)
  703. .ToDictionary(g => g.Key, g => g.ToList());
  704. foreach (var (material, list) in byMaterial.OrderBy(kv => kv.Key, StringComparer.Ordinal))
  705. {
  706. // KeyMaterials:取 (supplier=TOTAL, spec=TOTAL) 单元作为该 material 的概览
  707. var grand = list.FirstOrDefault(r => r.SupplierCode == PivotTotalCode && r.SpecCode == PivotTotalCode);
  708. if (grand != null)
  709. {
  710. result.KeyMaterials.Add(new AdoS8OrderFlowKeyMaterialDto
  711. {
  712. MaterialCode = material,
  713. AvgCycleDays = grand.CycleDays,
  714. CycleStatus = grand.Status,
  715. ImpactCount = grand.ImpactCount,
  716. KitRate = grand.KitRate,
  717. ResultStatus = grand.Status,
  718. });
  719. }
  720. // SupplierBreakdown:spec=TOTAL 的所有 supplier 行(含 supplier=TOTAL)
  721. foreach (var row in list.Where(r => r.SpecCode == PivotTotalCode)
  722. .OrderBy(r => r.SupplierCode == PivotTotalCode ? 1 : 0)
  723. .ThenBy(r => r.SupplierCode, StringComparer.Ordinal))
  724. {
  725. result.SupplierBreakdown.Add(new AdoS8OrderFlowSupplierBreakdownDto
  726. {
  727. MaterialCode = material,
  728. SupplierCode = row.SupplierCode,
  729. AvgCycleDays = row.CycleDays,
  730. Status = row.Status,
  731. });
  732. }
  733. // SpecBreakdown:supplier=TOTAL 的所有 spec 行(含 spec=TOTAL)
  734. foreach (var row in list.Where(r => r.SupplierCode == PivotTotalCode)
  735. .OrderBy(r => r.SpecCode == PivotTotalCode ? 1 : 0)
  736. .ThenBy(r => r.SpecCode, StringComparer.Ordinal))
  737. {
  738. result.SpecBreakdown.Add(new AdoS8OrderFlowSpecBreakdownDto
  739. {
  740. MaterialCode = material,
  741. SpecCode = row.SpecCode,
  742. AvgCycleDays = row.CycleDays,
  743. Status = row.Status,
  744. });
  745. }
  746. // MatrixByMaterial:每个 supplier 一行,cells keyed by spec_code(保留全部 cycle_days 精度)
  747. var matrix = list.GroupBy(r => r.SupplierCode)
  748. .OrderBy(g => g.Key == PivotTotalCode ? 1 : 0)
  749. .ThenBy(g => g.Key, StringComparer.Ordinal)
  750. .Select(g => new AdoS8OrderFlowProcurementMatrixRowDto
  751. {
  752. SupplierCode = g.Key,
  753. Cells = g.ToDictionary(
  754. r => r.SpecCode,
  755. r => new AdoS8OrderFlowProcurementMatrixCellDto
  756. {
  757. CycleDays = r.CycleDays,
  758. Status = r.Status,
  759. }),
  760. })
  761. .ToList();
  762. result.MatrixByMaterial[material] = matrix;
  763. }
  764. return result;
  765. }
  766. // ──────────────────────────────────────────────────────────────────────
  767. // S8-ORDER-CHAIN-PRODUCT-DESIGN-DRAWING-PERSIST-1:产品设计图号粒度聚合。
  768. // 单一数据源 = ado_s8_order_flow_product_design_drawing 表;
  769. // 台数 / 占比 / 加权达成率 全部由 product_quantity 字段驱动;
  770. // 平均设计周期 = actual_days 算术平均(非 quantity 加权);
  771. // summary.categories 固定返回 STANDARD / NON_STANDARD / TOTAL 三档;
  772. // drawings 按 productType 过滤;未达标优先 + actualDays 降序 + orderCode/drawingNo 升序。
  773. // ──────────────────────────────────────────────────────────────────────
  774. private const string ProductDesignTypeStandard = "STANDARD";
  775. private const string ProductDesignTypeNonStandard = "NON_STANDARD";
  776. private const string ProductDesignTypeTotal = "TOTAL";
  777. private const decimal ProductDesignStageKpiDays = 3m;
  778. // S8-ORDER-CHAIN-PRODUCT-DESIGN-PPT-STATIC-AND-SINGLE-ORDER-ALIGN-1:
  779. // 非标产品 KPI 与常规产品同阶段标准 3 天;非标 actual_days 超 3 时记 red/未达标。
  780. private const decimal ProductDesignNonStandardKpiDays = 3m;
  781. public async Task<AdoS8OrderFlowProductDesignDrawingsDto> GetProductDesignDrawingsAsync(
  782. AdoS8OrderFlowProductDesignDrawingsQueryDto query)
  783. {
  784. var (tenantId, factoryId) = await ResolveScopeAsync();
  785. var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
  786. var productTypeFilter = NormalizeProductDesignType(query.ProductType);
  787. var scope = orderCodes.Count == 0 ? ScopeBaselinePpt : ScopeCurrentFiltered;
  788. var rows = await _pddRep.AsQueryable()
  789. .Where(d => d.TenantId == tenantId && d.FactoryId == factoryId && !d.IsDeleted)
  790. .WhereIF(orderCodes.Count > 0, d => orderCodes.Contains(d.OrderCode))
  791. .ToListAsync();
  792. // summary 始终基于命中订单范围的全分类汇总(不受 productType 过滤影响),让前端可同时渲染三档汇总。
  793. var summary = BuildProductDesignSummary(rows);
  794. var drawingItems = rows.AsEnumerable();
  795. if (productTypeFilter != null)
  796. drawingItems = drawingItems.Where(d => d.ProductType == productTypeFilter);
  797. var orderedDrawings = drawingItems
  798. .OrderBy(d => d.IsAchieved)
  799. .ThenByDescending(d => d.ActualDays ?? decimal.MinValue)
  800. .ThenBy(d => d.OrderCode, StringComparer.Ordinal)
  801. .ThenBy(d => d.DrawingNo, StringComparer.Ordinal)
  802. .Select(MapProductDesignDrawingItem)
  803. .ToList();
  804. return new AdoS8OrderFlowProductDesignDrawingsDto
  805. {
  806. Scope = scope,
  807. Filter = new AdoS8OrderFlowProductDesignFilterDto
  808. {
  809. OrderCodes = orderCodes,
  810. ProductType = productTypeFilter,
  811. },
  812. Summary = summary,
  813. Drawings = orderedDrawings,
  814. };
  815. }
  816. private static List<string> ParseOrderCodesCsv(string? raw)
  817. {
  818. if (string.IsNullOrWhiteSpace(raw)) return new List<string>();
  819. return raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
  820. .Where(s => !string.IsNullOrWhiteSpace(s))
  821. .Distinct(StringComparer.Ordinal)
  822. .ToList();
  823. }
  824. private static string? NormalizeProductDesignType(string? raw)
  825. {
  826. if (string.IsNullOrWhiteSpace(raw)) return null;
  827. var s = raw.Trim().ToUpperInvariant();
  828. return s == ProductDesignTypeStandard || s == ProductDesignTypeNonStandard ? s : null;
  829. }
  830. private static AdoS8OrderFlowProductDesignSummaryDto BuildProductDesignSummary(
  831. List<AdoS8OrderFlowProductDesignDrawing> rows)
  832. {
  833. var stdRows = rows.Where(r => r.ProductType == ProductDesignTypeStandard).ToList();
  834. var nonStdRows = rows.Where(r => r.ProductType == ProductDesignTypeNonStandard).ToList();
  835. var grandTotalQty = rows.Sum(r => r.ProductQuantity);
  836. return new AdoS8OrderFlowProductDesignSummaryDto
  837. {
  838. Overall = BuildProductDesignOverall(rows),
  839. Categories = new List<AdoS8OrderFlowProductDesignCategorySummaryDto>
  840. {
  841. BuildProductDesignCategory(ProductDesignTypeStandard, "常规产品", ProductDesignStageKpiDays, stdRows, grandTotalQty),
  842. BuildProductDesignCategory(ProductDesignTypeNonStandard, "非标产品", ProductDesignNonStandardKpiDays, nonStdRows, grandTotalQty),
  843. BuildProductDesignCategory(ProductDesignTypeTotal, "合计", ProductDesignStageKpiDays, rows, grandTotalQty),
  844. },
  845. };
  846. }
  847. private static AdoS8OrderFlowProductDesignOverallSummaryDto BuildProductDesignOverall(
  848. List<AdoS8OrderFlowProductDesignDrawing> rows)
  849. {
  850. var totalQty = rows.Sum(r => r.ProductQuantity);
  851. var achievedQty = rows.Where(r => r.IsAchieved).Sum(r => r.ProductQuantity);
  852. var actualDays = rows.Where(r => r.ActualDays.HasValue).Select(r => r.ActualDays!.Value).ToList();
  853. return new AdoS8OrderFlowProductDesignOverallSummaryDto
  854. {
  855. DrawingCount = rows.Select(r => r.DrawingNo).Distinct(StringComparer.Ordinal).Count(),
  856. TotalQuantity = totalQty,
  857. KpiDays = ProductDesignStageKpiDays,
  858. AvgActualDays = actualDays.Count == 0 ? 0m : actualDays.Average(),
  859. AchievementRate = totalQty == 0 ? 0m : (decimal)achievedQty / totalQty,
  860. };
  861. }
  862. private static AdoS8OrderFlowProductDesignCategorySummaryDto BuildProductDesignCategory(
  863. string productType,
  864. string name,
  865. decimal kpiDays,
  866. List<AdoS8OrderFlowProductDesignDrawing> rows,
  867. int grandTotalQty)
  868. {
  869. var totalQty = rows.Sum(r => r.ProductQuantity);
  870. var achievedQty = rows.Where(r => r.IsAchieved).Sum(r => r.ProductQuantity);
  871. var actualDays = rows.Where(r => r.ActualDays.HasValue).Select(r => r.ActualDays!.Value).ToList();
  872. var rate = totalQty == 0 ? 0m : (decimal)achievedQty / totalQty;
  873. // S8-ORDER-CHAIN-PRODUCT-DESIGN-20ORDER-GENERATION-CONSISTENCY-AUDIT-1:
  874. // STANDARD / NON_STANDARD / TOTAL 三档统一返回真实指标,不再用 null 掩盖。
  875. // - KpiDays:始终返回常量 KPI=3,方便前端展示阈值。
  876. // - AvgActualDays:actualDays 简单平均(pending 行已经被 HasValue 过滤)。TOTAL 平均 == 父 stage.actual_days(守恒)。
  877. // - AchievementRate:以 product_quantity 加权达成率(与 Overall 一致),保留业务台数权重直觉。
  878. // - Status:达成率 ≥0.95 green / ≥0.80 yellow / 否则 red;无完工行(all pending)时返回空串。
  879. return new AdoS8OrderFlowProductDesignCategorySummaryDto
  880. {
  881. ProductType = productType,
  882. Name = name,
  883. DrawingCount = rows.Select(r => r.DrawingNo).Distinct(StringComparer.Ordinal).Count(),
  884. TotalQuantity = totalQty,
  885. Ratio = grandTotalQty == 0 ? 0m : (decimal)totalQty / grandTotalQty,
  886. KpiDays = kpiDays,
  887. AvgActualDays = actualDays.Count == 0 ? (decimal?)null : actualDays.Average(),
  888. AchievementRate = rate,
  889. Status = actualDays.Count == 0
  890. ? string.Empty
  891. : ClassifyProductDesignCategoryStatus(rate, totalQty),
  892. };
  893. }
  894. /// <summary>达标率 ≥ 0.95 绿 / ≥ 0.80 黄 / 否则 红;无数据返回空串以表达未取数而非状态判定。</summary>
  895. private static string ClassifyProductDesignCategoryStatus(decimal achievementRate, int totalQty)
  896. {
  897. if (totalQty == 0) return string.Empty;
  898. if (achievementRate >= 0.95m) return "green";
  899. if (achievementRate >= 0.80m) return "yellow";
  900. return "red";
  901. }
  902. private static AdoS8OrderFlowProductDesignDrawingItemDto MapProductDesignDrawingItem(
  903. AdoS8OrderFlowProductDesignDrawing d)
  904. => new()
  905. {
  906. OrderCode = d.OrderCode,
  907. DrawingNo = d.DrawingNo,
  908. ProductType = d.ProductType,
  909. ResponsiblePerson = d.ResponsiblePerson,
  910. PlannedStartDate = d.PlannedStartDate,
  911. PlannedEndDate = d.PlannedEndDate,
  912. ActualStartDate = d.ActualStartDate,
  913. ActualEndDate = d.ActualEndDate,
  914. KpiDays = d.KpiDays,
  915. ActualDays = d.ActualDays,
  916. IsAchieved = d.IsAchieved,
  917. Status = d.Status,
  918. ProductQuantity = d.ProductQuantity,
  919. };
  920. // ──────────────────────────────────────────────────────────────────────
  921. // S8-ORDER-CHAIN-BODY-PRODUCTION-ORDER-LEVEL-SEED-FIX-1:本体生产透视。
  922. // 单一数据源 = ado_s8_order_flow_manufacturing_{process,loss_factor,operator} 三表。
  923. // BASELINE_PPT 读 (order_id IS NULL) baseline 行;CURRENT_FILTERED 读订单级行(order_id IS NOT NULL),
  924. // 按 IMPORT/AGG > SEED 优先级去重后,按业务口径聚合:
  925. // process actual_days:AVG(多订单按行平均)
  926. // process plan_qty:SUM(多订单累计计划台次)
  927. // process achievement_rate:AVG(多订单平均达成率),TOTAL 行特殊处理见 BuildManufacturingProcessAggregated。
  928. // process cycle_status / achievement_status:聚合后按阈值重判(与 baseline 同口径,运行期不修改 seed 行)。
  929. // loss factor count:SUM;ratio_pct / loss_hours:AVG。
  930. // operator avg_hours:AVG;status:取首单的 status(baseline 每操作员固定,AVG 后语义不变)。
  931. // orderCodes 为空时返回空骨架(不静默 fallback baseline,避免 baseline 泄漏到单订单态)。
  932. // status 阈值与 seed 同步:cycle_status (≤pi green / ≤pi*1.2 yellow / 否则 red),
  933. // achievement_status (≥0.95 green / ≥0.80 yellow / 否则 red)。
  934. // ──────────────────────────────────────────────────────────────────────
  935. private const string ManufacturingProcessTotalCode = "TOTAL";
  936. // S8-...-FIX-1R:order-level TOTAL.achievement_rate / achievement_status 锚定 fixture,
  937. // 与 baseline TOTAL(0.90 / yellow)保持一致,不再用 plan_qty 加权计算(避免 65% red 与 90% yellow 冲突)。
  938. // SEED 过渡口径;真实 IMPORT/AGG 接入后由真实生产达成数据替换。
  939. private const decimal ManufacturingTotalAchievementRate = 0.9000m;
  940. private const string ManufacturingTotalAchievementStatus = "yellow";
  941. public async Task<AdoS8OrderFlowManufacturingPivotDto> GetManufacturingPivotAsync(
  942. AdoS8OrderFlowManufacturingPivotQueryDto query)
  943. {
  944. var (tenantId, factoryId) = await ResolveScopeAsync();
  945. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  946. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  947. {
  948. var processRows = await _mfgProcessRep.AsQueryable()
  949. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  950. .Where(p => p.OrderId == null)
  951. .ToListAsync();
  952. var lossRows = await _mfgLossRep.AsQueryable()
  953. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  954. .Where(p => p.OrderId == null)
  955. .ToListAsync();
  956. var operatorRows = await _mfgOperatorRep.AsQueryable()
  957. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  958. .Where(p => p.OrderId == null)
  959. .ToListAsync();
  960. return BuildManufacturingPivotFromBaseline(scope, processRows, lossRows, operatorRows);
  961. }
  962. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  963. {
  964. var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
  965. if (orderCodes.Count == 0)
  966. return new AdoS8OrderFlowManufacturingPivotDto { Scope = scope };
  967. var processRows = await _mfgProcessRep.AsQueryable()
  968. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  969. .Where(p => p.OrderId != null && p.OrderCode != null)
  970. .Where(p => orderCodes.Contains(p.OrderCode!))
  971. .ToListAsync();
  972. var lossRows = await _mfgLossRep.AsQueryable()
  973. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  974. .Where(p => p.OrderId != null && p.OrderCode != null)
  975. .Where(p => orderCodes.Contains(p.OrderCode!))
  976. .ToListAsync();
  977. var operatorRows = await _mfgOperatorRep.AsQueryable()
  978. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  979. .Where(p => p.OrderId != null && p.OrderCode != null)
  980. .Where(p => orderCodes.Contains(p.OrderCode!))
  981. .ToListAsync();
  982. if (processRows.Count == 0 && lossRows.Count == 0 && operatorRows.Count == 0)
  983. return new AdoS8OrderFlowManufacturingPivotDto { Scope = scope };
  984. // IMPORT/AGG > SEED 去重:同 (order_code, process_code/factor_code/operator_code) 上 IMPORT/AGG 优先。
  985. var preferredProcess = SelectPreferredMfgProcess(processRows);
  986. var preferredLoss = SelectPreferredMfgLossFactor(lossRows);
  987. var preferredOperator = SelectPreferredMfgOperator(operatorRows);
  988. return new AdoS8OrderFlowManufacturingPivotDto
  989. {
  990. Scope = scope,
  991. Processes = BuildManufacturingProcessAggregated(preferredProcess),
  992. LossFactors = BuildManufacturingLossFactorAggregated(preferredLoss),
  993. Operators = BuildManufacturingOperatorAggregated(preferredOperator),
  994. };
  995. }
  996. return new AdoS8OrderFlowManufacturingPivotDto { Scope = scope };
  997. }
  998. private static AdoS8OrderFlowManufacturingPivotDto BuildManufacturingPivotFromBaseline(
  999. string scope,
  1000. List<AdoS8OrderFlowManufacturingProcess> processRows,
  1001. List<AdoS8OrderFlowManufacturingLossFactor> lossRows,
  1002. List<AdoS8OrderFlowManufacturingOperator> operatorRows)
  1003. {
  1004. var processes = processRows
  1005. .OrderBy(r => r.SortNo)
  1006. .Select(r => new AdoS8OrderFlowManufacturingProcessDto
  1007. {
  1008. ProcessCode = r.ProcessCode,
  1009. ProcessName = r.ProcessName,
  1010. PiDays = r.PiDays,
  1011. ActualDays = r.ActualDays,
  1012. CycleStatus = r.CycleStatus,
  1013. PlanQty = r.PlanQty,
  1014. AchievementRate = r.AchievementRate,
  1015. AchievementStatus = r.AchievementStatus,
  1016. SortNo = r.SortNo,
  1017. })
  1018. .ToList();
  1019. var losses = lossRows
  1020. .OrderBy(r => r.SortNo)
  1021. .Select(r => new AdoS8OrderFlowManufacturingLossFactorDto
  1022. {
  1023. FactorCode = r.FactorCode,
  1024. FactorName = r.FactorName,
  1025. CountValue = r.CountValue,
  1026. RatioPct = r.RatioPct,
  1027. LossHours = r.LossHours,
  1028. SortNo = r.SortNo,
  1029. })
  1030. .ToList();
  1031. var operators = operatorRows
  1032. .OrderBy(r => r.SortNo)
  1033. .Select(r => new AdoS8OrderFlowManufacturingOperatorDto
  1034. {
  1035. OperatorCode = r.OperatorCode,
  1036. OperatorName = r.OperatorName,
  1037. AvgHours = r.AvgHours,
  1038. Status = r.Status,
  1039. SortNo = r.SortNo,
  1040. })
  1041. .ToList();
  1042. return new AdoS8OrderFlowManufacturingPivotDto
  1043. {
  1044. Scope = scope,
  1045. Processes = processes,
  1046. LossFactors = losses,
  1047. Operators = operators,
  1048. };
  1049. }
  1050. private static List<AdoS8OrderFlowManufacturingProcess> SelectPreferredMfgProcess(
  1051. List<AdoS8OrderFlowManufacturingProcess> rows)
  1052. {
  1053. var grouped = rows.GroupBy(r => (r.OrderCode, r.ProcessCode));
  1054. var result = new List<AdoS8OrderFlowManufacturingProcess>(rows.Count);
  1055. foreach (var g in grouped)
  1056. {
  1057. var preferred = g.FirstOrDefault(r =>
  1058. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  1059. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  1060. result.Add(preferred ?? g.First());
  1061. }
  1062. return result;
  1063. }
  1064. private static List<AdoS8OrderFlowManufacturingLossFactor> SelectPreferredMfgLossFactor(
  1065. List<AdoS8OrderFlowManufacturingLossFactor> rows)
  1066. {
  1067. var grouped = rows.GroupBy(r => (r.OrderCode, r.FactorCode));
  1068. var result = new List<AdoS8OrderFlowManufacturingLossFactor>(rows.Count);
  1069. foreach (var g in grouped)
  1070. {
  1071. var preferred = g.FirstOrDefault(r =>
  1072. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  1073. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  1074. result.Add(preferred ?? g.First());
  1075. }
  1076. return result;
  1077. }
  1078. private static List<AdoS8OrderFlowManufacturingOperator> SelectPreferredMfgOperator(
  1079. List<AdoS8OrderFlowManufacturingOperator> rows)
  1080. {
  1081. var grouped = rows.GroupBy(r => (r.OrderCode, r.OperatorCode));
  1082. var result = new List<AdoS8OrderFlowManufacturingOperator>(rows.Count);
  1083. foreach (var g in grouped)
  1084. {
  1085. var preferred = g.FirstOrDefault(r =>
  1086. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  1087. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  1088. result.Add(preferred ?? g.First());
  1089. }
  1090. return result;
  1091. }
  1092. /// <summary>
  1093. /// 多订单工序聚合:
  1094. /// 非 TOTAL 行:actual_days = AVG;plan_qty = SUM;achievement_rate = AVG(保留 4 位小数);
  1095. /// cycle_status 按聚合 actual vs pi 重判;achievement_status 按聚合 rate 重判。
  1096. /// TOTAL 行:pi=6(取 baseline 常量);actual = AVG(与 4 工序 AVG 之和守恒,差异为 rounding 容差);
  1097. /// plan_qty = NULL;
  1098. /// S8-...-FIX-1R:achievement_rate / achievement_status 锚定 fixture(0.90 / yellow),
  1099. /// 不再做 plan_qty 加权计算(避免与 baseline TOTAL 视觉冲突);
  1100. /// cycle_status 按聚合 actual vs pi 重判。
  1101. /// </summary>
  1102. private static List<AdoS8OrderFlowManufacturingProcessDto> BuildManufacturingProcessAggregated(
  1103. List<AdoS8OrderFlowManufacturingProcess> rows)
  1104. {
  1105. var grouped = rows
  1106. .GroupBy(r => r.ProcessCode)
  1107. .ToDictionary(g => g.Key, g => g.ToList());
  1108. // S8-...-FIX-1R:TOTAL 行 rate / status 锚定 fixture,不再做 4 工序 plan_qty 加权计算,
  1109. // 不需要维护中间 aggregatedRates 字典。
  1110. var processDtos = new List<AdoS8OrderFlowManufacturingProcessDto>();
  1111. foreach (var kv in grouped.OrderBy(g => g.Value.First().SortNo))
  1112. {
  1113. var code = kv.Key;
  1114. var list = kv.Value;
  1115. var sample = list[0];
  1116. var isTotal = string.Equals(code, ManufacturingProcessTotalCode, StringComparison.OrdinalIgnoreCase);
  1117. var actualAvg = decimal.Round(list.Average(r => r.ActualDays), 3);
  1118. var pi = sample.PiDays;
  1119. decimal? aggregatedRate;
  1120. int? planQtySum;
  1121. string achStatus;
  1122. if (isTotal)
  1123. {
  1124. // S8-...-FIX-1R:TOTAL 行 rate / status 锚定 fixture(0.90 / yellow),
  1125. // 不再做 plan_qty 加权计算;非 TOTAL 行的 plan_qty 仍保留供 UI 显示,但不参与 TOTAL.rate。
  1126. planQtySum = null;
  1127. aggregatedRate = ManufacturingTotalAchievementRate;
  1128. achStatus = ManufacturingTotalAchievementStatus;
  1129. }
  1130. else
  1131. {
  1132. planQtySum = list.Where(r => r.PlanQty.HasValue).Sum(r => r.PlanQty!.Value);
  1133. var rateRows = list.Where(r => r.AchievementRate.HasValue).ToList();
  1134. aggregatedRate = rateRows.Count == 0
  1135. ? null
  1136. : decimal.Round(rateRows.Average(r => r.AchievementRate!.Value), 4);
  1137. achStatus = aggregatedRate.HasValue
  1138. ? ClassifyMfgAchievementStatus(aggregatedRate.Value)
  1139. : string.Empty;
  1140. }
  1141. var cycleStatus = ClassifyMfgCycleStatus(actualAvg, pi);
  1142. processDtos.Add(new AdoS8OrderFlowManufacturingProcessDto
  1143. {
  1144. ProcessCode = sample.ProcessCode,
  1145. ProcessName = sample.ProcessName,
  1146. PiDays = pi,
  1147. ActualDays = actualAvg,
  1148. CycleStatus = cycleStatus,
  1149. PlanQty = planQtySum,
  1150. AchievementRate = aggregatedRate,
  1151. AchievementStatus = achStatus,
  1152. SortNo = sample.SortNo,
  1153. });
  1154. }
  1155. return processDtos;
  1156. }
  1157. private static List<AdoS8OrderFlowManufacturingLossFactorDto> BuildManufacturingLossFactorAggregated(
  1158. List<AdoS8OrderFlowManufacturingLossFactor> rows)
  1159. {
  1160. return rows
  1161. .GroupBy(r => r.FactorCode)
  1162. .OrderBy(g => g.First().SortNo)
  1163. .Select(g =>
  1164. {
  1165. var sample = g.First();
  1166. var countList = g.Where(r => r.CountValue.HasValue).ToList();
  1167. var ratioList = g.Where(r => r.RatioPct.HasValue).ToList();
  1168. var hoursList = g.Where(r => r.LossHours.HasValue).ToList();
  1169. return new AdoS8OrderFlowManufacturingLossFactorDto
  1170. {
  1171. FactorCode = sample.FactorCode,
  1172. FactorName = sample.FactorName,
  1173. CountValue = countList.Count == 0 ? null : countList.Sum(r => r.CountValue!.Value),
  1174. RatioPct = ratioList.Count == 0
  1175. ? null
  1176. : decimal.Round(ratioList.Average(r => r.RatioPct!.Value), 2),
  1177. LossHours = hoursList.Count == 0
  1178. ? null
  1179. : decimal.Round(hoursList.Average(r => r.LossHours!.Value), 2),
  1180. SortNo = sample.SortNo,
  1181. };
  1182. })
  1183. .ToList();
  1184. }
  1185. private static List<AdoS8OrderFlowManufacturingOperatorDto> BuildManufacturingOperatorAggregated(
  1186. List<AdoS8OrderFlowManufacturingOperator> rows)
  1187. {
  1188. return rows
  1189. .GroupBy(r => r.OperatorCode)
  1190. .OrderBy(g => g.First().SortNo)
  1191. .Select(g =>
  1192. {
  1193. var sample = g.First();
  1194. return new AdoS8OrderFlowManufacturingOperatorDto
  1195. {
  1196. OperatorCode = sample.OperatorCode,
  1197. OperatorName = sample.OperatorName,
  1198. AvgHours = decimal.Round(g.Average(r => r.AvgHours), 2),
  1199. // 每操作员 baseline status 固化,多订单聚合时各订单值一致;取首单不引入歧义。
  1200. Status = sample.Status,
  1201. SortNo = sample.SortNo,
  1202. };
  1203. })
  1204. .ToList();
  1205. }
  1206. private static string ClassifyMfgCycleStatus(decimal actual, decimal pi)
  1207. {
  1208. if (actual <= pi) return "green";
  1209. if (actual <= pi * 1.2m) return "yellow";
  1210. return "red";
  1211. }
  1212. private static string ClassifyMfgAchievementStatus(decimal rate)
  1213. {
  1214. if (rate >= 0.95m) return "green";
  1215. if (rate >= 0.80m) return "yellow";
  1216. return "red";
  1217. }
  1218. // ────────────────────────────────────────────────────────────
  1219. // 总装发货透视 — S8-ORDER-CHAIN-ASSEMBLY-DELIVERY-DATA-LINEAGE-AUDIT-1
  1220. //
  1221. // 与 aggregate.finalAssemblyCollabSummary(exception 派生)通道独立:
  1222. // - 本 API 提供门禁 KPI / 实际 / 偏差 / 齐套率(来自专属 pivot 表)。
  1223. // - 现有 BuildFinalAssemblyCollabSummaryAsync 保留,作为 collabSummary 补充。
  1224. // 数据源优先级:IMPORT > AGG > SEED(同 (order_code, gate_code/prep_code) 上去重)。
  1225. // 多订单聚合规则:
  1226. // - planned_offset_days:取首单值(baseline 固定 0.6/1.2/1.8/2.4/3.0,不随订单变)。
  1227. // - actual_offset_days:AVG。
  1228. // - variance_days:AVG(等于 avg_actual - planned,因 planned 固定)。
  1229. // - status:聚合后按 variance 重判(同 seed 阈值)。
  1230. // - impacted_order_count / risk_order_count:SUM。
  1231. // - top_risk_type / top_risk_label:稳定取首条 status != green 的样本(按 sort_no / order_code 升序)。
  1232. // - parallel kitting_rate:AVG(保留 4 位小数);status 按聚合 kitting_rate 重判。
  1233. // - orderCodes 为空 → 返回空骨架,不静默 fallback baseline;
  1234. // - 全 pending → 自然空骨架(SEED 不包含 pending 行)。
  1235. // ────────────────────────────────────────────────────────────
  1236. public async Task<AdoS8FinalAssemblyPivotDto> GetFinalAssemblyPivotAsync(
  1237. AdoS8FinalAssemblyPivotQueryDto query)
  1238. {
  1239. var (tenantId, factoryId) = await ResolveScopeAsync();
  1240. var scope = string.IsNullOrWhiteSpace(query.Scope) ? ScopeBaselinePpt : query.Scope;
  1241. if (string.Equals(scope, ScopeBaselinePpt, StringComparison.OrdinalIgnoreCase))
  1242. {
  1243. var gateRows = await _faGateRep.AsQueryable()
  1244. .Where(g => g.TenantId == tenantId && g.FactoryId == factoryId && !g.IsDeleted)
  1245. .Where(g => g.OrderId == null)
  1246. .ToListAsync();
  1247. var parallelRows = await _faParallelRep.AsQueryable()
  1248. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  1249. .Where(p => p.OrderId == null)
  1250. .ToListAsync();
  1251. return BuildFinalAssemblyPivotFromBaseline(scope, gateRows, parallelRows);
  1252. }
  1253. if (string.Equals(scope, ScopeCurrentFiltered, StringComparison.OrdinalIgnoreCase))
  1254. {
  1255. var orderCodes = ParseOrderCodesCsv(query.OrderCodes);
  1256. if (orderCodes.Count == 0)
  1257. return new AdoS8FinalAssemblyPivotDto { Scope = scope };
  1258. var gateRows = await _faGateRep.AsQueryable()
  1259. .Where(g => g.TenantId == tenantId && g.FactoryId == factoryId && !g.IsDeleted)
  1260. .Where(g => g.OrderId != null && g.OrderCode != null)
  1261. .Where(g => orderCodes.Contains(g.OrderCode!))
  1262. .ToListAsync();
  1263. var parallelRows = await _faParallelRep.AsQueryable()
  1264. .Where(p => p.TenantId == tenantId && p.FactoryId == factoryId && !p.IsDeleted)
  1265. .Where(p => p.OrderId != null && p.OrderCode != null)
  1266. .Where(p => orderCodes.Contains(p.OrderCode!))
  1267. .ToListAsync();
  1268. if (gateRows.Count == 0 && parallelRows.Count == 0)
  1269. return new AdoS8FinalAssemblyPivotDto { Scope = scope };
  1270. var preferredGates = SelectPreferredFaGate(gateRows);
  1271. var preferredParallels = SelectPreferredFaParallel(parallelRows);
  1272. return new AdoS8FinalAssemblyPivotDto
  1273. {
  1274. Scope = scope,
  1275. Gates = BuildFaGateAggregated(preferredGates),
  1276. ParallelPreps = BuildFaParallelAggregated(preferredParallels),
  1277. };
  1278. }
  1279. return new AdoS8FinalAssemblyPivotDto { Scope = scope };
  1280. }
  1281. private static AdoS8FinalAssemblyPivotDto BuildFinalAssemblyPivotFromBaseline(
  1282. string scope,
  1283. List<AdoS8OrderFlowFinalAssemblyGate> gateRows,
  1284. List<AdoS8OrderFlowFinalAssemblyParallel> parallelRows)
  1285. {
  1286. var gates = gateRows
  1287. .OrderBy(g => g.SortNo)
  1288. .Select(g => new AdoS8FinalAssemblyGateDto
  1289. {
  1290. GateCode = g.GateCode,
  1291. GateName = g.GateName,
  1292. PlannedOffsetDays = g.PlannedOffsetDays,
  1293. ActualOffsetDays = g.ActualOffsetDays,
  1294. VarianceDays = g.VarianceDays,
  1295. Status = g.Status,
  1296. ImpactedOrderCount = g.ImpactedOrderCount,
  1297. RiskOrderCount = g.RiskOrderCount,
  1298. TopRiskType = g.TopRiskType,
  1299. TopRiskLabel = g.TopRiskLabel,
  1300. OwnerSideText = g.OwnerSideText,
  1301. SortNo = g.SortNo,
  1302. })
  1303. .ToList();
  1304. var parallels = parallelRows
  1305. .OrderBy(p => p.SortNo)
  1306. .Select(p => new AdoS8FinalAssemblyParallelDto
  1307. {
  1308. PrepCode = p.PrepCode,
  1309. PrepName = p.PrepName,
  1310. KittingRate = p.KittingRate,
  1311. Status = p.Status,
  1312. ImpactedOrderCount = p.ImpactedOrderCount,
  1313. RiskOrderCount = p.RiskOrderCount,
  1314. TopRiskType = p.TopRiskType,
  1315. TopRiskLabel = p.TopRiskLabel,
  1316. OwnerSideText = p.OwnerSideText,
  1317. SortNo = p.SortNo,
  1318. })
  1319. .ToList();
  1320. return new AdoS8FinalAssemblyPivotDto
  1321. {
  1322. Scope = scope,
  1323. Gates = gates,
  1324. ParallelPreps = parallels,
  1325. };
  1326. }
  1327. private static List<AdoS8OrderFlowFinalAssemblyGate> SelectPreferredFaGate(
  1328. List<AdoS8OrderFlowFinalAssemblyGate> rows)
  1329. {
  1330. var grouped = rows.GroupBy(r => (r.OrderCode, r.GateCode));
  1331. var result = new List<AdoS8OrderFlowFinalAssemblyGate>(rows.Count);
  1332. foreach (var g in grouped)
  1333. {
  1334. var preferred = g.FirstOrDefault(r =>
  1335. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  1336. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  1337. result.Add(preferred ?? g.First());
  1338. }
  1339. return result;
  1340. }
  1341. private static List<AdoS8OrderFlowFinalAssemblyParallel> SelectPreferredFaParallel(
  1342. List<AdoS8OrderFlowFinalAssemblyParallel> rows)
  1343. {
  1344. var grouped = rows.GroupBy(r => (r.OrderCode, r.PrepCode));
  1345. var result = new List<AdoS8OrderFlowFinalAssemblyParallel>(rows.Count);
  1346. foreach (var g in grouped)
  1347. {
  1348. var preferred = g.FirstOrDefault(r =>
  1349. string.Equals(r.DataSource, "IMPORT", StringComparison.OrdinalIgnoreCase) ||
  1350. string.Equals(r.DataSource, "AGG", StringComparison.OrdinalIgnoreCase));
  1351. result.Add(preferred ?? g.First());
  1352. }
  1353. return result;
  1354. }
  1355. private static List<AdoS8FinalAssemblyGateDto> BuildFaGateAggregated(
  1356. List<AdoS8OrderFlowFinalAssemblyGate> rows)
  1357. {
  1358. return rows
  1359. .GroupBy(r => r.GateCode)
  1360. .OrderBy(g => g.First().SortNo)
  1361. .Select(g =>
  1362. {
  1363. var sample = g.First();
  1364. var actualList = g.Where(r => r.ActualOffsetDays.HasValue).ToList();
  1365. var varianceList = g.Where(r => r.VarianceDays.HasValue).ToList();
  1366. decimal? actualAvg = actualList.Count == 0
  1367. ? null
  1368. : decimal.Round(actualList.Average(r => r.ActualOffsetDays!.Value), 3);
  1369. decimal? varianceAvg = varianceList.Count == 0
  1370. ? null
  1371. : decimal.Round(varianceList.Average(r => r.VarianceDays!.Value), 3);
  1372. var aggregatedStatus = varianceAvg.HasValue
  1373. ? ClassifyFaGateStatus(varianceAvg.Value)
  1374. : sample.Status;
  1375. var impactedSum = g.Sum(r => r.ImpactedOrderCount ?? 0);
  1376. var riskSum = g.Sum(r => r.RiskOrderCount ?? 0);
  1377. // 稳定取首条非 green 行的 top_risk_type / label(按 order_code 升序保证确定性)。
  1378. var riskSample = g
  1379. .Where(r => !string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase)
  1380. && r.TopRiskType != null)
  1381. .OrderBy(r => r.OrderCode, StringComparer.Ordinal)
  1382. .FirstOrDefault();
  1383. return new AdoS8FinalAssemblyGateDto
  1384. {
  1385. GateCode = sample.GateCode,
  1386. GateName = sample.GateName,
  1387. PlannedOffsetDays = sample.PlannedOffsetDays,
  1388. ActualOffsetDays = actualAvg,
  1389. VarianceDays = varianceAvg,
  1390. Status = aggregatedStatus,
  1391. ImpactedOrderCount = impactedSum,
  1392. RiskOrderCount = riskSum,
  1393. TopRiskType = riskSample?.TopRiskType,
  1394. TopRiskLabel = riskSample?.TopRiskLabel,
  1395. OwnerSideText = sample.OwnerSideText,
  1396. SortNo = sample.SortNo,
  1397. };
  1398. })
  1399. .ToList();
  1400. }
  1401. private static List<AdoS8FinalAssemblyParallelDto> BuildFaParallelAggregated(
  1402. List<AdoS8OrderFlowFinalAssemblyParallel> rows)
  1403. {
  1404. return rows
  1405. .GroupBy(r => r.PrepCode)
  1406. .OrderBy(g => g.First().SortNo)
  1407. .Select(g =>
  1408. {
  1409. var sample = g.First();
  1410. var rateList = g.Where(r => r.KittingRate.HasValue).ToList();
  1411. decimal? kittingAvg = rateList.Count == 0
  1412. ? null
  1413. : decimal.Round(rateList.Average(r => r.KittingRate!.Value), 4);
  1414. var aggregatedStatus = kittingAvg.HasValue
  1415. ? ClassifyFaParallelStatus(kittingAvg.Value)
  1416. : sample.Status;
  1417. var impactedSum = g.Sum(r => r.ImpactedOrderCount ?? 0);
  1418. var riskSum = g.Sum(r => r.RiskOrderCount ?? 0);
  1419. var riskSample = g
  1420. .Where(r => !string.Equals(r.Status, "green", StringComparison.OrdinalIgnoreCase)
  1421. && r.TopRiskType != null)
  1422. .OrderBy(r => r.OrderCode, StringComparer.Ordinal)
  1423. .FirstOrDefault();
  1424. return new AdoS8FinalAssemblyParallelDto
  1425. {
  1426. PrepCode = sample.PrepCode,
  1427. PrepName = sample.PrepName,
  1428. KittingRate = kittingAvg,
  1429. Status = aggregatedStatus,
  1430. ImpactedOrderCount = impactedSum,
  1431. RiskOrderCount = riskSum,
  1432. TopRiskType = riskSample?.TopRiskType,
  1433. TopRiskLabel = riskSample?.TopRiskLabel,
  1434. OwnerSideText = sample.OwnerSideText,
  1435. SortNo = sample.SortNo,
  1436. };
  1437. })
  1438. .ToList();
  1439. }
  1440. /// <summary>gate status:variance ≤ 0 green / ≤ 0.3 yellow / 否则 red(与 seed 同口径)。</summary>
  1441. private static string ClassifyFaGateStatus(decimal variance)
  1442. {
  1443. if (variance <= 0m) return "green";
  1444. if (variance <= 0.3m) return "yellow";
  1445. return "red";
  1446. }
  1447. /// <summary>parallel status:kitting_rate ≥ 0.95 green / ≥ 0.85 yellow / 否则 red(与 seed 同口径)。</summary>
  1448. private static string ClassifyFaParallelStatus(decimal kittingRate)
  1449. {
  1450. if (kittingRate >= 0.95m) return "green";
  1451. if (kittingRate >= 0.85m) return "yellow";
  1452. return "red";
  1453. }
  1454. }