S8MonitoringService.cs 50 KB

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