S8ManualReportService.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. using Admin.NET.Plugin.AiDOP.Dto.S8;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
  3. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  4. using Admin.NET.Plugin.AiDOP.Entity.S8;
  5. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  6. using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
  7. using Admin.NET.Plugin.ApprovalFlow.Service;
  8. using Microsoft.Extensions.Logging;
  9. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  10. public class S8ManualReportService : ITransient
  11. {
  12. // 合法严重度白名单(与 GetFormOptionsAsync.severities 同源);前端/后端默认值 MEDIUM。
  13. private static readonly HashSet<string> AllowedSeverities = new(StringComparer.Ordinal)
  14. {
  15. "CRITICAL", "HIGH", "MEDIUM", "LOW"
  16. };
  17. // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 当前阶段对齐 S1-S7 订单主流程。
  18. // 优先 module_code(已是 S1-S7),其次按 scene_code 反推;都无法识别则 null。
  19. private static string? ResolveProcessNodeCode(string? sceneCode, string? moduleCode)
  20. {
  21. if (!string.IsNullOrWhiteSpace(moduleCode) && S8ModuleCode.All.Contains(moduleCode))
  22. return moduleCode;
  23. var fromScene = S8ModuleCode.FromScene(sceneCode);
  24. if (!string.IsNullOrWhiteSpace(fromScene) && S8ModuleCode.All.Contains(fromScene))
  25. return fromScene;
  26. return null;
  27. }
  28. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  29. private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
  30. private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
  31. private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
  32. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  33. private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
  34. private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
  35. private readonly UserManager _userManager;
  36. private readonly FlowEngineService _flowEngine;
  37. private readonly ILogger<S8ManualReportService> _logger;
  38. public S8ManualReportService(
  39. SqlSugarRepository<AdoS8Exception> rep,
  40. SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
  41. SqlSugarRepository<AdoS8Evidence> evidenceRep,
  42. SqlSugarRepository<AdoS8SceneConfig> sceneRep,
  43. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  44. SqlSugarRepository<AdoS0LineMaster> lineRep,
  45. SqlSugarRepository<AdoS8ExceptionType> typeRep,
  46. UserManager userManager,
  47. FlowEngineService flowEngine,
  48. ILogger<S8ManualReportService> logger)
  49. {
  50. _rep = rep;
  51. _timelineRep = timelineRep;
  52. _evidenceRep = evidenceRep;
  53. _sceneRep = sceneRep;
  54. _deptRep = deptRep;
  55. _lineRep = lineRep;
  56. _typeRep = typeRep;
  57. _userManager = userManager;
  58. _flowEngine = flowEngine;
  59. _logger = logger;
  60. }
  61. /// <summary>
  62. /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
  63. /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
  64. /// (tenantId 命中 OR 0) AND (factoryId 命中 OR 0)。ClearFilter 兜底全局多租户过滤器。
  65. /// 找不到返回 null(保持兼容)。
  66. /// </summary>
  67. private async Task<string?> InferExceptionTypeCodeAsync(long tenantId, long factoryId, string sceneCode)
  68. {
  69. if (string.IsNullOrWhiteSpace(sceneCode)) return null;
  70. return await _typeRep.AsQueryable().ClearFilter()
  71. .Where(x => (x.TenantId == tenantId || x.TenantId == 0)
  72. && (x.FactoryId == factoryId || x.FactoryId == 0)
  73. && x.SceneCode == sceneCode && x.Enabled)
  74. .OrderBy(x => x.SortNo)
  75. .Select(x => x.TypeCode)
  76. .FirstAsync();
  77. }
  78. /// <summary>
  79. /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
  80. /// </summary>
  81. private async Task TryStartIntakeFlowAsync(AdoS8Exception entity)
  82. {
  83. try
  84. {
  85. await _flowEngine.StartFlow(new StartFlowInput
  86. {
  87. BizType = "EXCEPTION_REPORT",
  88. BizId = entity.Id,
  89. BizNo = entity.ExceptionCode,
  90. Title = $"异常提报 - {entity.ExceptionCode}",
  91. Comment = entity.SourceType == "AUTO_WATCH" ? "自动监控触发" : "主动提报触发",
  92. BizData = new Dictionary<string, object>
  93. {
  94. ["sceneCode"] = entity.SceneCode ?? "",
  95. ["exceptionTypeCode"] = entity.ExceptionTypeCode ?? "",
  96. ["sourceType"] = entity.SourceType ?? ""
  97. }
  98. });
  99. }
  100. catch (Exception ex)
  101. {
  102. _logger.LogWarning(ex, "TB001 异常提报审批流触发失败 ExceptionId={Id} ExceptionCode={Code}", entity.Id, entity.ExceptionCode);
  103. }
  104. }
  105. public async Task<object> GetFormOptionsAsync(long tenantId, long factoryId)
  106. {
  107. var scenes = await _sceneRep.AsQueryable()
  108. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
  109. .OrderBy(x => x.SortNo)
  110. .Select(x => new { value = x.SceneCode, label = x.SceneName })
  111. .ToListAsync();
  112. // ClearFilter:DepartmentMaster.tenant_id 属 S0 域租户,不与登录 token TenantId 一致;
  113. // 用 factory_ref_id 做硬边界,安全等价。同 BUG-S8-EMPLOYEES-TENANT-FILTER 协议。
  114. var departments = await _deptRep.AsQueryable().ClearFilter()
  115. .Where(x => x.FactoryRefId == factoryId)
  116. .OrderBy(x => x.Department)
  117. .Take(500)
  118. .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
  119. .ToListAsync();
  120. var lines = await _lineRep.AsQueryable().ClearFilter()
  121. .Where(x => x.FactoryRefId == factoryId)
  122. .OrderBy(x => x.Line)
  123. .Take(500)
  124. .Select(x => new { value = x.Id, label = x.Describe ?? x.Line })
  125. .ToListAsync();
  126. return new
  127. {
  128. scenes,
  129. severities = new[]
  130. {
  131. new { value = "CRITICAL", label = "紧急" },
  132. new { value = "HIGH", label = "高" },
  133. new { value = "MEDIUM", label = "中" },
  134. new { value = "LOW", label = "低" }
  135. },
  136. departments,
  137. lines,
  138. materials = Array.Empty<object>()
  139. };
  140. }
  141. public async Task<AdoS8ManualReportResultDto> CreateAsync(AdoS8ManualReportCreateDto dto)
  142. {
  143. if (string.IsNullOrWhiteSpace(dto.Title)) throw new S8BizException("标题必填");
  144. if (string.IsNullOrWhiteSpace(dto.SceneCode)) throw new S8BizException("场景必填");
  145. // 严重度白名单校验:空值兜底为 MEDIUM;非法值直接拒绝,避免 P3 等错位写入。
  146. var severity = string.IsNullOrWhiteSpace(dto.Severity) ? "MEDIUM" : dto.Severity.Trim();
  147. if (!AllowedSeverities.Contains(severity))
  148. throw new S8BizException($"严重度 {severity} 非法,仅允许 CRITICAL/HIGH/MEDIUM/LOW");
  149. // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。
  150. var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null;
  151. // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
  152. var inferredType = await InferExceptionTypeCodeAsync(dto.TenantId, dto.FactoryId, dto.SceneCode.Trim());
  153. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  154. var entity = new AdoS8Exception
  155. {
  156. TenantId = dto.TenantId,
  157. FactoryId = dto.FactoryId,
  158. ExceptionCode = code,
  159. Title = dto.Title.Trim(),
  160. Description = dto.Description,
  161. SceneCode = dto.SceneCode.Trim(),
  162. SourceType = "MANUAL",
  163. Status = "NEW",
  164. Severity = severity,
  165. PriorityScore = 0,
  166. PriorityLevel = "P3",
  167. OccurrenceDeptId = dto.OccurrenceDeptId,
  168. ResponsibleDeptId = dto.ResponsibleDeptId,
  169. ReporterId = currentUserId,
  170. ExceptionTypeCode = inferredType,
  171. ModuleCode = S8ModuleCode.FromScene(dto.SceneCode.Trim()),
  172. ProcessNodeCode = ResolveProcessNodeCode(dto.SceneCode.Trim(), S8ModuleCode.FromScene(dto.SceneCode.Trim())),
  173. CreatedAt = DateTime.Now,
  174. IsDeleted = false
  175. };
  176. await _rep.AsTenant().UseTranAsync(async () =>
  177. {
  178. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  179. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  180. {
  181. ExceptionId = entity.Id,
  182. ActionCode = "CREATE",
  183. ActionLabel = "创建",
  184. FromStatus = null,
  185. ToStatus = "NEW",
  186. OperatorId = currentUserId,
  187. ActionRemark = "主动提报",
  188. CreatedAt = DateTime.Now
  189. });
  190. }, ex => throw ex);
  191. await TryStartIntakeFlowAsync(entity);
  192. return new AdoS8ManualReportResultDto
  193. {
  194. ExceptionId = entity.Id,
  195. ExceptionCode = entity.ExceptionCode,
  196. TaskId = entity.Id
  197. };
  198. }
  199. /// <summary>
  200. /// G01-06:自动建单分支(非第二套创建主链)。
  201. /// 这是本服务内的自动监控建单路径,与 <see cref="CreateAsync"/> 并列,
  202. /// 复用同一仓储(_rep / _timelineRep)、同一事务边界、同一 ExceptionCode 生成规则、
  203. /// 同一时间线主链(ActionCode="CREATE"、ToStatus="NEW");仅差异点:
  204. /// - SourceType 标识为自动监控来源
  205. /// - 填入 SourceRuleId / SourceDataSourceId / SourcePayload / RelatedObjectCode 追溯
  206. /// - ExceptionTypeCode 固定 EQUIP_FAULT(G-01 首版唯一映射)
  207. /// - SceneCode 固定 S2(G-01 首版唯一场景,迁移后从 S2S6_PRODUCTION 切到单模块 S2)
  208. /// 不做补偿、重试、对账;失败由调用方接住。
  209. /// </summary>
  210. public async Task<AdoS8Exception> CreateFromWatchAsync(S8WatchHitResult hit)
  211. {
  212. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  213. throw new S8BizException("自动建单缺失追溯键");
  214. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  215. var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})";
  216. var entity = new AdoS8Exception
  217. {
  218. // 租户/工厂:与 S8WatchSchedulerService.RunOnceAsync 当前固定上下文一致。
  219. TenantId = 1,
  220. FactoryId = 1,
  221. ExceptionCode = code,
  222. Title = title,
  223. Description = null,
  224. SceneCode = S8SceneCode.S2,
  225. // 首版自动监控建单来源标识(字符串值,先不抽常量类)。
  226. SourceType = "AUTO_WATCH",
  227. Status = "NEW",
  228. Severity = string.IsNullOrWhiteSpace(hit.Severity) ? "MEDIUM" : hit.Severity,
  229. PriorityScore = 0,
  230. PriorityLevel = "P3",
  231. // 首版兜底口径:Hit 未提供部门时置 0 仅为保证“能建成标准异常单并进入主链”,
  232. // 不是最终业务部门语义;后续需由上游查询结果提供,或在专项任务中补口径。
  233. OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
  234. ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
  235. ReporterId = null,
  236. CreatedAt = DateTime.Now,
  237. IsDeleted = false,
  238. // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
  239. ExceptionTypeCode = "EQUIP_FAULT",
  240. // S2 自映射 → S2。看板按 module_code 聚合,留空会导致模块卡空白。
  241. ModuleCode = S8ModuleCode.FromScene(S8SceneCode.S2),
  242. // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 与 module 对齐 S1-S7。
  243. ProcessNodeCode = ResolveProcessNodeCode(S8SceneCode.S2, S8ModuleCode.FromScene(S8SceneCode.S2)),
  244. // 追溯三件套(自动建单必填口径)。
  245. SourceRuleId = hit.SourceRuleId,
  246. SourceDataSourceId = hit.DataSourceId,
  247. SourcePayload = hit.SourcePayload,
  248. RelatedObjectCode = hit.RelatedObjectCode
  249. };
  250. await _rep.AsTenant().UseTranAsync(async () =>
  251. {
  252. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  253. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  254. {
  255. ExceptionId = entity.Id,
  256. ActionCode = "CREATE",
  257. ActionLabel = "创建",
  258. FromStatus = null,
  259. ToStatus = "NEW",
  260. OperatorId = null,
  261. ActionRemark = "自动建单",
  262. CreatedAt = DateTime.Now
  263. });
  264. }, ex => throw ex);
  265. await TryStartIntakeFlowAsync(entity);
  266. return entity;
  267. }
  268. /// <summary>
  269. /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。
  270. /// 与 <see cref="CreateFromWatchAsync"/> 并列:复用同一仓储 / 事务 / 时间线 ActionCode;
  271. /// 差异点:消费 <see cref="S8RuleHit"/> 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt /
  272. /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带,
  273. /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。
  274. /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。
  275. /// </summary>
  276. public async Task<AdoS8Exception> CreateFromHitAsync(S8RuleHit hit)
  277. {
  278. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  279. throw new S8BizException("自动建单缺失追溯键");
  280. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  281. var entity = new AdoS8Exception
  282. {
  283. // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。
  284. TenantId = 1,
  285. FactoryId = 1,
  286. ExceptionCode = code,
  287. Title = string.IsNullOrWhiteSpace(hit.Title)
  288. ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
  289. : hit.Title,
  290. Description = null,
  291. SceneCode = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode,
  292. SourceType = "AUTO_WATCH",
  293. Status = "NEW",
  294. Severity = string.IsNullOrWhiteSpace(hit.Severity) ? "MEDIUM" : hit.Severity,
  295. PriorityScore = 0,
  296. PriorityLevel = "P3",
  297. OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
  298. ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
  299. ReporterId = null,
  300. CreatedAt = DateTime.Now,
  301. IsDeleted = false,
  302. ExceptionTypeCode = hit.ExceptionTypeCode,
  303. // 优先使用 evaluator 显式提供的 module_code,否则按 hit.SceneCode 取代表模块。
  304. ModuleCode = !string.IsNullOrWhiteSpace(hit.ModuleCode)
  305. ? hit.ModuleCode
  306. : S8ModuleCode.FromScene(string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode),
  307. // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 与 module 对齐 S1-S7。
  308. ProcessNodeCode = ResolveProcessNodeCode(
  309. string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode,
  310. !string.IsNullOrWhiteSpace(hit.ModuleCode)
  311. ? hit.ModuleCode
  312. : S8ModuleCode.FromScene(string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode)),
  313. SourceRuleId = hit.SourceRuleId,
  314. SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId,
  315. SourcePayload = hit.SourcePayload,
  316. RelatedObjectCode = hit.RelatedObjectCode,
  317. // R2 新列回填
  318. DedupKey = hit.DedupKey,
  319. LastDetectedAt = hit.DetectedAt,
  320. RecoveredAt = null,
  321. SourceRuleCode = hit.SourceRuleCode,
  322. SourceObjectType = hit.SourceObjectType,
  323. SourceObjectId = hit.SourceObjectId
  324. };
  325. await _rep.AsTenant().UseTranAsync(async () =>
  326. {
  327. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  328. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  329. {
  330. ExceptionId = entity.Id,
  331. ActionCode = "CREATE",
  332. ActionLabel = "创建",
  333. FromStatus = null,
  334. ToStatus = "NEW",
  335. OperatorId = null,
  336. ActionRemark = "自动建单(R2)",
  337. CreatedAt = DateTime.Now
  338. });
  339. }, ex => throw ex);
  340. await TryStartIntakeFlowAsync(entity);
  341. return entity;
  342. }
  343. public async Task<AdoS8Exception?> GetAsync(long id) =>
  344. await _rep.GetByIdAsync(id);
  345. public async Task<AdoS8Evidence> AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto)
  346. {
  347. var entity = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted)
  348. ?? throw new S8BizException("异常不存在");
  349. if (string.IsNullOrWhiteSpace(dto.FileName) || string.IsNullOrWhiteSpace(dto.FileUrl))
  350. throw new S8BizException("附件名称和地址必填");
  351. var evidence = new AdoS8Evidence
  352. {
  353. ExceptionId = id,
  354. EvidenceType = string.IsNullOrWhiteSpace(dto.EvidenceType) ? "file" : dto.EvidenceType,
  355. FileName = dto.FileName.Trim(),
  356. FileUrl = dto.FileUrl.Trim(),
  357. SourceSystem = dto.SourceSystem,
  358. UploadedBy = dto.UploadedBy,
  359. UploadedAt = DateTime.Now,
  360. IsDeleted = false
  361. };
  362. await _evidenceRep.InsertAsync(evidence);
  363. return evidence;
  364. }
  365. }