S8ManualReportService.cs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  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. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举只保留 FOLLOW/SERIOUS。
  13. // 旧值 LOW/MEDIUM/HIGH/CRITICAL 仍可作为兼容输入(接收后由 S8SeverityCode.Normalize 归一)。
  14. private static readonly HashSet<string> AllowedSeverities = new(StringComparer.OrdinalIgnoreCase)
  15. {
  16. "FOLLOW", "SERIOUS",
  17. "LOW", "MEDIUM", "HIGH", "CRITICAL", // legacy compat
  18. };
  19. // S8-PROCESS-NODE-S1S7-ALIGN-1:process_node_code 当前阶段对齐 S1-S7 订单主流程。
  20. // 优先 module_code(已是 S1-S7),其次按 scene_code 反推;都无法识别则 null。
  21. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:保留函数挂起待用——本阶段所有建单点不再调用,
  22. // 由 module_code 承担 S1-S7 主流程归属;未来引入更细流程节点(如 S2.PLAN / S6.WO_RELEASE)时恢复使用。
  23. private static string? ResolveProcessNodeCode(string? sceneCode, string? moduleCode)
  24. {
  25. if (!string.IsNullOrWhiteSpace(moduleCode) && S8ModuleCode.All.Contains(moduleCode))
  26. return moduleCode;
  27. var fromScene = S8ModuleCode.FromScene(sceneCode);
  28. if (!string.IsNullOrWhiteSpace(fromScene) && S8ModuleCode.All.Contains(fromScene))
  29. return fromScene;
  30. return null;
  31. }
  32. /// <summary>
  33. /// S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:建单 module_code 派生统一入口。
  34. /// 优先级:显式 module → hit module → 单模块 sceneCode(S1-S7)→ exception_type.scene_code。
  35. /// 严格 S1-S7 校验,不接受 legacy 复合 scene;最终无法确定时返回 null(caller 决定记日志或拒绝)。
  36. /// </summary>
  37. private async Task<string?> ResolveModuleCodeAsync(
  38. string? explicitModuleCode,
  39. string? hitModuleCode,
  40. string? sceneCode,
  41. string? exceptionTypeCode)
  42. {
  43. var byExplicit = S8ModuleCode.Normalize(explicitModuleCode);
  44. if (byExplicit != null) return byExplicit;
  45. var byHit = S8ModuleCode.Normalize(hitModuleCode);
  46. if (byHit != null) return byHit;
  47. var byScene = S8ModuleCode.FromCanonicalScene(sceneCode);
  48. if (byScene != null) return byScene;
  49. if (!string.IsNullOrWhiteSpace(exceptionTypeCode))
  50. {
  51. var typeScene = await _typeRep.AsQueryable().ClearFilter()
  52. .Where(t => t.TypeCode == exceptionTypeCode && t.Enabled)
  53. .OrderByDescending(t => t.FactoryId)
  54. .Select(t => t.SceneCode)
  55. .FirstAsync();
  56. var byType = S8ModuleCode.FromCanonicalScene(typeScene);
  57. if (byType != null) return byType;
  58. }
  59. return null;
  60. }
  61. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  62. private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
  63. private readonly SqlSugarRepository<AdoS8Evidence> _evidenceRep;
  64. private readonly SqlSugarRepository<AdoS8SceneConfig> _sceneRep;
  65. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  66. private readonly SqlSugarRepository<AdoS0LineMaster> _lineRep;
  67. private readonly SqlSugarRepository<AdoS8ExceptionType> _typeRep;
  68. private readonly SqlSugarRepository<AdoS0EmployeeMaster> _empRep;
  69. private readonly UserManager _userManager;
  70. private readonly FlowEngineService _flowEngine;
  71. private readonly ILogger<S8ManualReportService> _logger;
  72. public S8ManualReportService(
  73. SqlSugarRepository<AdoS8Exception> rep,
  74. SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
  75. SqlSugarRepository<AdoS8Evidence> evidenceRep,
  76. SqlSugarRepository<AdoS8SceneConfig> sceneRep,
  77. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  78. SqlSugarRepository<AdoS0LineMaster> lineRep,
  79. SqlSugarRepository<AdoS8ExceptionType> typeRep,
  80. SqlSugarRepository<AdoS0EmployeeMaster> empRep,
  81. UserManager userManager,
  82. FlowEngineService flowEngine,
  83. ILogger<S8ManualReportService> logger)
  84. {
  85. _rep = rep;
  86. _timelineRep = timelineRep;
  87. _evidenceRep = evidenceRep;
  88. _sceneRep = sceneRep;
  89. _deptRep = deptRep;
  90. _lineRep = lineRep;
  91. _typeRep = typeRep;
  92. _empRep = empRep;
  93. _userManager = userManager;
  94. _flowEngine = flowEngine;
  95. _logger = logger;
  96. }
  97. // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):把当前登录 SysUser.Id 反查 EmployeeMaster.RecID。
  98. // 用于 reporter_id idspace 与 assignee_id / verifier_id 对齐。
  99. // 协议:sys_user_id + factory_ref_id 双键命中;EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位 →
  100. // ClearFilter,由 factoryRefId 做硬边界(与 S8MasterDataAdapter / S8TaskFlowService 同口径)。
  101. // 未绑定时返回 null,调用方负责诊断日志。
  102. private async Task<long?> ResolveCurrentEmployeeIdAsync(long factoryId, long sysUserId)
  103. {
  104. if (sysUserId <= 0 || factoryId <= 0) return null;
  105. var emp = await _empRep.AsQueryable().ClearFilter()
  106. .Where(x => x.SysUserId == sysUserId && x.FactoryRefId == factoryId && x.IsActive)
  107. .Select(x => new { x.Id })
  108. .FirstAsync();
  109. return emp?.Id;
  110. }
  111. /// <summary>
  112. /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
  113. /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
  114. /// (tenantId 命中 OR 0) AND (factoryId 命中 OR 0)。ClearFilter 兜底全局多租户过滤器。
  115. /// 找不到返回 null(保持兼容)。
  116. /// </summary>
  117. private async Task<string?> InferExceptionTypeCodeAsync(long tenantId, long factoryId, string sceneCode)
  118. {
  119. if (string.IsNullOrWhiteSpace(sceneCode)) return null;
  120. return await _typeRep.AsQueryable().ClearFilter()
  121. .Where(x => (x.TenantId == tenantId || x.TenantId == 0)
  122. && (x.FactoryId == factoryId || x.FactoryId == 0)
  123. && x.SceneCode == sceneCode && x.Enabled)
  124. .OrderBy(x => x.SortNo)
  125. .Select(x => x.TypeCode)
  126. .FirstAsync();
  127. }
  128. /// <summary>
  129. /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
  130. /// </summary>
  131. private async Task TryStartIntakeFlowAsync(AdoS8Exception entity)
  132. {
  133. try
  134. {
  135. await _flowEngine.StartFlow(new StartFlowInput
  136. {
  137. BizType = "EXCEPTION_REPORT",
  138. BizId = entity.Id,
  139. BizNo = entity.ExceptionCode,
  140. Title = $"异常提报 - {entity.ExceptionCode}",
  141. Comment = entity.SourceType == "AUTO_WATCH" ? "自动监控触发" : "主动提报触发",
  142. BizData = new Dictionary<string, object>
  143. {
  144. ["sceneCode"] = entity.SceneCode ?? "",
  145. ["exceptionTypeCode"] = entity.ExceptionTypeCode ?? "",
  146. ["sourceType"] = entity.SourceType ?? ""
  147. }
  148. });
  149. }
  150. catch (Exception ex)
  151. {
  152. _logger.LogWarning(ex, "TB001 异常提报审批流触发失败 ExceptionId={Id} ExceptionCode={Code}", entity.Id, entity.ExceptionCode);
  153. }
  154. }
  155. public async Task<object> GetFormOptionsAsync(long tenantId, long factoryId)
  156. {
  157. var scenes = await _sceneRep.AsQueryable()
  158. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
  159. .OrderBy(x => x.SortNo)
  160. .Select(x => new { value = x.SceneCode, label = x.SceneName })
  161. .ToListAsync();
  162. // ClearFilter:DepartmentMaster.tenant_id 属 S0 域租户,不与登录 token TenantId 一致;
  163. // 用 factory_ref_id 做硬边界,安全等价。同 BUG-S8-EMPLOYEES-TENANT-FILTER 协议。
  164. var departments = await _deptRep.AsQueryable().ClearFilter()
  165. .Where(x => x.FactoryRefId == factoryId)
  166. .OrderBy(x => x.Department)
  167. .Take(500)
  168. .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
  169. .ToListAsync();
  170. var lines = await _lineRep.AsQueryable().ClearFilter()
  171. .Where(x => x.FactoryRefId == factoryId)
  172. .OrderBy(x => x.Line)
  173. .Take(500)
  174. .Select(x => new { value = x.Id, label = x.Describe ?? x.Line })
  175. .ToListAsync();
  176. return new
  177. {
  178. scenes,
  179. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举 FOLLOW/SERIOUS 两档。
  180. severities = S8SeverityCode.Options(),
  181. departments,
  182. lines,
  183. materials = Array.Empty<object>()
  184. };
  185. }
  186. public async Task<AdoS8ManualReportResultDto> CreateAsync(AdoS8ManualReportCreateDto dto)
  187. {
  188. if (string.IsNullOrWhiteSpace(dto.Title)) throw new S8BizException("标题必填");
  189. if (string.IsNullOrWhiteSpace(dto.SceneCode)) throw new S8BizException("场景必填");
  190. // S8-MANUAL-REPORT-DEPT-ZERO-GUARD-1(P0-B-3):人工提报禁止 dept=0 入库。
  191. // 字段类型 long(非空),未填默认 0;guard 命中 <=0 直接拒绝,绝不转 null/默认部门/未归属。
  192. // 范围仅限本入口;自动建单(CreateFromWatchAsync/CreateFromHitAsync)的 ?? 0 风险归 P0-B-4。
  193. if (dto.OccurrenceDeptId <= 0) throw new S8BizException("发生部门不能为空,请选择有效发生部门");
  194. if (dto.ResponsibleDeptId <= 0) throw new S8BizException("处理部门不能为空,请选择有效处理部门");
  195. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:白名单接受新值(FOLLOW/SERIOUS)+ 旧值兼容;
  196. // 通过 Normalize 写入 DB 一律为 FOLLOW/SERIOUS。
  197. var rawSeverity = string.IsNullOrWhiteSpace(dto.Severity) ? S8SeverityCode.Follow : dto.Severity.Trim();
  198. if (!AllowedSeverities.Contains(rawSeverity))
  199. throw new S8BizException($"严重度 {rawSeverity} 非法,仅允许 FOLLOW/SERIOUS");
  200. var severity = S8SeverityCode.Normalize(rawSeverity);
  201. // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。
  202. // currentUserId 仍是 SysUser.Id,仅用作 timeline.OperatorId(与 S8TaskFlowService 同口径);
  203. // S8-REPORTER-IDSPACE-FIX-1:ReporterId 写入改用 EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。
  204. var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null;
  205. long? currentEmployeeId = null;
  206. if (currentUserId.HasValue)
  207. {
  208. currentEmployeeId = await ResolveCurrentEmployeeIdAsync(dto.FactoryId, currentUserId.Value);
  209. if (currentEmployeeId == null)
  210. {
  211. _logger.LogWarning(
  212. "manual_report_reporter_unbound sysUserId={SysUserId} factoryId={FactoryId} title={Title}",
  213. currentUserId.Value, dto.FactoryId, dto.Title);
  214. }
  215. }
  216. // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
  217. var inferredType = await InferExceptionTypeCodeAsync(dto.TenantId, dto.FactoryId, dto.SceneCode.Trim());
  218. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:手工提报 module_code 严格按 S1-S7 派生;
  219. // dto 暂不携带显式 module_code,按 scene → exception_type.scene 链路降级。
  220. var resolvedModule = await ResolveModuleCodeAsync(
  221. explicitModuleCode: null,
  222. hitModuleCode: null,
  223. sceneCode: dto.SceneCode.Trim(),
  224. exceptionTypeCode: inferredType);
  225. if (resolvedModule == null)
  226. {
  227. _logger.LogWarning(
  228. "manual_report_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} title={Title}",
  229. dto.SceneCode, inferredType, dto.Title);
  230. }
  231. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  232. var entity = new AdoS8Exception
  233. {
  234. TenantId = dto.TenantId,
  235. FactoryId = dto.FactoryId,
  236. ExceptionCode = code,
  237. Title = dto.Title.Trim(),
  238. Description = dto.Description,
  239. SceneCode = dto.SceneCode.Trim(),
  240. SourceType = "MANUAL",
  241. Status = "NEW",
  242. Severity = severity,
  243. PriorityScore = 0,
  244. PriorityLevel = "P3",
  245. OccurrenceDeptId = dto.OccurrenceDeptId,
  246. ResponsibleDeptId = dto.ResponsibleDeptId,
  247. // S8-REPORTER-IDSPACE-FIX-1:reporter_id idspace = EmployeeMaster.RecID(与 assignee/verifier 同)。
  248. ReporterId = currentEmployeeId,
  249. ExceptionTypeCode = inferredType,
  250. ModuleCode = resolvedModule,
  251. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  252. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  253. ProcessNodeCode = null,
  254. CreatedAt = DateTime.Now,
  255. IsDeleted = false
  256. };
  257. await _rep.AsTenant().UseTranAsync(async () =>
  258. {
  259. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  260. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  261. {
  262. ExceptionId = entity.Id,
  263. ActionCode = "CREATE",
  264. ActionLabel = "创建",
  265. FromStatus = null,
  266. ToStatus = "NEW",
  267. OperatorId = currentUserId,
  268. ActionRemark = "主动提报",
  269. CreatedAt = DateTime.Now
  270. });
  271. }, ex => throw ex);
  272. await TryStartIntakeFlowAsync(entity);
  273. return new AdoS8ManualReportResultDto
  274. {
  275. ExceptionId = entity.Id,
  276. ExceptionCode = entity.ExceptionCode,
  277. TaskId = entity.Id
  278. };
  279. }
  280. /// <summary>
  281. /// G01-06:自动建单分支(非第二套创建主链)。
  282. /// 这是本服务内的自动监控建单路径,与 <see cref="CreateAsync"/> 并列,
  283. /// 复用同一仓储(_rep / _timelineRep)、同一事务边界、同一 ExceptionCode 生成规则、
  284. /// 同一时间线主链(ActionCode="CREATE"、ToStatus="NEW");仅差异点:
  285. /// - SourceType 标识为自动监控来源
  286. /// - 填入 SourceRuleId / SourceDataSourceId / SourcePayload / RelatedObjectCode 追溯
  287. /// - ExceptionTypeCode 固定 EQUIP_FAULT(G-01 首版唯一映射)
  288. /// - SceneCode 固定 S2(G-01 首版唯一场景,迁移后从 S2S6_PRODUCTION 切到单模块 S2)
  289. /// 不做补偿、重试、对账;失败由调用方接住。
  290. /// </summary>
  291. public async Task<AdoS8Exception> CreateFromWatchAsync(S8WatchHitResult hit)
  292. {
  293. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  294. throw new S8BizException("自动建单缺失追溯键");
  295. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  296. var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})";
  297. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:固定 SceneCode=S2 + ExceptionTypeCode=EQUIP_FAULT,
  298. // 派生链路通过 ResolveModuleCodeAsync 严格按 S1-S7 走(结果稳定为 S2,但消除 FromScene 的 legacy 兼容路径)。
  299. var resolvedModule = await ResolveModuleCodeAsync(
  300. explicitModuleCode: null,
  301. hitModuleCode: null,
  302. sceneCode: S8SceneCode.S2,
  303. exceptionTypeCode: "EQUIP_FAULT");
  304. if (resolvedModule == null)
  305. {
  306. _logger.LogWarning(
  307. "auto_watch_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} ruleId={RuleId}",
  308. S8SceneCode.S2, "EQUIP_FAULT", hit.SourceRuleId);
  309. }
  310. var entity = new AdoS8Exception
  311. {
  312. // 租户/工厂:与 S8WatchSchedulerService.RunOnceAsync 当前固定上下文一致。
  313. TenantId = 1,
  314. FactoryId = 1,
  315. ExceptionCode = code,
  316. Title = title,
  317. Description = null,
  318. SceneCode = S8SceneCode.S2,
  319. // 首版自动监控建单来源标识(字符串值,先不抽常量类)。
  320. SourceType = "AUTO_WATCH",
  321. Status = "NEW",
  322. Severity = S8SeverityCode.Normalize(hit.Severity),
  323. PriorityScore = 0,
  324. PriorityLevel = "P3",
  325. // 首版兜底口径:Hit 未提供部门时置 0 仅为保证“能建成标准异常单并进入主链”,
  326. // 不是最终业务部门语义;后续需由上游查询结果提供,或在专项任务中补口径。
  327. OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
  328. ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
  329. ReporterId = null,
  330. CreatedAt = DateTime.Now,
  331. IsDeleted = false,
  332. // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
  333. ExceptionTypeCode = "EQUIP_FAULT",
  334. ModuleCode = resolvedModule,
  335. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  336. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  337. ProcessNodeCode = null,
  338. // 追溯三件套(自动建单必填口径)。
  339. SourceRuleId = hit.SourceRuleId,
  340. SourceDataSourceId = hit.DataSourceId,
  341. SourcePayload = hit.SourcePayload,
  342. RelatedObjectCode = hit.RelatedObjectCode
  343. };
  344. await _rep.AsTenant().UseTranAsync(async () =>
  345. {
  346. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  347. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  348. {
  349. ExceptionId = entity.Id,
  350. ActionCode = "CREATE",
  351. ActionLabel = "创建",
  352. FromStatus = null,
  353. ToStatus = "NEW",
  354. OperatorId = null,
  355. ActionRemark = "自动建单",
  356. CreatedAt = DateTime.Now
  357. });
  358. }, ex => throw ex);
  359. await TryStartIntakeFlowAsync(entity);
  360. return entity;
  361. }
  362. /// <summary>
  363. /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。
  364. /// 与 <see cref="CreateFromWatchAsync"/> 并列:复用同一仓储 / 事务 / 时间线 ActionCode;
  365. /// 差异点:消费 <see cref="S8RuleHit"/> 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt /
  366. /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带,
  367. /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。
  368. /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。
  369. /// </summary>
  370. public async Task<AdoS8Exception> CreateFromHitAsync(S8RuleHit hit)
  371. {
  372. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  373. throw new S8BizException("自动建单缺失追溯键");
  374. var effectiveScene = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode;
  375. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:R2 自动建单 module_code 派生统一走严格 S1-S7 链路;
  376. // hit.ModuleCode(evaluator 显式)→ rule/hit.SceneCode(当前 DB 100% S1-S7)→ exception_type.scene_code。
  377. var resolvedModule = await ResolveModuleCodeAsync(
  378. explicitModuleCode: null,
  379. hitModuleCode: hit.ModuleCode,
  380. sceneCode: effectiveScene,
  381. exceptionTypeCode: hit.ExceptionTypeCode);
  382. if (resolvedModule == null)
  383. {
  384. _logger.LogWarning(
  385. "auto_watch_hit_module_code_unresolved sceneCode={SceneCode} hitModule={HitModule} typeCode={TypeCode} ruleCode={RuleCode}",
  386. hit.SceneCode, hit.ModuleCode, hit.ExceptionTypeCode, hit.SourceRuleCode);
  387. }
  388. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  389. var entity = new AdoS8Exception
  390. {
  391. // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。
  392. TenantId = 1,
  393. FactoryId = 1,
  394. ExceptionCode = code,
  395. Title = string.IsNullOrWhiteSpace(hit.Title)
  396. ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
  397. : hit.Title,
  398. Description = null,
  399. SceneCode = effectiveScene,
  400. SourceType = "AUTO_WATCH",
  401. Status = "NEW",
  402. Severity = S8SeverityCode.Normalize(hit.Severity),
  403. PriorityScore = 0,
  404. PriorityLevel = "P3",
  405. OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
  406. ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
  407. ReporterId = null,
  408. CreatedAt = DateTime.Now,
  409. IsDeleted = false,
  410. ExceptionTypeCode = hit.ExceptionTypeCode,
  411. ModuleCode = resolvedModule,
  412. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  413. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  414. ProcessNodeCode = null,
  415. SourceRuleId = hit.SourceRuleId,
  416. SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId,
  417. SourcePayload = hit.SourcePayload,
  418. RelatedObjectCode = hit.RelatedObjectCode,
  419. // R2 新列回填
  420. DedupKey = hit.DedupKey,
  421. LastDetectedAt = hit.DetectedAt,
  422. RecoveredAt = null,
  423. SourceRuleCode = hit.SourceRuleCode,
  424. SourceObjectType = hit.SourceObjectType,
  425. SourceObjectId = hit.SourceObjectId
  426. };
  427. await _rep.AsTenant().UseTranAsync(async () =>
  428. {
  429. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  430. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  431. {
  432. ExceptionId = entity.Id,
  433. ActionCode = "CREATE",
  434. ActionLabel = "创建",
  435. FromStatus = null,
  436. ToStatus = "NEW",
  437. OperatorId = null,
  438. ActionRemark = "自动建单(R2)",
  439. CreatedAt = DateTime.Now
  440. });
  441. }, ex => throw ex);
  442. await TryStartIntakeFlowAsync(entity);
  443. return entity;
  444. }
  445. public async Task<AdoS8Exception?> GetAsync(long id) =>
  446. await _rep.GetByIdAsync(id);
  447. public async Task<AdoS8Evidence> AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto)
  448. {
  449. var entity = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted)
  450. ?? throw new S8BizException("异常不存在");
  451. if (string.IsNullOrWhiteSpace(dto.FileName) || string.IsNullOrWhiteSpace(dto.FileUrl))
  452. throw new S8BizException("附件名称和地址必填");
  453. var evidence = new AdoS8Evidence
  454. {
  455. ExceptionId = id,
  456. EvidenceType = string.IsNullOrWhiteSpace(dto.EvidenceType) ? "file" : dto.EvidenceType,
  457. FileName = dto.FileName.Trim(),
  458. FileUrl = dto.FileUrl.Trim(),
  459. SourceSystem = dto.SourceSystem,
  460. UploadedBy = dto.UploadedBy,
  461. UploadedAt = DateTime.Now,
  462. IsDeleted = false
  463. };
  464. await _evidenceRep.InsertAsync(evidence);
  465. return evidence;
  466. }
  467. }