| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279 |
- 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;
- /// <summary>
- /// S8 大屏卡片配置(ado_s8_dashboard_cell_config)CRUD。
- /// 列表合并全局基线(0/0)与工厂覆盖,同 (pageCode, cellCode) 以工厂记录优先。
- /// </summary>
- public class S8DashboardCellConfigService : ITransient
- {
- private readonly SqlSugarRepository<AdoS8DashboardCellConfig> _rep;
- public S8DashboardCellConfigService(SqlSugarRepository<AdoS8DashboardCellConfig> rep) => _rep = rep;
- public async Task<List<AdoS8DashboardCellConfig>> 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<AdoS8DashboardCellConfig> 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<AdoS8DashboardCellConfig> 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);
- }
- /// <summary>
- /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂(创建工厂覆盖)。
- /// 复制平台默认行业务字段 → 服务端盖章可信租户 / 工厂 → 存为工厂覆盖行;平台默认行不被修改。
- /// 已存在同 (page_code, cell_code) 工厂覆盖时幂等返回既有行。
- /// </summary>
- public async Task<AdoS8DashboardCellConfig> 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;
- }
- /// <summary>
- /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。
- /// 只删除当前工厂覆盖行,平台默认行不受影响。
- /// </summary>
- public async Task ResetToGlobalDefaultAsync(long overrideId, S8TrustedScope scope)
- {
- var e = await LoadScopedAsync(overrideId, scope);
- await _rep.DeleteByIdAsync(e.Id);
- }
- private async Task<AdoS8DashboardCellConfig> 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();
- /// <summary>
- /// 获取指定页面的渲染配置(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 查询。
- /// </summary>
- public async Task<AdoS8PageConfigDto> 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<AdoS8DashboardCellConfig>();
- // 4. 按 cell_code 合并,工厂覆盖优先
- var merged = new Dictionary<string, AdoS8DashboardCellConfig>();
- 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("筛选表达式过长");
- }
- }
|