S8MonitoringService.cs 52 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  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. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. public class S8MonitoringService : ITransient
  9. {
  10. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  11. private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
  12. // S8-OVERVIEW-DEPT-LABEL-HYDRATION-1:部门名称水合改用 AdoS0DepartmentMaster(DepartmentMaster 表,
  13. // RecID=1/2 映射真实部门),与 S8DashboardService / S8ExceptionService 主数据水合口径一致。
  14. // SysOrg 的 Id 为雪花,不与 ado_s8_exception.responsible_dept_id (1/2) 匹配,不能作部门名来源。
  15. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  16. // TASK-012-S9-QDC-RATIO-MIGRATION-2:result KPI summary 读取 RATIO 指标字典
  17. private readonly SqlSugarRepository<AdoS8MonitorMetric> _metricRep;
  18. // S9-RESULT-KPI-DEMO-BASELINE-AND-ORDER-FLOW-CALC-1:ORDER_FLOW_CYCLE_RATIO 半真实计算依赖订单链路阶段表
  19. private readonly SqlSugarRepository<AdoS8OrderFlowStage> _orderFlowStageRep;
  20. // S8-STEP4-KPI-CURRENT-VALUE-CLOSURE-1:ORDER_DELIVERY_RATE / PO_DELIVERY_RATE / WO_COMPLETION_RATE
  21. // 由 DEMO_BASELINE 常量改为订单链路真实聚合,各自依赖以下三张表。
  22. private readonly SqlSugarRepository<AdoS8OrderFlowOrder> _orderFlowOrderRep;
  23. private readonly SqlSugarRepository<AdoS8OrderFlowProcurementPivot> _procurementPivotRep;
  24. private readonly SqlSugarRepository<AdoS8OrderFlowManufacturingProcess> _manufacturingProcessRep;
  25. public S8MonitoringService(
  26. SqlSugarRepository<AdoS8Exception> rep,
  27. SqlSugarRepository<AdoS8ExceptionType> typeRep,
  28. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  29. SqlSugarRepository<AdoS8MonitorMetric> metricRep,
  30. SqlSugarRepository<AdoS8OrderFlowStage> orderFlowStageRep,
  31. SqlSugarRepository<AdoS8OrderFlowOrder> orderFlowOrderRep,
  32. SqlSugarRepository<AdoS8OrderFlowProcurementPivot> procurementPivotRep,
  33. SqlSugarRepository<AdoS8OrderFlowManufacturingProcess> manufacturingProcessRep)
  34. {
  35. _rep = rep;
  36. _typeRep = typeRep;
  37. _deptRep = deptRep;
  38. _metricRep = metricRep;
  39. _orderFlowStageRep = orderFlowStageRep;
  40. _orderFlowOrderRep = orderFlowOrderRep;
  41. _procurementPivotRep = procurementPivotRep;
  42. _manufacturingProcessRep = manufacturingProcessRep;
  43. }
  44. /// <summary>
  45. /// 9宫格数据:S1-S7 订单健康分布 + S8业务类别汇总 + S9部门汇总。
  46. /// 数据来源 ado_s8_exception 聚合;异常表为空时返回全 0(不再返回 Demo 数据)。
  47. /// </summary>
  48. public async Task<AdoS8OrderGridDto> GetOrderGridAsync(long tenantId, string? period = null)
  49. {
  50. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  51. // TASK-002-RESET-DIMENSION-MODEL-DEV-4A:聚合源切到 stage_code(DEV-2A 已与 module_code 对齐);
  52. // DTO 字段名保持 ModuleCode 以兼容前端,仅来源字段改为 StageCode。
  53. var events = await _rep.AsQueryable()
  54. .Where(e => e.TenantId == tenantId && !e.IsDeleted)
  55. .Where(e => S8ModuleCode.All.Contains(e.StageCode))
  56. .WhereIF(periodFrom.HasValue, e => e.CreatedAt >= periodFrom!.Value)
  57. .WhereIF(periodTo.HasValue, e => e.CreatedAt < periodTo!.Value)
  58. .Select(e => new
  59. {
  60. ModuleCode = e.StageCode,
  61. e.Severity,
  62. e.Status,
  63. e.TimeoutFlag,
  64. e.ExceptionTypeCode,
  65. e.SceneCode,
  66. e.ResponsibleDeptId,
  67. e.CreatedAt,
  68. e.ClosedAt
  69. })
  70. .ToListAsync();
  71. // ── Modules:按 module_code 聚合 ──
  72. var modules = S8ModuleCode.All.Select(mc =>
  73. {
  74. var rows = events.Where(e => e.ModuleCode == mc).ToList();
  75. var unclosed = rows.Where(e => e.Status != "CLOSED").ToList();
  76. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:red=SERIOUS / yellow=FOLLOW;
  77. // green 不再来自异常 severity(异常表无「正常」桶),保留 0 兜底,绿色由 useS8StageConfig 兜底。
  78. var red = unclosed.Count(e => S8SeverityCode.IsSerious(e.Severity));
  79. var yellow = unclosed.Count(e => S8SeverityCode.IsFollow(e.Severity));
  80. var green = 0;
  81. var closed = rows.Count(e => e.Status == "CLOSED");
  82. var total = rows.Count;
  83. return new AdoS8ModuleOrderSummary
  84. {
  85. ModuleCode = mc,
  86. ModuleLabel = S8ModuleCode.Label(mc),
  87. Green = green,
  88. Yellow = yellow,
  89. Red = red,
  90. Total = total,
  91. Frequency = total,
  92. AvgProcessHours = AvgHours(rows.Select(r => (r.CreatedAt, r.ClosedAt))),
  93. CloseRate = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
  94. };
  95. }).ToList();
  96. // ── ByCategory:按异常类型的 type_code 聚合为 5 大业务类别(CategoryOf) ──
  97. var typeMap = (await _typeRep.AsQueryable()
  98. .Where(t => t.TenantId == S8ConfigScope.GlobalTenantId || t.TenantId == tenantId)
  99. .ToListAsync())
  100. .GroupBy(t => t.TypeCode)
  101. // S8-TENANT-ONLY-BATCH6:precedence 判据由 FactoryId 降序改为「租户行优先」;
  102. // 本批之后租户覆盖行的 factory_id 恒为 0,按 FactoryId 排序会反过来选中平台默认。
  103. .ToDictionary(g => g.Key, g => g.OrderByDescending(x => x.TenantId != S8ConfigScope.GlobalTenantId).First());
  104. var byCategory = events
  105. .Where(e => !string.IsNullOrEmpty(e.ExceptionTypeCode) && typeMap.ContainsKey(e.ExceptionTypeCode!))
  106. .GroupBy(e => CategoryOf(typeMap[e.ExceptionTypeCode!]))
  107. .Where(g => !string.IsNullOrEmpty(g.Key))
  108. .Select(g =>
  109. {
  110. var total = g.Count();
  111. var closed = g.Count(e => e.Status == "CLOSED");
  112. return new AdoS8CategorySummary
  113. {
  114. Category = g.Key!,
  115. Total = total,
  116. AvgProcessHours = AvgHours(g.Select(e => (e.CreatedAt, e.ClosedAt))),
  117. CloseRate = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
  118. };
  119. })
  120. .OrderBy(c => CategoryOrder(c.Category))
  121. .ToList();
  122. // ── ByDept:按 ResponsibleDeptId 聚合,JOIN AdoS0DepartmentMaster(DepartmentMaster)取部门名称 ──
  123. // S8-OVERVIEW-DEPT-LABEL-HYDRATION-1:原使用 SysOrg 但其 Id 是雪花,与 dept_id=1/2 不匹配 → 100% fallback;
  124. // 改用 DepartmentMaster(RecID=1=质量部 / RecID=2=生产部),与 dashboard 部门口径对齐。
  125. var deptIds = events.Select(e => e.ResponsibleDeptId).Where(id => id > 0).Distinct().ToList();
  126. // S8-DEPT-DISPLAY-CONSISTENCY-1(P0-A-3):部门水合加 factory_ref_id 约束。
  127. var deptNameMap = deptIds.Count == 0
  128. ? new Dictionary<long, string>()
  129. : (await _deptRep.AsQueryable()
  130. .Where(d => deptIds.Contains(d.Id) && d.TenantId == tenantId)
  131. .Select(d => new { d.Id, Name = d.Descr ?? d.Department })
  132. .ToListAsync())
  133. .ToDictionary(d => d.Id, d => d.Name ?? string.Empty);
  134. var byDept = events
  135. .Where(e => e.ResponsibleDeptId > 0)
  136. .GroupBy(e => e.ResponsibleDeptId)
  137. .Select(g =>
  138. {
  139. var total = g.Count();
  140. var closed = g.Count(e => e.Status == "CLOSED");
  141. return new AdoS8DeptSummary
  142. {
  143. DeptName = deptNameMap.TryGetValue(g.Key, out var n) && !string.IsNullOrEmpty(n) ? n : $"部门{g.Key}",
  144. Total = total,
  145. AvgProcessHours = AvgHours(g.Select(e => (e.CreatedAt, e.ClosedAt))),
  146. CloseRate = total == 0 ? 0 : Math.Round(closed * 100.0 / total, 1),
  147. };
  148. })
  149. .OrderByDescending(d => d.Total)
  150. .ToList();
  151. return new AdoS8OrderGridDto
  152. {
  153. Modules = modules,
  154. ByCategory = byCategory,
  155. ByDept = byDept,
  156. };
  157. }
  158. private static double AvgHours(IEnumerable<(DateTime createdAt, DateTime? closedAt)> items)
  159. {
  160. var closed = items.Where(x => x.closedAt.HasValue).ToList();
  161. if (closed.Count == 0) return 0;
  162. var avg = closed.Average(x => (x.closedAt!.Value - x.createdAt).TotalHours);
  163. return Math.Round(avg, 1);
  164. }
  165. /// <summary>异常类型 → 业务类别(与 Overview 页 5 张类别卡对应)。
  166. /// 优先级:配置 (AdoS8ExceptionType.MonitoringCategoryKey → KeyToLabel) → legacy hardcode fallback → string.Empty。
  167. /// 配置缺失或非法 key 时由 CategoryOfLegacy 兜底,保证演示链路不丢类别卡。</summary>
  168. private static string CategoryOf(AdoS8ExceptionType? t)
  169. {
  170. if (t is null) return string.Empty;
  171. var configured = S8MonitoringCategory.KeyToLabel(t.MonitoringCategoryKey);
  172. if (!string.IsNullOrEmpty(configured)) return configured;
  173. return CategoryOfLegacy(t.TypeCode);
  174. }
  175. /// <summary>Legacy hardcode 映射,保留现有 15 条 enabled + 7 条 deprecated 兼容分支。
  176. /// 不在此处补新映射;新 type_code 通过 monitoring_category_key 配置驱动。</summary>
  177. private static string CategoryOfLegacy(string? typeCode) => typeCode switch
  178. {
  179. // 订单评审
  180. "ORDER_CHANGE" => "订单评审",
  181. // 总装发货
  182. "DELIVERY_DELAY" => "总装发货",
  183. "PENDING_SHIPMENT" => "总装发货",
  184. // 本体生产(新基线)
  185. "EQUIP_FAULT" => "本体生产",
  186. "MFG_MATERIAL_ABNORMAL" => "本体生产",
  187. "MFG_QUALITY_ABNORMAL" => "本体生产",
  188. "PRODUCTION_MATERIAL_ABNORMAL" => "本体生产",
  189. "PRODUCTION_QUALITY_ABNORMAL" => "本体生产",
  190. "WORK_ORDER_KITTING_ABNORMAL" => "本体生产",
  191. "WORK_ORDER_ISSUE_ABNORMAL" => "本体生产",
  192. // 材料采购(新基线)
  193. "SUPPLIER_ETA_ISSUE" => "材料采购",
  194. "SUPPLIER_SHIP_ISSUE" => "材料采购",
  195. "IQC_ISSUE" => "材料采购",
  196. "WH_PUTAWAY_ISSUE" => "材料采购",
  197. "WAREHOUSE_RECEIPT_ABNORMAL" => "材料采购",
  198. // 旧 7 条 deprecated 仍保留兼容(DB 已迁移;此分支防 Overview 临时回放历史 active)
  199. "MATERIAL_SHORTAGE" => "本体生产",
  200. "QUALITY_DEFECT" => "本体生产",
  201. "DIMENSION_DEVIATION" => "本体生产",
  202. "WH_KIT_ISSUE" => "本体生产",
  203. "WH_ISSUE_OUT_ISSUE" => "本体生产",
  204. "WH_INBOUND_ISSUE" => "材料采购",
  205. _ => string.Empty,
  206. };
  207. private static int CategoryOrder(string category) => category switch
  208. {
  209. "订单评审" => 1,
  210. "产品设计" => 2,
  211. "材料采购" => 3,
  212. "本体生产" => 4,
  213. "总装发货" => 5,
  214. _ => 99,
  215. };
  216. // ── QDC 常量映射(不建表,后端常量驱动) ──────────────────────────────────────
  217. private static readonly (string Code, string Title, string[] Types, string? Remark)[] QdcGroups =
  218. {
  219. ("QUALITY", "质量异常", new[] { "PURCHASE_QUALITY_ABNORMAL", "MFG_QUALITY_ABNORMAL", "PRODUCTION_QUALITY_ABNORMAL" }, null),
  220. ("DELIVERY", "交付异常", new[] { "ORDER_DELIVERY_DELAY_WARNING", "SUPPLIER_DELIVERY_DELAY_WARNING", "ORDER_CHANGE", "DELIVERY_DELAY", "PENDING_SHIPMENT" }, null),
  221. ("INVENTORY", "库存异常", new[] { "MATERIAL_STOCK_ABNORMAL", "INVENTORY_TURNOVER_ABNORMAL", "INVENTORY_AMOUNT_LEVEL_ABNORMAL" }, null),
  222. ("COST", "成本异常", Array.Empty<string>(), "待定义成本异常口径"),
  223. };
  224. /// <summary>
  225. /// S9 QDC 四主线运营聚合:质量 / 交付 / 成本 / 库存。
  226. /// 成本异常当前无 enabled exception_type,返回空桶。
  227. /// 及时关闭率依赖 sla_deadline,无 SLA 数据时返回 null。
  228. /// </summary>
  229. public async Task<AdoS8QdcSummaryDto> GetQdcSummaryAsync(long tenantId, string? period = null)
  230. {
  231. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  232. var events = await _rep.AsQueryable()
  233. .Where(e => e.TenantId == tenantId && !e.IsDeleted)
  234. .WhereIF(periodFrom.HasValue, e => e.CreatedAt >= periodFrom!.Value)
  235. .WhereIF(periodTo.HasValue, e => e.CreatedAt < periodTo!.Value)
  236. .Select(e => new
  237. {
  238. e.ExceptionTypeCode,
  239. e.Status,
  240. e.CreatedAt,
  241. e.ClosedAt,
  242. e.SlaDeadline,
  243. })
  244. .ToListAsync();
  245. var items = new List<AdoS8QdcSummaryItemDto>();
  246. foreach (var (code, title, types, remark) in QdcGroups)
  247. {
  248. if (types.Length == 0)
  249. {
  250. items.Add(new AdoS8QdcSummaryItemDto { Code = code, Title = title, Total = 0, Remark = remark });
  251. continue;
  252. }
  253. var rows = events
  254. .Where(e => e.ExceptionTypeCode != null && types.Contains(e.ExceptionTypeCode))
  255. .ToList();
  256. var total = rows.Count;
  257. var closedRows = rows.Where(e => e.Status == "CLOSED" && e.ClosedAt.HasValue).ToList();
  258. double? avgHours = closedRows.Count == 0
  259. ? null
  260. : Math.Round(closedRows.Average(e => (e.ClosedAt!.Value - e.CreatedAt).TotalHours), 1);
  261. double? closeRate = total == 0
  262. ? null
  263. : Math.Round(closedRows.Count * 100.0 / total, 1);
  264. double? onTimeCloseRate = null;
  265. if (closedRows.Count > 0)
  266. {
  267. var closedWithSla = closedRows.Where(e => e.SlaDeadline.HasValue).ToList();
  268. if (closedWithSla.Count > 0)
  269. onTimeCloseRate = Math.Round(
  270. closedWithSla.Count(e => e.ClosedAt!.Value <= e.SlaDeadline!.Value) * 100.0 / closedRows.Count, 1);
  271. }
  272. items.Add(new AdoS8QdcSummaryItemDto
  273. {
  274. Code = code,
  275. Title = title,
  276. Total = total,
  277. AvgProcessHours = avgHours,
  278. OnTimeCloseRate = onTimeCloseRate,
  279. CloseRate = closeRate,
  280. Remark = remark,
  281. });
  282. }
  283. return new AdoS8QdcSummaryDto { Items = items };
  284. }
  285. /// <summary>
  286. /// 异常监控汇总:按 module_code 分组统计红/黄/绿/超时数。
  287. /// 供综合全景页顶部徽标和模块汇总表使用。
  288. /// </summary>
  289. public async Task<AdoS8MonitoringSummaryDto> GetSummaryAsync(AdoS8MonitoringSummaryQueryDto q)
  290. {
  291. // 显式日期范围优先;未传显式范围时尝试从 period 解析。
  292. var (periodFrom, periodTo) = (!q.BizDateFrom.HasValue && !q.BizDateTo.HasValue)
  293. ? S8PeriodHelper.Resolve(q.Period)
  294. : (q.BizDateFrom, q.BizDateTo);
  295. var effectiveFrom = q.BizDateFrom ?? periodFrom;
  296. var effectiveTo = q.BizDateTo ?? periodTo;
  297. // 前端三专题页传入逗号分隔模块码("S1,S7" / "S2,S6" / "S3,S4,S5"),按 IN 过滤;空值/单值保持原语义。
  298. var moduleCodes = (q.ModuleCode ?? string.Empty)
  299. .Split(',', StringSplitOptions.RemoveEmptyEntries)
  300. .Select(s => s.Trim())
  301. .Where(s => s.Length > 0)
  302. .Distinct()
  303. .ToList();
  304. var query = _rep.AsQueryable()
  305. .Where(e => e.TenantId == q.TenantId && !e.IsDeleted)
  306. // TASK-002-RESET-DIMENSION-MODEL-DEV-4A:聚合源 module_code → stage_code。
  307. .Where(e => S8ModuleCode.All.Contains(e.StageCode))
  308. .WhereIF(!string.IsNullOrWhiteSpace(q.SceneCode), e => e.SceneCode == q.SceneCode)
  309. .WhereIF(moduleCodes.Count > 0, e => moduleCodes.Contains(e.StageCode))
  310. .WhereIF(effectiveFrom.HasValue, e => e.CreatedAt >= effectiveFrom!.Value)
  311. .WhereIF(effectiveTo.HasValue, e => e.CreatedAt < effectiveTo!.Value);
  312. // 聚合到内存(数据量在可控范围内,避免复杂 GROUP BY 兼容性问题)
  313. // S8-SLA-TIMEOUT-RUNTIME-1(P3):投影 SlaDeadline 替代 TimeoutFlag,timeout 改为运行时计算。
  314. var raw = await query
  315. .Select(e => new
  316. {
  317. ModuleCode = e.StageCode,
  318. e.SceneCode,
  319. e.Severity,
  320. e.SlaDeadline,
  321. e.Status
  322. })
  323. .ToListAsync();
  324. var nowForTimeout = DateTime.Now;
  325. // S8-OVERVIEW-STAGE-CARD-LIFECYCLE-COPY-AND-METRIC-FIX-1:超时口径 = SlaDeadline < now AND status NOT IN (CLOSED, RECOVERED)。
  326. // 按 severity 拆桶用于 stage card 「严重 N / 延误 M」「关注 N / 延误 M」展示。
  327. bool IsTimeout(DateTime? sla, string? status) =>
  328. sla != null && sla < nowForTimeout && status != "CLOSED" && status != "RECOVERED";
  329. var byModule = raw
  330. .GroupBy(e => new { mc = e.ModuleCode ?? string.Empty, sc = e.SceneCode ?? string.Empty })
  331. .Select(g => new AdoS8ModuleSummaryItem
  332. {
  333. ModuleCode = g.Key.mc,
  334. ModuleLabel = S8ModuleCode.Label(g.Key.mc),
  335. SceneCode = g.Key.sc,
  336. SceneLabel = S8SceneCode.Label(g.Key.sc),
  337. Total = g.Count(),
  338. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:red=SERIOUS / yellow=FOLLOW;green=0(异常表无正常桶)。
  339. Red = g.Count(e => S8SeverityCode.IsSerious(e.Severity)),
  340. Yellow = g.Count(e => S8SeverityCode.IsFollow(e.Severity)),
  341. Green = 0,
  342. Timeout = g.Count(e => IsTimeout(e.SlaDeadline, e.Status)),
  343. SeriousTimeout = g.Count(e => S8SeverityCode.IsSerious(e.Severity) && IsTimeout(e.SlaDeadline, e.Status)),
  344. FollowTimeout = g.Count(e => S8SeverityCode.IsFollow(e.Severity) && IsTimeout(e.SlaDeadline, e.Status)),
  345. })
  346. // 按 S8ModuleCode.All 顺序排列
  347. .OrderBy(r => Array.IndexOf(S8ModuleCode.All, r.ModuleCode))
  348. .ToList();
  349. return new AdoS8MonitoringSummaryDto
  350. {
  351. Total = raw.Count,
  352. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:red=SERIOUS / yellow=FOLLOW;green=0。
  353. Red = raw.Count(e => S8SeverityCode.IsSerious(e.Severity)),
  354. Yellow = raw.Count(e => S8SeverityCode.IsFollow(e.Severity)),
  355. Green = 0,
  356. Timeout = raw.Count(e => IsTimeout(e.SlaDeadline, e.Status)),
  357. SeriousTimeout = raw.Count(e => S8SeverityCode.IsSerious(e.Severity) && IsTimeout(e.SlaDeadline, e.Status)),
  358. FollowTimeout = raw.Count(e => S8SeverityCode.IsFollow(e.Severity) && IsTimeout(e.SlaDeadline, e.Status)),
  359. ByModule = byModule
  360. };
  361. }
  362. /// <summary>
  363. /// S8-DELIVERY-PAGE-TYPE-TOTAL-ALIGN-1:Delivery 页近 N 日交付异常趋势。
  364. /// 口径:module_code IN (S1,S7) AND exception_type_code IN
  365. /// (ORDER_DUE_DATE_DELAY / PRODUCT_DESIGN_DELAY / DELIVERY_DELAY_WARNING);
  366. /// 时间字段 created_at;不排除 CLOSED;按日聚合,缺失日期补 0;days 限 1-30;
  367. /// Total = OrderDueDateDelay + ProductDesignDelay + DeliveryDelayWarning,
  368. /// 与左侧 S1/S7 summary 总数对齐(PRODUCT_DESIGN_DELAY 属于 S1 stage)。
  369. /// </summary>
  370. public async Task<AdoS8DeliveryTrendDto> GetDeliveryTrendAsync(long tenantId, int days = 7, string? period = null)
  371. {
  372. DateTime from, toExclusive;
  373. int effectiveDays;
  374. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  375. if (periodFrom.HasValue && periodTo.HasValue)
  376. {
  377. from = periodFrom.Value;
  378. toExclusive = periodTo.Value;
  379. effectiveDays = Math.Max(1, Math.Min(90, (int)(toExclusive - from).TotalDays));
  380. }
  381. else
  382. {
  383. effectiveDays = Math.Clamp(days, 1, 30);
  384. var today = DateTime.Today;
  385. from = today.AddDays(-(effectiveDays - 1));
  386. toExclusive = today.AddDays(1);
  387. }
  388. var deliveryModules = new[] { "S1", "S7" };
  389. var deliveryTypes = new[] { "ORDER_DUE_DATE_DELAY", "PRODUCT_DESIGN_DELAY", "DELIVERY_DELAY_WARNING", "SHIPMENT_ABNORMAL" };
  390. var rows = await _rep.AsQueryable()
  391. .Where(e => e.TenantId == tenantId && !e.IsDeleted)
  392. .Where(e => deliveryModules.Contains(e.StageCode))
  393. .Where(e => e.ExceptionTypeCode != null && deliveryTypes.Contains(e.ExceptionTypeCode))
  394. .Where(e => e.CreatedAt >= from && e.CreatedAt < toExclusive)
  395. .Select(e => new { e.ExceptionTypeCode, e.CreatedAt })
  396. .ToListAsync();
  397. var byDate = rows
  398. .GroupBy(r => r.CreatedAt.Date)
  399. .ToDictionary(g => g.Key, g => g.ToList());
  400. var dayList = new List<AdoS8DeliveryTrendDayDto>(effectiveDays);
  401. for (var i = 0; i < effectiveDays; i++)
  402. {
  403. var d = from.AddDays(i);
  404. var bucket = byDate.TryGetValue(d, out var list) ? list : new();
  405. var orderDueDateDelay = bucket.Count(r => r.ExceptionTypeCode == "ORDER_DUE_DATE_DELAY");
  406. var productDesignDelay = bucket.Count(r => r.ExceptionTypeCode == "PRODUCT_DESIGN_DELAY");
  407. var deliveryDelayWarning = bucket.Count(r => r.ExceptionTypeCode == "DELIVERY_DELAY_WARNING");
  408. var shipmentAbnormal = bucket.Count(r => r.ExceptionTypeCode == "SHIPMENT_ABNORMAL");
  409. dayList.Add(new AdoS8DeliveryTrendDayDto
  410. {
  411. Date = d.ToString("MM/dd"),
  412. RawDate = d.ToString("yyyy-MM-dd"),
  413. OrderDueDateDelay = orderDueDateDelay,
  414. ProductDesignDelay = productDesignDelay,
  415. DeliveryDelayWarning = deliveryDelayWarning,
  416. ShipmentAbnormal = shipmentAbnormal,
  417. Total = orderDueDateDelay + productDesignDelay + deliveryDelayWarning + shipmentAbnormal,
  418. });
  419. }
  420. var totalSum = dayList.Sum(d => d.Total);
  421. var peak = dayList.OrderByDescending(d => d.Total).FirstOrDefault();
  422. var todayDay = dayList.Last();
  423. var yesterday = dayList.Count >= 2 ? dayList[^2] : null;
  424. double? changeRate = (yesterday is null || yesterday.Total == 0)
  425. ? (double?)null
  426. : Math.Round((todayDay.Total - yesterday.Total) * 100.0 / yesterday.Total, 1);
  427. var summary = new AdoS8DeliveryTrendSummaryDto
  428. {
  429. PeakValue = peak?.Total ?? 0,
  430. PeakDate = (peak is null || peak.Total == 0) ? null : peak.RawDate,
  431. AvgValue = Math.Round(totalSum / (double)effectiveDays, 2),
  432. TodayValue = todayDay.Total,
  433. TodayChangeRate = changeRate,
  434. };
  435. return new AdoS8DeliveryTrendDto { Days = dayList, Summary = summary };
  436. }
  437. /// <summary>
  438. /// S8-PRODUCTION-PAGE-TYPE-TITLE-AND-TREND-ALIGN-1:Production 页近 N 日生产异常趋势。
  439. /// 口径:module_code IN (S2,S6) AND exception_type_code IN
  440. /// (EQUIP_FAULT / MFG_MATERIAL_ABNORMAL / MFG_QUALITY_ABNORMAL / BODY_PRODUCTION_DELAY_WARNING);
  441. /// 时间字段 created_at;不排除 CLOSED;按日聚合,缺失日期补 0;days 限 1-30;
  442. /// Total = EquipmentFault + MaterialFault + QualityFault + BodyProductionDelay,与右侧明细对齐。
  443. /// </summary>
  444. public async Task<AdoS8ProductionTrendDto> GetProductionTrendAsync(long tenantId, int days = 7, string? period = null)
  445. {
  446. DateTime from, toExclusive;
  447. int effectiveDays;
  448. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  449. if (periodFrom.HasValue && periodTo.HasValue)
  450. {
  451. from = periodFrom.Value;
  452. toExclusive = periodTo.Value;
  453. effectiveDays = Math.Max(1, Math.Min(90, (int)(toExclusive - from).TotalDays));
  454. }
  455. else
  456. {
  457. effectiveDays = Math.Clamp(days, 1, 30);
  458. var today = DateTime.Today;
  459. from = today.AddDays(-(effectiveDays - 1));
  460. toExclusive = today.AddDays(1);
  461. }
  462. var prodModules = new[] { "S2", "S6" };
  463. var prodTypes = new[]
  464. {
  465. "EQUIP_FAULT", "MFG_MATERIAL_ABNORMAL", "MFG_QUALITY_ABNORMAL",
  466. "BODY_PRODUCTION_DELAY_WARNING", "PRODUCTION_WORK_ORDER_COMPLETION_DELAY"
  467. };
  468. var rows = await _rep.AsQueryable()
  469. .Where(e => e.TenantId == tenantId && !e.IsDeleted)
  470. .Where(e => prodModules.Contains(e.StageCode))
  471. .Where(e => e.ExceptionTypeCode != null && prodTypes.Contains(e.ExceptionTypeCode))
  472. .Where(e => e.CreatedAt >= from && e.CreatedAt < toExclusive)
  473. .Select(e => new { e.ExceptionTypeCode, e.CreatedAt })
  474. .ToListAsync();
  475. var byDate = rows.GroupBy(r => r.CreatedAt.Date).ToDictionary(g => g.Key, g => g.ToList());
  476. var dayList = new List<AdoS8ProductionTrendDayDto>(effectiveDays);
  477. for (var i = 0; i < effectiveDays; i++)
  478. {
  479. var d = from.AddDays(i);
  480. var bucket = byDate.TryGetValue(d, out var list) ? list : new();
  481. var ef = bucket.Count(r => r.ExceptionTypeCode == "EQUIP_FAULT");
  482. var mf = bucket.Count(r => r.ExceptionTypeCode == "MFG_MATERIAL_ABNORMAL");
  483. var qf = bucket.Count(r => r.ExceptionTypeCode == "MFG_QUALITY_ABNORMAL");
  484. var bpd = bucket.Count(r => r.ExceptionTypeCode == "BODY_PRODUCTION_DELAY_WARNING");
  485. var pwo = bucket.Count(r => r.ExceptionTypeCode == "PRODUCTION_WORK_ORDER_COMPLETION_DELAY");
  486. dayList.Add(new AdoS8ProductionTrendDayDto
  487. {
  488. Date = d.ToString("MM/dd"),
  489. RawDate = d.ToString("yyyy-MM-dd"),
  490. EquipmentFault = ef,
  491. MaterialFault = mf,
  492. QualityFault = qf,
  493. BodyProductionDelay = bpd,
  494. ProductionWorkOrderCompletionDelay = pwo,
  495. Total = ef + mf + qf + bpd + pwo,
  496. });
  497. }
  498. var totalSum = dayList.Sum(d => d.Total);
  499. var peak = dayList.OrderByDescending(d => d.Total).FirstOrDefault();
  500. var todayDay = dayList.Last();
  501. var yesterday = dayList.Count >= 2 ? dayList[^2] : null;
  502. double? changeRate = (yesterday is null || yesterday.Total == 0)
  503. ? (double?)null
  504. : Math.Round((todayDay.Total - yesterday.Total) * 100.0 / yesterday.Total, 1);
  505. var summary = new AdoS8DeliveryTrendSummaryDto
  506. {
  507. PeakValue = peak?.Total ?? 0,
  508. PeakDate = (peak is null || peak.Total == 0) ? null : peak.RawDate,
  509. AvgValue = Math.Round(totalSum / (double)effectiveDays, 2),
  510. TodayValue = todayDay.Total,
  511. TodayChangeRate = changeRate,
  512. };
  513. return new AdoS8ProductionTrendDto { Days = dayList, Summary = summary };
  514. }
  515. /// <summary>
  516. /// S8-SUPPLY-PAGE-TYPE-TOTAL-ALIGN-1:Supply 页近 N 日供应异常趋势。
  517. /// 口径:module_code IN (S3,S4,S5) AND exception_type_code IN 11 类
  518. /// (SUPPLIER_ETA_ISSUE / SUPPLIER_SHIP_ISSUE / SUPPLIER_DELIVERY_DELAY_WARNING /
  519. /// WAREHOUSE_RECEIPT_ABNORMAL / IQC_ISSUE / WH_PUTAWAY_ISSUE /
  520. /// WORK_ORDER_KITTING_ABNORMAL / WORK_ORDER_ISSUE_ABNORMAL /
  521. /// PURCHASE_EXECUTION_DELAY / MATERIAL_STOCK_ABNORMAL / INVENTORY_TURNOVER_ABNORMAL);
  522. /// 时间字段 created_at;不排除 CLOSED;按日聚合,缺失日期补 0;days 限 1-30;
  523. /// Total = 11 个字段之和(避免历史 bucket.Count 含未承载类型导致 total 与图线不一致)。
  524. /// </summary>
  525. public async Task<AdoS8SupplyTrendDto> GetSupplyTrendAsync(long tenantId, int days = 7, string? period = null)
  526. {
  527. DateTime from, toExclusive;
  528. int effectiveDays;
  529. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  530. if (periodFrom.HasValue && periodTo.HasValue)
  531. {
  532. from = periodFrom.Value;
  533. toExclusive = periodTo.Value;
  534. effectiveDays = Math.Max(1, Math.Min(90, (int)(toExclusive - from).TotalDays));
  535. }
  536. else
  537. {
  538. effectiveDays = Math.Clamp(days, 1, 30);
  539. var today = DateTime.Today;
  540. from = today.AddDays(-(effectiveDays - 1));
  541. toExclusive = today.AddDays(1);
  542. }
  543. var supplyModules = new[] { "S3", "S4", "S5" };
  544. var supplyTypes = new[]
  545. {
  546. "SUPPLIER_ETA_ISSUE", "SUPPLIER_SHIP_ISSUE", "SUPPLIER_DELIVERY_DELAY_WARNING",
  547. "WAREHOUSE_RECEIPT_ABNORMAL", "IQC_ISSUE", "WH_PUTAWAY_ISSUE",
  548. "WORK_ORDER_KITTING_ABNORMAL", "WORK_ORDER_ISSUE_ABNORMAL",
  549. "PURCHASE_EXECUTION_DELAY", "MATERIAL_STOCK_ABNORMAL", "INVENTORY_TURNOVER_ABNORMAL",
  550. "PURCHASE_DELIVERY_ABNORMAL"
  551. };
  552. var rows = await _rep.AsQueryable()
  553. .Where(e => e.TenantId == tenantId && !e.IsDeleted)
  554. .Where(e => supplyModules.Contains(e.StageCode))
  555. .Where(e => e.ExceptionTypeCode != null && supplyTypes.Contains(e.ExceptionTypeCode))
  556. .Where(e => e.CreatedAt >= from && e.CreatedAt < toExclusive)
  557. .Select(e => new { e.ExceptionTypeCode, e.CreatedAt })
  558. .ToListAsync();
  559. var byDate = rows.GroupBy(r => r.CreatedAt.Date).ToDictionary(g => g.Key, g => g.ToList());
  560. var dayList = new List<AdoS8SupplyTrendDayDto>(effectiveDays);
  561. for (var i = 0; i < effectiveDays; i++)
  562. {
  563. var d = from.AddDays(i);
  564. var bucket = byDate.TryGetValue(d, out var list) ? list : new();
  565. var sEta = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_ETA_ISSUE");
  566. var sShip = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_SHIP_ISSUE");
  567. var sDdw = bucket.Count(r => r.ExceptionTypeCode == "SUPPLIER_DELIVERY_DELAY_WARNING");
  568. var whR = bucket.Count(r => r.ExceptionTypeCode == "WAREHOUSE_RECEIPT_ABNORMAL");
  569. var iqc = bucket.Count(r => r.ExceptionTypeCode == "IQC_ISSUE");
  570. var whP = bucket.Count(r => r.ExceptionTypeCode == "WH_PUTAWAY_ISSUE");
  571. var woK = bucket.Count(r => r.ExceptionTypeCode == "WORK_ORDER_KITTING_ABNORMAL");
  572. var woI = bucket.Count(r => r.ExceptionTypeCode == "WORK_ORDER_ISSUE_ABNORMAL");
  573. var ped = bucket.Count(r => r.ExceptionTypeCode == "PURCHASE_EXECUTION_DELAY");
  574. var msa = bucket.Count(r => r.ExceptionTypeCode == "MATERIAL_STOCK_ABNORMAL");
  575. var ita = bucket.Count(r => r.ExceptionTypeCode == "INVENTORY_TURNOVER_ABNORMAL");
  576. var pda = bucket.Count(r => r.ExceptionTypeCode == "PURCHASE_DELIVERY_ABNORMAL");
  577. dayList.Add(new AdoS8SupplyTrendDayDto
  578. {
  579. Date = d.ToString("MM/dd"),
  580. RawDate = d.ToString("yyyy-MM-dd"),
  581. SupplierEtaIssue = sEta,
  582. SupplierShipIssue = sShip,
  583. SupplierDeliveryDelayWarning = sDdw,
  584. WarehouseReceiptAbnormal = whR,
  585. IqcIssue = iqc,
  586. WarehousePutawayIssue = whP,
  587. WorkOrderKittingAbnormal = woK,
  588. WorkOrderIssueAbnormal = woI,
  589. PurchaseExecutionDelay = ped,
  590. MaterialStockAbnormal = msa,
  591. InventoryTurnoverAbnormal = ita,
  592. PurchaseDeliveryAbnormal = pda,
  593. Total = sEta + sShip + sDdw + whR + iqc + whP + woK + woI + ped + msa + ita + pda,
  594. });
  595. }
  596. var totalSum = dayList.Sum(d => d.Total);
  597. var peak = dayList.OrderByDescending(d => d.Total).FirstOrDefault();
  598. var todayDay = dayList.Last();
  599. var yesterday = dayList.Count >= 2 ? dayList[^2] : null;
  600. double? changeRate = (yesterday is null || yesterday.Total == 0)
  601. ? (double?)null
  602. : Math.Round((todayDay.Total - yesterday.Total) * 100.0 / yesterday.Total, 1);
  603. var summary = new AdoS8DeliveryTrendSummaryDto
  604. {
  605. PeakValue = peak?.Total ?? 0,
  606. PeakDate = (peak is null || peak.Total == 0) ? null : peak.RawDate,
  607. AvgValue = Math.Round(totalSum / (double)effectiveDays, 2),
  608. TodayValue = todayDay.Total,
  609. TodayChangeRate = changeRate,
  610. };
  611. return new AdoS8SupplyTrendDto { Days = dayList, Summary = summary };
  612. }
  613. /// <summary>
  614. /// S8-SIDEBAR-TYPE-CARD-WINDOW-TOGGLE-1:专题页右侧类型卡按 (domain, window) 统一聚合。
  615. /// 单一时间窗口下 total / open / closed / avgProcessHours / closeRate 共用同一分母。
  616. /// 公共过滤:tenant/factory + is_deleted=0 + module_code IN S1-S7 + exception_type_code IN domain 类型集 + created_at IN window。
  617. /// </summary>
  618. public async Task<AdoS8DomainTypeMetricsDto> GetDomainTypeMetricsAsync(string domain, string window, long tenantId, string? period = null)
  619. {
  620. var d = (domain ?? string.Empty).Trim().ToUpperInvariant();
  621. var w = (window ?? string.Empty).Trim().ToUpperInvariant();
  622. if (w != "LAST_24H" && w != "LAST_7D") w = "LAST_24H";
  623. (string key, string label, string typeCode)[] specs = d switch
  624. {
  625. // S8-DELIVERY-PAGE-TYPE-TOTAL-ALIGN-1:Delivery 页类型明细 3 类,与左侧 S1/S7 stage summary 对齐;
  626. // PRODUCT_DESIGN_DELAY 属于 S1 stage,须计入交付明细才能让左右合计一致。
  627. // 显示名「总装发货延期」仅作大屏展示,不改 ado_s8_exception_type.type_name。
  628. "DELIVERY" => new (string, string, string)[]
  629. {
  630. ("order-due-date-delay", "订单交期延期", "ORDER_DUE_DATE_DELAY"),
  631. ("product-design-delay", "产品设计延期", "PRODUCT_DESIGN_DELAY"),
  632. ("delivery-delay-warning", "总装发货延期", "DELIVERY_DELAY_WARNING"),
  633. ("shipment-abnormal", "发运异常", "SHIPMENT_ABNORMAL"),
  634. },
  635. "PRODUCTION" => new (string, string, string)[]
  636. {
  637. ("equipment-fault", "设备异常", "EQUIP_FAULT"),
  638. ("material-fault", "物料异常", "MFG_MATERIAL_ABNORMAL"),
  639. ("quality-fault", "质量异常", "MFG_QUALITY_ABNORMAL"),
  640. // S8-PRODUCTION-PAGE-TYPE-TITLE-AND-TREND-ALIGN-1:大屏显示「本体生产延期」(去预警),
  641. // typeCode 保持 BODY_PRODUCTION_DELAY_WARNING,不改 ado_s8_exception_type.type_name。
  642. ("body-production-delay-warning", "本体生产延期", "BODY_PRODUCTION_DELAY_WARNING"),
  643. ("production-work-order-completion-delay", "工单完工延期", "PRODUCTION_WORK_ORDER_COMPLETION_DELAY"),
  644. },
  645. "SUPPLY" => new (string, string, string)[]
  646. {
  647. // S8-SUPPLY-PAGE-TYPE-TOTAL-ALIGN-1:SUPPLY 大屏明细 11 类,与左侧 S3/S4/S5 stage summary 对齐。
  648. // 新增 SUPPLIER_DELIVERY_DELAY_WARNING (S3) / MATERIAL_STOCK_ABNORMAL (S5) / INVENTORY_TURNOVER_ABNORMAL (S5),
  649. // 显示名沿用 DB type_name 既有命名,不改 ado_s8_exception_type.type_name。
  650. ("supplier-reply-delay", "供应商回复交期异常", "SUPPLIER_ETA_ISSUE"),
  651. ("supplier-ship-fault", "供应商发货异常", "SUPPLIER_SHIP_ISSUE"),
  652. ("supplier-delivery-delay-warning","供应商交付延期预警", "SUPPLIER_DELIVERY_DELAY_WARNING"),
  653. ("warehouse-receipt", "仓库收货异常", "WAREHOUSE_RECEIPT_ABNORMAL"),
  654. ("iqc-inspection", "IQC 检验异常", "IQC_ISSUE"),
  655. ("warehouse-shelving", "仓库上架入库异常", "WH_PUTAWAY_ISSUE"),
  656. ("work-order-prepare", "仓库工单备料异常", "WORK_ORDER_KITTING_ABNORMAL"),
  657. ("work-order-issue", "仓库工单发料异常", "WORK_ORDER_ISSUE_ABNORMAL"),
  658. ("purchase-execution-delay", "采购执行延期", "PURCHASE_EXECUTION_DELAY"),
  659. ("material-stock-abnormal", "物料库存异常", "MATERIAL_STOCK_ABNORMAL"),
  660. ("inventory-turnover-abnormal", "库存周转异常", "INVENTORY_TURNOVER_ABNORMAL"),
  661. ("purchase-delivery-abnormal", "采购到货异常", "PURCHASE_DELIVERY_ABNORMAL"),
  662. },
  663. _ => Array.Empty<(string, string, string)>(),
  664. };
  665. var typeCodes = specs.Select(s => s.typeCode).ToArray();
  666. if (typeCodes.Length == 0)
  667. {
  668. return new AdoS8DomainTypeMetricsDto { Domain = d, Window = w, Total = 0, Items = new() };
  669. }
  670. DateTime from, to;
  671. var (periodFrom, periodTo) = S8PeriodHelper.Resolve(period);
  672. if (periodFrom.HasValue && periodTo.HasValue)
  673. {
  674. from = periodFrom.Value;
  675. to = periodTo.Value;
  676. }
  677. else if (w == "LAST_7D")
  678. {
  679. // 与 trend 接口保持一致:[today-6, today+1)
  680. var today = DateTime.Today;
  681. from = today.AddDays(-6);
  682. to = today.AddDays(1);
  683. }
  684. else
  685. {
  686. // LAST_24H:滑动窗口 NOW()-24h ~ NOW()
  687. var now = DateTime.Now;
  688. from = now.AddHours(-24);
  689. to = now.AddMinutes(1);
  690. }
  691. var rows = await _rep.AsQueryable()
  692. .Where(e => e.TenantId == tenantId && !e.IsDeleted)
  693. .Where(e => S8ModuleCode.All.Contains(e.StageCode))
  694. .Where(e => e.ExceptionTypeCode != null && typeCodes.Contains(e.ExceptionTypeCode))
  695. .Where(e => e.CreatedAt >= from && e.CreatedAt < to)
  696. .Select(e => new { e.ExceptionTypeCode, e.Status, e.CreatedAt, e.ClosedAt })
  697. .ToListAsync();
  698. var byType = rows.GroupBy(r => r.ExceptionTypeCode!).ToDictionary(g => g.Key, g => g.ToList());
  699. var items = specs.Select(s =>
  700. {
  701. var bucket = byType.TryGetValue(s.typeCode, out var list) ? list : new();
  702. var total = bucket.Count;
  703. var closedCount = bucket.Count(r => r.Status == "CLOSED");
  704. var openCount = total - closedCount;
  705. double? avgHours = null;
  706. var closedSamples = bucket.Where(r => r.ClosedAt.HasValue).ToList();
  707. if (closedSamples.Count > 0)
  708. {
  709. avgHours = Math.Round(closedSamples.Average(r => (r.ClosedAt!.Value - r.CreatedAt).TotalHours), 1);
  710. }
  711. double? closeRate = total == 0 ? null : Math.Round(closedCount * 100.0 / total, 1);
  712. return new AdoS8DomainTypeMetricItemDto
  713. {
  714. Key = s.key,
  715. Label = s.label,
  716. TypeCode = s.typeCode,
  717. Total = total,
  718. OpenCount = openCount,
  719. ClosedCount = closedCount,
  720. AvgProcessHours = avgHours,
  721. CloseRate = closeRate,
  722. };
  723. }).ToList();
  724. return new AdoS8DomainTypeMetricsDto
  725. {
  726. Domain = d,
  727. Window = w,
  728. Total = items.Sum(i => i.Total),
  729. Items = items,
  730. };
  731. }
  732. /// <summary>
  733. /// S9-RESULT-KPI-DEMO-BASELINE-AND-ORDER-FLOW-CALC-1:A+B 混合方案。
  734. /// 4 个 KPI(ORDER_DELIVERY_RATE/PO_DELIVERY_RATE/IQC_PASS_RATE/WO_COMPLETION_RATE)→ DEMO_BASELINE 演示基线;
  735. /// 1 个 KPI(ORDER_FLOW_CYCLE_RATIO)→ ORDER_FLOW_CALC 基于 ado_s8_order_flow_stage 半真实计算;
  736. /// 不写 DB / 不接 period / 不启用 watch_rule。
  737. /// 来源:ado_s8_monitor_metric WHERE mechanism='RATIO' AND is_result_kpi=1(保留字典顺序与 metricName)。
  738. /// </summary>
  739. /// <summary>
  740. /// S8-CONFIG-AUTHORITY-FIX-1:结果 KPI 的「覆盖解析 → 启用门禁」。
  741. ///
  742. /// <b>为什么门禁必须在覆盖解析之后</b>:候选行同时含平台全局行(tenant/factory=0)与租户覆盖行。
  743. /// 若把 <c>Enabled</c> 下推成 SQL 的 WHERE,租户停用某指标(override enabled=false)时该行会先被滤掉,
  744. /// 分组里只剩 enabled=true 的全局行,结果反而回落成「显示」——与用户的停用意图完全相反。
  745. /// 因此:先按 工厂行 &gt; 租户行 &gt; 全局行 定出唯一 EffectiveMetric,再判它的 Enabled。
  746. ///
  747. /// 本方法只做纳入与否的门禁,<b>不参与任何 KPI 数值计算</b>;
  748. /// 也只作用于结果 KPI 路径,不影响 Rule 向导消费的机制字典(mechanism = DATE / VALUE_RANGE)。
  749. /// internal 暴露供契约测试直接驱动(沿用 evaluator EvaluateRows 的既有范式)。
  750. /// </summary>
  751. internal static List<AdoS8MonitorMetric> ResolveEnabledEffectiveResultKpis(IEnumerable<AdoS8MonitorMetric> candidateRows) =>
  752. candidateRows
  753. .GroupBy(x => x.MetricCode)
  754. .Select(g => g.OrderByDescending(x => x.TenantId != S8ConfigScope.GlobalTenantId).First())
  755. .Where(m => m.Enabled)
  756. .OrderBy(x => x.SortNo)
  757. .ThenBy(x => x.MetricCode)
  758. .ToList();
  759. public async Task<AdoS8ResultKpiSummaryDto> GetResultKpiSummaryAsync(long tenantId)
  760. {
  761. var rows = await _metricRep.AsQueryable()
  762. .Where(x => x.Mechanism == "RATIO" && x.IsResultKpi
  763. && (x.TenantId == S8ConfigScope.GlobalTenantId || x.TenantId == tenantId))
  764. .ToListAsync();
  765. var resolved = ResolveEnabledEffectiveResultKpis(rows);
  766. var items = new List<AdoS8ResultKpiItemDto>(resolved.Count);
  767. foreach (var m in resolved)
  768. {
  769. var unit = string.IsNullOrWhiteSpace(m.Unit) ? "%" : m.Unit!;
  770. // S8-STEP4-KPI-CURRENT-VALUE-CLOSURE-1:CurrentValue 优先走订单链路真实聚合;
  771. // 仅 IQC_PASS_RATE 因 S8 无权威 IQC 源,保留 DEMO_BASELINE(provenance 由 Source 字段如实标注)。
  772. var computed = await ComputeResultKpiCurrentAsync(m.MetricCode, tenantId);
  773. if (computed != null)
  774. {
  775. items.Add(new AdoS8ResultKpiItemDto
  776. {
  777. MetricCode = m.MetricCode,
  778. MetricName = m.MetricName,
  779. ObjectCode = m.ObjectCode,
  780. Unit = unit,
  781. CurrentValue = computed.Value.CurrentValue,
  782. // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:目标值是 DB 唯一事实源
  783. // (工厂覆盖 > 平台默认),与 CurrentValue 完全解耦;未配置时返回 null,
  784. // 前端显示「目标 待配置」,不做 C# 常量兜底。
  785. TargetRatio = ResolveEffectiveTarget(m),
  786. DictionaryEnabled = true,
  787. Remark = computed.Value.Remark,
  788. Source = computed.Value.Source,
  789. });
  790. }
  791. else if (_demoBaselineMap.TryGetValue(m.MetricCode, out var baseline))
  792. {
  793. items.Add(new AdoS8ResultKpiItemDto
  794. {
  795. MetricCode = m.MetricCode,
  796. MetricName = m.MetricName,
  797. ObjectCode = m.ObjectCode,
  798. Unit = unit,
  799. CurrentValue = baseline.CurrentValue,
  800. TargetRatio = ResolveEffectiveTarget(m),
  801. DictionaryEnabled = true,
  802. Remark = baseline.Remark,
  803. Source = "DEMO_BASELINE",
  804. });
  805. }
  806. else
  807. {
  808. items.Add(new AdoS8ResultKpiItemDto
  809. {
  810. MetricCode = m.MetricCode,
  811. MetricName = m.MetricName,
  812. ObjectCode = m.ObjectCode,
  813. Unit = unit,
  814. CurrentValue = null,
  815. TargetRatio = m.DefaultTargetRatio,
  816. DictionaryEnabled = false,
  817. Remark = m.Remark,
  818. Source = "PENDING_REAL",
  819. });
  820. }
  821. }
  822. return new AdoS8ResultKpiSummaryDto
  823. {
  824. Items = items,
  825. Source = "MIXED_BASELINE",
  826. };
  827. }
  828. /// <summary>
  829. /// KPI 当前值演示基线。S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1 已把 TargetRatio 从本 map 解耦
  830. /// (目标值由 ado_s8_monitor_metric.default_target_ratio 承载),本 map 只保留 CurrentValue 与 Remark。
  831. /// S8-STEP4-KPI-CURRENT-VALUE-CLOSURE-1:ORDER_DELIVERY_RATE / PO_DELIVERY_RATE / WO_COMPLETION_RATE
  832. /// 三项已迁到订单链路真实聚合(见 ComputeResultKpiCurrentAsync),从本 map 移除。
  833. /// 仅 IQC_PASS_RATE 保留——S8 域内无任何权威 IQC 检验源表(IQC 明细归属 S6 qms_gcjydzb,未纳入 S8 订单链路),
  834. /// 因此它是**演示基线而非实算**,由 Source="DEMO_BASELINE" 如实对外标注,前端文案为「当前基线」。
  835. /// </summary>
  836. private static readonly Dictionary<string, (decimal CurrentValue, string Remark)> _demoBaselineMap = new()
  837. {
  838. ["IQC_PASS_RATE"] = (96.0m, "演示基线(非实算):S8 域内无权威 IQC 检验源表,待接入后改为真实计算"),
  839. };
  840. /// <summary>
  841. /// S8-STEP4-KPI-CURRENT-VALUE-CLOSURE-1:结果 KPI 当前值的真实聚合分发。
  842. /// 返回 null 表示该指标不由订单链路计算(调用方回落 DEMO_BASELINE / PENDING_REAL)。
  843. /// 统一口径:只统计 tenant/factory 内未删除、且 <c>order_code</c> 非空的**订单关联业务记录**;
  844. /// order_code 为空的 BASELINE_PPT / DEMO 演示装置行不是业务记录,一律排除。
  845. /// </summary>
  846. private async Task<(decimal? CurrentValue, string Source, string Remark)?> ComputeResultKpiCurrentAsync(
  847. string metricCode, long tenantId) => metricCode switch
  848. {
  849. "ORDER_DELIVERY_RATE" => await ComputeOrderDeliveryRateAsync(tenantId),
  850. "PO_DELIVERY_RATE" => await ComputePoDeliveryRateAsync(tenantId),
  851. "WO_COMPLETION_RATE" => await ComputeWoCompletionRateAsync(tenantId),
  852. "ORDER_FLOW_CYCLE_RATIO" => await ComputeOrderFlowCycleRatioItemAsync(tenantId),
  853. _ => null,
  854. };
  855. /// <summary>
  856. /// 订单交付满足率 = 已完工订单中 actual_cycle_days &lt;= target_cycle_days 的占比。
  857. /// 分母只含 actual_cycle_days 已回填的订单(在制订单尚无实际周期,纳入会系统性低估)。
  858. /// </summary>
  859. private async Task<(decimal? CurrentValue, string Source, string Remark)> ComputeOrderDeliveryRateAsync(
  860. long tenantId)
  861. {
  862. var rows = await _orderFlowOrderRep.AsQueryable()
  863. .Where(x => !x.IsDeleted && x.TenantId == tenantId
  864. && x.ActualCycleDays != null)
  865. .Select(x => new { x.ActualCycleDays, x.TargetCycleDays })
  866. .ToListAsync();
  867. const string remark = "基于订单链路已完工订单 actual_cycle_days/target_cycle_days 计算,在制订单不纳入分母";
  868. if (rows.Count == 0) return (null, "ORDER_FLOW_CALC_EMPTY", remark);
  869. var numerator = rows.Count(x => x.ActualCycleDays!.Value <= x.TargetCycleDays);
  870. return (Math.Round(numerator * 100m / rows.Count, 1), "ORDER_FLOW_CALC", remark);
  871. }
  872. /// <summary>
  873. /// 到货达成率 = 采购透视中 status='green'(按期到货)的占比;yellow/red 均计入分母。
  874. /// </summary>
  875. private async Task<(decimal? CurrentValue, string Source, string Remark)> ComputePoDeliveryRateAsync(
  876. long tenantId)
  877. {
  878. var rows = await _procurementPivotRep.AsQueryable()
  879. .Where(x => !x.IsDeleted && x.TenantId == tenantId
  880. && x.OrderCode != null && x.OrderCode != "")
  881. .Select(x => x.Status)
  882. .ToListAsync();
  883. const string remark = "基于订单关联采购透视 status 计算:green 为按期到货,yellow/red 计入分母";
  884. if (rows.Count == 0) return (null, "ORDER_FLOW_CALC_EMPTY", remark);
  885. var numerator = rows.Count(s => s == "green");
  886. return (Math.Round(numerator * 100m / rows.Count, 1), "ORDER_FLOW_CALC", remark);
  887. }
  888. /// <summary>
  889. /// 工单完工率 = 订单关联制造工序 achievement_rate 的算术平均(存储为 0~1 比率,×100 转百分比)。
  890. /// </summary>
  891. private async Task<(decimal? CurrentValue, string Source, string Remark)> ComputeWoCompletionRateAsync(
  892. long tenantId)
  893. {
  894. var rows = await _manufacturingProcessRep.AsQueryable()
  895. .Where(x => !x.IsDeleted && x.TenantId == tenantId
  896. && x.OrderCode != null && x.OrderCode != "" && x.AchievementRate != null)
  897. .Select(x => x.AchievementRate)
  898. .ToListAsync();
  899. const string remark = "基于订单关联制造工序 achievement_rate 平均值计算(0~1 比率换算百分比)";
  900. if (rows.Count == 0) return (null, "ORDER_FLOW_CALC_EMPTY", remark);
  901. return (Math.Round(rows.Sum(x => x!.Value) * 100m / rows.Count, 1), "ORDER_FLOW_CALC", remark);
  902. }
  903. /// <summary>ORDER_FLOW_CYCLE_RATIO 适配到统一分发签名;计算逻辑保持不变。</summary>
  904. private async Task<(decimal? CurrentValue, string Source, string Remark)> ComputeOrderFlowCycleRatioItemAsync(
  905. long tenantId)
  906. {
  907. var (cv, source) = await ComputeOrderFlowCycleRatioAsync(tenantId);
  908. return (cv, source, "基于已完成订单链路阶段 actual_days/planned_days 计算,pending 阶段暂不纳入");
  909. }
  910. /// <summary>
  911. /// 生效目标值:rows 已按 (0,0) 平台默认 ‖ 本租户覆盖取回并按「租户行优先」去重,
  912. /// 因此 <paramref name="m"/> 本身即生效行,直接取其 default_target_ratio。
  913. /// 未配置返回 null —— 由 DB 作为唯一事实源,不回落任何 C# 常量。
  914. /// </summary>
  915. private static decimal? ResolveEffectiveTarget(AdoS8MonitorMetric m) => m.DefaultTargetRatio;
  916. /// <summary>
  917. /// 候选 A:actual_days IS NOT NULL 的已完成阶段中 actual_days <= planned_days 的占比。
  918. /// pending 阶段不纳入 denominator;不依赖 status 字段;按 tenant 过滤(S8-TENANT-ONLY-BATCH6)。
  919. /// </summary>
  920. private async Task<(decimal? CurrentValue, string Source)> ComputeOrderFlowCycleRatioAsync(long tenantId)
  921. {
  922. try
  923. {
  924. var stages = await _orderFlowStageRep.AsQueryable()
  925. .Where(x => !x.IsDeleted && x.TenantId == tenantId
  926. && x.ActualDays != null)
  927. .Select(x => new { x.ActualDays, x.PlannedDays })
  928. .ToListAsync();
  929. var denominator = stages.Count;
  930. if (denominator == 0) return (null, "ORDER_FLOW_CALC_EMPTY");
  931. var numerator = stages.Count(s => s.ActualDays!.Value <= s.PlannedDays);
  932. var value = Math.Round(numerator * 100m / denominator, 1);
  933. return (value, "ORDER_FLOW_CALC");
  934. }
  935. catch
  936. {
  937. return (null, "ORDER_FLOW_CALC_EMPTY");
  938. }
  939. }
  940. }