using Admin.NET.Core; using Admin.NET.Plugin.AiDOP.Dto.SmartOps; using Admin.NET.Plugin.AiDOP.Entity; using Admin.NET.Plugin.AiDOP.Infrastructure; using Microsoft.AspNetCore.Http; using MiniExcelLibs; using SqlSugar; namespace Admin.NET.Plugin.AiDOP.Controllers; [ApiController] [Route("api/[controller]")] [AllowAnonymous] [NonUnify] public class AdoSmartOpsKpiMasterController : ControllerBase { private readonly ISqlSugarClient _db; public AdoSmartOpsKpiMasterController(ISqlSugarClient db) { _db = db; } /// 获取树形结构(左侧树用) [HttpGet("tree")] public async Task GetTree([FromQuery] string? moduleCode = null, [FromQuery] string? keyword = null) { var tenantId = AidopTenantHelper.GetTenantId(HttpContext); var q = _db.Queryable().Where(x => x.TenantId == tenantId); if (!string.IsNullOrWhiteSpace(moduleCode)) q = q.Where(x => x.ModuleCode == moduleCode); if (!string.IsNullOrWhiteSpace(keyword)) q = q.Where(x => x.MetricName.Contains(keyword) || x.MetricCode.Contains(keyword)); var all = await q.OrderBy(x => x.SortNo).ToListAsync(); var nodes = all.Select(x => new KpiMasterTreeNodeDto { Id = x.Id, MetricCode = x.MetricCode, ModuleCode = x.ModuleCode, MetricLevel = x.MetricLevel, MetricName = x.MetricName, ParentId = x.ParentId, SortNo = x.SortNo, IsEnabled = x.IsEnabled }).ToList(); if (!string.IsNullOrWhiteSpace(keyword)) { var matchIds = new HashSet(nodes.Select(n => n.Id)); var allForAncestor = await _db.Queryable() .Where(x => x.TenantId == tenantId) .WhereIF(!string.IsNullOrWhiteSpace(moduleCode), x => x.ModuleCode == moduleCode) .ToListAsync(); var byId = allForAncestor.ToDictionary(x => x.Id); var needed = new HashSet(matchIds); foreach (var id in matchIds) { var cur = byId.GetValueOrDefault(id); while (cur?.ParentId != null && needed.Add(cur.ParentId.Value)) cur = byId.GetValueOrDefault(cur.ParentId.Value); } var extra = allForAncestor.Where(x => needed.Contains(x.Id) && !matchIds.Contains(x.Id)); foreach (var x in extra) nodes.Add(new KpiMasterTreeNodeDto { Id = x.Id, MetricCode = x.MetricCode, ModuleCode = x.ModuleCode, MetricLevel = x.MetricLevel, MetricName = x.MetricName, ParentId = x.ParentId, SortNo = x.SortNo, IsEnabled = x.IsEnabled }); } var tree = BuildTree(nodes); return Ok(tree); } /// 获取单条详情(右侧面板用) [HttpGet("{id:long}")] public async Task GetDetail(long id) { var e = await _db.Queryable().FirstAsync(x => x.Id == id); if (e == null) return NotFound(); string? parentName = null; if (e.ParentId.HasValue) { var p = await _db.Queryable().FirstAsync(x => x.Id == e.ParentId.Value); parentName = p?.MetricName; } return Ok(new KpiMasterDetailDto { Id = e.Id, MetricCode = e.MetricCode, ModuleCode = e.ModuleCode, MetricLevel = e.MetricLevel, ParentId = e.ParentId, ParentName = parentName, MetricName = e.MetricName, Description = e.Description, Formula = e.Formula, CalcRule = e.CalcRule, DataSource = e.DataSource, StatFrequency = e.StatFrequency, Department = e.Department, DopFields = e.DopFields, Unit = e.Unit, Direction = e.Direction, YellowThreshold = e.YellowThreshold, RedThreshold = e.RedThreshold, IsHomePage = e.IsHomePage, SortNo = e.SortNo, Remark = e.Remark, IsEnabled = e.IsEnabled, TenantId = e.TenantId.GetValueOrDefault(), CreatedAt = e.CreatedAt, UpdatedAt = e.UpdatedAt }); } /// 新增指标(MetricCode 自动生成) [HttpPost] public async Task Create([FromBody] KpiMasterUpsertDto dto) { if (string.IsNullOrWhiteSpace(dto.MetricName)) return BadRequest(new { message = "指标名称不能为空" }); if (dto.MetricLevel < 1 || dto.MetricLevel > 3) return BadRequest(new { message = "层级必须为 1/2/3" }); if (dto.MetricLevel > 1 && dto.ParentId == null) return BadRequest(new { message = "L2/L3 必须指定父指标" }); var tenantId = AidopTenantHelper.GetTenantId(HttpContext); var code = await GenerateMetricCodeAsync(dto.ModuleCode, dto.MetricLevel, tenantId); var entity = new AdoSmartOpsKpiMaster { MetricCode = code, ModuleCode = dto.ModuleCode, MetricLevel = dto.MetricLevel, ParentId = dto.ParentId, MetricName = dto.MetricName, Description = dto.Description, Formula = dto.Formula, CalcRule = dto.CalcRule, DataSource = dto.DataSource, StatFrequency = dto.StatFrequency, Department = dto.Department, DopFields = dto.DopFields, Unit = dto.Unit, Direction = dto.Direction, YellowThreshold = dto.YellowThreshold, RedThreshold = dto.RedThreshold, IsHomePage = dto.IsHomePage, SortNo = dto.SortNo, Remark = dto.Remark, IsEnabled = dto.IsEnabled, TenantId = tenantId, CreatedAt = DateTime.Now }; var newId = await _db.Insertable(entity).ExecuteReturnBigIdentityAsync(); entity.Id = newId; return Ok(new { id = newId, metricCode = code }); } /// 编辑指标 [HttpPut("{id:long}")] public async Task Update(long id, [FromBody] KpiMasterUpsertDto dto) { var entity = await _db.Queryable().FirstAsync(x => x.Id == id); if (entity == null) return NotFound(); entity.ModuleCode = dto.ModuleCode; entity.MetricLevel = dto.MetricLevel; entity.ParentId = dto.ParentId; entity.MetricName = dto.MetricName; entity.Description = dto.Description; entity.Formula = dto.Formula; entity.CalcRule = dto.CalcRule; entity.DataSource = dto.DataSource; entity.StatFrequency = dto.StatFrequency; entity.Department = dto.Department; entity.DopFields = dto.DopFields; entity.Unit = dto.Unit; entity.Direction = dto.Direction; entity.YellowThreshold = dto.YellowThreshold; entity.RedThreshold = dto.RedThreshold; entity.IsHomePage = dto.IsHomePage; entity.SortNo = dto.SortNo; entity.Remark = dto.Remark; entity.IsEnabled = dto.IsEnabled; entity.UpdatedAt = DateTime.Now; await _db.Updateable(entity).ExecuteCommandAsync(); return Ok(new { ok = true }); } /// 删除指标(含子节点级联删除) [HttpDelete("{id:long}")] public async Task Delete(long id) { var entity = await _db.Queryable().FirstAsync(x => x.Id == id); if (entity == null) return NotFound(); var idsToDelete = new List { id }; await CollectChildIds(id, idsToDelete); await _db.Deleteable().Where(x => idsToDelete.Contains(x.Id)).ExecuteCommandAsync(); return Ok(new { ok = true, deletedCount = idsToDelete.Count }); } /// 启用/禁用 [HttpPatch("{id:long}/toggle-enabled")] public async Task ToggleEnabled(long id) { var entity = await _db.Queryable().FirstAsync(x => x.Id == id); if (entity == null) return NotFound(); entity.IsEnabled = !entity.IsEnabled; entity.UpdatedAt = DateTime.Now; await _db.Updateable(entity).UpdateColumns(x => new { x.IsEnabled, x.UpdatedAt }).ExecuteCommandAsync(); return Ok(new { ok = true, isEnabled = entity.IsEnabled }); } /// 批量更新排序 [HttpPut("sort")] public async Task UpdateSort([FromBody] List items) { if (items == null || items.Count == 0) return BadRequest(new { message = "排序列表不能为空" }); foreach (var item in items) { await _db.Updateable() .SetColumns(x => new AdoSmartOpsKpiMaster { SortNo = item.SortNo, UpdatedAt = DateTime.Now }) .Where(x => x.Id == item.Id) .ExecuteCommandAsync(); } return Ok(new { ok = true }); } /// 拖拽移动(变更 ParentId + SortNo) [HttpPut("{id:long}/move")] public async Task Move(long id, [FromBody] KpiMasterMoveDto dto) { var entity = await _db.Queryable().FirstAsync(x => x.Id == id); if (entity == null) return NotFound(); if (dto.NewParentId.HasValue) { var parent = await _db.Queryable().FirstAsync(x => x.Id == dto.NewParentId.Value); if (parent == null) return BadRequest(new { message = "目标父节点不存在" }); if (parent.MetricLevel >= entity.MetricLevel) return BadRequest(new { message = "不能移动到同级或更低层级下" }); entity.ParentId = dto.NewParentId; entity.MetricLevel = parent.MetricLevel + 1; } else { entity.ParentId = null; entity.MetricLevel = 1; } entity.SortNo = dto.NewSortNo; entity.UpdatedAt = DateTime.Now; await _db.Updateable(entity).ExecuteCommandAsync(); return Ok(new { ok = true }); } /// 导入 Demo 数据(上传 Excel,解析后生成 30 天日值写入 DB) [HttpPost("import-demo-data")] public async Task ImportDemoData(IFormFile file) { if (file == null || file.Length == 0) return BadRequest(new { message = "请上传 Excel 文件" }); var tenantId = AidopTenantHelper.GetTenantId(HttpContext); const int DAYS = 30; var today = DateTime.Today; var startDate = today.AddDays(-(DAYS - 1)); using var stream = new MemoryStream(); await file.CopyToAsync(stream); stream.Position = 0; var rows = stream.Query(useHeaderRow: true).ToList(); var masters = await _db.Queryable() .Where(x => x.TenantId == tenantId) .ToListAsync(); var masterByCode = masters.ToDictionary(m => m.MetricCode, m => m); var excelOverrides = new Dictionary(); foreach (IDictionary row in rows) { object? Val(string key) => row.TryGetValue(key, out var v) ? v : null; var code = Val("指标编码")?.ToString()?.Trim(); if (string.IsNullOrEmpty(code) || !code.StartsWith("S")) continue; if (!masterByCode.TryGetValue(code, out var master)) continue; decimal? target = TryDecimal(Val("目标值 ★")); decimal? actual = TryDecimal(Val("实际值 ★")); decimal? yellowTh = TryDecimal(Val("黄色阈值% ★")); decimal? redTh = TryDecimal(Val("红色阈值% ★")); excelOverrides[code] = new DemoImportRow { MetricCode = code, Master = master, Target = target ?? 0, Actual = actual ?? 0, HasTarget = target != null, HasActual = actual != null, YellowThreshold = yellowTh, RedThreshold = redTh, }; } var rng = new Random(42); var parsed = new List(); foreach (var m in masters) { if (excelOverrides.TryGetValue(m.MetricCode, out var ov)) { var tgt = ov.HasTarget ? ov.Target : ov.HasActual ? ov.Actual * 1.05m : AutoTarget(m, rng); var act = ov.HasActual ? ov.Actual : ov.HasTarget ? ov.Target * AutoActualFactor(m, rng) : AutoActual(AutoTarget(m, rng), m, rng); parsed.Add(new DemoImportRow { MetricCode = m.MetricCode, Master = m, Target = tgt, Actual = act, HasTarget = true, HasActual = true, YellowThreshold = ov.YellowThreshold, RedThreshold = ov.RedThreshold, }); } else { var tgt = AutoTarget(m, rng); var act = AutoActual(tgt, m, rng); parsed.Add(new DemoImportRow { MetricCode = m.MetricCode, Master = m, Target = tgt, Actual = act, HasTarget = true, HasActual = true, YellowThreshold = null, RedThreshold = null, }); } } foreach (var p in parsed) { if (p.YellowThreshold != null || p.RedThreshold != null) { var m = p.Master; if (p.YellowThreshold != null) m.YellowThreshold = p.YellowThreshold; if (p.RedThreshold != null) m.RedThreshold = p.RedThreshold; m.UpdatedAt = DateTime.Now; await _db.Updateable(m) .UpdateColumns(x => new { x.YellowThreshold, x.RedThreshold, x.UpdatedAt }) .ExecuteCommandAsync(); } } var l1 = parsed.Where(p => p.Master.MetricLevel == 1).ToList(); var l2 = parsed.Where(p => p.Master.MetricLevel == 2).ToList(); var l3 = parsed.Where(p => p.Master.MetricLevel == 3).ToList(); await _db.Ado.ExecuteCommandAsync( $"DELETE FROM ado_s9_kpi_value_l1_day WHERE tenant_id = {tenantId}"); await _db.Ado.ExecuteCommandAsync( $"DELETE FROM ado_s9_kpi_value_l2_day WHERE tenant_id = {tenantId}"); await _db.Ado.ExecuteCommandAsync( $"DELETE FROM ado_s9_kpi_value_l3_day WHERE tenant_id = {tenantId}"); long seq = 0; async Task InsertLevel(List items, string table) { if (items.Count == 0) return; var levelRng = new Random(42); var batchValues = new List(); foreach (var p in items) { var dir = p.Master.Direction ?? "higher_is_better"; var yTh = p.YellowThreshold ?? p.Master.YellowThreshold; var rTh = p.RedThreshold ?? p.Master.RedThreshold; decimal prevVal = p.Actual; for (int i = 0; i < DAYS; i++) { seq++; var d = startDate.AddDays(i); decimal val; if (i == DAYS - 1) val = p.Actual; else { var noise = (decimal)(levelRng.NextDouble() * 0.06 - 0.03); val = Math.Max(0, p.Actual * (1 + noise)); } val = Math.Round(val, 4); var sc = AidopS4KpiMerge.AchievementLevel(val, p.Target, dir, yTh, rTh); var tf = "flat"; if (i > 0) { var change = prevVal == 0 ? 0 : (val - prevVal) / prevVal; tf = change > 0.02m ? "up" : change < -0.02m ? "down" : "flat"; } prevVal = val; batchValues.Add( $"({seq},{tenantId},1,'{d:yyyy-MM-dd}'," + $"'{p.Master.ModuleCode}','{p.MetricCode}',{val},{p.Target},'{sc}','{tf}',0,1)"); } } const int BATCH = 500; for (int i = 0; i < batchValues.Count; i += BATCH) { var chunk = batchValues.Skip(i).Take(BATCH); var sql = $"INSERT INTO `{table}` " + "(id,tenant_id,factory_id,biz_date,module_code,metric_code," + "metric_value,target_value,status_color,trend_flag,is_deleted,is_active) VALUES\n" + string.Join(",\n", chunk); await _db.Ado.ExecuteCommandAsync(sql); } } await InsertLevel(l1, "ado_s9_kpi_value_l1_day"); await InsertLevel(l2, "ado_s9_kpi_value_l2_day"); await InsertLevel(l3, "ado_s9_kpi_value_l3_day"); var fromExcel = excelOverrides.Values.Count(v => v.HasTarget || v.HasActual); return Ok(new { ok = true, message = $"导入成功:Excel 填入 {fromExcel} 条,自动补全 {parsed.Count - fromExcel} 条,共 {parsed.Count} 条指标 × {DAYS} 天", detail = new { l1 = l1.Count, l2 = l2.Count, l3 = l3.Count, days = DAYS, fromExcel, autoGen = parsed.Count - fromExcel } }); } /// 下载 Demo 数据 Excel 模板 [HttpGet("demo-template")] public async Task DownloadDemoTemplate() { var tenantId = AidopTenantHelper.GetTenantId(HttpContext); var masters = await _db.Queryable() .Where(x => x.TenantId == tenantId) .OrderBy(x => x.ModuleCode).OrderBy(x => x.MetricLevel).OrderBy(x => x.SortNo) .ToListAsync(); var templateRows = masters.Select(m => new Dictionary { ["指标编码"] = m.MetricCode, ["模块"] = m.ModuleCode, ["层级"] = $"L{m.MetricLevel}", ["指标名称"] = m.MetricName, ["单位"] = m.Unit ?? "", ["方向"] = m.Direction == "lower_is_better" ? "越低越好" : "越高越好", ["目标值 ★"] = (object)null!, ["实际值 ★"] = (object)null!, ["黄色阈值% ★"] = (object)(m.YellowThreshold ?? (m.Direction == "lower_is_better" ? 110m : 95m)), ["红色阈值% ★"] = (object)(m.RedThreshold ?? (m.Direction == "lower_is_better" ? 120m : 80m)), }).ToList(); var ms = new MemoryStream(); await ms.SaveAsAsync(templateRows); ms.Position = 0; return new FileStreamResult(ms, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") { FileDownloadName = $"demo_data_template_{DateTime.Now:yyyyMMdd}.xlsx" }; } private static decimal? TryDecimal(object? val) { if (val == null) return null; var s = val.ToString()?.Trim(); if (string.IsNullOrEmpty(s)) return null; return decimal.TryParse(s, out var d) ? d : null; } private sealed class DemoImportRow { public string MetricCode { get; set; } = ""; public AdoSmartOpsKpiMaster Master { get; set; } = null!; public decimal Target { get; set; } public decimal Actual { get; set; } public bool HasTarget { get; set; } public bool HasActual { get; set; } public decimal? YellowThreshold { get; set; } public decimal? RedThreshold { get; set; } } private static decimal AutoTarget(AdoSmartOpsKpiMaster m, Random rng) { var unit = (m.Unit ?? "").Trim(); if (unit == "%") return Math.Round((decimal)(rng.NextDouble() * 18 + 80), 2); if (unit == "天") return Math.Round((decimal)(rng.NextDouble() * 13 + 2), 2); if (unit.Contains("PPM")) return Math.Round((decimal)(rng.NextDouble() * 5000 + 3000), 0); if (unit.Contains("/")) return Math.Round((decimal)(rng.NextDouble() * 250 + 50), 2); if (unit is "个" or "颗/人" or "家/人" or "小时" or "颗/人" or "人") return Math.Round((decimal)(rng.NextDouble() * 195 + 5), 0); return Math.Round((decimal)(rng.NextDouble() * 150 + 50), 2); } private static decimal AutoActual(decimal target, AdoSmartOpsKpiMaster m, Random rng) { var factor = (m.Direction == "lower_is_better") ? (decimal)(rng.NextDouble() * 0.35 + 0.90) : (decimal)(rng.NextDouble() * 0.23 + 0.82); return Math.Round(target * factor, 4); } private static decimal AutoActualFactor(AdoSmartOpsKpiMaster m, Random rng) { return (m.Direction == "lower_is_better") ? (decimal)(rng.NextDouble() * 0.35 + 0.90) : (decimal)(rng.NextDouble() * 0.23 + 0.82); } // ── 私有方法 ── private async Task GenerateMetricCodeAsync(string moduleCode, int level, long tenantId) { var prefix = $"{moduleCode}_L{level}_"; var maxCode = await _db.Queryable() .Where(x => x.TenantId == tenantId && x.ModuleCode == moduleCode && x.MetricLevel == level) .OrderByDescending(x => x.MetricCode) .Select(x => x.MetricCode) .FirstAsync(); var seq = 1; if (!string.IsNullOrEmpty(maxCode) && maxCode.StartsWith(prefix) && int.TryParse(maxCode[prefix.Length..], out var n)) seq = n + 1; return $"{prefix}{seq:D3}"; } private async Task CollectChildIds(long parentId, List result) { var children = await _db.Queryable() .Where(x => x.ParentId == parentId) .Select(x => x.Id) .ToListAsync(); foreach (var cid in children) { result.Add(cid); await CollectChildIds(cid, result); } } private static List BuildTree(List flat) { var byId = flat.ToDictionary(n => n.Id); var roots = new List(); foreach (var n in flat) { if (n.ParentId.HasValue && byId.TryGetValue(n.ParentId.Value, out var parent)) parent.Children.Add(n); else roots.Add(n); } SortChildren(roots); return roots; } private static void SortChildren(List nodes) { nodes.Sort((a, b) => a.SortNo.CompareTo(b.SortNo)); foreach (var n in nodes) { if (n.Children.Count > 0) SortChildren(n.Children); } } }