S8MonitoringService.cs 52 KB

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