AdoSmartOpsKpiMasterController.cs 27 KB

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