S8MonitoringService.cs 37 KB

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