S8OrderFlowService.cs 79 KB

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