S8ManualReportService.cs 15 KB

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