S8MonitoringService.cs 45 KB

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