S8MonitoringService.cs 50 KB

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