S8DashboardCellConfigService.cs 13 KB

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