AidopKanbanController.S4.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using SqlSugar;
  4. namespace Admin.NET.Plugin.AiDOP.Controllers;
  5. public partial class AidopKanbanController
  6. {
  7. public const int MaxL1PerModule = 8;
  8. /// <summary>S4 指标列表(从 KpiMaster 读取,可按层级过滤)。</summary>
  9. [HttpGet("s4-metric-catalog")]
  10. public async Task<IActionResult> GetS4MetricCatalog([FromQuery] int? metricLevel = null)
  11. {
  12. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  13. var q = _db.Queryable<AdoSmartOpsKpiMaster>()
  14. .Where(x => x.TenantId == tenantId && x.ModuleCode == "S4" && x.IsEnabled);
  15. if (metricLevel is > 0)
  16. q = q.Where(x => x.MetricLevel == metricLevel);
  17. var list = await q.OrderBy(x => x.SortNo).ToListAsync();
  18. return Ok(list.Select(x => new
  19. {
  20. id = x.Id,
  21. metricCode = x.MetricCode,
  22. moduleCode = x.ModuleCode,
  23. metricLevel = x.MetricLevel,
  24. defaultName = x.MetricName,
  25. unit = x.Unit,
  26. direction = x.Direction,
  27. sortHint = x.SortNo,
  28. parentId = x.ParentId,
  29. description = x.Description,
  30. formula = x.Formula,
  31. calcRule = x.CalcRule,
  32. dataSource = x.DataSource,
  33. statFrequency = x.StatFrequency,
  34. department = x.Department
  35. }));
  36. }
  37. /// <summary>S4 运营布局(L1/L2/L3 + 九宫格模块行)。</summary>
  38. [HttpGet("s4-operation-layout")]
  39. public async Task<IActionResult> GetS4OperationLayout([FromQuery] long factoryId = 1)
  40. {
  41. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  42. var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  43. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4")
  44. .ToListAsync()).FirstOrDefault();
  45. var items = await _db.Queryable<AdoSmartOpsLayoutItem>()
  46. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4" && x.IsEnabled == 1)
  47. .OrderBy(x => x.SortNo)
  48. .ToListAsync();
  49. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>().Where(x => x.TenantId == tenantId && x.ModuleCode == "S4").ToListAsync();
  50. var kpiByCode = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  51. object Row(AdoSmartOpsLayoutItem r)
  52. {
  53. kpiByCode.TryGetValue(r.MetricCode, out var k);
  54. return new
  55. {
  56. rowId = r.RowId,
  57. metricCode = r.MetricCode,
  58. metricLevel = r.MetricLevel,
  59. displayName = string.IsNullOrWhiteSpace(r.DisplayName) ? k?.MetricName : r.DisplayName,
  60. sortNo = r.SortNo,
  61. parentRowId = r.ParentRowId,
  62. formulaText = r.FormulaText,
  63. panelZone = r.PanelZone,
  64. unit = k?.Unit,
  65. direction = k?.Direction
  66. };
  67. }
  68. return Ok(new
  69. {
  70. layoutPattern = hm?.LayoutPattern ?? "card_grid",
  71. l1 = items.Where(x => x.MetricLevel == 1).Select(Row).ToList(),
  72. l2 = items.Where(x => x.MetricLevel >= 2).Select(Row).ToList()
  73. });
  74. }
  75. public class S4LayoutSaveDto
  76. {
  77. public List<S4LayoutL1Dto>? L1 { get; set; }
  78. public List<S4LayoutL2Dto>? L2 { get; set; }
  79. }
  80. public class S4LayoutL1Dto
  81. {
  82. public string? RowId { get; set; }
  83. public string? MetricCode { get; set; }
  84. public string? DisplayName { get; set; }
  85. public int SortNo { get; set; }
  86. public string? FormulaText { get; set; }
  87. }
  88. public class S4LayoutL2Dto
  89. {
  90. public string? RowId { get; set; }
  91. public string? MetricCode { get; set; }
  92. public string? DisplayName { get; set; }
  93. public int SortNo { get; set; }
  94. public string? ParentRowId { get; set; }
  95. public string? FormulaText { get; set; }
  96. public string? PanelZone { get; set; }
  97. }
  98. /// <summary>保存 S4 布局并同步更新 KpiMaster;L1 最多 8 条。</summary>
  99. [HttpPut("s4-operation-layout")]
  100. public async Task<IActionResult> PutS4OperationLayout([FromBody] S4LayoutSaveDto? body, [FromQuery] long factoryId = 1)
  101. {
  102. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  103. if (body?.L1 == null || body.L2 == null)
  104. return BadRequest(new { message = "L1/L2 不能为空" });
  105. if (body.L1.Count > MaxL1PerModule)
  106. return BadRequest(new { message = $"L1 最多 {MaxL1PerModule} 个" });
  107. var kpiAll = await _db.Queryable<AdoSmartOpsKpiMaster>()
  108. .Where(x => x.TenantId == tenantId && x.ModuleCode == "S4" && x.IsEnabled)
  109. .ToListAsync();
  110. var kpiByCode = kpiAll.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  111. foreach (var r in body.L1)
  112. {
  113. if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel != 1)
  114. return BadRequest(new { message = $"无效的 L1 指标: {r.MetricCode}" });
  115. }
  116. var l1Ids = new HashSet<string>(StringComparer.Ordinal);
  117. foreach (var r in body.L1)
  118. {
  119. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"S4-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
  120. if (!l1Ids.Add(rid))
  121. return BadRequest(new { message = $"重复的 L1 RowId: {rid}" });
  122. }
  123. foreach (var r in body.L2)
  124. {
  125. if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel < 2)
  126. return BadRequest(new { message = $"无效的 L2/L3/L4 指标: {r.MetricCode}" });
  127. }
  128. var now = DateTime.Now;
  129. try
  130. {
  131. _db.Ado.BeginTran();
  132. // -- 保存 LayoutItem --
  133. await _db.Deleteable<AdoSmartOpsLayoutItem>()
  134. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4")
  135. .ExecuteCommandAsync();
  136. var rows = new List<AdoSmartOpsLayoutItem>();
  137. foreach (var r in body.L1)
  138. {
  139. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"S4-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
  140. rows.Add(new AdoSmartOpsLayoutItem
  141. {
  142. TenantId = tenantId, FactoryId = factoryId, ModuleCode = "S4",
  143. RowId = rid, MetricLevel = 1, MetricCode = r.MetricCode!.Trim(),
  144. DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
  145. SortNo = r.SortNo, ParentRowId = null,
  146. FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText.Trim(),
  147. PanelZone = null, IsEnabled = 1, UpdateTime = now
  148. });
  149. }
  150. foreach (var r in body.L2)
  151. {
  152. var code = r.MetricCode!.Trim();
  153. var level = kpiByCode.TryGetValue(code, out var kk) ? kk.MetricLevel : 2;
  154. var prefix = $"S4-L{level}-";
  155. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{prefix}{code}" : r.RowId.Trim();
  156. var p = string.IsNullOrWhiteSpace(r.ParentRowId) ? null : r.ParentRowId.Trim();
  157. rows.Add(new AdoSmartOpsLayoutItem
  158. {
  159. TenantId = tenantId, FactoryId = factoryId, ModuleCode = "S4",
  160. RowId = rid, MetricLevel = level, MetricCode = code,
  161. DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
  162. SortNo = r.SortNo, ParentRowId = p,
  163. FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText,
  164. PanelZone = string.IsNullOrWhiteSpace(r.PanelZone) ? null : r.PanelZone.Trim(),
  165. IsEnabled = 1, UpdateTime = now
  166. });
  167. }
  168. if (rows.Count > 0)
  169. await _db.Insertable(rows).ExecuteCommandAsync();
  170. // -- 同步 KpiMaster:如果 DisplayName 有值且与 MetricName 不同,更新 MetricName --
  171. foreach (var r in body.L1.Concat<object>(body.L2).Cast<dynamic>())
  172. {
  173. string? code = (string?)r.MetricCode?.Trim();
  174. string? dn = (string?)r.DisplayName?.Trim();
  175. if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(dn)) continue;
  176. if (!kpiByCode.TryGetValue(code, out var kpi)) continue;
  177. if (!string.Equals(kpi.MetricName, dn, StringComparison.Ordinal))
  178. {
  179. await _db.Updateable<AdoSmartOpsKpiMaster>()
  180. .SetColumns(x => new AdoSmartOpsKpiMaster { MetricName = dn, UpdatedAt = now })
  181. .Where(x => x.Id == kpi.Id)
  182. .ExecuteCommandAsync();
  183. }
  184. }
  185. var hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
  186. .SetColumns(x => new AdoSmartOpsHomeModule { UpdateTime = now })
  187. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4")
  188. .ExecuteCommandAsync();
  189. if (hmRows == 0)
  190. {
  191. await _db.Insertable(new AdoSmartOpsHomeModule
  192. {
  193. TenantId = tenantId, FactoryId = factoryId, ModuleCode = "S4",
  194. LayoutPattern = "card_grid", UpdateTime = now
  195. }).ExecuteCommandAsync();
  196. }
  197. _db.Ado.CommitTran();
  198. }
  199. catch (Exception ex)
  200. {
  201. _db.Ado.RollbackTran();
  202. return BadRequest(new { message = ex.Message });
  203. }
  204. return Ok(new { ok = true });
  205. }
  206. /// <summary>九宫格 S4:合并 L1 布局与日表。</summary>
  207. [HttpGet("s4-home-grid")]
  208. public async Task<IActionResult> GetS4HomeGrid([FromQuery] long factoryId = 1)
  209. {
  210. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  211. var layout = await _db.Queryable<AdoSmartOpsLayoutItem>()
  212. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4" && x.IsEnabled == 1 && x.MetricLevel == 1)
  213. .OrderBy(x => x.SortNo)
  214. .ToListAsync();
  215. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>().Where(x => x.TenantId == tenantId && x.ModuleCode == "S4").ToListAsync();
  216. var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  217. var bizDate = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l1_day", tenantId, factoryId);
  218. const string valSql = """
  219. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
  220. FROM ado_s9_kpi_value_l1_day
  221. WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND biz_date=@d
  222. """;
  223. var vals = await _db.Ado.SqlQueryAsync<S4ValRow>(valSql, new { t = tenantId, f = factoryId, d = bizDate });
  224. var valBy = vals.ToDictionary(x => x.MetricCode ?? "", StringComparer.OrdinalIgnoreCase);
  225. var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  226. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4")
  227. .ToListAsync()).FirstOrDefault();
  228. var items = new List<object>();
  229. foreach (var row in layout)
  230. {
  231. kpiBy.TryGetValue(row.MetricCode, out var k);
  232. valBy.TryGetValue(row.MetricCode, out var v);
  233. var cur = v?.MetricValue;
  234. var tgt = v?.TargetValue;
  235. var dir = k?.Direction ?? "higher_is_better";
  236. var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
  237. var gap = AidopS4KpiMerge.GapValue(cur, tgt);
  238. var arrow = AidopS4KpiMerge.GapArrow(gap);
  239. var label = FormatGapLabel(gap, k?.Unit);
  240. items.Add(new
  241. {
  242. rowId = row.RowId,
  243. metricCode = row.MetricCode,
  244. displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
  245. unit = k?.Unit ?? "",
  246. currentValue = cur,
  247. targetValue = tgt,
  248. gapValue = gap,
  249. gapLabel = label,
  250. gapArrow = arrow,
  251. achievementLevel = level,
  252. formulaText = row.FormulaText
  253. });
  254. }
  255. return Ok(new
  256. {
  257. layoutPattern = hm?.LayoutPattern ?? "card_grid",
  258. bizDate = bizDate.ToString("yyyy-MM-dd"),
  259. items
  260. });
  261. }
  262. /// <summary>S4 详情主 KPI:合并 L2/L3/L4 布局与日表,可按 panelZone 过滤。</summary>
  263. [HttpGet("s4-detail-kpis")]
  264. public async Task<IActionResult> GetS4DetailKpis([FromQuery] long factoryId = 1, [FromQuery] string? panelZone = null)
  265. {
  266. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  267. var q = _db.Queryable<AdoSmartOpsLayoutItem>()
  268. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == "S4" && x.IsEnabled == 1 && x.MetricLevel >= 2);
  269. if (!string.IsNullOrWhiteSpace(panelZone))
  270. q = q.Where(x => x.PanelZone == panelZone);
  271. var layout = await q.OrderBy(x => x.SortNo).ToListAsync();
  272. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>().Where(x => x.TenantId == tenantId && x.ModuleCode == "S4").ToListAsync();
  273. var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  274. var bizDateL2 = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l2_day", tenantId, factoryId);
  275. var bizDateL3 = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l3_day", tenantId, factoryId);
  276. AidopTenantMigration.EnsureKpiValueL4Table(_db);
  277. var bizDateL4 = await ResolveMaxBizDateAsync("ado_s9_kpi_value_l4_day", tenantId, factoryId);
  278. var bizDate = new[] { bizDateL2, bizDateL3, bizDateL4 }.Max();
  279. const string valSqlL2 = """
  280. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
  281. FROM ado_s9_kpi_value_l2_day
  282. WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND biz_date=@d
  283. """;
  284. const string valSqlL3 = """
  285. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
  286. FROM ado_s9_kpi_value_l3_day
  287. WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND biz_date=@d
  288. """;
  289. const string valSqlL4 = """
  290. SELECT metric_code AS MetricCode, metric_value AS MetricValue, target_value AS TargetValue
  291. FROM ado_s9_kpi_value_l4_day
  292. WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0 AND biz_date=@d
  293. """;
  294. var valsL2 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL2, new { t = tenantId, f = factoryId, d = bizDateL2 });
  295. var valsL3 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL3, new { t = tenantId, f = factoryId, d = bizDateL3 });
  296. var valsL4 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL4, new { t = tenantId, f = factoryId, d = bizDateL4 });
  297. var valBy = new Dictionary<string, S4ValRow>(StringComparer.OrdinalIgnoreCase);
  298. foreach (var r in valsL2)
  299. if (!string.IsNullOrEmpty(r.MetricCode))
  300. valBy[r.MetricCode] = r;
  301. foreach (var r in valsL3)
  302. if (!string.IsNullOrEmpty(r.MetricCode))
  303. valBy[r.MetricCode] = r;
  304. foreach (var r in valsL4)
  305. if (!string.IsNullOrEmpty(r.MetricCode))
  306. valBy[r.MetricCode] = r;
  307. var items = new List<object>();
  308. foreach (var row in layout)
  309. {
  310. kpiBy.TryGetValue(row.MetricCode, out var k);
  311. valBy.TryGetValue(row.MetricCode, out var v);
  312. var cur = v?.MetricValue;
  313. var tgt = v?.TargetValue;
  314. var dir = k?.Direction ?? "higher_is_better";
  315. var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
  316. var gap = AidopS4KpiMerge.GapValue(cur, tgt);
  317. var arrow = AidopS4KpiMerge.GapArrow(gap);
  318. items.Add(new
  319. {
  320. rowId = row.RowId,
  321. metricCode = row.MetricCode,
  322. displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
  323. unit = k?.Unit ?? "",
  324. currentValue = cur,
  325. targetValue = tgt,
  326. gapValue = gap,
  327. gapLabel = FormatGapLabel(gap, k?.Unit),
  328. gapArrow = arrow,
  329. achievementLevel = level,
  330. formulaText = row.FormulaText,
  331. parentRowId = row.ParentRowId,
  332. panelZone = row.PanelZone
  333. });
  334. }
  335. return Ok(new { bizDate = bizDate.ToString("yyyy-MM-dd"), items });
  336. }
  337. private async Task<DateTime> ResolveMaxBizDateAsync(string table, long tenantId, long factoryId)
  338. {
  339. var sql = $"""
  340. SELECT MAX(biz_date) AS MaxBiz FROM {table}
  341. WHERE tenant_id=@t AND factory_id=@f AND module_code='S4' AND is_deleted=0
  342. """;
  343. var rows = await _db.Ado.SqlQueryAsync<S4MaxDateRow>(sql, new { t = tenantId, f = factoryId });
  344. return rows.FirstOrDefault()?.MaxBiz ?? DateTime.Today;
  345. }
  346. private sealed class S4MaxDateRow
  347. {
  348. public DateTime? MaxBiz { get; set; }
  349. }
  350. private static string FormatGapLabel(decimal? gap, string? unit)
  351. {
  352. if (gap == null) return "—";
  353. var g = gap.Value;
  354. var sign = g >= 0 ? "+" : "";
  355. var s = $"{sign}{Math.Round(g, 2)}";
  356. if (unit == "%")
  357. return s + "%";
  358. return s;
  359. }
  360. private sealed class S4ValRow
  361. {
  362. public string? MetricCode { get; set; }
  363. public decimal? MetricValue { get; set; }
  364. public decimal? TargetValue { get; set; }
  365. }
  366. }