using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure; namespace Admin.NET.Plugin.AiDOP.Service.S8; /// /// S8 大屏卡片配置(ado_s8_dashboard_cell_config)CRUD。 /// 列表合并全局基线(0/0)与工厂覆盖,同 (pageCode, cellCode) 以工厂记录优先。 /// public class S8DashboardCellConfigService : ITransient { private readonly SqlSugarRepository _rep; public S8DashboardCellConfigService(SqlSugarRepository rep) => _rep = rep; public async Task> ListAsync(long tenantId, long factoryId) { var all = await _rep.AsQueryable() .Where(x => (x.TenantId == 0 && x.FactoryId == 0) || (x.TenantId == tenantId && x.FactoryId == factoryId)) .ToListAsync(); // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:同 (page_code, cell_code) 取工厂覆盖优先的唯一有效行, // 并标记该业务键是否存在平台默认,供前端区分「工厂覆盖」与「本工厂自建」。 var globalKeys = all .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId)) .Select(x => (x.PageCode, x.CellCode)) .ToHashSet(); var effective = all .GroupBy(x => (x.PageCode, x.CellCode)) .Select(g => g.OrderByDescending(x => x.FactoryId).First()) .OrderBy(x => x.PageCode) .ThenBy(x => x.SortNo) .ThenBy(x => x.CellCode) .ToList(); foreach (var row in effective) row.HasGlobalDefault = globalKeys.Contains((row.PageCode, row.CellCode)); return effective; } // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。 // 新建一律落工厂覆盖行;全局基线 (0/0) 只能由种子 / 运维脚本维护,不接受租户侧写入。 public async Task CreateAsync(AdoS8DashboardCellConfig body, S8TrustedScope scope) { ValidateAndNormalize(body); body.TenantId = scope.TenantId; body.FactoryId = scope.FactoryId; var exists = await _rep.AsQueryable() .AnyAsync(x => x.TenantId == body.TenantId && x.FactoryId == body.FactoryId && x.PageCode == body.PageCode && x.CellCode == body.CellCode); if (exists) throw new S8BizException("同一工厂下页面与卡片编码组合已存在"); body.Id = 0; body.CreatedAt = DateTime.Now; body.UpdatedAt = null; await _rep.InsertAsync(body); return body; } // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。 // 全局基线 (0/0) 不在任何租户作用域内,因此不可被租户侧改写——覆盖请新建本工厂行(列表按工厂优先合并)。 public async Task UpdateAsync(long id, AdoS8DashboardCellConfig body, S8TrustedScope scope) { var e = await LoadScopedAsync(id, scope); ValidateAndNormalize(body); var dup = await _rep.AsQueryable() .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId && x.FactoryId == e.FactoryId && x.PageCode == body.PageCode && x.CellCode == body.CellCode); if (dup) throw new S8BizException("同一工厂下页面与卡片编码组合已存在"); body.Id = id; body.TenantId = e.TenantId; body.FactoryId = e.FactoryId; body.CreatedAt = e.CreatedAt; body.UpdatedAt = DateTime.Now; await _rep.UpdateAsync(body); return body; } // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。 public async Task DeleteAsync(long id, S8TrustedScope scope) { var e = await LoadScopedAsync(id, scope); await _rep.DeleteByIdAsync(e.Id); } /// /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂(创建工厂覆盖)。 /// 复制平台默认行业务字段 → 服务端盖章可信租户 / 工厂 → 存为工厂覆盖行;平台默认行不被修改。 /// 已存在同 (page_code, cell_code) 工厂覆盖时幂等返回既有行。 /// public async Task CreateFactoryOverrideAsync(long globalId, S8TrustedScope scope) { var global = await _rep.AsQueryable() .Where(x => x.Id == globalId && x.TenantId == S8ConfigScope.GlobalTenantId && x.FactoryId == S8ConfigScope.GlobalFactoryId) .FirstAsync() ?? throw new S8NotFoundException("平台默认配置不存在"); var existing = await _rep.AsQueryable() .Where(x => x.PageCode == global.PageCode && x.CellCode == global.CellCode && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId) .FirstAsync(); if (existing != null) return existing; var copy = new AdoS8DashboardCellConfig { Id = 0, TenantId = scope.TenantId, FactoryId = scope.FactoryId, PageCode = global.PageCode, CellCode = global.CellCode, CellTitle = global.CellTitle, Icon = global.Icon, LayoutArea = global.LayoutArea, DisplayMode = global.DisplayMode, SortNo = global.SortNo, BindingType = global.BindingType, ExceptionTypeCode = global.ExceptionTypeCode, AggregateScope = global.AggregateScope, StatMetric = global.StatMetric, TimeWindow = global.TimeWindow, DeptGroupBy = global.DeptGroupBy, ShowInSidebar = global.ShowInSidebar, FilterExpression = global.FilterExpression, Enabled = global.Enabled, CreatedAt = DateTime.Now, UpdatedAt = null, }; copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync(); return copy; } /// /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。 /// 只删除当前工厂覆盖行,平台默认行不受影响。 /// public async Task ResetToGlobalDefaultAsync(long overrideId, S8TrustedScope scope) { var e = await LoadScopedAsync(overrideId, scope); await _rep.DeleteByIdAsync(e.Id); } private async Task LoadScopedAsync(long id, S8TrustedScope scope) => await _rep.AsQueryable() .Where(x => x.Id == id && x.TenantId == scope.TenantId && x.FactoryId == scope.FactoryId) .FirstAsync() ?? throw new S8NotFoundException(); /// /// 获取指定页面的渲染配置(G-09 一期)。 /// 合并规则:全局基线 (0/0) + 工厂覆盖 (tenantId/factoryId),同 cell_code 工厂优先; /// 过滤规则:合并后再过滤 enabled=true(禁止读取 override 时提前过滤 enabled); /// 排序规则:layout_area(MODULES→ANALYSIS→SIDEBAR)→ sort_no → cell_code。 /// 本接口只读 ado_s8_dashboard_cell_config,不触发 ado_s8_exception 查询。 /// public async Task GetPageConfigAsync(AdoS8PageConfigQueryDto q) { // 1. pageCode 严格校验(大写精确匹配,非法返回 400) if (string.IsNullOrWhiteSpace(q.PageCode)) throw new S8BizException("pageCode 不能为空"); var allowed = new[] { "OVERVIEW", "DELIVERY", "PRODUCTION", "SUPPLY" }; if (!allowed.Contains(q.PageCode)) throw new S8BizException($"Invalid pageCode: {q.PageCode}"); // 2. 取全局基线(不过滤 enabled) var baseline = await _rep.AsQueryable() .Where(x => x.TenantId == 0 && x.FactoryId == 0 && x.PageCode == q.PageCode) .ToListAsync(); // 3. 取工厂覆盖(若有;同样不过滤 enabled,否则工厂显式关闭的卡会被基线重新激活) var overrides = (q.TenantId != 0 || q.FactoryId != 0) ? await _rep.AsQueryable() .Where(x => x.TenantId == q.TenantId && x.FactoryId == q.FactoryId && x.PageCode == q.PageCode) .ToListAsync() : new List(); // 4. 按 cell_code 合并,工厂覆盖优先 var merged = new Dictionary(); foreach (var cfg in baseline) merged[cfg.CellCode] = cfg; foreach (var cfg in overrides) merged[cfg.CellCode] = cfg; // 5. 合并后再过滤 enabled=true,然后按 layout_area → sort_no → cell_code 排序 var ordered = merged.Values .Where(c => c.Enabled) .OrderBy(c => LayoutAreaOrder(c.LayoutArea)) .ThenBy(c => c.SortNo) .ThenBy(c => c.CellCode) .ToList(); return new AdoS8PageConfigDto { PageCode = q.PageCode, Cells = ordered.Select(ToCellDto).ToList(), }; } private static int LayoutAreaOrder(string? area) => (area ?? string.Empty).ToUpperInvariant() switch { "MODULES" => 1, "ANALYSIS" => 2, "SIDEBAR" => 3, _ => 99, }; private static AdoS8PageConfigCellDto ToCellDto(AdoS8DashboardCellConfig c) => new() { CellCode = c.CellCode, CellTitle = c.CellTitle, Icon = c.Icon, LayoutArea = string.IsNullOrWhiteSpace(c.LayoutArea) ? "ANALYSIS" : c.LayoutArea, DisplayMode = string.IsNullOrWhiteSpace(c.DisplayMode) ? "CATEGORY_CARD" : c.DisplayMode, SortNo = c.SortNo, BindingType = c.BindingType, ExceptionTypeCode = c.ExceptionTypeCode, AggregateScope = c.AggregateScope, StatMetric = c.StatMetric, TimeWindow = c.TimeWindow, DeptGroupBy = c.DeptGroupBy, Enabled = c.Enabled, // 注意:不映射 ShowInSidebar / FilterExpression / TenantId / FactoryId / Id / CreatedAt / UpdatedAt }; private static void ValidateAndNormalize(AdoS8DashboardCellConfig body) { if (string.IsNullOrWhiteSpace(body.PageCode) || string.IsNullOrWhiteSpace(body.CellCode)) throw new S8BizException("页面编码与卡片编码必填"); body.PageCode = body.PageCode.Trim(); body.CellCode = body.CellCode.Trim(); if (body.CellTitle != null) body.CellTitle = body.CellTitle.Trim(); if (string.IsNullOrWhiteSpace(body.BindingType)) body.BindingType = "CUSTOM"; body.BindingType = body.BindingType.Trim().ToUpperInvariant(); if (body.BindingType is not ("EXCEPTION_TYPE" or "AGGREGATE" or "CUSTOM")) throw new S8BizException("绑定类型须为 EXCEPTION_TYPE / AGGREGATE / CUSTOM"); switch (body.BindingType) { case "EXCEPTION_TYPE": if (string.IsNullOrWhiteSpace(body.ExceptionTypeCode)) throw new S8BizException("绑定类型为异常类型时须填写异常类型编码"); body.ExceptionTypeCode = body.ExceptionTypeCode.Trim(); body.AggregateScope = null; break; case "AGGREGATE": if (string.IsNullOrWhiteSpace(body.AggregateScope)) throw new S8BizException("绑定类型为域聚合时须选择聚合范围"); body.AggregateScope = body.AggregateScope!.Trim(); body.ExceptionTypeCode = null; break; default: body.ExceptionTypeCode = null; body.AggregateScope = null; break; } if (string.IsNullOrWhiteSpace(body.StatMetric)) body.StatMetric = "OPEN_COUNT"; body.StatMetric = body.StatMetric.Trim().ToUpperInvariant(); if (body.StatMetric is not ("OPEN_COUNT" or "FREQUENCY" or "AVG_DURATION" or "CLOSE_RATE")) throw new S8BizException("统计指标须为 OPEN_COUNT / FREQUENCY / AVG_DURATION / CLOSE_RATE"); if (string.IsNullOrWhiteSpace(body.TimeWindow)) body.TimeWindow = "LAST_24H"; body.TimeWindow = body.TimeWindow.Trim().ToUpperInvariant(); if (body.TimeWindow is not ("TODAY" or "LAST_24H" or "LAST_7D" or "LAST_30D")) throw new S8BizException("时间窗须为 TODAY / LAST_24H / LAST_7D / LAST_30D"); if (string.IsNullOrWhiteSpace(body.DeptGroupBy)) body.DeptGroupBy = "OWNER"; body.DeptGroupBy = body.DeptGroupBy.Trim().ToUpperInvariant(); if (body.DeptGroupBy is not ("OWNER" or "OCCUR")) throw new S8BizException("部门聚合维度须为 OWNER(责任部门)或 OCCUR(发生部门)"); if (body.FilterExpression != null && body.FilterExpression.Length > 1000) throw new S8BizException("筛选表达式过长"); } }