AdoSmartOpsKpiMasterController.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. using Admin.NET.Core;
  2. using Admin.NET.Plugin.AiDOP.Dto.SmartOps;
  3. using Admin.NET.Plugin.AiDOP.Entity;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure;
  5. using Microsoft.AspNetCore.Http;
  6. using MiniExcelLibs;
  7. using SqlSugar;
  8. namespace Admin.NET.Plugin.AiDOP.Controllers;
  9. [ApiController]
  10. [Route("api/[controller]")]
  11. [AllowAnonymous]
  12. [NonUnify]
  13. public class AdoSmartOpsKpiMasterController : ControllerBase
  14. {
  15. private readonly ISqlSugarClient _db;
  16. public AdoSmartOpsKpiMasterController(ISqlSugarClient db)
  17. {
  18. _db = db;
  19. }
  20. /// <summary>获取树形结构(左侧树用)</summary>
  21. [HttpGet("tree")]
  22. public async Task<IActionResult> GetTree([FromQuery] string? moduleCode = null, [FromQuery] string? keyword = null)
  23. {
  24. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  25. var q = _db.Queryable<AdoSmartOpsKpiMaster>().Where(x => x.TenantId == tenantId);
  26. if (!string.IsNullOrWhiteSpace(moduleCode))
  27. q = q.Where(x => x.ModuleCode == moduleCode);
  28. if (!string.IsNullOrWhiteSpace(keyword))
  29. q = q.Where(x => x.MetricName.Contains(keyword) || x.MetricCode.Contains(keyword));
  30. var all = await q.OrderBy(x => x.SortNo).ToListAsync();
  31. var nodes = all.Select(x => new KpiMasterTreeNodeDto
  32. {
  33. Id = x.Id,
  34. MetricCode = x.MetricCode,
  35. ModuleCode = x.ModuleCode,
  36. MetricLevel = x.MetricLevel,
  37. MetricName = x.MetricName,
  38. ParentId = x.ParentId,
  39. SortNo = x.SortNo,
  40. IsEnabled = x.IsEnabled
  41. }).ToList();
  42. if (!string.IsNullOrWhiteSpace(keyword))
  43. {
  44. var matchIds = new HashSet<long>(nodes.Select(n => n.Id));
  45. var allForAncestor = await _db.Queryable<AdoSmartOpsKpiMaster>()
  46. .Where(x => x.TenantId == tenantId)
  47. .WhereIF(!string.IsNullOrWhiteSpace(moduleCode), x => x.ModuleCode == moduleCode)
  48. .ToListAsync();
  49. var byId = allForAncestor.ToDictionary(x => x.Id);
  50. var needed = new HashSet<long>(matchIds);
  51. foreach (var id in matchIds)
  52. {
  53. var cur = byId.GetValueOrDefault(id);
  54. while (cur?.ParentId != null && needed.Add(cur.ParentId.Value))
  55. cur = byId.GetValueOrDefault(cur.ParentId.Value);
  56. }
  57. var extra = allForAncestor.Where(x => needed.Contains(x.Id) && !matchIds.Contains(x.Id));
  58. foreach (var x in extra)
  59. nodes.Add(new KpiMasterTreeNodeDto
  60. {
  61. Id = x.Id, MetricCode = x.MetricCode, ModuleCode = x.ModuleCode,
  62. MetricLevel = x.MetricLevel, MetricName = x.MetricName,
  63. ParentId = x.ParentId, SortNo = x.SortNo, IsEnabled = x.IsEnabled
  64. });
  65. }
  66. var tree = BuildTree(nodes);
  67. return Ok(tree);
  68. }
  69. /// <summary>获取单条详情(右侧面板用)</summary>
  70. [HttpGet("{id:long}")]
  71. public async Task<IActionResult> GetDetail(long id)
  72. {
  73. var e = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  74. if (e == null) return NotFound();
  75. string? parentName = null;
  76. if (e.ParentId.HasValue)
  77. {
  78. var p = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == e.ParentId.Value);
  79. parentName = p?.MetricName;
  80. }
  81. return Ok(new KpiMasterDetailDto
  82. {
  83. Id = e.Id, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode,
  84. MetricLevel = e.MetricLevel, ParentId = e.ParentId, ParentName = parentName,
  85. MetricName = e.MetricName, Description = e.Description, Formula = e.Formula,
  86. CalcRule = e.CalcRule, DataSource = e.DataSource, StatFrequency = e.StatFrequency,
  87. Department = e.Department, DopFields = e.DopFields, Unit = e.Unit,
  88. Direction = e.Direction,
  89. YellowThreshold = e.YellowThreshold, RedThreshold = e.RedThreshold,
  90. IsHomePage = e.IsHomePage, SortNo = e.SortNo,
  91. Remark = e.Remark, IsEnabled = e.IsEnabled, TenantId = e.TenantId.GetValueOrDefault(),
  92. CreatedAt = e.CreatedAt, UpdatedAt = e.UpdatedAt
  93. });
  94. }
  95. /// <summary>新增指标(MetricCode 自动生成)</summary>
  96. [HttpPost]
  97. public async Task<IActionResult> Create([FromBody] KpiMasterUpsertDto dto)
  98. {
  99. if (string.IsNullOrWhiteSpace(dto.MetricName))
  100. return BadRequest(new { message = "指标名称不能为空" });
  101. if (dto.MetricLevel < 1 || dto.MetricLevel > 3)
  102. return BadRequest(new { message = "层级必须为 1/2/3" });
  103. if (dto.MetricLevel > 1 && dto.ParentId == null)
  104. return BadRequest(new { message = "L2/L3 必须指定父指标" });
  105. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  106. var code = await GenerateMetricCodeAsync(dto.ModuleCode, dto.MetricLevel, tenantId);
  107. var entity = new AdoSmartOpsKpiMaster
  108. {
  109. MetricCode = code,
  110. ModuleCode = dto.ModuleCode,
  111. MetricLevel = dto.MetricLevel,
  112. ParentId = dto.ParentId,
  113. MetricName = dto.MetricName,
  114. Description = dto.Description,
  115. Formula = dto.Formula,
  116. CalcRule = dto.CalcRule,
  117. DataSource = dto.DataSource,
  118. StatFrequency = dto.StatFrequency,
  119. Department = dto.Department,
  120. DopFields = dto.DopFields,
  121. Unit = dto.Unit,
  122. Direction = dto.Direction,
  123. YellowThreshold = dto.YellowThreshold,
  124. RedThreshold = dto.RedThreshold,
  125. IsHomePage = dto.IsHomePage,
  126. SortNo = dto.SortNo,
  127. Remark = dto.Remark,
  128. IsEnabled = dto.IsEnabled,
  129. TenantId = tenantId,
  130. CreatedAt = DateTime.Now
  131. };
  132. var newId = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync();
  133. entity.Id = newId;
  134. return Ok(new { id = newId, metricCode = code });
  135. }
  136. /// <summary>编辑指标</summary>
  137. [HttpPut("{id:long}")]
  138. public async Task<IActionResult> Update(long id, [FromBody] KpiMasterUpsertDto dto)
  139. {
  140. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  141. if (entity == null) return NotFound();
  142. entity.ModuleCode = dto.ModuleCode;
  143. entity.MetricLevel = dto.MetricLevel;
  144. entity.ParentId = dto.ParentId;
  145. entity.MetricName = dto.MetricName;
  146. entity.Description = dto.Description;
  147. entity.Formula = dto.Formula;
  148. entity.CalcRule = dto.CalcRule;
  149. entity.DataSource = dto.DataSource;
  150. entity.StatFrequency = dto.StatFrequency;
  151. entity.Department = dto.Department;
  152. entity.DopFields = dto.DopFields;
  153. entity.Unit = dto.Unit;
  154. entity.Direction = dto.Direction;
  155. entity.YellowThreshold = dto.YellowThreshold;
  156. entity.RedThreshold = dto.RedThreshold;
  157. entity.IsHomePage = dto.IsHomePage;
  158. entity.SortNo = dto.SortNo;
  159. entity.Remark = dto.Remark;
  160. entity.IsEnabled = dto.IsEnabled;
  161. entity.UpdatedAt = DateTime.Now;
  162. await _db.Updateable(entity).ExecuteCommandAsync();
  163. return Ok(new { ok = true });
  164. }
  165. /// <summary>删除指标(含子节点级联删除)</summary>
  166. [HttpDelete("{id:long}")]
  167. public async Task<IActionResult> Delete(long id)
  168. {
  169. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  170. if (entity == null) return NotFound();
  171. var idsToDelete = new List<long> { id };
  172. await CollectChildIds(id, idsToDelete);
  173. await _db.Deleteable<AdoSmartOpsKpiMaster>().Where(x => idsToDelete.Contains(x.Id)).ExecuteCommandAsync();
  174. return Ok(new { ok = true, deletedCount = idsToDelete.Count });
  175. }
  176. /// <summary>启用/禁用</summary>
  177. [HttpPatch("{id:long}/toggle-enabled")]
  178. public async Task<IActionResult> ToggleEnabled(long id)
  179. {
  180. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  181. if (entity == null) return NotFound();
  182. entity.IsEnabled = !entity.IsEnabled;
  183. entity.UpdatedAt = DateTime.Now;
  184. await _db.Updateable(entity).UpdateColumns(x => new { x.IsEnabled, x.UpdatedAt }).ExecuteCommandAsync();
  185. return Ok(new { ok = true, isEnabled = entity.IsEnabled });
  186. }
  187. /// <summary>批量更新排序</summary>
  188. [HttpPut("sort")]
  189. public async Task<IActionResult> UpdateSort([FromBody] List<KpiMasterSortItemDto> items)
  190. {
  191. if (items == null || items.Count == 0)
  192. return BadRequest(new { message = "排序列表不能为空" });
  193. foreach (var item in items)
  194. {
  195. await _db.Updateable<AdoSmartOpsKpiMaster>()
  196. .SetColumns(x => new AdoSmartOpsKpiMaster { SortNo = item.SortNo, UpdatedAt = DateTime.Now })
  197. .Where(x => x.Id == item.Id)
  198. .ExecuteCommandAsync();
  199. }
  200. return Ok(new { ok = true });
  201. }
  202. /// <summary>拖拽移动(变更 ParentId + SortNo)</summary>
  203. [HttpPut("{id:long}/move")]
  204. public async Task<IActionResult> Move(long id, [FromBody] KpiMasterMoveDto dto)
  205. {
  206. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  207. if (entity == null) return NotFound();
  208. if (dto.NewParentId.HasValue)
  209. {
  210. var parent = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == dto.NewParentId.Value);
  211. if (parent == null) return BadRequest(new { message = "目标父节点不存在" });
  212. if (parent.MetricLevel >= entity.MetricLevel)
  213. return BadRequest(new { message = "不能移动到同级或更低层级下" });
  214. entity.ParentId = dto.NewParentId;
  215. entity.MetricLevel = parent.MetricLevel + 1;
  216. }
  217. else
  218. {
  219. entity.ParentId = null;
  220. entity.MetricLevel = 1;
  221. }
  222. entity.SortNo = dto.NewSortNo;
  223. entity.UpdatedAt = DateTime.Now;
  224. await _db.Updateable(entity).ExecuteCommandAsync();
  225. return Ok(new { ok = true });
  226. }
  227. /// <summary>导入 Demo 数据(上传 Excel,解析后生成 30 天日值写入 DB)</summary>
  228. [HttpPost("import-demo-data")]
  229. public async Task<IActionResult> ImportDemoData(IFormFile file)
  230. {
  231. if (file == null || file.Length == 0)
  232. return BadRequest(new { message = "请上传 Excel 文件" });
  233. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  234. const int DAYS = 30;
  235. var today = DateTime.Today;
  236. var startDate = today.AddDays(-(DAYS - 1));
  237. using var stream = new MemoryStream();
  238. await file.CopyToAsync(stream);
  239. stream.Position = 0;
  240. var rows = stream.Query(useHeaderRow: true).ToList();
  241. var masters = await _db.Queryable<AdoSmartOpsKpiMaster>()
  242. .Where(x => x.TenantId == tenantId)
  243. .ToListAsync();
  244. var masterByCode = masters.ToDictionary(m => m.MetricCode, m => m);
  245. var excelOverrides = new Dictionary<string, DemoImportRow>();
  246. foreach (IDictionary<string, object> row in rows)
  247. {
  248. object? Val(string key) => row.TryGetValue(key, out var v) ? v : null;
  249. var code = Val("指标编码")?.ToString()?.Trim();
  250. if (string.IsNullOrEmpty(code) || !code.StartsWith("S")) continue;
  251. if (!masterByCode.TryGetValue(code, out var master)) continue;
  252. decimal? target = TryDecimal(Val("目标值 ★"));
  253. decimal? actual = TryDecimal(Val("实际值 ★"));
  254. decimal? yellowTh = TryDecimal(Val("黄色阈值% ★"));
  255. decimal? redTh = TryDecimal(Val("红色阈值% ★"));
  256. excelOverrides[code] = new DemoImportRow
  257. {
  258. MetricCode = code,
  259. Master = master,
  260. Target = target ?? 0,
  261. Actual = actual ?? 0,
  262. HasTarget = target != null,
  263. HasActual = actual != null,
  264. YellowThreshold = yellowTh,
  265. RedThreshold = redTh,
  266. };
  267. }
  268. var rng = new Random(42);
  269. var parsed = new List<DemoImportRow>();
  270. foreach (var m in masters)
  271. {
  272. if (excelOverrides.TryGetValue(m.MetricCode, out var ov))
  273. {
  274. var tgt = ov.HasTarget ? ov.Target : ov.HasActual ? ov.Actual * 1.05m : AutoTarget(m, rng);
  275. var act = ov.HasActual ? ov.Actual : ov.HasTarget ? ov.Target * AutoActualFactor(m, rng) : AutoActual(AutoTarget(m, rng), m, rng);
  276. parsed.Add(new DemoImportRow
  277. {
  278. MetricCode = m.MetricCode, Master = m,
  279. Target = tgt, Actual = act,
  280. HasTarget = true, HasActual = true,
  281. YellowThreshold = ov.YellowThreshold, RedThreshold = ov.RedThreshold,
  282. });
  283. }
  284. else
  285. {
  286. var tgt = AutoTarget(m, rng);
  287. var act = AutoActual(tgt, m, rng);
  288. parsed.Add(new DemoImportRow
  289. {
  290. MetricCode = m.MetricCode, Master = m,
  291. Target = tgt, Actual = act,
  292. HasTarget = true, HasActual = true,
  293. YellowThreshold = null, RedThreshold = null,
  294. });
  295. }
  296. }
  297. foreach (var p in parsed)
  298. {
  299. if (p.YellowThreshold != null || p.RedThreshold != null)
  300. {
  301. var m = p.Master;
  302. if (p.YellowThreshold != null) m.YellowThreshold = p.YellowThreshold;
  303. if (p.RedThreshold != null) m.RedThreshold = p.RedThreshold;
  304. m.UpdatedAt = DateTime.Now;
  305. await _db.Updateable(m)
  306. .UpdateColumns(x => new { x.YellowThreshold, x.RedThreshold, x.UpdatedAt })
  307. .ExecuteCommandAsync();
  308. }
  309. }
  310. var l1 = parsed.Where(p => p.Master.MetricLevel == 1).ToList();
  311. var l2 = parsed.Where(p => p.Master.MetricLevel == 2).ToList();
  312. var l3 = parsed.Where(p => p.Master.MetricLevel == 3).ToList();
  313. await _db.Ado.ExecuteCommandAsync(
  314. $"DELETE FROM ado_s9_kpi_value_l1_day WHERE tenant_id = {tenantId}");
  315. await _db.Ado.ExecuteCommandAsync(
  316. $"DELETE FROM ado_s9_kpi_value_l2_day WHERE tenant_id = {tenantId}");
  317. await _db.Ado.ExecuteCommandAsync(
  318. $"DELETE FROM ado_s9_kpi_value_l3_day WHERE tenant_id = {tenantId}");
  319. long seq = 0;
  320. async Task InsertLevel(List<DemoImportRow> items, string table)
  321. {
  322. if (items.Count == 0) return;
  323. var levelRng = new Random(42);
  324. var batchValues = new List<string>();
  325. foreach (var p in items)
  326. {
  327. var dir = p.Master.Direction ?? "higher_is_better";
  328. var yTh = p.YellowThreshold ?? p.Master.YellowThreshold;
  329. var rTh = p.RedThreshold ?? p.Master.RedThreshold;
  330. decimal prevVal = p.Actual;
  331. for (int i = 0; i < DAYS; i++)
  332. {
  333. seq++;
  334. var d = startDate.AddDays(i);
  335. decimal val;
  336. if (i == DAYS - 1)
  337. val = p.Actual;
  338. else
  339. {
  340. var noise = (decimal)(levelRng.NextDouble() * 0.06 - 0.03);
  341. val = Math.Max(0, p.Actual * (1 + noise));
  342. }
  343. val = Math.Round(val, 4);
  344. var sc = AidopS4KpiMerge.AchievementLevel(val, p.Target, dir, yTh, rTh);
  345. var tf = "flat";
  346. if (i > 0)
  347. {
  348. var change = prevVal == 0 ? 0 : (val - prevVal) / prevVal;
  349. tf = change > 0.02m ? "up" : change < -0.02m ? "down" : "flat";
  350. }
  351. prevVal = val;
  352. batchValues.Add(
  353. $"({seq},{tenantId},1,'{d:yyyy-MM-dd}'," +
  354. $"'{p.Master.ModuleCode}','{p.MetricCode}',{val},{p.Target},'{sc}','{tf}',0,1)");
  355. }
  356. }
  357. const int BATCH = 500;
  358. for (int i = 0; i < batchValues.Count; i += BATCH)
  359. {
  360. var chunk = batchValues.Skip(i).Take(BATCH);
  361. var sql = $"INSERT INTO `{table}` " +
  362. "(id,tenant_id,factory_id,biz_date,module_code,metric_code," +
  363. "metric_value,target_value,status_color,trend_flag,is_deleted,is_active) VALUES\n" +
  364. string.Join(",\n", chunk);
  365. await _db.Ado.ExecuteCommandAsync(sql);
  366. }
  367. }
  368. await InsertLevel(l1, "ado_s9_kpi_value_l1_day");
  369. await InsertLevel(l2, "ado_s9_kpi_value_l2_day");
  370. await InsertLevel(l3, "ado_s9_kpi_value_l3_day");
  371. var fromExcel = excelOverrides.Values.Count(v => v.HasTarget || v.HasActual);
  372. return Ok(new
  373. {
  374. ok = true,
  375. message = $"导入成功:Excel 填入 {fromExcel} 条,自动补全 {parsed.Count - fromExcel} 条,共 {parsed.Count} 条指标 × {DAYS} 天",
  376. detail = new { l1 = l1.Count, l2 = l2.Count, l3 = l3.Count, days = DAYS, fromExcel, autoGen = parsed.Count - fromExcel }
  377. });
  378. }
  379. /// <summary>下载 Demo 数据 Excel 模板</summary>
  380. [HttpGet("demo-template")]
  381. public async Task<IActionResult> DownloadDemoTemplate()
  382. {
  383. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  384. var masters = await _db.Queryable<AdoSmartOpsKpiMaster>()
  385. .Where(x => x.TenantId == tenantId)
  386. .OrderBy(x => x.ModuleCode).OrderBy(x => x.MetricLevel).OrderBy(x => x.SortNo)
  387. .ToListAsync();
  388. var templateRows = masters.Select(m => new Dictionary<string, object>
  389. {
  390. ["指标编码"] = m.MetricCode,
  391. ["模块"] = m.ModuleCode,
  392. ["层级"] = $"L{m.MetricLevel}",
  393. ["指标名称"] = m.MetricName,
  394. ["单位"] = m.Unit ?? "",
  395. ["方向"] = m.Direction == "lower_is_better" ? "越低越好" : "越高越好",
  396. ["目标值 ★"] = (object)null!,
  397. ["实际值 ★"] = (object)null!,
  398. ["黄色阈值% ★"] = (object)(m.YellowThreshold ?? (m.Direction == "lower_is_better" ? 110m : 95m)),
  399. ["红色阈值% ★"] = (object)(m.RedThreshold ?? (m.Direction == "lower_is_better" ? 120m : 80m)),
  400. }).ToList();
  401. var ms = new MemoryStream();
  402. await ms.SaveAsAsync(templateRows);
  403. ms.Position = 0;
  404. return new FileStreamResult(ms, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
  405. {
  406. FileDownloadName = $"demo_data_template_{DateTime.Now:yyyyMMdd}.xlsx"
  407. };
  408. }
  409. private static decimal? TryDecimal(object? val)
  410. {
  411. if (val == null) return null;
  412. var s = val.ToString()?.Trim();
  413. if (string.IsNullOrEmpty(s)) return null;
  414. return decimal.TryParse(s, out var d) ? d : null;
  415. }
  416. private sealed class DemoImportRow
  417. {
  418. public string MetricCode { get; set; } = "";
  419. public AdoSmartOpsKpiMaster Master { get; set; } = null!;
  420. public decimal Target { get; set; }
  421. public decimal Actual { get; set; }
  422. public bool HasTarget { get; set; }
  423. public bool HasActual { get; set; }
  424. public decimal? YellowThreshold { get; set; }
  425. public decimal? RedThreshold { get; set; }
  426. }
  427. private static decimal AutoTarget(AdoSmartOpsKpiMaster m, Random rng)
  428. {
  429. var unit = (m.Unit ?? "").Trim();
  430. if (unit == "%") return Math.Round((decimal)(rng.NextDouble() * 18 + 80), 2);
  431. if (unit == "天") return Math.Round((decimal)(rng.NextDouble() * 13 + 2), 2);
  432. if (unit.Contains("PPM")) return Math.Round((decimal)(rng.NextDouble() * 5000 + 3000), 0);
  433. if (unit.Contains("/")) return Math.Round((decimal)(rng.NextDouble() * 250 + 50), 2);
  434. if (unit is "个" or "颗/人" or "家/人" or "小时" or "颗/人" or "人")
  435. return Math.Round((decimal)(rng.NextDouble() * 195 + 5), 0);
  436. return Math.Round((decimal)(rng.NextDouble() * 150 + 50), 2);
  437. }
  438. private static decimal AutoActual(decimal target, AdoSmartOpsKpiMaster m, Random rng)
  439. {
  440. var factor = (m.Direction == "lower_is_better")
  441. ? (decimal)(rng.NextDouble() * 0.35 + 0.90)
  442. : (decimal)(rng.NextDouble() * 0.23 + 0.82);
  443. return Math.Round(target * factor, 4);
  444. }
  445. private static decimal AutoActualFactor(AdoSmartOpsKpiMaster m, Random rng)
  446. {
  447. return (m.Direction == "lower_is_better")
  448. ? (decimal)(rng.NextDouble() * 0.35 + 0.90)
  449. : (decimal)(rng.NextDouble() * 0.23 + 0.82);
  450. }
  451. // ── 私有方法 ──
  452. private async Task<string> GenerateMetricCodeAsync(string moduleCode, int level, long tenantId)
  453. {
  454. var prefix = $"{moduleCode}_L{level}_";
  455. var maxCode = await _db.Queryable<AdoSmartOpsKpiMaster>()
  456. .Where(x => x.TenantId == tenantId && x.ModuleCode == moduleCode && x.MetricLevel == level)
  457. .OrderByDescending(x => x.MetricCode)
  458. .Select(x => x.MetricCode)
  459. .FirstAsync();
  460. var seq = 1;
  461. if (!string.IsNullOrEmpty(maxCode) && maxCode.StartsWith(prefix) && int.TryParse(maxCode[prefix.Length..], out var n))
  462. seq = n + 1;
  463. return $"{prefix}{seq:D3}";
  464. }
  465. private async Task CollectChildIds(long parentId, List<long> result)
  466. {
  467. var children = await _db.Queryable<AdoSmartOpsKpiMaster>()
  468. .Where(x => x.ParentId == parentId)
  469. .Select(x => x.Id)
  470. .ToListAsync();
  471. foreach (var cid in children)
  472. {
  473. result.Add(cid);
  474. await CollectChildIds(cid, result);
  475. }
  476. }
  477. private static List<KpiMasterTreeNodeDto> BuildTree(List<KpiMasterTreeNodeDto> flat)
  478. {
  479. var byId = flat.ToDictionary(n => n.Id);
  480. var roots = new List<KpiMasterTreeNodeDto>();
  481. foreach (var n in flat)
  482. {
  483. if (n.ParentId.HasValue && byId.TryGetValue(n.ParentId.Value, out var parent))
  484. parent.Children.Add(n);
  485. else
  486. roots.Add(n);
  487. }
  488. SortChildren(roots);
  489. return roots;
  490. }
  491. private static void SortChildren(List<KpiMasterTreeNodeDto> nodes)
  492. {
  493. nodes.Sort((a, b) => a.SortNo.CompareTo(b.SortNo));
  494. foreach (var n in nodes)
  495. {
  496. if (n.Children.Count > 0)
  497. SortChildren(n.Children);
  498. }
  499. }
  500. }