S8TaskFlowService.cs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. using Admin.NET.Core.Service;
  2. using Admin.NET.Plugin.AiDOP.Entity.S0.Warehouse;
  3. using Admin.NET.Plugin.AiDOP.Entity.S8;
  4. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  5. using Admin.NET.Plugin.ApprovalFlow;
  6. using Admin.NET.Plugin.ApprovalFlow.Service;
  7. using Microsoft.Extensions.Logging;
  8. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  9. public class S8TaskFlowService : ITransient
  10. {
  11. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  12. private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
  13. private readonly SqlSugarRepository<AdoS0EmployeeMaster> _employeeRep;
  14. private readonly SqlSugarRepository<AdoS0DepartmentMaster> _deptRep;
  15. private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
  16. private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
  17. private readonly FlowEngineService _flowEngine;
  18. private readonly UserManager _userManager;
  19. private readonly S8NotificationLayerResolver _notificationLayerResolver;
  20. private readonly SysNoticeService _noticeService;
  21. private readonly ILogger<S8TaskFlowService> _logger;
  22. public S8TaskFlowService(
  23. SqlSugarRepository<AdoS8Exception> rep,
  24. SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
  25. SqlSugarRepository<AdoS0EmployeeMaster> employeeRep,
  26. SqlSugarRepository<AdoS0DepartmentMaster> deptRep,
  27. SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
  28. SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
  29. FlowEngineService flowEngine,
  30. UserManager userManager,
  31. S8NotificationLayerResolver notificationLayerResolver,
  32. SysNoticeService noticeService,
  33. ILogger<S8TaskFlowService> logger)
  34. {
  35. _rep = rep;
  36. _timelineRep = timelineRep;
  37. _employeeRep = employeeRep;
  38. _deptRep = deptRep;
  39. _flowInstanceRep = flowInstanceRep;
  40. _flowTaskRep = flowTaskRep;
  41. _flowEngine = flowEngine;
  42. _userManager = userManager;
  43. _notificationLayerResolver = notificationLayerResolver;
  44. _noticeService = noticeService;
  45. _logger = logger;
  46. }
  47. public async Task<AdoS8Exception> ClaimAsync(long id, long tenantId, long factoryId, long assigneeId, string? remark)
  48. {
  49. if (assigneeId <= 0) throw new S8BizException("认领需指定处理人 AssigneeId");
  50. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  51. if (!S8StatusRules.IsAllowedTransition(e.Status, "ASSIGNED"))
  52. throw new S8BizException($"状态 {e.Status} 不可认领");
  53. var fromStatus = e.Status;
  54. e.Status = "ASSIGNED";
  55. e.AssigneeId = assigneeId;
  56. e.AssignedAt = DateTime.Now;
  57. e.UpdatedAt = DateTime.Now;
  58. // S8-ASSIGNEE-RESP-DEPT-SYNC-1(P0-B-2):派单同步处理部门 = 处理人所属部门 RecID。
  59. // 兜底策略:解析失败保留原 ResponsibleDeptId,仅 warn;避免误覆盖既有归属。
  60. var resolvedDeptId = await ResolveEmployeeResponsibleDeptIdAsync(assigneeId, factoryId);
  61. if (resolvedDeptId.HasValue)
  62. {
  63. e.ResponsibleDeptId = resolvedDeptId.Value;
  64. }
  65. else
  66. {
  67. _logger.LogWarning(
  68. "s8_claim_dept_unresolved exceptionId={Id} assigneeId={AssigneeId} factoryId={FactoryId}",
  69. e.Id, assigneeId, factoryId);
  70. }
  71. await _rep.AsTenant().UseTranAsync(async () =>
  72. {
  73. await _rep.UpdateAsync(e);
  74. await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", assigneeId, null, remark);
  75. }, ex => throw ex);
  76. // 注:认领仅承接异常,不视为审批流完成。审批流的"通过"在"开始处理"那一步触发。
  77. await NotifyAssigneeAsync(e, assigneeId, "S8异常已分派给你");
  78. return e;
  79. }
  80. public async Task<AdoS8Exception> TransferAsync(long id, long tenantId, long factoryId, long newAssigneeId, string? remark)
  81. {
  82. var allowedStatuses = new HashSet<string> { "ASSIGNED", "IN_PROGRESS" };
  83. if (newAssigneeId <= 0) throw new S8BizException("转派目标无效");
  84. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  85. if (e.ActiveFlowInstanceId.HasValue)
  86. throw new S8BizException("审批进行中不可转派");
  87. if (e.Status == "ESCALATED")
  88. throw new S8BizException("升级审批中不可转派");
  89. if (S8StatusRules.IsTerminal(e.Status))
  90. throw new S8BizException("已关闭不可转派");
  91. if (!allowedStatuses.Contains(e.Status))
  92. throw new S8BizException($"状态 {e.Status} 不可转派");
  93. e.AssigneeId = newAssigneeId;
  94. e.UpdatedAt = DateTime.Now;
  95. // S8-ASSIGNEE-RESP-DEPT-SYNC-1(P0-B-2):转派同步处理部门 = 新处理人所属部门 RecID。
  96. // 兜底策略:解析失败保留原 ResponsibleDeptId,仅 warn;避免误覆盖既有归属。
  97. var resolvedDeptId = await ResolveEmployeeResponsibleDeptIdAsync(newAssigneeId, factoryId);
  98. if (resolvedDeptId.HasValue)
  99. {
  100. e.ResponsibleDeptId = resolvedDeptId.Value;
  101. }
  102. else
  103. {
  104. _logger.LogWarning(
  105. "s8_transfer_dept_unresolved exceptionId={Id} newAssigneeId={AssigneeId} factoryId={FactoryId}",
  106. e.Id, newAssigneeId, factoryId);
  107. }
  108. await _rep.AsTenant().UseTranAsync(async () =>
  109. {
  110. await _rep.UpdateAsync(e);
  111. await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, newAssigneeId, null, remark);
  112. }, ex => throw ex);
  113. await NotifyAssigneeAsync(e, newAssigneeId, "S8异常已转派给你");
  114. // 双线合一:S8 转派 = TB001 任务转办给新处理人。
  115. await TryTransferIntakeOnTransferAsync(e.Id, newAssigneeId, remark);
  116. return e;
  117. }
  118. private async Task TryTransferIntakeOnTransferAsync(long exceptionId, long newAssigneeRecId, string? remark)
  119. {
  120. try
  121. {
  122. var instance = await _flowInstanceRep.AsQueryable()
  123. .Where(x => x.BizType == "EXCEPTION_REPORT"
  124. && x.BizId == exceptionId
  125. && x.Status == FlowInstanceStatusEnum.Running)
  126. .FirstAsync();
  127. if (instance == null) return;
  128. var currentUserId = _userManager.UserId;
  129. var task = await _flowTaskRep.AsQueryable()
  130. .Where(x => x.InstanceId == instance.Id
  131. && x.AssigneeId == currentUserId
  132. && x.Status == FlowTaskStatusEnum.Pending)
  133. .FirstAsync();
  134. if (task == null) return;
  135. // newAssigneeRecId 是 EmployeeMaster.RecID;FlowEngine.Transfer 要 SysUser.UserId。
  136. var targetSysUserId = await _employeeRep.AsQueryable().ClearFilter()
  137. .Where(x => x.Id == newAssigneeRecId && x.SysUserId != null)
  138. .Select(x => x.SysUserId)
  139. .FirstAsync();
  140. if (targetSysUserId == null || targetSysUserId == 0)
  141. {
  142. _logger.LogWarning(
  143. "S8 转派联动审批流跳过:员工 {RecId} 未绑定 SysUser,TB001 任务保留原 assignee",
  144. newAssigneeRecId);
  145. return;
  146. }
  147. await _flowEngine.Transfer(task.Id, targetSysUserId.Value, remark ?? "S8 转派(双线合一自动转办)");
  148. }
  149. catch (Exception ex)
  150. {
  151. _logger.LogWarning(ex,
  152. "S8 转派时自动转办 TB001 任务失败 exceptionId={Id} newAssigneeRecId={AssigneeId}",
  153. exceptionId, newAssigneeRecId);
  154. }
  155. }
  156. public async Task<AdoS8Exception> StartProgressAsync(long id, long tenantId, long factoryId, string? remark)
  157. {
  158. var currentUserId = GetCurrentUserId();
  159. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  160. if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS"))
  161. throw new S8BizException($"状态 {e.Status} 不可开始处理");
  162. var fromStatus = e.Status;
  163. e.Status = "IN_PROGRESS";
  164. e.UpdatedAt = DateTime.Now;
  165. await _rep.AsTenant().UseTranAsync(async () =>
  166. {
  167. await _rep.UpdateAsync(e);
  168. await InsertTimelineAsync(e.Id, "START_PROGRESS", "开始处理", fromStatus, "IN_PROGRESS", currentUserId, null, remark);
  169. }, ex => throw ex);
  170. // 双线合一:开始处理 = TB001 异常提报审批通过。
  171. // 当前用户必须是 TB001 task 的 AssigneeId,FlowEngine 强校验。
  172. await TryApproveIntakeOnStartProgressAsync(e, currentUserId);
  173. return e;
  174. }
  175. private async Task TryApproveIntakeOnStartProgressAsync(AdoS8Exception e, long currentUserId)
  176. {
  177. long? instanceId = null, taskId = null;
  178. try
  179. {
  180. // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:实例/任务查询同样清数据范围过滤(ApprovalFlowInstance/Task 继承 EntityBaseOrg)。
  181. // 非超管用户在 DataScope=Self 时被「CreateUserId==当前用户」过滤、Dept/DeptChild 时被 OrgId 过滤,会查不到
  182. // 由他人(或系统)创建的 flow,双线合一静默失效。ClearFilter() 清全部数据范围过滤;BizType+BizId+assignee 已限定,
  183. // 无跨 BizType 误伤;ApprovalFlowInstance/Task 无软删与租户过滤。
  184. var instance = await _flowInstanceRep.AsQueryable()
  185. .ClearFilter()
  186. .Where(x => x.BizType == "EXCEPTION_REPORT"
  187. && x.BizId == e.Id
  188. && x.Status == FlowInstanceStatusEnum.Running)
  189. .FirstAsync();
  190. if (instance == null) return;
  191. instanceId = instance.Id;
  192. var task = await _flowTaskRep.AsQueryable()
  193. .ClearFilter()
  194. .Where(x => x.InstanceId == instance.Id
  195. && x.AssigneeId == currentUserId
  196. && x.Status == FlowTaskStatusEnum.Pending)
  197. .FirstAsync();
  198. if (task == null) return;
  199. taskId = task.Id;
  200. await _flowEngine.Approve(task.Id, "S8 已开始处理(双线合一自动同意)");
  201. }
  202. catch (Exception ex)
  203. {
  204. _logger.LogWarning(ex,
  205. "S8 开始处理时自动同意 TB001 任务失败 exceptionId={Id} exceptionCode={Code} userId={UserId} assigneeId={Assignee} status={Status} instanceId={InstanceId} taskId={TaskId} err={Err}",
  206. e.Id, e.ExceptionCode, currentUserId, e.AssigneeId, e.Status, instanceId, taskId, ex.Message);
  207. }
  208. }
  209. public async Task<AdoS8Exception> UpgradeAsync(long id, long tenantId, long factoryId, string? remark)
  210. {
  211. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  212. if (e.ActiveFlowInstanceId.HasValue)
  213. throw new S8BizException("该异常已有进行中的审批流程,请等待审批完成");
  214. if (!S8StatusRules.IsAllowedTransition(e.Status, "ESCALATED"))
  215. throw new S8BizException($"状态 {e.Status} 不可升级");
  216. // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:UpgradeAsync 本身已持有可信 tenantId(人工点击=HTTP 走
  217. // S8TrustedScopeResolver;S8TimeoutAutoEscalationJob 自动升级=后台 Job 显式传参),统一走
  218. // 受信任重载,不依赖 _userManager.TenantId(后台 Job 场景下恒为 0)。
  219. await _flowEngine.StartFlow(new StartFlowInput
  220. {
  221. BizType = "EXCEPTION_ESCALATION",
  222. BizId = e.Id,
  223. Title = $"异常升级审批 - {e.ExceptionCode}",
  224. Comment = remark,
  225. BizData = new Dictionary<string, object>
  226. {
  227. ["severity"] = e.Severity,
  228. ["sceneCode"] = e.SceneCode,
  229. ["priorityLevel"] = e.PriorityLevel,
  230. }
  231. }, tenantId);
  232. // 状态和时间线由 ExceptionEscalationBizHandler.OnFlowStarted 回调更新
  233. return await LoadAsync(id, tenantId, factoryId) ?? e;
  234. }
  235. public async Task<AdoS8Exception> RejectAsync(long id, long tenantId, long factoryId, string? remark)
  236. {
  237. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  238. if (!S8StatusRules.IsAllowedTransition(e.Status, "REJECTED"))
  239. throw new S8BizException($"状态 {e.Status} 不可驳回");
  240. var from = e.Status;
  241. e.Status = "REJECTED";
  242. e.UpdatedAt = DateTime.Now;
  243. await _rep.AsTenant().UseTranAsync(async () =>
  244. {
  245. await _rep.UpdateAsync(e);
  246. await InsertTimelineAsync(e.Id, "REJECT", "驳回", from, "REJECTED", null, null, remark);
  247. }, ex => throw ex);
  248. // 双线合一:S8 驳回 = TB001 流程整体拒绝(取消所有 pending 任务、Instance 终止)。
  249. await TryRejectIntakeOnRejectAsync(e, remark);
  250. return e;
  251. }
  252. private async Task TryRejectIntakeOnRejectAsync(AdoS8Exception e, string? remark)
  253. {
  254. var currentUserId = _userManager.UserId;
  255. long? instanceId = null, taskId = null;
  256. try
  257. {
  258. // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:实例/任务查询同样清数据范围过滤(ApprovalFlowInstance/Task 继承 EntityBaseOrg)。
  259. // 非超管用户在 DataScope=Self 时被「CreateUserId==当前用户」过滤、Dept/DeptChild 时被 OrgId 过滤,会查不到
  260. // 由他人(或系统)创建的 flow,双线合一静默失效。ClearFilter() 清全部数据范围过滤;BizType+BizId+assignee 已限定,
  261. // 无跨 BizType 误伤;ApprovalFlowInstance/Task 无软删与租户过滤。
  262. var instance = await _flowInstanceRep.AsQueryable()
  263. .ClearFilter()
  264. .Where(x => x.BizType == "EXCEPTION_REPORT"
  265. && x.BizId == e.Id
  266. && x.Status == FlowInstanceStatusEnum.Running)
  267. .FirstAsync();
  268. if (instance == null) return;
  269. instanceId = instance.Id;
  270. var task = await _flowTaskRep.AsQueryable()
  271. .ClearFilter()
  272. .Where(x => x.InstanceId == instance.Id
  273. && x.AssigneeId == currentUserId
  274. && x.Status == FlowTaskStatusEnum.Pending)
  275. .FirstAsync();
  276. if (task == null) return;
  277. taskId = task.Id;
  278. await _flowEngine.Reject(task.Id, remark ?? "S8 已驳回(双线合一自动拒绝)");
  279. }
  280. catch (Exception ex)
  281. {
  282. _logger.LogWarning(ex,
  283. "S8 驳回时自动拒绝 TB001 流程失败 exceptionId={Id} exceptionCode={Code} userId={UserId} assigneeId={Assignee} status={Status} instanceId={InstanceId} taskId={TaskId} err={Err}",
  284. e.Id, e.ExceptionCode, currentUserId, e.AssigneeId, e.Status, instanceId, taskId, ex.Message);
  285. }
  286. }
  287. public async Task<AdoS8Exception> SubmitVerificationAsync(
  288. long id, long tenantId, long factoryId,
  289. long verifierId, string? remark)
  290. {
  291. var currentUserId = GetCurrentUserId();
  292. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  293. await EnsureCurrentUserIsOperatorAsync(e.AssigneeId, currentUserId,
  294. "只有当前处理人才能提交复检(或当前账号未绑定员工主数据)");
  295. if (verifierId <= 0)
  296. throw new S8BizException("请选择检验人");
  297. if (!S8StatusRules.IsAllowedTransition(e.Status, "PENDING_VERIFICATION"))
  298. throw new S8BizException($"状态 {e.Status} 不可提交复检");
  299. var from = e.Status;
  300. e.Status = "PENDING_VERIFICATION";
  301. e.VerifierId = verifierId;
  302. e.VerificationAssignedAt = DateTime.Now;
  303. e.UpdatedAt = DateTime.Now;
  304. await _rep.AsTenant().UseTranAsync(async () =>
  305. {
  306. await _rep.UpdateAsync(e);
  307. await InsertTimelineAsync(e.Id, "VERIFY_SUBMITTED", "提交复检", from, "PENDING_VERIFICATION",
  308. currentUserId, null, remark);
  309. }, ex => throw ex);
  310. // 双线合一:提交复检 = 启动 EXCEPTION_CLOSURE 流程,指派检验人。
  311. // 该流程定义复用,作为复检/关闭确认通用审批载体;handler 只做 ActiveFlowInstanceId 维护。
  312. await TryStartVerificationFlowAsync(e, verifierId, remark);
  313. return await LoadAsync(id, tenantId, factoryId) ?? e;
  314. }
  315. private async Task TryStartVerificationFlowAsync(AdoS8Exception e, long verifierRecId, string? remark)
  316. {
  317. try
  318. {
  319. await _flowEngine.StartFlow(new StartFlowInput
  320. {
  321. BizType = "EXCEPTION_CLOSURE",
  322. BizId = e.Id,
  323. BizNo = e.ExceptionCode,
  324. Title = $"异常复检 - {e.ExceptionCode}",
  325. Comment = remark,
  326. BizData = new Dictionary<string, object>
  327. {
  328. ["sceneCode"] = e.SceneCode ?? string.Empty,
  329. ["verifierRecId"] = verifierRecId,
  330. }
  331. });
  332. }
  333. catch (Exception ex)
  334. {
  335. _logger.LogWarning(ex,
  336. "S8 提交复检时启动 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} verifierRecId={VerifierId}",
  337. e.Id, verifierRecId);
  338. }
  339. }
  340. public async Task<AdoS8Exception> ApproveVerificationAsync(
  341. long id, long tenantId, long factoryId,
  342. string? remark)
  343. {
  344. var currentUserId = GetCurrentUserId();
  345. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  346. await EnsureCurrentUserIsOperatorAsync(e.VerifierId, currentUserId,
  347. "只有指定检验人才能检验通过(或当前账号未绑定员工主数据)");
  348. if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED"))
  349. throw new S8BizException($"状态 {e.Status} 不可检验通过");
  350. var from = e.Status;
  351. e.Status = "CLOSED";
  352. e.VerifiedAt = DateTime.Now;
  353. e.VerificationResult = "APPROVED";
  354. e.VerificationRemark = remark;
  355. e.ClosedAt = DateTime.Now;
  356. e.UpdatedAt = DateTime.Now;
  357. await _rep.AsTenant().UseTranAsync(async () =>
  358. {
  359. await _rep.UpdateAsync(e);
  360. await InsertTimelineAsync(e.Id, "VERIFY_APPROVED", "检验通过", from, "CLOSED",
  361. currentUserId, null, remark);
  362. }, ex => throw ex);
  363. // 双线合一:检验通过 = EXCEPTION_CLOSURE 复检流程审批通过。
  364. await TryApproveVerificationFlowAsync(e.Id, currentUserId);
  365. // S8-R03-OVERDUE-CLOSE-NOTICE-1:闭环及时性提醒。
  366. // 触发条件:closedAt > slaDeadline;与 TimeoutFlag 运行时口径分离,仅对已关闭异常做闭环回顾。
  367. // 主流程已 commit(含状态机 + Timeline),异常隔离仅 LogWarning,不影响 CloseAsync。
  368. await TryDispatchOverdueCloseNotificationAsync(e);
  369. return e;
  370. }
  371. /// <summary>
  372. /// S8-R03-OVERDUE-CLOSE-NOTICE-1:异常关闭后若 closedAt &gt; slaDeadline 触发一条独立"超时关闭"通知。
  373. /// 复用 <see cref="S8NotificationLayerResolver.DispatchByLayerAsync"/> 分层链路,不写 ApprovalFlowNotifyLog。
  374. /// 与 <see cref="S8TimeoutAutoEscalationService"/>(未关闭超时升级)语义分离:本钩子仅在已关闭后回顾闭环及时性。
  375. /// 任何异常仅 LogWarning,不抛回 ApproveVerificationAsync 主流程。
  376. /// </summary>
  377. private async Task TryDispatchOverdueCloseNotificationAsync(AdoS8Exception e)
  378. {
  379. if (e == null || e.Id <= 0) return;
  380. if (e.ClosedAt == null || e.SlaDeadline == null) return;
  381. if (e.ClosedAt.Value <= e.SlaDeadline.Value) return;
  382. try
  383. {
  384. var overdueCloseHours = Math.Round(
  385. (decimal)(e.ClosedAt.Value - e.SlaDeadline.Value).TotalHours, 1);
  386. var sceneCode = string.IsNullOrWhiteSpace(e.SceneCode) ? "S8_DEMO_DEFAULT" : e.SceneCode;
  387. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:派发前 Normalize 落 FOLLOW/SERIOUS。
  388. var severity = S8SeverityCode.Normalize(e.Severity);
  389. var content =
  390. $"异常 {e.ExceptionCode} 已关闭,但关闭时间超过 SLA 截止时间,超时关闭 {overdueCloseHours.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)} 小时,请关注闭环及时性。";
  391. await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
  392. {
  393. TenantId = e.TenantId,
  394. FactoryId = e.FactoryId,
  395. ExceptionId = e.Id,
  396. ExceptionNo = e.ExceptionCode,
  397. SceneCode = sceneCode,
  398. Severity = severity,
  399. Title = $"【超时关闭】{e.ExceptionCode}",
  400. Content = content,
  401. Status = e.Status,
  402. SourceRuleCode = e.SourceRuleCode,
  403. JumpUrl = $"/aidop/s8/exceptions/{e.Id}",
  404. OverdueClosed = true,
  405. ClosedAt = e.ClosedAt,
  406. SlaDeadlineRef = e.SlaDeadline,
  407. OverdueCloseHours = overdueCloseHours,
  408. });
  409. }
  410. catch (Exception ex)
  411. {
  412. _logger.LogWarning(ex, "notify_overdue_close_dispatch_throw exceptionId={Id}", e.Id);
  413. }
  414. }
  415. private async Task TryApproveVerificationFlowAsync(long exceptionId, long currentUserId)
  416. {
  417. try
  418. {
  419. var instance = await _flowInstanceRep.AsQueryable()
  420. .Where(x => x.BizType == "EXCEPTION_CLOSURE"
  421. && x.BizId == exceptionId
  422. && x.Status == FlowInstanceStatusEnum.Running)
  423. .FirstAsync();
  424. if (instance == null) return;
  425. var task = await _flowTaskRep.AsQueryable()
  426. .Where(x => x.InstanceId == instance.Id
  427. && x.AssigneeId == currentUserId
  428. && x.Status == FlowTaskStatusEnum.Pending)
  429. .FirstAsync();
  430. if (task == null) return;
  431. await _flowEngine.Approve(task.Id, "S8 检验通过(双线合一自动同意)");
  432. }
  433. catch (Exception ex)
  434. {
  435. _logger.LogWarning(ex,
  436. "S8 检验通过时自动同意 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}",
  437. exceptionId, currentUserId);
  438. }
  439. }
  440. public async Task<AdoS8Exception> RejectVerificationAsync(
  441. long id, long tenantId, long factoryId,
  442. string remark)
  443. {
  444. var currentUserId = GetCurrentUserId();
  445. var e = await LoadAsync(id, tenantId, factoryId) ?? throw new S8BizException("异常不存在");
  446. await EnsureCurrentUserIsOperatorAsync(e.VerifierId, currentUserId,
  447. "只有指定检验人才能检验退回(或当前账号未绑定员工主数据)");
  448. if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS"))
  449. throw new S8BizException($"状态 {e.Status} 不可检验退回");
  450. if (string.IsNullOrWhiteSpace(remark))
  451. throw new S8BizException("检验退回必须填写退回原因");
  452. var from = e.Status;
  453. e.Status = "IN_PROGRESS";
  454. e.VerifiedAt = DateTime.Now;
  455. e.VerificationResult = "REJECTED";
  456. e.VerificationRemark = remark;
  457. e.UpdatedAt = DateTime.Now;
  458. await _rep.AsTenant().UseTranAsync(async () =>
  459. {
  460. await _rep.UpdateAsync(e);
  461. await InsertTimelineAsync(e.Id, "VERIFY_REJECTED", "检验退回", from, "IN_PROGRESS",
  462. currentUserId, null, remark);
  463. }, ex => throw ex);
  464. // 双线合一:检验退回 = EXCEPTION_CLOSURE 复检流程整体拒绝。
  465. await TryRejectVerificationFlowAsync(e.Id, currentUserId, remark);
  466. return e;
  467. }
  468. private async Task TryRejectVerificationFlowAsync(long exceptionId, long currentUserId, string? remark)
  469. {
  470. try
  471. {
  472. var instance = await _flowInstanceRep.AsQueryable()
  473. .Where(x => x.BizType == "EXCEPTION_CLOSURE"
  474. && x.BizId == exceptionId
  475. && x.Status == FlowInstanceStatusEnum.Running)
  476. .FirstAsync();
  477. if (instance == null) return;
  478. var task = await _flowTaskRep.AsQueryable()
  479. .Where(x => x.InstanceId == instance.Id
  480. && x.AssigneeId == currentUserId
  481. && x.Status == FlowTaskStatusEnum.Pending)
  482. .FirstAsync();
  483. if (task == null) return;
  484. await _flowEngine.Reject(task.Id, remark ?? "S8 检验退回(双线合一自动拒绝)");
  485. }
  486. catch (Exception ex)
  487. {
  488. _logger.LogWarning(ex,
  489. "S8 检验退回时自动拒绝 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}",
  490. exceptionId, currentUserId);
  491. }
  492. }
  493. // S8-TENANT-FACTORY-P0-CLOSURE-1:补充说明同样按可信作用域绑行,禁止裸 Id 写他租户时间线。
  494. public async Task CommentAsync(long id, long tenantId, long factoryId, string? remark)
  495. {
  496. var e = await LoadAsync(id, tenantId, factoryId)
  497. ?? throw new S8BizException("异常不存在");
  498. await InsertTimelineAsync(e.Id, "COMMENT", "补充说明", e.Status, e.Status, null, null, remark);
  499. }
  500. private Task<AdoS8Exception?> LoadAsync(long id, long tenantId, long factoryId) =>
  501. _rep.GetFirstAsync(x => x.Id == id && x.TenantId == tenantId && x.FactoryId == factoryId && !x.IsDeleted);
  502. // 统一复用框架登录上下文,避免业务身份继续信任前端传参。
  503. private long GetCurrentUserId()
  504. {
  505. var currentUserId = _userManager.UserId;
  506. if (currentUserId <= 0)
  507. throw new S8BizException("未获取到当前登录用户");
  508. return currentUserId;
  509. }
  510. // S8-ASSIGNEE-RESP-DEPT-SYNC-1(P0-B-2):处理人 RecID → 所属部门 RecID。
  511. // 链路:EmployeeMaster.Department(codename) + FactoryRefId → DepartmentMaster.Department + FactoryRefId → RecID。
  512. // ClearFilter 同口径(S8MasterDataAdapter / GetEmployeeSysUserIdAsync),factoryId 做硬边界。
  513. // 解析失败返回 null —— 调用方按"保留原值 + warn"处理,绝不覆盖既有 ResponsibleDeptId。
  514. private async Task<long?> ResolveEmployeeResponsibleDeptIdAsync(long assigneeId, long factoryId)
  515. {
  516. if (assigneeId <= 0 || factoryId <= 0) return null;
  517. var emp = await _employeeRep.AsQueryable().ClearFilter()
  518. .Where(x => x.Id == assigneeId && x.FactoryRefId == factoryId)
  519. .Select(x => new { x.Department })
  520. .FirstAsync();
  521. if (emp == null || string.IsNullOrWhiteSpace(emp.Department)) return null;
  522. var deptId = await _deptRep.AsQueryable().ClearFilter()
  523. .Where(x => x.Department == emp.Department && x.FactoryRefId == factoryId && x.IsActive)
  524. .Select(x => (long?)x.Id)
  525. .FirstAsync();
  526. return deptId;
  527. }
  528. private async Task NotifyAssigneeAsync(AdoS8Exception exception, long employeeRecId, string title)
  529. {
  530. var sysUserId = await GetEmployeeSysUserIdAsync(employeeRecId);
  531. if (sysUserId is not > 0)
  532. {
  533. _logger.LogWarning(
  534. "s8_assignee_notice_unmapped exceptionId={Id} employeeRecId={EmployeeRecId} title={Title}",
  535. exception.Id, employeeRecId, title);
  536. return;
  537. }
  538. var jump = $"/#/aidop/s8/exceptions/{exception.Id}";
  539. var content =
  540. $"<p>{exception.ExceptionCode} {exception.Title}</p>" +
  541. $"<p><a href=\"{jump}\">查看异常详情</a></p>";
  542. try
  543. {
  544. await _noticeService.PublishToUsersAsync(
  545. title,
  546. content,
  547. new[] { sysUserId.Value },
  548. _userManager.UserId,
  549. string.IsNullOrWhiteSpace(_userManager.RealName) ? "S8异常监控" : _userManager.RealName);
  550. }
  551. catch (Exception ex)
  552. {
  553. _logger.LogWarning(ex, "s8_assignee_notice_failed exceptionId={Id} sysUserId={UserId}", exception.Id, sysUserId);
  554. }
  555. }
  556. // 把异常上的处理人/检验人(employeeId) 经 EmployeeMaster.SysUserId 解析到系统账号 ID。
  557. // EmployeeMaster.tenant_id 与 SysUser.TenantId 历史错位,必须 ClearFilter 跳过多租户全局 filter;
  558. // 通过 employee.Id 主键精确查询作为安全边界,无跨租户泄漏风险。
  559. private async Task<long?> GetEmployeeSysUserIdAsync(long? employeeId)
  560. {
  561. if (!employeeId.HasValue || employeeId.Value <= 0) return null;
  562. var emp = await _employeeRep.AsQueryable().ClearFilter()
  563. .Where(x => x.Id == employeeId.Value)
  564. .FirstAsync();
  565. return emp?.SysUserId;
  566. }
  567. // 鉴权统一入口:要求当前登录用户必须是 employeeId 解析后的 SysUserId。
  568. private async Task EnsureCurrentUserIsOperatorAsync(long? employeeId, long currentUserId, string failMessage)
  569. {
  570. var ownerSysUserId = await GetEmployeeSysUserIdAsync(employeeId);
  571. if (ownerSysUserId != currentUserId)
  572. throw new S8BizException(failMessage);
  573. }
  574. private async Task InsertTimelineAsync(long exceptionId, string code, string label, string? from, string? to,
  575. long? operatorId, string? operatorName, string? remark) =>
  576. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  577. {
  578. ExceptionId = exceptionId,
  579. ActionCode = code,
  580. ActionLabel = label,
  581. FromStatus = from,
  582. ToStatus = to,
  583. OperatorId = operatorId,
  584. OperatorName = operatorName,
  585. ActionRemark = remark,
  586. CreatedAt = DateTime.Now
  587. });
  588. }