AidopKanbanController.Generic.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.Infrastructure;
  3. using Admin.NET.Plugin.AiDOP.SmartOps;
  4. using Microsoft.Extensions.Logging;
  5. using SqlSugar;
  6. namespace Admin.NET.Plugin.AiDOP.Controllers;
  7. public partial class AidopKanbanController
  8. {
  9. // S4.cs 中的 FormatGapLabel / ResolveMaxBizDateAsync 是 private,在同一 partial 类里可直接复用
  10. /// <summary>
  11. /// 通用运营布局(L1/L2/L3/L4)。moduleCode 指定模块;S4 也可走这个端点。
  12. /// </summary>
  13. [HttpGet("operation-layout/{moduleCode}")]
  14. public async Task<IActionResult> GetOperationLayoutGeneric(string moduleCode, [FromQuery] long factoryId = 1)
  15. {
  16. var mc = (moduleCode ?? "").ToUpperInvariant();
  17. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  18. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  19. var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  20. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  21. .ToListAsync()).FirstOrDefault();
  22. var items = await _db.Queryable<AdoSmartOpsLayoutItem>()
  23. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1)
  24. .OrderBy(x => x.SortNo)
  25. .ToListAsync();
  26. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
  27. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
  28. .ToListAsync();
  29. var kpiByCode = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  30. object Row(AdoSmartOpsLayoutItem r)
  31. {
  32. kpiByCode.TryGetValue(r.MetricCode, out var k);
  33. return new
  34. {
  35. rowId = r.RowId,
  36. metricCode = r.MetricCode,
  37. metricLevel = r.MetricLevel,
  38. displayName = string.IsNullOrWhiteSpace(r.DisplayName) ? k?.MetricName : r.DisplayName,
  39. sortNo = r.SortNo,
  40. parentRowId = r.ParentRowId,
  41. formulaText = r.FormulaText,
  42. panelZone = r.PanelZone,
  43. unit = k?.Unit,
  44. direction = k?.Direction
  45. };
  46. }
  47. return Ok(new
  48. {
  49. moduleCode = mc,
  50. layoutPattern = hm?.LayoutPattern ?? "card_grid",
  51. l1 = items.Where(x => x.MetricLevel == 1).Select(Row).ToList(),
  52. l2 = items.Where(x => x.MetricLevel >= 2).Select(Row).ToList()
  53. });
  54. }
  55. /// <summary>
  56. /// 通用九宫格/首页(L1 合并日表)。moduleCode 指定模块。
  57. /// </summary>
  58. [HttpGet("home-grid/{moduleCode}")]
  59. public async Task<IActionResult> GetHomeGridGeneric(
  60. string moduleCode,
  61. [FromQuery] long factoryId = 1,
  62. [FromQuery] string? dateStart = null,
  63. [FromQuery] string? dateEnd = null,
  64. [FromQuery] string? product = null,
  65. [FromQuery] string? orderNo = null,
  66. [FromQuery] string? productionLine = null)
  67. {
  68. var mc = (moduleCode ?? "").ToUpperInvariant();
  69. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  70. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  71. if (mc == "S8")
  72. await _s8KpiSnapshot.RefreshAsync(tenantId, factoryId);
  73. var filter = SmartOpsDashboardFilter.FromQuery(
  74. dateStart, dateEnd, product, orderNo, productionLine,
  75. null, null, null, null, null, null, null, null);
  76. var layout = await _db.Queryable<AdoSmartOpsLayoutItem>()
  77. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1 && x.MetricLevel == 1)
  78. .OrderBy(x => x.SortNo)
  79. .ToListAsync();
  80. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
  81. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
  82. .ToListAsync();
  83. var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  84. var bizDate = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l1_day", tenantId, factoryId, mc, filter.DateStart, filter.DateEnd);
  85. const string latestAttemptSql = """
  86. WITH ranked AS (
  87. SELECT v.*,
  88. ROW_NUMBER() OVER (
  89. PARTITION BY v.metric_code
  90. ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
  91. ) AS rn
  92. FROM ado_s9_kpi_value_l1_day v
  93. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
  94. AND v.is_deleted=0
  95. AND (@ds IS NULL OR v.biz_date>=@ds)
  96. AND (@de IS NULL OR v.biz_date<=@de)
  97. )
  98. SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
  99. target_value AS TargetValue, result_status AS ResultStatus,
  100. result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
  101. FROM ranked
  102. WHERE rn=1
  103. """;
  104. const string latestUsableSql = """
  105. WITH ranked AS (
  106. SELECT v.*,
  107. ROW_NUMBER() OVER (
  108. PARTITION BY v.metric_code
  109. ORDER BY v.biz_date DESC, v.calc_time DESC, v.id DESC
  110. ) AS rn
  111. FROM ado_s9_kpi_value_l1_day v
  112. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m
  113. AND v.is_deleted=0
  114. AND (@ds IS NULL OR v.biz_date>=@ds)
  115. AND (@de IS NULL OR v.biz_date<=@de)
  116. AND v.metric_value IS NOT NULL
  117. AND (v.result_status='OK' OR v.result_status IS NULL OR v.result_status='')
  118. )
  119. SELECT id AS Id, metric_code AS MetricCode, metric_value AS MetricValue,
  120. target_value AS TargetValue, result_status AS ResultStatus,
  121. result_reason AS ResultReason, biz_date AS BizDate, calc_time AS CalcTime
  122. FROM ranked
  123. WHERE rn=1
  124. """;
  125. var valueArgs = new
  126. {
  127. t = tenantId,
  128. f = factoryId,
  129. m = mc,
  130. ds = filter.DateStart,
  131. de = filter.DateEnd
  132. };
  133. var latestBy = IndexHomeGridValues(await _db.Ado.SqlQueryAsync<HomeGridValueRow>(latestAttemptSql, valueArgs));
  134. var usableBy = IndexHomeGridValues(await _db.Ado.SqlQueryAsync<HomeGridValueRow>(latestUsableSql, valueArgs));
  135. DashboardValuesBundle? filteredBundle = null;
  136. if (filter.HasBusinessDimension)
  137. filteredBundle = await ResolveDashboardValuesBundleAsync(tenantId, factoryId, mc, filter, kpi);
  138. var hm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  139. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  140. .ToListAsync()).FirstOrDefault();
  141. var items = new List<object>();
  142. foreach (var row in layout)
  143. {
  144. kpiBy.TryGetValue(row.MetricCode, out var k);
  145. HomeGridDisplayPolicy.Card card;
  146. if (filteredBundle != null)
  147. {
  148. // 业务维度旁路没有历史业务日。只返回本次筛选结果,禁止拿未筛选的最后真实值补上。
  149. filteredBundle.Values.TryGetValue(row.MetricCode, out var fv);
  150. var filteredStatus = fv?.MetricValue == null
  151. ? (string.IsNullOrWhiteSpace(fv?.ResultStatus) ? "NO_DATA" : fv.ResultStatus)
  152. : (string.IsNullOrWhiteSpace(fv.ResultStatus) ? "OK" : fv.ResultStatus);
  153. card = new HomeGridDisplayPolicy.Card
  154. {
  155. CurrentValue = fv?.MetricValue,
  156. TargetValue = fv?.TargetValue,
  157. ResultStatus = filteredStatus,
  158. ResultReason = fv?.ResultReason,
  159. DisplayState = fv?.MetricValue == null
  160. ? (string.Equals(fv?.ResultStatus, "SOURCE_UNSUPPORTED", StringComparison.OrdinalIgnoreCase)
  161. ? "SOURCE_UNSUPPORTED" : "NO_DATA")
  162. : "CURRENT_VALUE",
  163. FreshnessState = "NO_VALUE",
  164. FreshnessSlaDays = k?.FreshnessSlaDays,
  165. };
  166. }
  167. else
  168. {
  169. usableBy.TryGetValue(row.MetricCode, out var usable);
  170. latestBy.TryGetValue(row.MetricCode, out var latest);
  171. card = HomeGridDisplayPolicy.Decide(
  172. ToPolicyRow(usable), ToPolicyRow(latest), k?.FreshnessSlaDays, DateTime.Today);
  173. if (card.FutureBizDate)
  174. {
  175. _logger.LogWarning(
  176. "home grid future biz date tenant={TenantId} factory={FactoryId} metric={MetricCode} bizDate={BizDate}",
  177. tenantId, factoryId, row.MetricCode, card.BizDate?.ToString("yyyy-MM-dd"));
  178. }
  179. }
  180. var cur = card.CurrentValue;
  181. var tgt = card.TargetValue;
  182. var dir = k?.Direction ?? "higher_is_better";
  183. var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
  184. var gap = AidopS4KpiMerge.GapValue(cur, tgt);
  185. var arrow = AidopS4KpiMerge.GapArrow(gap);
  186. var label = FormatGapLabelGeneric(gap, k?.Unit);
  187. items.Add(new
  188. {
  189. rowId = row.RowId,
  190. metricCode = row.MetricCode,
  191. displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
  192. unit = k?.Unit ?? "",
  193. currentValue = cur,
  194. targetValue = tgt,
  195. resultStatus = card.ResultStatus,
  196. resultReason = card.ResultReason,
  197. bizDate = card.BizDate?.ToString("yyyy-MM-dd"),
  198. latestResultStatus = card.LatestResultStatus,
  199. latestResultReason = card.LatestResultReason,
  200. latestBizDate = card.LatestBizDate?.ToString("yyyy-MM-dd"),
  201. displayState = card.DisplayState,
  202. freshnessState = card.FreshnessState,
  203. valueAgeDays = card.ValueAgeDays,
  204. freshnessSlaDays = card.FreshnessSlaDays,
  205. gapValue = gap,
  206. gapLabel = label,
  207. gapArrow = arrow,
  208. achievementLevel = level,
  209. formulaText = row.FormulaText
  210. });
  211. }
  212. return Ok(new
  213. {
  214. moduleCode = mc,
  215. layoutPattern = hm?.LayoutPattern ?? "card_grid",
  216. // 模块内最新的一天,不是每张卡的业务日。卡片日期看 items[].bizDate。
  217. bizDate = bizDate.ToString("yyyy-MM-dd"),
  218. items
  219. });
  220. }
  221. /// <summary>
  222. /// 通用详情 KPI(L2/L3/L4 合并日表),可按 panelZone 过滤。
  223. /// </summary>
  224. [HttpGet("detail-kpis/{moduleCode}")]
  225. public async Task<IActionResult> GetDetailKpisGeneric(string moduleCode, [FromQuery] long factoryId = 1, [FromQuery] string? panelZone = null)
  226. {
  227. var mc = (moduleCode ?? "").ToUpperInvariant();
  228. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  229. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  230. if (mc == "S8")
  231. await _s8KpiSnapshot.RefreshAsync(tenantId, factoryId);
  232. var q = _db.Queryable<AdoSmartOpsLayoutItem>()
  233. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc && x.IsEnabled == 1 && x.MetricLevel >= 2);
  234. if (!string.IsNullOrWhiteSpace(panelZone))
  235. q = q.Where(x => x.PanelZone == panelZone);
  236. var layout = await q.OrderBy(x => x.SortNo).ToListAsync();
  237. var kpi = await _db.Queryable<AdoSmartOpsKpiMaster>()
  238. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc)
  239. .ToListAsync();
  240. var kpiBy = kpi.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  241. var bizDateL2 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l2_day", tenantId, factoryId, mc);
  242. var bizDateL3 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l3_day", tenantId, factoryId, mc);
  243. AidopTenantMigration.EnsureKpiValueL4Table(_db);
  244. var bizDateL4 = await ResolveMaxBizDateGenericAsync("ado_s9_kpi_value_l4_day", tenantId, factoryId, mc);
  245. var bizDate = new[] { bizDateL2, bizDateL3, bizDateL4 }.Max();
  246. const string valSqlL2 = """
  247. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
  248. v.result_status AS ResultStatus, v.result_reason AS ResultReason
  249. FROM ado_s9_kpi_value_l2_day v
  250. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  251. AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l2_day v2
  252. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  253. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0)
  254. """;
  255. const string valSqlL3 = """
  256. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
  257. v.result_status AS ResultStatus, v.result_reason AS ResultReason
  258. FROM ado_s9_kpi_value_l3_day v
  259. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  260. AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l3_day v2
  261. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  262. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0)
  263. """;
  264. const string valSqlL4 = """
  265. SELECT v.metric_code AS MetricCode, v.metric_value AS MetricValue, v.target_value AS TargetValue,
  266. v.result_status AS ResultStatus, v.result_reason AS ResultReason
  267. FROM ado_s9_kpi_value_l4_day v
  268. WHERE v.tenant_id=@t AND v.factory_id=@f AND v.module_code=@m AND v.is_deleted=0
  269. AND v.biz_date=(SELECT MAX(v2.biz_date) FROM ado_s9_kpi_value_l4_day v2
  270. WHERE v2.tenant_id=v.tenant_id AND v2.factory_id=v.factory_id
  271. AND v2.module_code=v.module_code AND v2.metric_code=v.metric_code AND v2.is_deleted=0)
  272. """;
  273. var valsL2 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL2, new { t = tenantId, f = factoryId, m = mc });
  274. var valsL3 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL3, new { t = tenantId, f = factoryId, m = mc });
  275. var valsL4 = await _db.Ado.SqlQueryAsync<S4ValRow>(valSqlL4, new { t = tenantId, f = factoryId, m = mc });
  276. var valBy = new Dictionary<string, S4ValRow>(StringComparer.OrdinalIgnoreCase);
  277. foreach (var r in valsL2)
  278. if (!string.IsNullOrEmpty(r.MetricCode))
  279. valBy[r.MetricCode] = r;
  280. foreach (var r in valsL3)
  281. if (!string.IsNullOrEmpty(r.MetricCode))
  282. valBy[r.MetricCode] = r;
  283. foreach (var r in valsL4)
  284. if (!string.IsNullOrEmpty(r.MetricCode))
  285. valBy[r.MetricCode] = r;
  286. var items = new List<object>();
  287. foreach (var row in layout)
  288. {
  289. kpiBy.TryGetValue(row.MetricCode, out var k);
  290. valBy.TryGetValue(row.MetricCode, out var v);
  291. var cur = v?.MetricValue;
  292. var tgt = v?.TargetValue;
  293. var dir = k?.Direction ?? "higher_is_better";
  294. var level = AidopS4KpiMerge.AchievementLevel(cur, tgt, dir, k?.YellowThreshold, k?.RedThreshold);
  295. var gap = AidopS4KpiMerge.GapValue(cur, tgt);
  296. var arrow = AidopS4KpiMerge.GapArrow(gap);
  297. items.Add(new
  298. {
  299. rowId = row.RowId,
  300. metricCode = row.MetricCode,
  301. displayName = string.IsNullOrWhiteSpace(row.DisplayName) ? k?.MetricName : row.DisplayName,
  302. unit = k?.Unit ?? "",
  303. currentValue = cur,
  304. targetValue = tgt,
  305. resultStatus = v?.ResultStatus,
  306. resultReason = v?.ResultReason,
  307. gapValue = gap,
  308. gapLabel = FormatGapLabelGeneric(gap, k?.Unit),
  309. gapArrow = arrow,
  310. achievementLevel = level,
  311. formulaText = row.FormulaText,
  312. parentRowId = row.ParentRowId,
  313. panelZone = row.PanelZone
  314. });
  315. }
  316. return Ok(new { moduleCode = mc, bizDate = bizDate.ToString("yyyy-MM-dd"), items });
  317. }
  318. private async Task<DateTime> ResolveMaxBizDateGenericAsync(
  319. string table, long tenantId, long factoryId, string moduleCode,
  320. DateTime? dateStart = null, DateTime? dateEnd = null)
  321. {
  322. var sql = $"""
  323. SELECT MAX(biz_date) AS MaxBiz FROM {table}
  324. WHERE tenant_id=@t AND module_code=@m AND is_deleted=0
  325. AND (factory_id IS NULL OR factory_id=0 OR factory_id=1 OR factory_id=@f)
  326. AND (factory_id IS NULL OR factory_id<>@t)
  327. AND (@ds IS NULL OR biz_date >= @ds)
  328. AND (@de IS NULL OR biz_date <= @de)
  329. """;
  330. var rows = await _db.Ado.SqlQueryAsync<GenericMaxDateRow>(sql, new
  331. {
  332. t = tenantId,
  333. f = factoryId,
  334. m = moduleCode,
  335. ds = dateStart,
  336. de = dateEnd
  337. });
  338. return rows.FirstOrDefault()?.MaxBiz ?? DateTime.Today;
  339. }
  340. private static string FormatGapLabelGeneric(decimal? gap, string? unit)
  341. {
  342. if (gap == null) return "—";
  343. var g = gap.Value;
  344. var sign = g >= 0 ? "+" : "";
  345. var s = $"{sign}{Math.Round(g, 2)}";
  346. if (unit == "%")
  347. return s + "%";
  348. return s;
  349. }
  350. private sealed class GenericMaxDateRow
  351. {
  352. public DateTime? MaxBiz { get; set; }
  353. }
  354. /// <summary>
  355. /// 通用指标字典(按模块)。与 s4-metric-catalog 等价,moduleCode 必填。
  356. /// </summary>
  357. [HttpGet("metric-catalog/{moduleCode}")]
  358. public async Task<IActionResult> GetMetricCatalogGeneric(string moduleCode, [FromQuery] int? metricLevel = null)
  359. {
  360. var mc = (moduleCode ?? "").ToUpperInvariant();
  361. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  362. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  363. var q = _db.Queryable<AdoSmartOpsKpiMaster>()
  364. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled);
  365. if (metricLevel is > 0)
  366. q = q.Where(x => x.MetricLevel == metricLevel);
  367. var list = await q.OrderBy(x => x.SortNo).ToListAsync();
  368. return Ok(list.Select(x => new
  369. {
  370. id = x.Id,
  371. metricCode = x.MetricCode,
  372. moduleCode = x.ModuleCode,
  373. metricLevel = x.MetricLevel,
  374. defaultName = x.MetricName,
  375. unit = x.Unit,
  376. direction = x.Direction,
  377. sortHint = x.SortNo,
  378. parentId = x.ParentId,
  379. description = x.Description,
  380. formula = x.Formula,
  381. formulaExpr = x.FormulaExpr,
  382. formulaPreview = x.FormulaPreview,
  383. calcRule = x.CalcRule,
  384. dataSource = x.DataSource,
  385. statFrequency = x.StatFrequency,
  386. department = x.Department
  387. }));
  388. }
  389. public class GenericLayoutSaveDto
  390. {
  391. public List<GenericLayoutL1Dto>? L1 { get; set; }
  392. public List<GenericLayoutL2Dto>? L2 { get; set; }
  393. /// <summary>
  394. /// 九宫格首页模块卡呈现样式(card_grid | table_list | 未来新增)。
  395. /// 可选:未传或空值时保持 HomeModule 当前值;首次无记录时回落默认 card_grid。
  396. /// </summary>
  397. public string? LayoutPattern { get; set; }
  398. }
  399. /// <summary>
  400. /// 允许的 LayoutPattern 白名单。新增样式时在此增加条目(与前端 homeCardStyles.ts 注册表保持一致)。
  401. /// </summary>
  402. private static readonly HashSet<string> AllowedLayoutPatterns = new(StringComparer.OrdinalIgnoreCase)
  403. {
  404. "card_grid",
  405. "table_list",
  406. "tile_cards",
  407. };
  408. public class GenericLayoutL1Dto
  409. {
  410. public string? RowId { get; set; }
  411. public string? MetricCode { get; set; }
  412. public string? DisplayName { get; set; }
  413. public int SortNo { get; set; }
  414. public string? FormulaText { get; set; }
  415. }
  416. public class GenericLayoutL2Dto
  417. {
  418. public string? RowId { get; set; }
  419. public string? MetricCode { get; set; }
  420. public string? DisplayName { get; set; }
  421. public int SortNo { get; set; }
  422. public string? ParentRowId { get; set; }
  423. public string? FormulaText { get; set; }
  424. public string? PanelZone { get; set; }
  425. }
  426. /// <summary>
  427. /// 通用保存运营布局:按 moduleCode 保存 L1/L2 到 LayoutItem;同步 KpiMaster.DisplayName。
  428. /// 约束:L1 最多 MaxL1PerModule;必须命中该 moduleCode 的字典项。
  429. /// </summary>
  430. [HttpPut("operation-layout/{moduleCode}")]
  431. public async Task<IActionResult> PutOperationLayoutGeneric(string moduleCode, [FromBody] GenericLayoutSaveDto? body, [FromQuery] long factoryId = 1)
  432. {
  433. var mc = (moduleCode ?? "").ToUpperInvariant();
  434. if (string.IsNullOrWhiteSpace(mc)) return BadRequest(new { message = "moduleCode 必填" });
  435. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  436. if (body?.L1 == null || body.L2 == null)
  437. return BadRequest(new { message = "L1/L2 不能为空" });
  438. if (body.L1.Count > MaxL1PerModule)
  439. return BadRequest(new { message = $"L1 最多 {MaxL1PerModule} 个" });
  440. var kpiAll = await _db.Queryable<AdoSmartOpsKpiMaster>()
  441. .Where(x => x.TenantId == tenantId && x.ModuleCode == mc && x.IsEnabled)
  442. .ToListAsync();
  443. var kpiByCode = kpiAll.ToDictionary(x => x.MetricCode, StringComparer.OrdinalIgnoreCase);
  444. foreach (var r in body.L1)
  445. {
  446. if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel != 1)
  447. return BadRequest(new { message = $"无效的 L1 指标: {r.MetricCode}" });
  448. }
  449. var l1Ids = new HashSet<string>(StringComparer.Ordinal);
  450. foreach (var r in body.L1)
  451. {
  452. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{mc}-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
  453. if (!l1Ids.Add(rid))
  454. return BadRequest(new { message = $"重复的 L1 RowId: {rid}" });
  455. }
  456. foreach (var r in body.L2)
  457. {
  458. if (string.IsNullOrWhiteSpace(r.MetricCode) || !kpiByCode.TryGetValue(r.MetricCode.Trim(), out var k) || k.MetricLevel < 2)
  459. return BadRequest(new { message = $"无效的 L2/L3/L4 指标: {r.MetricCode}" });
  460. }
  461. var now = DateTime.Now;
  462. try
  463. {
  464. _db.Ado.BeginTran();
  465. await _db.Deleteable<AdoSmartOpsLayoutItem>()
  466. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  467. .ExecuteCommandAsync();
  468. var rows = new List<AdoSmartOpsLayoutItem>();
  469. foreach (var r in body.L1)
  470. {
  471. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{mc}-L1-{r.MetricCode!.Trim()}" : r.RowId.Trim();
  472. rows.Add(new AdoSmartOpsLayoutItem
  473. {
  474. TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
  475. RowId = rid, MetricLevel = 1, MetricCode = r.MetricCode!.Trim(),
  476. DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
  477. SortNo = r.SortNo, ParentRowId = null,
  478. FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText.Trim(),
  479. PanelZone = null, IsEnabled = 1, UpdateTime = now
  480. });
  481. }
  482. foreach (var r in body.L2)
  483. {
  484. var code = r.MetricCode!.Trim();
  485. var level = kpiByCode.TryGetValue(code, out var kk) ? kk.MetricLevel : 2;
  486. var prefix = $"{mc}-L{level}-";
  487. var rid = string.IsNullOrWhiteSpace(r.RowId) ? $"{prefix}{code}" : r.RowId.Trim();
  488. var p = string.IsNullOrWhiteSpace(r.ParentRowId) ? null : r.ParentRowId.Trim();
  489. rows.Add(new AdoSmartOpsLayoutItem
  490. {
  491. TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
  492. RowId = rid, MetricLevel = level, MetricCode = code,
  493. DisplayName = string.IsNullOrWhiteSpace(r.DisplayName) ? null : r.DisplayName.Trim(),
  494. SortNo = r.SortNo, ParentRowId = p,
  495. FormulaText = string.IsNullOrWhiteSpace(r.FormulaText) ? null : r.FormulaText,
  496. PanelZone = string.IsNullOrWhiteSpace(r.PanelZone) ? null : r.PanelZone.Trim(),
  497. IsEnabled = 1, UpdateTime = now
  498. });
  499. }
  500. if (rows.Count > 0)
  501. await _db.Insertable(rows).ExecuteCommandAsync();
  502. foreach (var r in body.L1.Concat<object>(body.L2).Cast<dynamic>())
  503. {
  504. string? code = (string?)r.MetricCode?.Trim();
  505. string? dn = (string?)r.DisplayName?.Trim();
  506. if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(dn)) continue;
  507. if (!kpiByCode.TryGetValue(code, out var kpi)) continue;
  508. if (!string.Equals(kpi.MetricName, dn, StringComparison.Ordinal))
  509. {
  510. await _db.Updateable<AdoSmartOpsKpiMaster>()
  511. .SetColumns(x => new AdoSmartOpsKpiMaster { MetricName = dn, UpdatedAt = now })
  512. .Where(x => x.Id == kpi.Id)
  513. .ExecuteCommandAsync();
  514. }
  515. }
  516. var lp = (body.LayoutPattern ?? "").Trim();
  517. if (!string.IsNullOrEmpty(lp) && !AllowedLayoutPatterns.Contains(lp))
  518. return BadRequest(new { message = $"无效的 LayoutPattern: {body.LayoutPattern}" });
  519. var lpNormalized = string.IsNullOrEmpty(lp) ? null : lp.ToLowerInvariant();
  520. int hmRows;
  521. if (lpNormalized != null)
  522. {
  523. hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
  524. .SetColumns(x => new AdoSmartOpsHomeModule { LayoutPattern = lpNormalized, UpdateTime = now })
  525. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  526. .ExecuteCommandAsync();
  527. }
  528. else
  529. {
  530. hmRows = await _db.Updateable<AdoSmartOpsHomeModule>()
  531. .SetColumns(x => new AdoSmartOpsHomeModule { UpdateTime = now })
  532. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.ModuleCode == mc)
  533. .ExecuteCommandAsync();
  534. }
  535. if (hmRows == 0)
  536. {
  537. await _db.Insertable(new AdoSmartOpsHomeModule
  538. {
  539. TenantId = tenantId, FactoryId = factoryId, ModuleCode = mc,
  540. LayoutPattern = lpNormalized ?? "card_grid", UpdateTime = now
  541. }).ExecuteCommandAsync();
  542. }
  543. _db.Ado.CommitTran();
  544. }
  545. catch (Exception ex)
  546. {
  547. _db.Ado.RollbackTran();
  548. return BadRequest(new { message = ex.Message });
  549. }
  550. return Ok(new { ok = true });
  551. }
  552. private sealed class HomeGridValueRow
  553. {
  554. public long Id { get; set; }
  555. public string? MetricCode { get; set; }
  556. public decimal? MetricValue { get; set; }
  557. public decimal? TargetValue { get; set; }
  558. public string? ResultStatus { get; set; }
  559. public string? ResultReason { get; set; }
  560. public DateTime BizDate { get; set; }
  561. public DateTime? CalcTime { get; set; }
  562. }
  563. private static Dictionary<string, HomeGridValueRow> IndexHomeGridValues(List<HomeGridValueRow> rows)
  564. {
  565. var map = new Dictionary<string, HomeGridValueRow>(StringComparer.OrdinalIgnoreCase);
  566. foreach (var row in rows ?? [])
  567. {
  568. var code = row.MetricCode ?? "";
  569. if (!map.TryAdd(code, row))
  570. throw new InvalidOperationException($"home grid duplicated metric {code}");
  571. }
  572. return map;
  573. }
  574. private static HomeGridDisplayPolicy.Row? ToPolicyRow(HomeGridValueRow? row) =>
  575. row == null ? null : new HomeGridDisplayPolicy.Row
  576. {
  577. Id = row.Id,
  578. MetricValue = row.MetricValue,
  579. TargetValue = row.TargetValue,
  580. ResultStatus = row.ResultStatus,
  581. ResultReason = row.ResultReason,
  582. BizDate = row.BizDate,
  583. };
  584. }