AdoSmartOpsKpiMasterController.cs 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  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 > 4)
  106. return BadRequest(new { message = "层级必须为 1/2/3/4" });
  107. if (dto.MetricLevel > 1 && dto.ParentId == null)
  108. return BadRequest(new { message = "L2/L3/L4 必须指定父指标" });
  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. if (dto.MetricLevel < 1 || dto.MetricLevel > 4)
  161. return BadRequest(new { message = "层级必须为 1/2/3/4" });
  162. if (dto.MetricLevel > 1 && dto.ParentId == null)
  163. return BadRequest(new { message = "L2/L3/L4 必须指定父指标" });
  164. // Q4-C:公式解析 + 校验
  165. var fxResult = await ProcessFormulaAsync(tenantId, dto.FormulaExpr, dto.IsEnabled, excludeKpiId: id);
  166. if (fxResult.BlockSave)
  167. return BadRequest(new { message = "公式校验失败", errors = fxResult.Errors });
  168. entity.ModuleCode = dto.ModuleCode;
  169. entity.MetricLevel = dto.MetricLevel;
  170. entity.ParentId = dto.ParentId;
  171. entity.MetricName = dto.MetricName;
  172. entity.Description = dto.Description;
  173. entity.Formula = dto.Formula;
  174. entity.CalcRule = dto.CalcRule;
  175. entity.FormulaExpr = dto.FormulaExpr;
  176. entity.FormulaPreview = fxResult.Preview;
  177. entity.FormulaRefs = fxResult.RefsJson;
  178. entity.DataSource = dto.DataSource;
  179. entity.StatFrequency = dto.StatFrequency;
  180. entity.Department = dto.Department;
  181. entity.DopFields = dto.DopFields;
  182. entity.Unit = dto.Unit;
  183. entity.Direction = dto.Direction;
  184. entity.YellowThreshold = dto.YellowThreshold;
  185. entity.RedThreshold = dto.RedThreshold;
  186. entity.IsHomePage = dto.IsHomePage;
  187. entity.SortNo = dto.SortNo;
  188. entity.Remark = dto.Remark;
  189. entity.IsEnabled = dto.IsEnabled;
  190. entity.UpdatedAt = DateTime.Now;
  191. await _db.Updateable(entity).ExecuteCommandAsync();
  192. return Ok(new
  193. {
  194. ok = true,
  195. formulaPreview = fxResult.Preview,
  196. formulaWarnings = fxResult.Warnings
  197. });
  198. }
  199. /// <summary>删除指标(含子节点级联删除)</summary>
  200. [HttpDelete("{id:long}")]
  201. public async Task<IActionResult> Delete(long id)
  202. {
  203. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  204. if (entity == null) return NotFound();
  205. var idsToDelete = new List<long> { id };
  206. await CollectChildIds(id, idsToDelete);
  207. await _db.Deleteable<AdoSmartOpsKpiMaster>().Where(x => idsToDelete.Contains(x.Id)).ExecuteCommandAsync();
  208. return Ok(new { ok = true, deletedCount = idsToDelete.Count });
  209. }
  210. /// <summary>启用/禁用</summary>
  211. [HttpPatch("{id:long}/toggle-enabled")]
  212. public async Task<IActionResult> ToggleEnabled(long id)
  213. {
  214. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  215. if (entity == null) return NotFound();
  216. entity.IsEnabled = !entity.IsEnabled;
  217. entity.UpdatedAt = DateTime.Now;
  218. await _db.Updateable(entity).UpdateColumns(x => new { x.IsEnabled, x.UpdatedAt }).ExecuteCommandAsync();
  219. return Ok(new { ok = true, isEnabled = entity.IsEnabled });
  220. }
  221. /// <summary>批量更新排序</summary>
  222. [HttpPut("sort")]
  223. public async Task<IActionResult> UpdateSort([FromBody] List<KpiMasterSortItemDto> items)
  224. {
  225. if (items == null || items.Count == 0)
  226. return BadRequest(new { message = "排序列表不能为空" });
  227. foreach (var item in items)
  228. {
  229. await _db.Updateable<AdoSmartOpsKpiMaster>()
  230. .SetColumns(x => new AdoSmartOpsKpiMaster { SortNo = item.SortNo, UpdatedAt = DateTime.Now })
  231. .Where(x => x.Id == item.Id)
  232. .ExecuteCommandAsync();
  233. }
  234. return Ok(new { ok = true });
  235. }
  236. /// <summary>拖拽移动(变更 ParentId + SortNo)</summary>
  237. [HttpPut("{id:long}/move")]
  238. public async Task<IActionResult> Move(long id, [FromBody] KpiMasterMoveDto dto)
  239. {
  240. var entity = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == id);
  241. if (entity == null) return NotFound();
  242. if (dto.NewParentId.HasValue)
  243. {
  244. var parent = await _db.Queryable<AdoSmartOpsKpiMaster>().FirstAsync(x => x.Id == dto.NewParentId.Value);
  245. if (parent == null) return BadRequest(new { message = "目标父节点不存在" });
  246. if (parent.MetricLevel >= entity.MetricLevel)
  247. return BadRequest(new { message = "不能移动到同级或更低层级下" });
  248. entity.ParentId = dto.NewParentId;
  249. entity.MetricLevel = parent.MetricLevel + 1;
  250. }
  251. else
  252. {
  253. entity.ParentId = null;
  254. entity.MetricLevel = 1;
  255. }
  256. entity.SortNo = dto.NewSortNo;
  257. entity.UpdatedAt = DateTime.Now;
  258. await _db.Updateable(entity).ExecuteCommandAsync();
  259. return Ok(new { ok = true });
  260. }
  261. /// <summary>导入 Demo 数据(上传 Excel,解析后生成 30 天日值写入 DB)</summary>
  262. [HttpPost("import-demo-data")]
  263. public async Task<IActionResult> ImportDemoData(IFormFile file)
  264. {
  265. if (file == null || file.Length == 0)
  266. return BadRequest(new { message = "请上传 Excel 文件" });
  267. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  268. const int DAYS = 30;
  269. var today = DateTime.Today;
  270. var startDate = today.AddDays(-(DAYS - 1));
  271. using var stream = new MemoryStream();
  272. await file.CopyToAsync(stream);
  273. stream.Position = 0;
  274. var rows = stream.Query(useHeaderRow: true).ToList();
  275. var masters = await _db.Queryable<AdoSmartOpsKpiMaster>()
  276. .Where(x => x.TenantId == tenantId)
  277. .ToListAsync();
  278. var masterByCode = masters.ToDictionary(m => m.MetricCode, m => m);
  279. var excelOverrides = new Dictionary<string, DemoImportRow>();
  280. foreach (IDictionary<string, object> row in rows)
  281. {
  282. object? Val(string key) => row.TryGetValue(key, out var v) ? v : null;
  283. var code = Val("指标编码")?.ToString()?.Trim();
  284. if (string.IsNullOrEmpty(code) || !code.StartsWith("S")) continue;
  285. if (!masterByCode.TryGetValue(code, out var master)) continue;
  286. decimal? target = TryDecimal(Val("目标值 ★"));
  287. decimal? actual = TryDecimal(Val("实际值 ★"));
  288. decimal? yellowTh = TryDecimal(Val("黄色阈值% ★"));
  289. decimal? redTh = TryDecimal(Val("红色阈值% ★"));
  290. excelOverrides[code] = new DemoImportRow
  291. {
  292. MetricCode = code,
  293. Master = master,
  294. Target = target ?? 0,
  295. Actual = actual ?? 0,
  296. HasTarget = target != null,
  297. HasActual = actual != null,
  298. YellowThreshold = yellowTh,
  299. RedThreshold = redTh,
  300. };
  301. }
  302. var rng = new Random(42);
  303. var parsed = new List<DemoImportRow>();
  304. foreach (var m in masters)
  305. {
  306. if (TryGetFixedS1DiagnosisDemoValue(m.MetricCode, out var fixedValue))
  307. {
  308. parsed.Add(new DemoImportRow
  309. {
  310. MetricCode = m.MetricCode, Master = m,
  311. Target = fixedValue.Target, Actual = fixedValue.Actual,
  312. HasTarget = true, HasActual = true,
  313. YellowThreshold = null, RedThreshold = null,
  314. FixedStatus = fixedValue.Status,
  315. });
  316. }
  317. else if (excelOverrides.TryGetValue(m.MetricCode, out var ov))
  318. {
  319. var tgt = ov.HasTarget ? ov.Target : ov.HasActual ? ov.Actual * 1.05m : AutoTarget(m, rng);
  320. var act = ov.HasActual ? ov.Actual : ov.HasTarget ? ov.Target * AutoActualFactor(m, rng) : AutoActual(AutoTarget(m, rng), m, rng);
  321. parsed.Add(new DemoImportRow
  322. {
  323. MetricCode = m.MetricCode, Master = m,
  324. Target = tgt, Actual = act,
  325. HasTarget = true, HasActual = true,
  326. YellowThreshold = ov.YellowThreshold, RedThreshold = ov.RedThreshold,
  327. });
  328. }
  329. else
  330. {
  331. var tgt = AutoTarget(m, rng);
  332. var act = AutoActual(tgt, m, rng);
  333. parsed.Add(new DemoImportRow
  334. {
  335. MetricCode = m.MetricCode, Master = m,
  336. Target = tgt, Actual = act,
  337. HasTarget = true, HasActual = true,
  338. YellowThreshold = null, RedThreshold = null,
  339. });
  340. }
  341. }
  342. foreach (var p in parsed)
  343. {
  344. if (p.YellowThreshold != null || p.RedThreshold != null)
  345. {
  346. var m = p.Master;
  347. if (p.YellowThreshold != null) m.YellowThreshold = p.YellowThreshold;
  348. if (p.RedThreshold != null) m.RedThreshold = p.RedThreshold;
  349. m.UpdatedAt = DateTime.Now;
  350. await _db.Updateable(m)
  351. .UpdateColumns(x => new { x.YellowThreshold, x.RedThreshold, x.UpdatedAt })
  352. .ExecuteCommandAsync();
  353. }
  354. }
  355. var l1 = parsed.Where(p => p.Master.MetricLevel == 1).ToList();
  356. var l2 = parsed.Where(p => p.Master.MetricLevel == 2).ToList();
  357. var l3 = parsed.Where(p => p.Master.MetricLevel == 3).ToList();
  358. var l4 = parsed.Where(p => p.Master.MetricLevel == 4).ToList();
  359. AidopTenantMigration.EnsureKpiValueL4Table(_db);
  360. await _db.Ado.ExecuteCommandAsync(
  361. $"DELETE FROM ado_s9_kpi_value_l1_day WHERE tenant_id = {tenantId}");
  362. await _db.Ado.ExecuteCommandAsync(
  363. $"DELETE FROM ado_s9_kpi_value_l2_day WHERE tenant_id = {tenantId}");
  364. await _db.Ado.ExecuteCommandAsync(
  365. $"DELETE FROM ado_s9_kpi_value_l3_day WHERE tenant_id = {tenantId}");
  366. await _db.Ado.ExecuteCommandAsync(
  367. $"DELETE FROM ado_s9_kpi_value_l4_day WHERE tenant_id = {tenantId}");
  368. async Task InsertLevel(List<DemoImportRow> items, string table)
  369. {
  370. if (items.Count == 0) return;
  371. var maxIdValue = await _db.Ado.GetScalarAsync($"SELECT COALESCE(MAX(id), 0) FROM `{table}`");
  372. var nextId = Convert.ToInt64(maxIdValue);
  373. var levelRng = new Random(42);
  374. var batchValues = new List<string>();
  375. foreach (var p in items)
  376. {
  377. var dir = p.Master.Direction ?? "higher_is_better";
  378. var yTh = p.YellowThreshold ?? p.Master.YellowThreshold;
  379. var rTh = p.RedThreshold ?? p.Master.RedThreshold;
  380. decimal prevVal = p.Actual;
  381. for (int i = 0; i < DAYS; i++)
  382. {
  383. nextId++;
  384. var d = startDate.AddDays(i);
  385. decimal val;
  386. if (i == DAYS - 1)
  387. val = p.Actual;
  388. else
  389. {
  390. var noise = (decimal)(levelRng.NextDouble() * 0.06 - 0.03);
  391. val = Math.Max(0, p.Actual * (1 + noise));
  392. }
  393. val = Math.Round(val, 4);
  394. var sc = i == DAYS - 1 && !string.IsNullOrWhiteSpace(p.FixedStatus)
  395. ? p.FixedStatus
  396. : AidopS4KpiMerge.AchievementLevel(val, p.Target, dir, yTh, rTh);
  397. var tf = "flat";
  398. if (i > 0)
  399. {
  400. var change = prevVal == 0 ? 0 : (val - prevVal) / prevVal;
  401. tf = change > 0.02m ? "up" : change < -0.02m ? "down" : "flat";
  402. }
  403. prevVal = val;
  404. batchValues.Add(
  405. $"({nextId},{tenantId},1,'{d:yyyy-MM-dd}'," +
  406. $"'{p.Master.ModuleCode}','{p.MetricCode}',{val},{p.Target},'{sc}','{tf}',0,1)");
  407. }
  408. }
  409. const int BATCH = 500;
  410. for (int i = 0; i < batchValues.Count; i += BATCH)
  411. {
  412. var chunk = batchValues.Skip(i).Take(BATCH);
  413. var sql = $"INSERT INTO `{table}` " +
  414. "(id,tenant_id,factory_id,biz_date,module_code,metric_code," +
  415. "metric_value,target_value,status_color,trend_flag,is_deleted,is_active) VALUES\n" +
  416. string.Join(",\n", chunk);
  417. await _db.Ado.ExecuteCommandAsync(sql);
  418. }
  419. }
  420. await InsertLevel(l1, "ado_s9_kpi_value_l1_day");
  421. await InsertLevel(l2, "ado_s9_kpi_value_l2_day");
  422. await InsertLevel(l3, "ado_s9_kpi_value_l3_day");
  423. await InsertLevel(l4, "ado_s9_kpi_value_l4_day");
  424. // ---- 同步 LayoutItem / HomeModule(只补不改,保护已有布局配置) ----
  425. // 背景:
  426. // seed_layout_items_all_modules.py 的语义是"(tenant,module) 已有布局就 skip";
  427. // 之后在 KpiMaster 里新增某个 L1/L2 指标时,LayoutItem 不会自动跟进,九宫格 / 详情会少条目。
  428. // 这里按本次 Demo 导入涉及的指标做"缺啥补啥"的幂等同步,不覆盖任何已有行。
  429. const long layoutFactoryId = 1;
  430. var existingLayout = await _db.Queryable<AdoSmartOpsLayoutItem>()
  431. .Where(x => x.TenantId == tenantId && x.FactoryId == layoutFactoryId)
  432. .ToListAsync();
  433. var layoutKey = new HashSet<string>(
  434. existingLayout.Select(x => $"{x.ModuleCode}|{x.MetricCode}|{x.MetricLevel}"),
  435. StringComparer.OrdinalIgnoreCase);
  436. var now = DateTime.Now;
  437. var toInsertLayout = new List<AdoSmartOpsLayoutItem>();
  438. foreach (var p in parsed)
  439. {
  440. var m = p.Master;
  441. var key = $"{m.ModuleCode}|{m.MetricCode}|{m.MetricLevel}";
  442. if (layoutKey.Contains(key)) continue;
  443. string? zone = null;
  444. if (m.MetricLevel >= 2)
  445. {
  446. var nm = m.MetricName ?? "";
  447. if (nm.Contains("周期") || nm.Contains("人效")) zone = "left";
  448. else if (nm.Contains("满足率") || nm.Contains("周转")) zone = "right";
  449. else zone = (m.SortNo % 2 == 1) ? "left" : "right";
  450. }
  451. toInsertLayout.Add(new AdoSmartOpsLayoutItem
  452. {
  453. TenantId = tenantId,
  454. FactoryId = layoutFactoryId,
  455. ModuleCode = m.ModuleCode,
  456. RowId = $"{m.ModuleCode}-L{m.MetricLevel}-{m.MetricCode}",
  457. MetricLevel = m.MetricLevel,
  458. MetricCode = m.MetricCode,
  459. DisplayName = null,
  460. SortNo = m.SortNo,
  461. ParentRowId = null,
  462. FormulaText = null,
  463. PanelZone = zone,
  464. IsEnabled = 1,
  465. UpdateTime = now,
  466. });
  467. layoutKey.Add(key);
  468. }
  469. if (toInsertLayout.Count > 0)
  470. await _db.Insertable(toInsertLayout).ExecuteCommandAsync();
  471. var moduleCodes = parsed.Select(p => p.Master.ModuleCode).Distinct().ToList();
  472. var existingHm = (await _db.Queryable<AdoSmartOpsHomeModule>()
  473. .Where(x => x.TenantId == tenantId && x.FactoryId == layoutFactoryId)
  474. .ToListAsync())
  475. .Select(x => x.ModuleCode)
  476. .ToHashSet(StringComparer.OrdinalIgnoreCase);
  477. var toInsertHm = new List<AdoSmartOpsHomeModule>();
  478. foreach (var mc in moduleCodes)
  479. {
  480. if (string.IsNullOrWhiteSpace(mc) || existingHm.Contains(mc)) continue;
  481. toInsertHm.Add(new AdoSmartOpsHomeModule
  482. {
  483. TenantId = tenantId,
  484. FactoryId = layoutFactoryId,
  485. ModuleCode = mc,
  486. LayoutPattern = "card_grid",
  487. UpdateTime = now,
  488. });
  489. existingHm.Add(mc);
  490. }
  491. if (toInsertHm.Count > 0)
  492. await _db.Insertable(toInsertHm).ExecuteCommandAsync();
  493. var fromExcel = excelOverrides.Values.Count(v => v.HasTarget || v.HasActual);
  494. return Ok(new
  495. {
  496. ok = true,
  497. message = $"导入成功:Excel 填入 {fromExcel} 条,自动补全 {parsed.Count - fromExcel} 条,共 {parsed.Count} 条指标 × {DAYS} 天;补齐布局行 {toInsertLayout.Count} 条、模块模板 {toInsertHm.Count} 条",
  498. detail = new
  499. {
  500. l1 = l1.Count,
  501. l2 = l2.Count,
  502. l3 = l3.Count,
  503. l4 = l4.Count,
  504. days = DAYS,
  505. fromExcel,
  506. autoGen = parsed.Count - fromExcel,
  507. layoutInserted = toInsertLayout.Count,
  508. homeModuleInserted = toInsertHm.Count
  509. }
  510. });
  511. }
  512. /// <summary>下载 Demo 数据 Excel 模板</summary>
  513. [HttpGet("demo-template")]
  514. public async Task<IActionResult> DownloadDemoTemplate()
  515. {
  516. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  517. var masters = await _db.Queryable<AdoSmartOpsKpiMaster>()
  518. .Where(x => x.TenantId == tenantId)
  519. .OrderBy(x => x.ModuleCode).OrderBy(x => x.MetricLevel).OrderBy(x => x.SortNo)
  520. .ToListAsync();
  521. var templateRows = masters.Select(m => new Dictionary<string, object>
  522. {
  523. ["指标编码"] = m.MetricCode,
  524. ["模块"] = m.ModuleCode,
  525. ["层级"] = $"L{m.MetricLevel}",
  526. ["指标名称"] = m.MetricName,
  527. ["单位"] = m.Unit ?? "",
  528. ["方向"] = m.Direction == "lower_is_better" ? "越低越好" : "越高越好",
  529. ["目标值 ★"] = (object)null!,
  530. ["实际值 ★"] = (object)null!,
  531. ["黄色阈值% ★"] = (object)(m.YellowThreshold ?? (m.Direction == "lower_is_better" ? 110m : 95m)),
  532. ["红色阈值% ★"] = (object)(m.RedThreshold ?? (m.Direction == "lower_is_better" ? 120m : 80m)),
  533. }).ToList();
  534. var ms = new MemoryStream();
  535. await ms.SaveAsAsync(templateRows);
  536. ms.Position = 0;
  537. return new FileStreamResult(ms, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
  538. {
  539. FileDownloadName = $"demo_data_template_{DateTime.Now:yyyyMMdd}.xlsx"
  540. };
  541. }
  542. private static decimal? TryDecimal(object? val)
  543. {
  544. if (val == null) return null;
  545. var s = val.ToString()?.Trim();
  546. if (string.IsNullOrEmpty(s)) return null;
  547. return decimal.TryParse(s, out var d) ? d : null;
  548. }
  549. private sealed class DemoImportRow
  550. {
  551. public string MetricCode { get; set; } = "";
  552. public AdoSmartOpsKpiMaster Master { get; set; } = null!;
  553. public decimal Target { get; set; }
  554. public decimal Actual { get; set; }
  555. public bool HasTarget { get; set; }
  556. public bool HasActual { get; set; }
  557. public decimal? YellowThreshold { get; set; }
  558. public decimal? RedThreshold { get; set; }
  559. public string? FixedStatus { get; set; }
  560. }
  561. private static decimal AutoTarget(AdoSmartOpsKpiMaster m, Random rng)
  562. {
  563. var unit = (m.Unit ?? "").Trim();
  564. if (unit == "%") return Math.Round((decimal)(rng.NextDouble() * 18 + 80), 2);
  565. if (unit == "天") return Math.Round((decimal)(rng.NextDouble() * 13 + 2), 2);
  566. if (unit.Contains("PPM")) return Math.Round((decimal)(rng.NextDouble() * 5000 + 3000), 0);
  567. if (unit.Contains("/")) return Math.Round((decimal)(rng.NextDouble() * 250 + 50), 2);
  568. if (unit is "个" or "颗/人" or "家/人" or "小时" or "颗/人" or "人")
  569. return Math.Round((decimal)(rng.NextDouble() * 195 + 5), 0);
  570. return Math.Round((decimal)(rng.NextDouble() * 150 + 50), 2);
  571. }
  572. private static decimal AutoActual(decimal target, AdoSmartOpsKpiMaster m, Random rng)
  573. {
  574. var factor = (m.Direction == "lower_is_better")
  575. ? (decimal)(rng.NextDouble() * 0.35 + 0.90)
  576. : (decimal)(rng.NextDouble() * 0.23 + 0.82);
  577. return Math.Round(target * factor, 4);
  578. }
  579. private static bool TryGetFixedS1DiagnosisDemoValue(string? metricCode, out (decimal Target, decimal Actual, string Status) value)
  580. {
  581. value = default;
  582. if (string.IsNullOrWhiteSpace(metricCode)) return false;
  583. var fixedValues = new Dictionary<string, (decimal Target, decimal Actual, string Status)>(StringComparer.OrdinalIgnoreCase)
  584. {
  585. ["S1_L2_004"] = (5m, 6.2m, "red"),
  586. ["S1_L3_201"] = (8m, 14.4m, "red"),
  587. ["S1_L3_202"] = (12m, 14.2m, "yellow"),
  588. ["S1_L3_203"] = (8m, 7m, "green"),
  589. ["S1_L3_204"] = (10m, 12m, "yellow"),
  590. ["S1_L3_205"] = (2m, 2m, "green"),
  591. ["S1_L4_201"] = (2m, 2m, "green"),
  592. ["S1_L4_202"] = (3m, 9.2m, "red"),
  593. ["S1_L4_203"] = (1m, 1.2m, "yellow"),
  594. ["S1_L4_204"] = (2m, 2m, "green"),
  595. };
  596. return fixedValues.TryGetValue(metricCode, out value);
  597. }
  598. private static decimal AutoActualFactor(AdoSmartOpsKpiMaster m, Random rng)
  599. {
  600. return (m.Direction == "lower_is_better")
  601. ? (decimal)(rng.NextDouble() * 0.35 + 0.90)
  602. : (decimal)(rng.NextDouble() * 0.23 + 0.82);
  603. }
  604. // ── 私有方法 ──
  605. private async Task<string> GenerateMetricCodeAsync(string moduleCode, int level, long tenantId)
  606. {
  607. var prefix = $"{moduleCode}_L{level}_";
  608. var maxCode = await _db.Queryable<AdoSmartOpsKpiMaster>()
  609. .Where(x => x.TenantId == tenantId && x.ModuleCode == moduleCode && x.MetricLevel == level)
  610. .OrderByDescending(x => x.MetricCode)
  611. .Select(x => x.MetricCode)
  612. .FirstAsync();
  613. var seq = 1;
  614. if (!string.IsNullOrEmpty(maxCode) && maxCode.StartsWith(prefix) && int.TryParse(maxCode[prefix.Length..], out var n))
  615. seq = n + 1;
  616. return $"{prefix}{seq:D3}";
  617. }
  618. private async Task CollectChildIds(long parentId, List<long> result)
  619. {
  620. var children = await _db.Queryable<AdoSmartOpsKpiMaster>()
  621. .Where(x => x.ParentId == parentId)
  622. .Select(x => x.Id)
  623. .ToListAsync();
  624. foreach (var cid in children)
  625. {
  626. result.Add(cid);
  627. await CollectChildIds(cid, result);
  628. }
  629. }
  630. private static List<KpiMasterTreeNodeDto> BuildTree(List<KpiMasterTreeNodeDto> flat)
  631. {
  632. var byId = flat.ToDictionary(n => n.Id);
  633. var roots = new List<KpiMasterTreeNodeDto>();
  634. foreach (var n in flat)
  635. {
  636. if (n.ParentId.HasValue && byId.TryGetValue(n.ParentId.Value, out var parent))
  637. parent.Children.Add(n);
  638. else
  639. roots.Add(n);
  640. }
  641. SortChildren(roots);
  642. return roots;
  643. }
  644. private static void SortChildren(List<KpiMasterTreeNodeDto> nodes)
  645. {
  646. nodes.Sort((a, b) => a.SortNo.CompareTo(b.SortNo));
  647. foreach (var n in nodes)
  648. {
  649. if (n.Children.Count > 0)
  650. SortChildren(n.Children);
  651. }
  652. }
  653. // ── 公式解析 / 校验 / 预览:第三批新增 ──
  654. private sealed class FormulaProcessResult
  655. {
  656. public string? Preview { get; init; }
  657. public string? RefsJson { get; init; }
  658. public List<string> Errors { get; init; } = new();
  659. public List<string> Warnings { get; init; } = new();
  660. public bool BlockSave { get; init; }
  661. }
  662. private async Task<FormulaProcessResult> ProcessFormulaAsync(
  663. long tenantId, string? expr, bool isEnabled, long? excludeKpiId = null)
  664. {
  665. var parsed = FormulaParser.Parse(expr);
  666. if (parsed.IsEmpty)
  667. return new FormulaProcessResult();
  668. var validation = await FormulaValidator.ValidateAsync(_db, tenantId, parsed, isEnabled, excludeKpiId);
  669. // 启用指标 → 硬校验;否则仅警告
  670. if (isEnabled && validation.HasErrors)
  671. {
  672. return new FormulaProcessResult
  673. {
  674. Errors = validation.Errors,
  675. Warnings = validation.Warnings,
  676. BlockSave = true,
  677. };
  678. }
  679. var preview = await FormulaPreviewBuilder.BuildAsync(_db, tenantId, expr, parsed);
  680. var refsJson = JsonSerializer.Serialize(new
  681. {
  682. metrics = parsed.MetricRefs,
  683. facts = parsed.FactRefs,
  684. });
  685. return new FormulaProcessResult
  686. {
  687. Preview = preview,
  688. RefsJson = refsJson,
  689. Errors = validation.Errors,
  690. Warnings = validation.Warnings,
  691. BlockSave = false,
  692. };
  693. }
  694. /// <summary>实时预览公式(不落库)。前端编辑器失焦/定时调用。</summary>
  695. [HttpPost("preview-formula")]
  696. public async Task<IActionResult> PreviewFormula([FromBody] PreviewFormulaIn input)
  697. {
  698. var tenantId = AidopTenantHelper.GetTenantId(HttpContext);
  699. var parsed = FormulaParser.Parse(input?.FormulaExpr);
  700. if (parsed.IsEmpty)
  701. return Ok(new { preview = "", metrics = new List<string>(), facts = new List<string>(), errors = Array.Empty<string>(), warnings = Array.Empty<string>() });
  702. var validation = await FormulaValidator.ValidateAsync(
  703. _db, tenantId, parsed, isEnabled: input?.IsEnabled ?? true, excludeKpiId: input?.ExcludeKpiId);
  704. var preview = await FormulaPreviewBuilder.BuildAsync(_db, tenantId, input!.FormulaExpr, parsed);
  705. return Ok(new
  706. {
  707. preview,
  708. metrics = parsed.MetricRefs,
  709. facts = parsed.FactRefs,
  710. errors = validation.Errors,
  711. warnings = validation.Warnings,
  712. });
  713. }
  714. public sealed class PreviewFormulaIn
  715. {
  716. public string? FormulaExpr { get; set; }
  717. public bool IsEnabled { get; set; } = true;
  718. public long? ExcludeKpiId { get; set; }
  719. }
  720. }