S8DashboardCellConfigService.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure;
  4. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  5. /// <summary>
  6. /// S8 大屏卡片配置(ado_s8_dashboard_cell_config)CRUD。
  7. /// 列表合并全局基线(0/0)与工厂覆盖,同 (pageCode, cellCode) 以工厂记录优先。
  8. /// </summary>
  9. public class S8DashboardCellConfigService : ITransient
  10. {
  11. private readonly SqlSugarRepository<AdoS8DashboardCellConfig> _rep;
  12. public S8DashboardCellConfigService(SqlSugarRepository<AdoS8DashboardCellConfig> rep) => _rep = rep;
  13. public async Task<List<AdoS8DashboardCellConfig>> ListAsync(long tenantId)
  14. {
  15. var all = await _rep.AsQueryable()
  16. .Where(x => x.TenantId == S8ConfigScope.GlobalTenantId || x.TenantId == tenantId)
  17. .ToListAsync();
  18. // S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:同 (page_code, cell_code) 取工厂覆盖优先的唯一有效行,
  19. // 并标记该业务键是否存在平台默认,供前端区分「工厂覆盖」与「本工厂自建」。
  20. var globalKeys = all
  21. .Where(x => S8ConfigScope.IsGlobal(x.TenantId, x.FactoryId))
  22. .Select(x => (x.PageCode, x.CellCode))
  23. .ToHashSet();
  24. var effective = all
  25. .GroupBy(x => (x.PageCode, x.CellCode))
  26. .Select(g => g.OrderByDescending(x => x.TenantId != S8ConfigScope.GlobalTenantId).First())
  27. .OrderBy(x => x.PageCode)
  28. .ThenBy(x => x.SortNo)
  29. .ThenBy(x => x.CellCode)
  30. .ToList();
  31. foreach (var row in effective)
  32. row.HasGlobalDefault = globalKeys.Contains((row.PageCode, row.CellCode));
  33. return effective;
  34. }
  35. // S8-TENANT-FACTORY-P0-CLOSURE-1:归属一律由服务端可信作用域盖章,忽略 body.TenantId / body.FactoryId。
  36. // 新建一律落工厂覆盖行;全局基线 (0/0) 只能由种子 / 运维脚本维护,不接受租户侧写入。
  37. public async Task<AdoS8DashboardCellConfig> CreateAsync(AdoS8DashboardCellConfig body, S8TrustedScope scope)
  38. {
  39. ValidateAndNormalize(body);
  40. body.TenantId = scope.TenantId;
  41. body.FactoryId = scope.LegacyFactoryId; // S8-TENANT-ONLY-BATCH6:兼容列,恒 0
  42. var exists = await _rep.AsQueryable()
  43. .AnyAsync(x => x.TenantId == body.TenantId
  44. && x.PageCode == body.PageCode && x.CellCode == body.CellCode);
  45. if (exists) throw new S8BizException("同一工厂下页面与卡片编码组合已存在");
  46. body.Id = 0;
  47. body.CreatedAt = DateTime.Now;
  48. body.UpdatedAt = null;
  49. await _rep.InsertAsync(body);
  50. return body;
  51. }
  52. // S8-TENANT-FACTORY-P0-CLOSURE-1:按 Id + 可信作用域绑行;越权 Id 视为不存在,归属不可被 body 改写。
  53. // 全局基线 (0/0) 不在任何租户作用域内,因此不可被租户侧改写——覆盖请新建本工厂行(列表按工厂优先合并)。
  54. public async Task<AdoS8DashboardCellConfig> UpdateAsync(long id, AdoS8DashboardCellConfig body, S8TrustedScope scope)
  55. {
  56. var e = await LoadScopedAsync(id, scope);
  57. ValidateAndNormalize(body);
  58. var dup = await _rep.AsQueryable()
  59. .AnyAsync(x => x.Id != id && x.TenantId == e.TenantId
  60. && x.PageCode == body.PageCode && x.CellCode == body.CellCode);
  61. if (dup) throw new S8BizException("同一工厂下页面与卡片编码组合已存在");
  62. body.Id = id;
  63. body.TenantId = e.TenantId;
  64. body.FactoryId = e.FactoryId;
  65. body.CreatedAt = e.CreatedAt;
  66. body.UpdatedAt = DateTime.Now;
  67. await _rep.UpdateAsync(body);
  68. return body;
  69. }
  70. // S8-TENANT-FACTORY-P0-CLOSURE-1:删除必须先按可信作用域绑行,禁止裸 DeleteByIdAsync(id)。
  71. public async Task DeleteAsync(long id, S8TrustedScope scope)
  72. {
  73. var e = await LoadScopedAsync(id, scope);
  74. await _rep.DeleteByIdAsync(e.Id);
  75. }
  76. /// <summary>
  77. /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:自定义本工厂(创建工厂覆盖)。
  78. /// 复制平台默认行业务字段 → 服务端盖章可信租户 / 工厂 → 存为工厂覆盖行;平台默认行不被修改。
  79. /// 已存在同 (page_code, cell_code) 工厂覆盖时幂等返回既有行。
  80. /// </summary>
  81. public async Task<AdoS8DashboardCellConfig> CreateFactoryOverrideAsync(long globalId, S8TrustedScope scope)
  82. {
  83. var global = await _rep.AsQueryable()
  84. .Where(x => x.Id == globalId
  85. && x.TenantId == S8ConfigScope.GlobalTenantId
  86. && x.FactoryId == S8ConfigScope.GlobalFactoryId)
  87. .FirstAsync() ?? throw new S8NotFoundException("平台默认配置不存在");
  88. var existing = await _rep.AsQueryable()
  89. .Where(x => x.PageCode == global.PageCode && x.CellCode == global.CellCode
  90. && x.TenantId == scope.TenantId)
  91. .FirstAsync();
  92. if (existing != null) return existing;
  93. var copy = new AdoS8DashboardCellConfig
  94. {
  95. Id = 0,
  96. TenantId = scope.TenantId,
  97. FactoryId = scope.LegacyFactoryId,
  98. PageCode = global.PageCode,
  99. CellCode = global.CellCode,
  100. CellTitle = global.CellTitle,
  101. Icon = global.Icon,
  102. LayoutArea = global.LayoutArea,
  103. DisplayMode = global.DisplayMode,
  104. SortNo = global.SortNo,
  105. BindingType = global.BindingType,
  106. ExceptionTypeCode = global.ExceptionTypeCode,
  107. AggregateScope = global.AggregateScope,
  108. StatMetric = global.StatMetric,
  109. TimeWindow = global.TimeWindow,
  110. DeptGroupBy = global.DeptGroupBy,
  111. ShowInSidebar = global.ShowInSidebar,
  112. FilterExpression = global.FilterExpression,
  113. Enabled = global.Enabled,
  114. CreatedAt = DateTime.Now,
  115. UpdatedAt = null,
  116. };
  117. copy.Id = await _rep.AsInsertable(copy).ExecuteReturnBigIdentityAsync();
  118. return copy;
  119. }
  120. /// <summary>
  121. /// S8-CONFIG-GLOBAL-ROW-SEMANTICS-AND-KPI-TARGET-1:恢复平台默认。
  122. /// 只删除当前工厂覆盖行,平台默认行不受影响。
  123. /// </summary>
  124. public async Task ResetToGlobalDefaultAsync(long overrideId, S8TrustedScope scope)
  125. {
  126. var e = await LoadScopedAsync(overrideId, scope);
  127. await _rep.DeleteByIdAsync(e.Id);
  128. }
  129. private async Task<AdoS8DashboardCellConfig> LoadScopedAsync(long id, S8TrustedScope scope) =>
  130. await _rep.AsQueryable()
  131. .Where(x => x.Id == id && x.TenantId == scope.TenantId)
  132. .FirstAsync() ?? throw new S8NotFoundException();
  133. /// <summary>
  134. /// 获取指定页面的渲染配置(G-09 一期)。
  135. /// 合并规则:全局基线 (0/0) + 工厂覆盖 (tenantId/factoryId),同 cell_code 工厂优先;
  136. /// 过滤规则:合并后再过滤 enabled=true(禁止读取 override 时提前过滤 enabled);
  137. /// 排序规则:layout_area(MODULES→ANALYSIS→SIDEBAR)→ sort_no → cell_code。
  138. /// 本接口只读 ado_s8_dashboard_cell_config,不触发 ado_s8_exception 查询。
  139. /// </summary>
  140. public async Task<AdoS8PageConfigDto> GetPageConfigAsync(AdoS8PageConfigQueryDto q)
  141. {
  142. // 1. pageCode 严格校验(大写精确匹配,非法返回 400)
  143. if (string.IsNullOrWhiteSpace(q.PageCode))
  144. throw new S8BizException("pageCode 不能为空");
  145. var allowed = new[] { "OVERVIEW", "DELIVERY", "PRODUCTION", "SUPPLY" };
  146. if (!allowed.Contains(q.PageCode))
  147. throw new S8BizException($"Invalid pageCode: {q.PageCode}");
  148. // 2. 取全局基线(不过滤 enabled)
  149. var baseline = await _rep.AsQueryable()
  150. .Where(x => x.TenantId == 0 && x.FactoryId == 0 && x.PageCode == q.PageCode)
  151. .ToListAsync();
  152. // 3. 取租户覆盖(若有;同样不过滤 enabled,否则租户显式关闭的卡会被基线重新激活)
  153. // S8-TENANT-ONLY-BATCH6:判据由 (tenant, factory) 收敛为 tenant。
  154. var overrides = q.TenantId != S8ConfigScope.GlobalTenantId
  155. ? await _rep.AsQueryable()
  156. .Where(x => x.TenantId == q.TenantId && x.PageCode == q.PageCode)
  157. .ToListAsync()
  158. : new List<AdoS8DashboardCellConfig>();
  159. // 4. 按 cell_code 合并,租户覆盖优先
  160. var merged = new Dictionary<string, AdoS8DashboardCellConfig>();
  161. foreach (var cfg in baseline) merged[cfg.CellCode] = cfg;
  162. foreach (var cfg in overrides) merged[cfg.CellCode] = cfg;
  163. // 5. 合并后再过滤 enabled=true,然后按 layout_area → sort_no → cell_code 排序
  164. var ordered = merged.Values
  165. .Where(c => c.Enabled)
  166. .OrderBy(c => LayoutAreaOrder(c.LayoutArea))
  167. .ThenBy(c => c.SortNo)
  168. .ThenBy(c => c.CellCode)
  169. .ToList();
  170. return new AdoS8PageConfigDto
  171. {
  172. PageCode = q.PageCode,
  173. Cells = ordered.Select(ToCellDto).ToList(),
  174. };
  175. }
  176. private static int LayoutAreaOrder(string? area) => (area ?? string.Empty).ToUpperInvariant() switch
  177. {
  178. "MODULES" => 1,
  179. "ANALYSIS" => 2,
  180. "SIDEBAR" => 3,
  181. _ => 99,
  182. };
  183. private static AdoS8PageConfigCellDto ToCellDto(AdoS8DashboardCellConfig c) => new()
  184. {
  185. CellCode = c.CellCode,
  186. CellTitle = c.CellTitle,
  187. Icon = c.Icon,
  188. LayoutArea = string.IsNullOrWhiteSpace(c.LayoutArea) ? "ANALYSIS" : c.LayoutArea,
  189. DisplayMode = string.IsNullOrWhiteSpace(c.DisplayMode) ? "CATEGORY_CARD" : c.DisplayMode,
  190. SortNo = c.SortNo,
  191. BindingType = c.BindingType,
  192. ExceptionTypeCode = c.ExceptionTypeCode,
  193. AggregateScope = c.AggregateScope,
  194. StatMetric = c.StatMetric,
  195. TimeWindow = c.TimeWindow,
  196. DeptGroupBy = c.DeptGroupBy,
  197. Enabled = c.Enabled,
  198. // 注意:不映射 ShowInSidebar / FilterExpression / TenantId / FactoryId / Id / CreatedAt / UpdatedAt
  199. };
  200. private static void ValidateAndNormalize(AdoS8DashboardCellConfig body)
  201. {
  202. if (string.IsNullOrWhiteSpace(body.PageCode) || string.IsNullOrWhiteSpace(body.CellCode))
  203. throw new S8BizException("页面编码与卡片编码必填");
  204. body.PageCode = body.PageCode.Trim();
  205. body.CellCode = body.CellCode.Trim();
  206. if (body.CellTitle != null) body.CellTitle = body.CellTitle.Trim();
  207. if (string.IsNullOrWhiteSpace(body.BindingType))
  208. body.BindingType = "CUSTOM";
  209. body.BindingType = body.BindingType.Trim().ToUpperInvariant();
  210. if (body.BindingType is not ("EXCEPTION_TYPE" or "AGGREGATE" or "CUSTOM"))
  211. throw new S8BizException("绑定类型须为 EXCEPTION_TYPE / AGGREGATE / CUSTOM");
  212. switch (body.BindingType)
  213. {
  214. case "EXCEPTION_TYPE":
  215. if (string.IsNullOrWhiteSpace(body.ExceptionTypeCode))
  216. throw new S8BizException("绑定类型为异常类型时须填写异常类型编码");
  217. body.ExceptionTypeCode = body.ExceptionTypeCode.Trim();
  218. body.AggregateScope = null;
  219. break;
  220. case "AGGREGATE":
  221. if (string.IsNullOrWhiteSpace(body.AggregateScope))
  222. throw new S8BizException("绑定类型为域聚合时须选择聚合范围");
  223. body.AggregateScope = body.AggregateScope!.Trim();
  224. body.ExceptionTypeCode = null;
  225. break;
  226. default:
  227. body.ExceptionTypeCode = null;
  228. body.AggregateScope = null;
  229. break;
  230. }
  231. if (string.IsNullOrWhiteSpace(body.StatMetric)) body.StatMetric = "OPEN_COUNT";
  232. body.StatMetric = body.StatMetric.Trim().ToUpperInvariant();
  233. if (body.StatMetric is not ("OPEN_COUNT" or "FREQUENCY" or "AVG_DURATION" or "CLOSE_RATE"))
  234. throw new S8BizException("统计指标须为 OPEN_COUNT / FREQUENCY / AVG_DURATION / CLOSE_RATE");
  235. if (string.IsNullOrWhiteSpace(body.TimeWindow)) body.TimeWindow = "LAST_24H";
  236. body.TimeWindow = body.TimeWindow.Trim().ToUpperInvariant();
  237. if (body.TimeWindow is not ("TODAY" or "LAST_24H" or "LAST_7D" or "LAST_30D"))
  238. throw new S8BizException("时间窗须为 TODAY / LAST_24H / LAST_7D / LAST_30D");
  239. if (string.IsNullOrWhiteSpace(body.DeptGroupBy)) body.DeptGroupBy = "OWNER";
  240. body.DeptGroupBy = body.DeptGroupBy.Trim().ToUpperInvariant();
  241. if (body.DeptGroupBy is not ("OWNER" or "OCCUR"))
  242. throw new S8BizException("部门聚合维度须为 OWNER(责任部门)或 OCCUR(发生部门)");
  243. if (body.FilterExpression != null && body.FilterExpression.Length > 1000)
  244. throw new S8BizException("筛选表达式过长");
  245. }
  246. }