S8ManualReportService.cs 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633
  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-AUTO-WATCH-DEPT-ZERO-AUDIT-1(P0-B-4):自动建单将写入 dept=0 时打 warning,行为不变。
  98. // 触发:raw 部门字段为 null 或 <=0。两条自动建单路径(CreateFromWatchAsync / CreateFromHitAsync)
  99. // 当前都用 `?? 0` 兜底;本助手只观测、不阻断、不替换值。部门派生器属 P2,不在本批。
  100. private void WarnIfAutoWatchZeroDept(
  101. string path,
  102. long? ruleId,
  103. string? ruleCode,
  104. string? moduleCode,
  105. string? sceneCode,
  106. string? exceptionTypeCode,
  107. string? sourceObjectType,
  108. string? sourceObjectId,
  109. string? relatedObjectCode,
  110. string? dedupKey,
  111. long? rawOccurrenceDeptId,
  112. long? rawResponsibleDeptId,
  113. long finalOccurrenceDeptId,
  114. long finalResponsibleDeptId,
  115. long tenantId,
  116. long factoryId,
  117. string? exceptionCode)
  118. {
  119. var occMissing = !rawOccurrenceDeptId.HasValue || rawOccurrenceDeptId.Value <= 0;
  120. var respMissing = !rawResponsibleDeptId.HasValue || rawResponsibleDeptId.Value <= 0;
  121. if (!occMissing && !respMissing) return;
  122. _logger.LogWarning(
  123. "s8_auto_watch_zero_dept_detected path={Path} ruleId={RuleId} ruleCode={RuleCode} moduleCode={ModuleCode} sceneCode={SceneCode} exceptionTypeCode={ExceptionTypeCode} sourceObjectType={SourceObjectType} sourceObjectId={SourceObjectId} relatedObjectCode={RelatedObjectCode} dedupKey={DedupKey} rawOccurrenceDeptId={RawOccDept} rawResponsibleDeptId={RawRespDept} finalOccurrenceDeptId={FinalOccDept} finalResponsibleDeptId={FinalRespDept} tenantId={TenantId} factoryId={FactoryId} exceptionCode={ExceptionCode}",
  124. path, ruleId, ruleCode, moduleCode, sceneCode, exceptionTypeCode, sourceObjectType, sourceObjectId, relatedObjectCode, dedupKey, rawOccurrenceDeptId, rawResponsibleDeptId, finalOccurrenceDeptId, finalResponsibleDeptId, tenantId, factoryId, exceptionCode);
  125. }
  126. // S8-REPORTER-IDSPACE-FIX-1(P0-B-1):把当前登录 SysUser.Id 反查 EmployeeMaster.RecID。
  127. // 用于 reporter_id idspace 与 assignee_id / verifier_id 对齐。
  128. // 协议:sys_user_id + factory_ref_id 双键命中;EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位 →
  129. // ClearFilter,由 factoryRefId 做硬边界(与 S8MasterDataAdapter / S8TaskFlowService 同口径)。
  130. // 未绑定时返回 null,调用方负责诊断日志。
  131. private async Task<long?> ResolveCurrentEmployeeIdAsync(long factoryId, long sysUserId)
  132. {
  133. if (sysUserId <= 0 || factoryId <= 0) return null;
  134. var emp = await _empRep.AsQueryable().ClearFilter()
  135. .Where(x => x.SysUserId == sysUserId && x.FactoryRefId == factoryId && x.IsActive)
  136. .Select(x => new { x.Id })
  137. .FirstAsync();
  138. return emp?.Id;
  139. }
  140. // S8-MANUAL-REPORT-DEFAULT-OCCURRENCE-DEPT-1(P1):取当前登录员工的业务部门作为默认发生部门。
  141. // 链路:SysUser.Id → EmployeeMaster.sys_user_id → EmployeeMaster.Department(string)
  142. // + EmployeeMaster.FactoryRefId → DepartmentMaster.Department + FactoryRefId → RecID。
  143. // 不绑定 / 部门为空 / 部门未匹配 / 命中多条:均返回 (null,null) + warning,绝不报错也不阻断页面。
  144. // 仅作为前端 form 默认初值;用户仍可改,CreateAsync 的 dept=0 guard 不变。
  145. private async Task<(long? DeptId, string? DeptName)> ResolveCurrentEmployeeDefaultDeptAsync(long factoryId, long sysUserId)
  146. {
  147. if (sysUserId <= 0 || factoryId <= 0) return (null, null);
  148. var emp = await _empRep.AsQueryable().ClearFilter()
  149. .Where(x => x.SysUserId == sysUserId && x.FactoryRefId == factoryId && x.IsActive)
  150. .Select(x => new { x.Id, x.Department, x.FactoryRefId })
  151. .FirstAsync();
  152. if (emp == null)
  153. {
  154. _logger.LogWarning(
  155. "s8_manual_report_default_dept_unbound_employee sysUserId={SysUserId} factoryId={FactoryId}",
  156. sysUserId, factoryId);
  157. return (null, null);
  158. }
  159. if (string.IsNullOrWhiteSpace(emp.Department))
  160. {
  161. _logger.LogWarning(
  162. "s8_manual_report_default_dept_employee_dept_empty sysUserId={SysUserId} factoryId={FactoryId} employeeId={EmployeeId}",
  163. sysUserId, factoryId, emp.Id);
  164. return (null, null);
  165. }
  166. var depts = await _deptRep.AsQueryable().ClearFilter()
  167. .Where(x => x.Department == emp.Department && x.FactoryRefId == emp.FactoryRefId && x.IsActive)
  168. .Select(x => new { x.Id, x.Department, x.Descr })
  169. .Take(2)
  170. .ToListAsync();
  171. if (depts.Count == 0)
  172. {
  173. _logger.LogWarning(
  174. "s8_manual_report_default_dept_unmatched sysUserId={SysUserId} factoryId={FactoryId} employeeId={EmployeeId} departmentCode={DeptCode}",
  175. sysUserId, factoryId, emp.Id, emp.Department);
  176. return (null, null);
  177. }
  178. if (depts.Count > 1)
  179. {
  180. _logger.LogWarning(
  181. "s8_manual_report_default_dept_ambiguous sysUserId={SysUserId} factoryId={FactoryId} employeeId={EmployeeId} departmentCode={DeptCode} matchedCount={Count}",
  182. sysUserId, factoryId, emp.Id, emp.Department, depts.Count);
  183. return (null, null);
  184. }
  185. var d = depts[0];
  186. return (d.Id, string.IsNullOrWhiteSpace(d.Descr) ? d.Department : d.Descr);
  187. }
  188. /// <summary>
  189. /// 主动提报推断 ExceptionTypeCode:场景下取启用且 SortNo 最小的一条。
  190. /// baseline 异常类型当前 tenant_id=0/factory_id=0(全局基线),所以匹配条件为
  191. /// (tenantId 命中 OR 0) AND (factoryId 命中 OR 0)。ClearFilter 兜底全局多租户过滤器。
  192. /// 找不到返回 null(保持兼容)。
  193. /// </summary>
  194. private async Task<string?> InferExceptionTypeCodeAsync(long tenantId, long factoryId, string sceneCode)
  195. {
  196. if (string.IsNullOrWhiteSpace(sceneCode)) return null;
  197. return await _typeRep.AsQueryable().ClearFilter()
  198. .Where(x => (x.TenantId == tenantId || x.TenantId == 0)
  199. && (x.FactoryId == factoryId || x.FactoryId == 0)
  200. && x.SceneCode == sceneCode && x.Enabled)
  201. .OrderBy(x => x.SortNo)
  202. .Select(x => x.TypeCode)
  203. .FirstAsync();
  204. }
  205. /// <summary>
  206. /// TB001 异常提报审批流:自动监控 + 主动提报后软触发,失败仅 warn 日志,不阻断建单。
  207. /// </summary>
  208. private async Task TryStartIntakeFlowAsync(AdoS8Exception entity)
  209. {
  210. try
  211. {
  212. await _flowEngine.StartFlow(new StartFlowInput
  213. {
  214. BizType = "EXCEPTION_REPORT",
  215. BizId = entity.Id,
  216. BizNo = entity.ExceptionCode,
  217. Title = $"异常提报 - {entity.ExceptionCode}",
  218. Comment = entity.SourceType == "AUTO_WATCH" ? "自动监控触发" : "主动提报触发",
  219. BizData = new Dictionary<string, object>
  220. {
  221. ["sceneCode"] = entity.SceneCode ?? "",
  222. ["exceptionTypeCode"] = entity.ExceptionTypeCode ?? "",
  223. ["sourceType"] = entity.SourceType ?? ""
  224. }
  225. });
  226. }
  227. catch (Exception ex)
  228. {
  229. _logger.LogWarning(ex, "TB001 异常提报审批流触发失败 ExceptionId={Id} ExceptionCode={Code}", entity.Id, entity.ExceptionCode);
  230. }
  231. }
  232. public async Task<object> GetFormOptionsAsync(long tenantId, long factoryId)
  233. {
  234. var scenes = await _sceneRep.AsQueryable()
  235. .Where(x => x.TenantId == tenantId && x.FactoryId == factoryId && x.Enabled)
  236. .OrderBy(x => x.SortNo)
  237. .Select(x => new { value = x.SceneCode, label = x.SceneName })
  238. .ToListAsync();
  239. // ClearFilter:DepartmentMaster.tenant_id 属 S0 域租户,不与登录 token TenantId 一致;
  240. // 用 factory_ref_id 做硬边界,安全等价。同 BUG-S8-EMPLOYEES-TENANT-FILTER 协议。
  241. var departments = await _deptRep.AsQueryable().ClearFilter()
  242. .Where(x => x.FactoryRefId == factoryId)
  243. .OrderBy(x => x.Department)
  244. .Take(500)
  245. .Select(x => new { value = x.Id, label = x.Descr ?? x.Department })
  246. .ToListAsync();
  247. var lines = await _lineRep.AsQueryable().ClearFilter()
  248. .Where(x => x.FactoryRefId == factoryId)
  249. .OrderBy(x => x.Line)
  250. .Take(500)
  251. .Select(x => new { value = x.Id, label = x.Describe ?? x.Line })
  252. .ToListAsync();
  253. // S8-MANUAL-REPORT-DEFAULT-OCCURRENCE-DEPT-1(P1):默认发生部门 = 当前登录员工的业务部门。
  254. // 未登录 / 未绑定 / 部门不可解析 → 返回 null,前端继续要求用户手动选择。
  255. var sysUserId = _userManager.UserId;
  256. var (defaultOccDeptId, defaultOccDeptName) = sysUserId > 0
  257. ? await ResolveCurrentEmployeeDefaultDeptAsync(factoryId, sysUserId)
  258. : (null, null);
  259. return new
  260. {
  261. scenes,
  262. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:业务枚举 FOLLOW/SERIOUS 两档。
  263. severities = S8SeverityCode.Options(),
  264. departments,
  265. lines,
  266. materials = Array.Empty<object>(),
  267. defaultOccurrenceDeptId = defaultOccDeptId,
  268. defaultOccurrenceDeptName = defaultOccDeptName,
  269. };
  270. }
  271. public async Task<AdoS8ManualReportResultDto> CreateAsync(AdoS8ManualReportCreateDto dto)
  272. {
  273. if (string.IsNullOrWhiteSpace(dto.Title)) throw new S8BizException("标题必填");
  274. if (string.IsNullOrWhiteSpace(dto.SceneCode)) throw new S8BizException("场景必填");
  275. // S8-MANUAL-REPORT-DEPT-ZERO-GUARD-1(P0-B-3):人工提报禁止 dept=0 入库。
  276. // 字段类型 long(非空),未填默认 0;guard 命中 <=0 直接拒绝,绝不转 null/默认部门/未归属。
  277. // 范围仅限本入口;自动建单(CreateFromWatchAsync/CreateFromHitAsync)的 ?? 0 风险归 P0-B-4。
  278. if (dto.OccurrenceDeptId <= 0) throw new S8BizException("发生部门不能为空,请选择有效发生部门");
  279. if (dto.ResponsibleDeptId <= 0) throw new S8BizException("处理部门不能为空,请选择有效处理部门");
  280. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:白名单接受新值(FOLLOW/SERIOUS)+ 旧值兼容;
  281. // 通过 Normalize 写入 DB 一律为 FOLLOW/SERIOUS。
  282. var rawSeverity = string.IsNullOrWhiteSpace(dto.Severity) ? S8SeverityCode.Follow : dto.Severity.Trim();
  283. if (!AllowedSeverities.Contains(rawSeverity))
  284. throw new S8BizException($"严重度 {rawSeverity} 非法,仅允许 FOLLOW/SERIOUS");
  285. var severity = S8SeverityCode.Normalize(rawSeverity);
  286. // 提报人以服务端登录上下文为准,忽略前端传入;未登录上下文落 null。
  287. // currentUserId 仍是 SysUser.Id,仅用作 timeline.OperatorId(与 S8TaskFlowService 同口径);
  288. // S8-REPORTER-IDSPACE-FIX-1:ReporterId 写入改用 EmployeeMaster.RecID(与 assignee/verifier 同 idspace)。
  289. var currentUserId = _userManager.UserId > 0 ? _userManager.UserId : (long?)null;
  290. long? currentEmployeeId = null;
  291. if (currentUserId.HasValue)
  292. {
  293. currentEmployeeId = await ResolveCurrentEmployeeIdAsync(dto.FactoryId, currentUserId.Value);
  294. if (currentEmployeeId == null)
  295. {
  296. _logger.LogWarning(
  297. "manual_report_reporter_unbound sysUserId={SysUserId} factoryId={FactoryId} title={Title}",
  298. currentUserId.Value, dto.FactoryId, dto.Title);
  299. }
  300. }
  301. // 主动提报无前端 type 字段,按场景兜底推断;保证不进"未分类"桶。
  302. var inferredType = await InferExceptionTypeCodeAsync(dto.TenantId, dto.FactoryId, dto.SceneCode.Trim());
  303. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:手工提报 module_code 严格按 S1-S7 派生;
  304. // dto 暂不携带显式 module_code,按 scene → exception_type.scene 链路降级。
  305. var resolvedModule = await ResolveModuleCodeAsync(
  306. explicitModuleCode: null,
  307. hitModuleCode: null,
  308. sceneCode: dto.SceneCode.Trim(),
  309. exceptionTypeCode: inferredType);
  310. if (resolvedModule == null)
  311. {
  312. _logger.LogWarning(
  313. "manual_report_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} title={Title}",
  314. dto.SceneCode, inferredType, dto.Title);
  315. }
  316. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  317. var entity = new AdoS8Exception
  318. {
  319. TenantId = dto.TenantId,
  320. FactoryId = dto.FactoryId,
  321. ExceptionCode = code,
  322. Title = dto.Title.Trim(),
  323. Description = dto.Description,
  324. SceneCode = dto.SceneCode.Trim(),
  325. SourceType = "MANUAL",
  326. Status = "NEW",
  327. Severity = severity,
  328. PriorityScore = 0,
  329. PriorityLevel = "P3",
  330. OccurrenceDeptId = dto.OccurrenceDeptId,
  331. ResponsibleDeptId = dto.ResponsibleDeptId,
  332. // S8-REPORTER-IDSPACE-FIX-1:reporter_id idspace = EmployeeMaster.RecID(与 assignee/verifier 同)。
  333. ReporterId = currentEmployeeId,
  334. ExceptionTypeCode = inferredType,
  335. ModuleCode = resolvedModule,
  336. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  337. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  338. ProcessNodeCode = null,
  339. CreatedAt = DateTime.Now,
  340. IsDeleted = false
  341. };
  342. await _rep.AsTenant().UseTranAsync(async () =>
  343. {
  344. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  345. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  346. {
  347. ExceptionId = entity.Id,
  348. ActionCode = "CREATE",
  349. ActionLabel = "创建",
  350. FromStatus = null,
  351. ToStatus = "NEW",
  352. OperatorId = currentUserId,
  353. ActionRemark = "主动提报",
  354. CreatedAt = DateTime.Now
  355. });
  356. }, ex => throw ex);
  357. await TryStartIntakeFlowAsync(entity);
  358. return new AdoS8ManualReportResultDto
  359. {
  360. ExceptionId = entity.Id,
  361. ExceptionCode = entity.ExceptionCode,
  362. TaskId = entity.Id
  363. };
  364. }
  365. /// <summary>
  366. /// G01-06:自动建单分支(非第二套创建主链)。
  367. /// 这是本服务内的自动监控建单路径,与 <see cref="CreateAsync"/> 并列,
  368. /// 复用同一仓储(_rep / _timelineRep)、同一事务边界、同一 ExceptionCode 生成规则、
  369. /// 同一时间线主链(ActionCode="CREATE"、ToStatus="NEW");仅差异点:
  370. /// - SourceType 标识为自动监控来源
  371. /// - 填入 SourceRuleId / SourceDataSourceId / SourcePayload / RelatedObjectCode 追溯
  372. /// - ExceptionTypeCode 固定 EQUIP_FAULT(G-01 首版唯一映射)
  373. /// - SceneCode 固定 S2(G-01 首版唯一场景,迁移后从 S2S6_PRODUCTION 切到单模块 S2)
  374. /// 不做补偿、重试、对账;失败由调用方接住。
  375. /// </summary>
  376. public async Task<AdoS8Exception> CreateFromWatchAsync(S8WatchHitResult hit)
  377. {
  378. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  379. throw new S8BizException("自动建单缺失追溯键");
  380. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  381. var title = $"[自动] 设备 {hit.RelatedObjectCode} {hit.TriggerCondition} {hit.ThresholdValue}(当前 {hit.CurrentValue})";
  382. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:固定 SceneCode=S2 + ExceptionTypeCode=EQUIP_FAULT,
  383. // 派生链路通过 ResolveModuleCodeAsync 严格按 S1-S7 走(结果稳定为 S2,但消除 FromScene 的 legacy 兼容路径)。
  384. var resolvedModule = await ResolveModuleCodeAsync(
  385. explicitModuleCode: null,
  386. hitModuleCode: null,
  387. sceneCode: S8SceneCode.S2,
  388. exceptionTypeCode: "EQUIP_FAULT");
  389. if (resolvedModule == null)
  390. {
  391. _logger.LogWarning(
  392. "auto_watch_module_code_unresolved sceneCode={SceneCode} exceptionTypeCode={TypeCode} ruleId={RuleId}",
  393. S8SceneCode.S2, "EQUIP_FAULT", hit.SourceRuleId);
  394. }
  395. var entity = new AdoS8Exception
  396. {
  397. // 租户/工厂:与 S8WatchSchedulerService.RunOnceAsync 当前固定上下文一致。
  398. TenantId = 1,
  399. FactoryId = 1,
  400. ExceptionCode = code,
  401. Title = title,
  402. Description = null,
  403. SceneCode = S8SceneCode.S2,
  404. // 首版自动监控建单来源标识(字符串值,先不抽常量类)。
  405. SourceType = "AUTO_WATCH",
  406. Status = "NEW",
  407. Severity = S8SeverityCode.Normalize(hit.Severity),
  408. PriorityScore = 0,
  409. PriorityLevel = "P3",
  410. // 首版兜底口径:Hit 未提供部门时置 0 仅为保证“能建成标准异常单并进入主链”,
  411. // 不是最终业务部门语义;后续需由上游查询结果提供,或在专项任务中补口径。
  412. OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
  413. ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
  414. ReporterId = null,
  415. CreatedAt = DateTime.Now,
  416. IsDeleted = false,
  417. // G-01 首版唯一异常类型映射(baseline 已迁后 EQUIP_FAULT 属 S2 制造协同场景)。
  418. ExceptionTypeCode = "EQUIP_FAULT",
  419. ModuleCode = resolvedModule,
  420. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  421. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  422. ProcessNodeCode = null,
  423. // 追溯三件套(自动建单必填口径)。
  424. SourceRuleId = hit.SourceRuleId,
  425. SourceDataSourceId = hit.DataSourceId,
  426. SourcePayload = hit.SourcePayload,
  427. RelatedObjectCode = hit.RelatedObjectCode
  428. };
  429. // S8-AUTO-WATCH-DEPT-ZERO-AUDIT-1(P0-B-4):raw dept 缺失时打 warning,行为不变。
  430. WarnIfAutoWatchZeroDept(
  431. path: nameof(CreateFromWatchAsync),
  432. ruleId: hit.SourceRuleId,
  433. ruleCode: null,
  434. moduleCode: resolvedModule,
  435. sceneCode: S8SceneCode.S2,
  436. exceptionTypeCode: "EQUIP_FAULT",
  437. sourceObjectType: null,
  438. sourceObjectId: null,
  439. relatedObjectCode: hit.RelatedObjectCode,
  440. dedupKey: null,
  441. rawOccurrenceDeptId: hit.OccurrenceDeptId,
  442. rawResponsibleDeptId: hit.ResponsibleDeptId,
  443. finalOccurrenceDeptId: entity.OccurrenceDeptId,
  444. finalResponsibleDeptId: entity.ResponsibleDeptId,
  445. tenantId: entity.TenantId,
  446. factoryId: entity.FactoryId,
  447. exceptionCode: entity.ExceptionCode);
  448. await _rep.AsTenant().UseTranAsync(async () =>
  449. {
  450. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  451. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  452. {
  453. ExceptionId = entity.Id,
  454. ActionCode = "CREATE",
  455. ActionLabel = "创建",
  456. FromStatus = null,
  457. ToStatus = "NEW",
  458. OperatorId = null,
  459. ActionRemark = "自动建单",
  460. CreatedAt = DateTime.Now
  461. });
  462. }, ex => throw ex);
  463. await TryStartIntakeFlowAsync(entity);
  464. return entity;
  465. }
  466. /// <summary>
  467. /// R2 自动建单分支(TIMEOUT 等新 evaluator 走此路径)。
  468. /// 与 <see cref="CreateFromWatchAsync"/> 并列:复用同一仓储 / 事务 / 时间线 ActionCode;
  469. /// 差异点:消费 <see cref="S8RuleHit"/> 一份命中模型,把 R2 新列(DedupKey / LastDetectedAt /
  470. /// SourceRuleCode / SourceObjectType / SourceObjectId)落齐;ExceptionTypeCode 由 hit 自带,
  471. /// 不再硬编码 EQUIP_FAULT。RecoveredAt 本轮不写。
  472. /// 调用方负责前置检查 ExceptionTypeCode 是否在 baseline;本方法不再二次校验。
  473. /// </summary>
  474. public async Task<AdoS8Exception> CreateFromHitAsync(S8RuleHit hit)
  475. {
  476. if (hit.SourceRuleId <= 0 || string.IsNullOrWhiteSpace(hit.RelatedObjectCode))
  477. throw new S8BizException("自动建单缺失追溯键");
  478. var effectiveScene = string.IsNullOrWhiteSpace(hit.SceneCode) ? S8SceneCode.S2 : hit.SceneCode;
  479. // S8-EXCEPTION-CREATION-MODULE-CODE-FIX-1:R2 自动建单 module_code 派生统一走严格 S1-S7 链路;
  480. // hit.ModuleCode(evaluator 显式)→ rule/hit.SceneCode(当前 DB 100% S1-S7)→ exception_type.scene_code。
  481. var resolvedModule = await ResolveModuleCodeAsync(
  482. explicitModuleCode: null,
  483. hitModuleCode: hit.ModuleCode,
  484. sceneCode: effectiveScene,
  485. exceptionTypeCode: hit.ExceptionTypeCode);
  486. if (resolvedModule == null)
  487. {
  488. _logger.LogWarning(
  489. "auto_watch_hit_module_code_unresolved sceneCode={SceneCode} hitModule={HitModule} typeCode={TypeCode} ruleCode={RuleCode}",
  490. hit.SceneCode, hit.ModuleCode, hit.ExceptionTypeCode, hit.SourceRuleCode);
  491. }
  492. var code = $"EX-{DateTime.Now:yyyyMMdd}-{Guid.NewGuid().ToString("N")[..8].ToUpperInvariant()}";
  493. var entity = new AdoS8Exception
  494. {
  495. // 与 CreateFromWatchAsync 当前固定上下文一致;R2 不变更租户/工厂上下文语义。
  496. TenantId = 1,
  497. FactoryId = 1,
  498. ExceptionCode = code,
  499. Title = string.IsNullOrWhiteSpace(hit.Title)
  500. ? $"[自动] {hit.SourceObjectType} {hit.SourceObjectId}"
  501. : hit.Title,
  502. Description = null,
  503. SceneCode = effectiveScene,
  504. SourceType = "AUTO_WATCH",
  505. Status = "NEW",
  506. Severity = S8SeverityCode.Normalize(hit.Severity),
  507. PriorityScore = 0,
  508. PriorityLevel = "P3",
  509. OccurrenceDeptId = hit.OccurrenceDeptId ?? 0,
  510. ResponsibleDeptId = hit.ResponsibleDeptId ?? 0,
  511. ReporterId = null,
  512. CreatedAt = DateTime.Now,
  513. IsDeleted = false,
  514. ExceptionTypeCode = hit.ExceptionTypeCode,
  515. ModuleCode = resolvedModule,
  516. // S8-PROCESS-NODE-MODULE-CODE-ALIGNMENT-EXEC-1:当前阶段 process_node_code 留空,
  517. // module_code 承担 S1-S7 主流程归属;process_node_code 留给未来更细流程节点。
  518. ProcessNodeCode = null,
  519. SourceRuleId = hit.SourceRuleId,
  520. SourceDataSourceId = hit.DataSourceId == 0 ? null : hit.DataSourceId,
  521. SourcePayload = hit.SourcePayload,
  522. RelatedObjectCode = hit.RelatedObjectCode,
  523. // R2 新列回填
  524. DedupKey = hit.DedupKey,
  525. LastDetectedAt = hit.DetectedAt,
  526. RecoveredAt = null,
  527. SourceRuleCode = hit.SourceRuleCode,
  528. SourceObjectType = hit.SourceObjectType,
  529. SourceObjectId = hit.SourceObjectId
  530. };
  531. // S8-AUTO-WATCH-DEPT-ZERO-AUDIT-1(P0-B-4):raw dept 缺失时打 warning,行为不变。
  532. WarnIfAutoWatchZeroDept(
  533. path: nameof(CreateFromHitAsync),
  534. ruleId: hit.SourceRuleId,
  535. ruleCode: hit.SourceRuleCode,
  536. moduleCode: resolvedModule,
  537. sceneCode: effectiveScene,
  538. exceptionTypeCode: hit.ExceptionTypeCode,
  539. sourceObjectType: hit.SourceObjectType,
  540. sourceObjectId: hit.SourceObjectId,
  541. relatedObjectCode: hit.RelatedObjectCode,
  542. dedupKey: hit.DedupKey,
  543. rawOccurrenceDeptId: hit.OccurrenceDeptId,
  544. rawResponsibleDeptId: hit.ResponsibleDeptId,
  545. finalOccurrenceDeptId: entity.OccurrenceDeptId,
  546. finalResponsibleDeptId: entity.ResponsibleDeptId,
  547. tenantId: entity.TenantId,
  548. factoryId: entity.FactoryId,
  549. exceptionCode: entity.ExceptionCode);
  550. await _rep.AsTenant().UseTranAsync(async () =>
  551. {
  552. entity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
  553. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  554. {
  555. ExceptionId = entity.Id,
  556. ActionCode = "CREATE",
  557. ActionLabel = "创建",
  558. FromStatus = null,
  559. ToStatus = "NEW",
  560. OperatorId = null,
  561. ActionRemark = "自动建单(R2)",
  562. CreatedAt = DateTime.Now
  563. });
  564. }, ex => throw ex);
  565. await TryStartIntakeFlowAsync(entity);
  566. return entity;
  567. }
  568. public async Task<AdoS8Exception?> GetAsync(long id) =>
  569. await _rep.GetByIdAsync(id);
  570. public async Task<AdoS8Evidence> AddAttachmentAsync(long id, AdoS8AttachmentCreateDto dto)
  571. {
  572. var entity = await _rep.GetFirstAsync(x => x.Id == id && !x.IsDeleted)
  573. ?? throw new S8BizException("异常不存在");
  574. if (string.IsNullOrWhiteSpace(dto.FileName) || string.IsNullOrWhiteSpace(dto.FileUrl))
  575. throw new S8BizException("附件名称和地址必填");
  576. var evidence = new AdoS8Evidence
  577. {
  578. ExceptionId = id,
  579. EvidenceType = string.IsNullOrWhiteSpace(dto.EvidenceType) ? "file" : dto.EvidenceType,
  580. FileName = dto.FileName.Trim(),
  581. FileUrl = dto.FileUrl.Trim(),
  582. SourceSystem = dto.SourceSystem,
  583. UploadedBy = dto.UploadedBy,
  584. UploadedAt = DateTime.Now,
  585. IsDeleted = false
  586. };
  587. await _evidenceRep.InsertAsync(evidence);
  588. return evidence;
  589. }
  590. }