S8TaskFlowService.cs 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. using Admin.NET.Core.Service;
  2. using Admin.NET.Plugin.AiDOP.Entity.S8;
  3. using Admin.NET.Plugin.AiDOP.Infrastructure.S8;
  4. using Admin.NET.Plugin.ApprovalFlow;
  5. using Admin.NET.Plugin.ApprovalFlow.Service;
  6. using Microsoft.Extensions.Logging;
  7. namespace Admin.NET.Plugin.AiDOP.Service.S8;
  8. public class S8TaskFlowService : ITransient
  9. {
  10. private readonly SqlSugarRepository<AdoS8Exception> _rep;
  11. private readonly SqlSugarRepository<AdoS8ExceptionTimeline> _timelineRep;
  12. private readonly IS8UserScopeValidator _userScope;
  13. private readonly SqlSugarRepository<ApprovalFlowInstance> _flowInstanceRep;
  14. private readonly SqlSugarRepository<ApprovalFlowTask> _flowTaskRep;
  15. private readonly FlowEngineService _flowEngine;
  16. private readonly UserManager _userManager;
  17. private readonly S8NotificationLayerResolver _notificationLayerResolver;
  18. private readonly SysNoticeService _noticeService;
  19. private readonly ILogger<S8TaskFlowService> _logger;
  20. public S8TaskFlowService(
  21. SqlSugarRepository<AdoS8Exception> rep,
  22. SqlSugarRepository<AdoS8ExceptionTimeline> timelineRep,
  23. IS8UserScopeValidator userScope,
  24. SqlSugarRepository<ApprovalFlowInstance> flowInstanceRep,
  25. SqlSugarRepository<ApprovalFlowTask> flowTaskRep,
  26. FlowEngineService flowEngine,
  27. UserManager userManager,
  28. S8NotificationLayerResolver notificationLayerResolver,
  29. SysNoticeService noticeService,
  30. ILogger<S8TaskFlowService> logger)
  31. {
  32. _rep = rep;
  33. _timelineRep = timelineRep;
  34. _userScope = userScope;
  35. _flowInstanceRep = flowInstanceRep;
  36. _flowTaskRep = flowTaskRep;
  37. _flowEngine = flowEngine;
  38. _userManager = userManager;
  39. _notificationLayerResolver = notificationLayerResolver;
  40. _noticeService = noticeService;
  41. _logger = logger;
  42. }
  43. /// <summary>
  44. /// 认领。<b>处理人 = 当前登录账号,客户端说了不算。</b>
  45. ///
  46. /// <para>旧签名收 <c>assigneeId</c>,意味着任何有认领权限的人都能替别人认领
  47. /// —— 而「替别人指派」在业务上是另一个动作(<see cref="TransferAsync"/>),
  48. /// 有自己的权限位 <c>ExceptionAssign</c>。两者混在一条 API 里,
  49. /// 结果是弱权限位能干强权限位的事。</para>
  50. ///
  51. /// <para><b>First Claim Wins</b>:并发认领由<b>一条带前置条件的 UPDATE</b> 裁决,
  52. /// 不靠先 SELECT 再 UPDATE —— 那两步之间的窗口正是两个人同时认领成功的地方。
  53. /// 谓词里的 <c>assignee_user_id IS NULL</c> 与状态白名单由数据库在同一行锁内求值,
  54. /// 影响行数 0 即表示已被别人抢先。</para>
  55. /// </summary>
  56. public async Task<AdoS8Exception> ClaimAsync(long id, long tenantId, string? remark)
  57. {
  58. var currentUserId = GetCurrentUserId();
  59. // 认领人必须是本租户的可用账号。当前登录态理论上必然满足,
  60. // 但这条断言同时挡住「跨租户 scope 解析出错」这类上游故障。
  61. await _userScope.EnsureActiveUserAsync(tenantId, currentUserId, "认领人");
  62. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  63. if (!S8StatusRules.IsAllowedTransition(e.Status, "ASSIGNED"))
  64. throw new S8BizException($"状态 {e.Status} 不可认领");
  65. var fromStatus = e.Status;
  66. var now = DateTime.Now;
  67. // ── 原子裁决:整条 UPDATE 就是那把锁 ──
  68. var affected = await _rep.Context.Updateable<AdoS8Exception>()
  69. .SetColumns(x => new AdoS8Exception
  70. {
  71. Status = "ASSIGNED",
  72. AssigneeUserId = currentUserId,
  73. AssignedAt = now,
  74. UpdatedAt = now
  75. })
  76. .Where(x => x.Id == e.Id
  77. && x.TenantId == tenantId
  78. && !x.IsDeleted
  79. && x.Status == fromStatus
  80. && x.AssigneeUserId == null)
  81. .ExecuteCommandAsync();
  82. if (affected == 0)
  83. {
  84. _logger.LogInformation(
  85. "s8_claim_lost_race exceptionId={Id} tenantId={TenantId} userId={UserId} fromStatus={From}",
  86. e.Id, tenantId, currentUserId, fromStatus);
  87. throw new S8BizException("该异常已被其他人员认领");
  88. }
  89. await InsertTimelineAsync(e.Id, "CLAIM", "认领", fromStatus, "ASSIGNED", currentUserId, null, remark);
  90. // 注:认领仅承接异常,不视为审批流完成。审批流的"通过"在"开始处理"那一步触发。
  91. await NotifyUserAsync(e, currentUserId, "S8异常已分派给你");
  92. return await LoadAsync(id, tenantId) ?? e;
  93. }
  94. /// <summary>
  95. /// 转派 / 指派。与 <see cref="ClaimAsync"/> 语义分离:这里<b>可以</b>指定别人,
  96. /// 因此它挂的是 <c>ExceptionAssign</c> 权限位,而不是 <c>ExceptionClaim</c>。
  97. /// 目标必须是<b>当前租户</b>的<b>启用</b>账号,工厂不参与判定。
  98. /// </summary>
  99. public async Task<AdoS8Exception> TransferAsync(long id, long tenantId, long newAssigneeUserId, string? remark)
  100. {
  101. var allowedStatuses = new HashSet<string> { "ASSIGNED", "IN_PROGRESS" };
  102. var target = await _userScope.EnsureActiveUserAsync(tenantId, newAssigneeUserId, "转派目标账号");
  103. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  104. if (e.ActiveFlowInstanceId.HasValue)
  105. throw new S8BizException("审批进行中不可转派");
  106. if (e.Status == "ESCALATED")
  107. throw new S8BizException("升级审批中不可转派");
  108. if (S8StatusRules.IsTerminal(e.Status))
  109. throw new S8BizException("已关闭不可转派");
  110. if (!allowedStatuses.Contains(e.Status))
  111. throw new S8BizException($"状态 {e.Status} 不可转派");
  112. e.AssigneeUserId = target.UserId;
  113. e.UpdatedAt = DateTime.Now;
  114. // S8-SYSUSER-ONLY-1:原「转派同步处理部门 = 新处理人所属部门」已退役。
  115. // 那条链路是 EmployeeMaster.Department(字符串) → DepartmentMaster,**只有员工主数据才有部门**;
  116. // SysUser 侧没有任何指向 DepartmentMaster 的关系(SysUser.OrgId 指的是 SysOrg 公司/工厂树,
  117. // 与 DepartmentMaster 是两棵互不相干的树)。在 SysUser-only 模型下它无法成立,
  118. // 因此不保留、也不用 SysOrg 硬凑一个近似值 —— 责任部门由建单时确定(规则默认部门 / 人工提报选择),
  119. // 需要变更由人显式改,不再随处理人静默漂移。
  120. await _rep.AsTenant().UseTranAsync(async () =>
  121. {
  122. await _rep.UpdateAsync(e);
  123. await InsertTimelineAsync(e.Id, "TRANSFER", "转派", e.Status, e.Status, GetCurrentUserId(), null, remark);
  124. }, ex => throw ex);
  125. await NotifyUserAsync(e, target.UserId, "S8异常已转派给你");
  126. // 双线合一:S8 转派 = TB001 任务转办给新处理人。
  127. await TryTransferIntakeOnTransferAsync(e.Id, target.UserId, remark);
  128. return e;
  129. }
  130. private async Task TryTransferIntakeOnTransferAsync(long exceptionId, long newAssigneeUserId, string? remark)
  131. {
  132. try
  133. {
  134. var instance = await _flowInstanceRep.AsQueryable()
  135. .Where(x => x.BizType == "EXCEPTION_REPORT"
  136. && x.BizId == exceptionId
  137. && x.Status == FlowInstanceStatusEnum.Running)
  138. .FirstAsync();
  139. if (instance == null) return;
  140. var currentUserId = _userManager.UserId;
  141. var task = await _flowTaskRep.AsQueryable()
  142. .Where(x => x.InstanceId == instance.Id
  143. && x.AssigneeId == currentUserId
  144. && x.Status == FlowTaskStatusEnum.Pending)
  145. .FirstAsync();
  146. if (task == null) return;
  147. // S8-SYSUSER-ONLY-1:S8 处理人与 FlowEngine assignee 现在是同一个 ID 空间,
  148. // 原来的 EmployeeMaster.SysUserId 桥接(以及它「未绑定就静默跳过」的失败模式)已删除。
  149. await _flowEngine.Transfer(task.Id, newAssigneeUserId, remark ?? "S8 转派(双线合一自动转办)");
  150. }
  151. catch (Exception ex)
  152. {
  153. _logger.LogWarning(ex,
  154. "S8 转派时自动转办 TB001 任务失败 exceptionId={Id} newAssigneeUserId={AssigneeUserId}",
  155. exceptionId, newAssigneeUserId);
  156. }
  157. }
  158. public async Task<AdoS8Exception> StartProgressAsync(long id, long tenantId, string? remark)
  159. {
  160. var currentUserId = GetCurrentUserId();
  161. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  162. if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS"))
  163. throw new S8BizException($"状态 {e.Status} 不可开始处理");
  164. var fromStatus = e.Status;
  165. e.Status = "IN_PROGRESS";
  166. e.UpdatedAt = DateTime.Now;
  167. await _rep.AsTenant().UseTranAsync(async () =>
  168. {
  169. await _rep.UpdateAsync(e);
  170. await InsertTimelineAsync(e.Id, "START_PROGRESS", "开始处理", fromStatus, "IN_PROGRESS", currentUserId, null, remark);
  171. }, ex => throw ex);
  172. // 双线合一:开始处理 = TB001 异常提报审批通过。
  173. // 当前用户必须是 TB001 task 的 AssigneeId,FlowEngine 强校验。
  174. await TryApproveIntakeOnStartProgressAsync(e, currentUserId);
  175. return e;
  176. }
  177. private async Task TryApproveIntakeOnStartProgressAsync(AdoS8Exception e, long currentUserId)
  178. {
  179. long? instanceId = null, taskId = null;
  180. try
  181. {
  182. // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:实例/任务查询同样清数据范围过滤(ApprovalFlowInstance/Task 继承 EntityBaseOrg)。
  183. // 非超管用户在 DataScope=Self 时被「CreateUserId==当前用户」过滤、Dept/DeptChild 时被 OrgId 过滤,会查不到
  184. // 由他人(或系统)创建的 flow,双线合一静默失效。ClearFilter() 清全部数据范围过滤;BizType+BizId+assignee 已限定,
  185. // 无跨 BizType 误伤;ApprovalFlowInstance/Task 无软删与租户过滤。
  186. var instance = await _flowInstanceRep.AsQueryable()
  187. .ClearFilter()
  188. .Where(x => x.BizType == "EXCEPTION_REPORT"
  189. && x.BizId == e.Id
  190. && x.Status == FlowInstanceStatusEnum.Running)
  191. .FirstAsync();
  192. if (instance == null) return;
  193. instanceId = instance.Id;
  194. var task = await _flowTaskRep.AsQueryable()
  195. .ClearFilter()
  196. .Where(x => x.InstanceId == instance.Id
  197. && x.AssigneeId == currentUserId
  198. && x.Status == FlowTaskStatusEnum.Pending)
  199. .FirstAsync();
  200. if (task == null) return;
  201. taskId = task.Id;
  202. await _flowEngine.Approve(task.Id, "S8 已开始处理(双线合一自动同意)");
  203. }
  204. catch (Exception ex)
  205. {
  206. _logger.LogWarning(ex,
  207. "S8 开始处理时自动同意 TB001 任务失败 exceptionId={Id} exceptionCode={Code} userId={UserId} assigneeId={Assignee} status={Status} instanceId={InstanceId} taskId={TaskId} err={Err}",
  208. e.Id, e.ExceptionCode, currentUserId, e.AssigneeUserId, e.Status, instanceId, taskId, ex.Message);
  209. }
  210. }
  211. public async Task<AdoS8Exception> UpgradeAsync(long id, long tenantId, string? remark)
  212. {
  213. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  214. if (e.ActiveFlowInstanceId.HasValue)
  215. throw new S8BizException("该异常已有进行中的审批流程,请等待审批完成");
  216. if (!S8StatusRules.IsAllowedTransition(e.Status, "ESCALATED"))
  217. throw new S8BizException($"状态 {e.Status} 不可升级");
  218. // S8-EXCEPTION-FLOW-TENANT-CONTEXT-1:UpgradeAsync 本身已持有可信 tenantId(人工点击=HTTP 走
  219. // S8TrustedScopeResolver;S8TimeoutAutoEscalationJob 自动升级=后台 Job 显式传参),统一走
  220. // 受信任重载,不依赖 _userManager.TenantId(后台 Job 场景下恒为 0)。
  221. await _flowEngine.StartFlow(new StartFlowInput
  222. {
  223. BizType = "EXCEPTION_ESCALATION",
  224. BizId = e.Id,
  225. Title = $"异常升级审批 - {e.ExceptionCode}",
  226. Comment = remark,
  227. BizData = new Dictionary<string, object>
  228. {
  229. ["severity"] = e.Severity,
  230. ["sceneCode"] = e.SceneCode,
  231. ["priorityLevel"] = e.PriorityLevel,
  232. }
  233. }, tenantId);
  234. // 状态和时间线由 ExceptionEscalationBizHandler.OnFlowStarted 回调更新
  235. return await LoadAsync(id, tenantId) ?? e;
  236. }
  237. public async Task<AdoS8Exception> RejectAsync(long id, long tenantId, string? remark)
  238. {
  239. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  240. if (!S8StatusRules.IsAllowedTransition(e.Status, "REJECTED"))
  241. throw new S8BizException($"状态 {e.Status} 不可驳回");
  242. var from = e.Status;
  243. e.Status = "REJECTED";
  244. e.UpdatedAt = DateTime.Now;
  245. await _rep.AsTenant().UseTranAsync(async () =>
  246. {
  247. await _rep.UpdateAsync(e);
  248. await InsertTimelineAsync(e.Id, "REJECT", "驳回", from, "REJECTED", null, null, remark);
  249. }, ex => throw ex);
  250. // 双线合一:S8 驳回 = TB001 流程整体拒绝(取消所有 pending 任务、Instance 终止)。
  251. await TryRejectIntakeOnRejectAsync(e, remark);
  252. return e;
  253. }
  254. private async Task TryRejectIntakeOnRejectAsync(AdoS8Exception e, string? remark)
  255. {
  256. var currentUserId = _userManager.UserId;
  257. long? instanceId = null, taskId = null;
  258. try
  259. {
  260. // S8-S1-EXCEPTION-FLOW-SYNC-FIX-1:实例/任务查询同样清数据范围过滤(ApprovalFlowInstance/Task 继承 EntityBaseOrg)。
  261. // 非超管用户在 DataScope=Self 时被「CreateUserId==当前用户」过滤、Dept/DeptChild 时被 OrgId 过滤,会查不到
  262. // 由他人(或系统)创建的 flow,双线合一静默失效。ClearFilter() 清全部数据范围过滤;BizType+BizId+assignee 已限定,
  263. // 无跨 BizType 误伤;ApprovalFlowInstance/Task 无软删与租户过滤。
  264. var instance = await _flowInstanceRep.AsQueryable()
  265. .ClearFilter()
  266. .Where(x => x.BizType == "EXCEPTION_REPORT"
  267. && x.BizId == e.Id
  268. && x.Status == FlowInstanceStatusEnum.Running)
  269. .FirstAsync();
  270. if (instance == null) return;
  271. instanceId = instance.Id;
  272. var task = await _flowTaskRep.AsQueryable()
  273. .ClearFilter()
  274. .Where(x => x.InstanceId == instance.Id
  275. && x.AssigneeId == currentUserId
  276. && x.Status == FlowTaskStatusEnum.Pending)
  277. .FirstAsync();
  278. if (task == null) return;
  279. taskId = task.Id;
  280. await _flowEngine.Reject(task.Id, remark ?? "S8 已驳回(双线合一自动拒绝)");
  281. }
  282. catch (Exception ex)
  283. {
  284. _logger.LogWarning(ex,
  285. "S8 驳回时自动拒绝 TB001 流程失败 exceptionId={Id} exceptionCode={Code} userId={UserId} assigneeId={Assignee} status={Status} instanceId={InstanceId} taskId={TaskId} err={Err}",
  286. e.Id, e.ExceptionCode, currentUserId, e.AssigneeUserId, e.Status, instanceId, taskId, ex.Message);
  287. }
  288. }
  289. public async Task<AdoS8Exception> SubmitVerificationAsync(
  290. long id, long tenantId,
  291. long verifierUserId, string? remark)
  292. {
  293. var currentUserId = GetCurrentUserId();
  294. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  295. EnsureCurrentUserIsOperator(e.AssigneeUserId, currentUserId, "只有当前处理人才能提交复检");
  296. var verifier = await _userScope.EnsureActiveUserAsync(tenantId, verifierUserId, "检验人");
  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.VerifierUserId = verifier.UserId;
  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, verifier.UserId, remark);
  313. return await LoadAsync(id, tenantId) ?? e;
  314. }
  315. private async Task TryStartVerificationFlowAsync(AdoS8Exception e, long verifierUserId, 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. ["verifierUserId"] = verifierUserId,
  330. }
  331. });
  332. }
  333. catch (Exception ex)
  334. {
  335. _logger.LogWarning(ex,
  336. "S8 提交复检时启动 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} verifierUserId={VerifierUserId}",
  337. e.Id, verifierUserId);
  338. }
  339. }
  340. public async Task<AdoS8Exception> ApproveVerificationAsync(
  341. long id, long tenantId,
  342. string? remark)
  343. {
  344. var currentUserId = GetCurrentUserId();
  345. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  346. EnsureCurrentUserIsOperator(e.VerifierUserId, currentUserId, "只有指定检验人才能检验通过");
  347. if (!S8StatusRules.IsAllowedTransition(e.Status, "CLOSED"))
  348. throw new S8BizException($"状态 {e.Status} 不可检验通过");
  349. var from = e.Status;
  350. e.Status = "CLOSED";
  351. e.VerifiedAt = DateTime.Now;
  352. e.VerificationResult = "APPROVED";
  353. e.VerificationRemark = remark;
  354. e.ClosedAt = DateTime.Now;
  355. e.UpdatedAt = DateTime.Now;
  356. await _rep.AsTenant().UseTranAsync(async () =>
  357. {
  358. await _rep.UpdateAsync(e);
  359. await InsertTimelineAsync(e.Id, "VERIFY_APPROVED", "检验通过", from, "CLOSED",
  360. currentUserId, null, remark);
  361. }, ex => throw ex);
  362. // 双线合一:检验通过 = EXCEPTION_CLOSURE 复检流程审批通过。
  363. await TryApproveVerificationFlowAsync(e.Id, currentUserId);
  364. // S8-R03-OVERDUE-CLOSE-NOTICE-1:闭环及时性提醒。
  365. // 触发条件:closedAt > slaDeadline;与 TimeoutFlag 运行时口径分离,仅对已关闭异常做闭环回顾。
  366. // 主流程已 commit(含状态机 + Timeline),异常隔离仅 LogWarning,不影响 CloseAsync。
  367. await TryDispatchOverdueCloseNotificationAsync(e);
  368. return e;
  369. }
  370. /// <summary>
  371. /// S8-R03-OVERDUE-CLOSE-NOTICE-1:异常关闭后若 closedAt &gt; slaDeadline 触发一条独立"超时关闭"通知。
  372. /// 复用 <see cref="S8NotificationLayerResolver.DispatchByLayerAsync"/> 分层链路,不写 ApprovalFlowNotifyLog。
  373. /// 与 <see cref="S8TimeoutAutoEscalationService"/>(未关闭超时升级)语义分离:本钩子仅在已关闭后回顾闭环及时性。
  374. /// 任何异常仅 LogWarning,不抛回 ApproveVerificationAsync 主流程。
  375. /// </summary>
  376. private async Task TryDispatchOverdueCloseNotificationAsync(AdoS8Exception e)
  377. {
  378. if (e == null || e.Id <= 0) return;
  379. if (e.ClosedAt == null || e.SlaDeadline == null) return;
  380. if (e.ClosedAt.Value <= e.SlaDeadline.Value) return;
  381. try
  382. {
  383. var overdueCloseHours = Math.Round(
  384. (decimal)(e.ClosedAt.Value - e.SlaDeadline.Value).TotalHours, 1);
  385. var sceneCode = string.IsNullOrWhiteSpace(e.SceneCode) ? "S8_DEMO_DEFAULT" : e.SceneCode;
  386. // S8-SEVERITY-FOLLOW-SERIOUS-STANDARDIZE-EXEC-1:派发前 Normalize 落 FOLLOW/SERIOUS。
  387. var severity = S8SeverityCode.Normalize(e.Severity);
  388. var content =
  389. $"异常 {e.ExceptionCode} 已关闭,但关闭时间超过 SLA 截止时间,超时关闭 {overdueCloseHours.ToString("0.#", System.Globalization.CultureInfo.InvariantCulture)} 小时,请关注闭环及时性。";
  390. await _notificationLayerResolver.DispatchByLayerAsync(new S8NotificationLayerResolver.DispatchByLayerInput
  391. {
  392. TenantId = e.TenantId,
  393. ExceptionId = e.Id,
  394. ExceptionNo = e.ExceptionCode,
  395. SceneCode = sceneCode,
  396. Severity = severity,
  397. Title = $"【超时关闭】{e.ExceptionCode}",
  398. Content = content,
  399. Status = e.Status,
  400. SourceRuleCode = e.SourceRuleCode,
  401. JumpUrl = $"/aidop/s8/exceptions/{e.Id}",
  402. OverdueClosed = true,
  403. ClosedAt = e.ClosedAt,
  404. SlaDeadlineRef = e.SlaDeadline,
  405. OverdueCloseHours = overdueCloseHours,
  406. });
  407. }
  408. catch (Exception ex)
  409. {
  410. _logger.LogWarning(ex, "notify_overdue_close_dispatch_throw exceptionId={Id}", e.Id);
  411. }
  412. }
  413. private async Task TryApproveVerificationFlowAsync(long exceptionId, long currentUserId)
  414. {
  415. try
  416. {
  417. var instance = await _flowInstanceRep.AsQueryable()
  418. .Where(x => x.BizType == "EXCEPTION_CLOSURE"
  419. && x.BizId == exceptionId
  420. && x.Status == FlowInstanceStatusEnum.Running)
  421. .FirstAsync();
  422. if (instance == null) return;
  423. var task = await _flowTaskRep.AsQueryable()
  424. .Where(x => x.InstanceId == instance.Id
  425. && x.AssigneeId == currentUserId
  426. && x.Status == FlowTaskStatusEnum.Pending)
  427. .FirstAsync();
  428. if (task == null) return;
  429. await _flowEngine.Approve(task.Id, "S8 检验通过(双线合一自动同意)");
  430. }
  431. catch (Exception ex)
  432. {
  433. _logger.LogWarning(ex,
  434. "S8 检验通过时自动同意 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}",
  435. exceptionId, currentUserId);
  436. }
  437. }
  438. public async Task<AdoS8Exception> RejectVerificationAsync(
  439. long id, long tenantId,
  440. string remark)
  441. {
  442. var currentUserId = GetCurrentUserId();
  443. var e = await LoadAsync(id, tenantId) ?? throw new S8BizException("异常不存在");
  444. EnsureCurrentUserIsOperator(e.VerifierUserId, currentUserId, "只有指定检验人才能检验退回");
  445. if (!S8StatusRules.IsAllowedTransition(e.Status, "IN_PROGRESS"))
  446. throw new S8BizException($"状态 {e.Status} 不可检验退回");
  447. if (string.IsNullOrWhiteSpace(remark))
  448. throw new S8BizException("检验退回必须填写退回原因");
  449. var from = e.Status;
  450. e.Status = "IN_PROGRESS";
  451. e.VerifiedAt = DateTime.Now;
  452. e.VerificationResult = "REJECTED";
  453. e.VerificationRemark = remark;
  454. e.UpdatedAt = DateTime.Now;
  455. await _rep.AsTenant().UseTranAsync(async () =>
  456. {
  457. await _rep.UpdateAsync(e);
  458. await InsertTimelineAsync(e.Id, "VERIFY_REJECTED", "检验退回", from, "IN_PROGRESS",
  459. currentUserId, null, remark);
  460. }, ex => throw ex);
  461. // 双线合一:检验退回 = EXCEPTION_CLOSURE 复检流程整体拒绝。
  462. await TryRejectVerificationFlowAsync(e.Id, currentUserId, remark);
  463. return e;
  464. }
  465. private async Task TryRejectVerificationFlowAsync(long exceptionId, long currentUserId, string? remark)
  466. {
  467. try
  468. {
  469. var instance = await _flowInstanceRep.AsQueryable()
  470. .Where(x => x.BizType == "EXCEPTION_CLOSURE"
  471. && x.BizId == exceptionId
  472. && x.Status == FlowInstanceStatusEnum.Running)
  473. .FirstAsync();
  474. if (instance == null) return;
  475. var task = await _flowTaskRep.AsQueryable()
  476. .Where(x => x.InstanceId == instance.Id
  477. && x.AssigneeId == currentUserId
  478. && x.Status == FlowTaskStatusEnum.Pending)
  479. .FirstAsync();
  480. if (task == null) return;
  481. await _flowEngine.Reject(task.Id, remark ?? "S8 检验退回(双线合一自动拒绝)");
  482. }
  483. catch (Exception ex)
  484. {
  485. _logger.LogWarning(ex,
  486. "S8 检验退回时自动拒绝 EXCEPTION_CLOSURE 流程失败 exceptionId={Id} userId={UserId}",
  487. exceptionId, currentUserId);
  488. }
  489. }
  490. // S8-TENANT-FACTORY-P0-CLOSURE-1:补充说明同样按可信作用域绑行,禁止裸 Id 写他租户时间线。
  491. public async Task CommentAsync(long id, long tenantId, string? remark)
  492. {
  493. var e = await LoadAsync(id, tenantId)
  494. ?? throw new S8BizException("异常不存在");
  495. await InsertTimelineAsync(e.Id, "COMMENT", "补充说明", e.Status, e.Status, null, null, remark);
  496. }
  497. private Task<AdoS8Exception?> LoadAsync(long id, long tenantId) =>
  498. _rep.GetFirstAsync(x => x.Id == id && x.TenantId == tenantId && !x.IsDeleted);
  499. // 统一复用框架登录上下文,避免业务身份继续信任前端传参。
  500. private long GetCurrentUserId()
  501. {
  502. var currentUserId = _userManager.UserId;
  503. if (currentUserId <= 0)
  504. throw new S8BizException("未获取到当前登录用户");
  505. return currentUserId;
  506. }
  507. /// <summary>
  508. /// 站内信通知某个<b>系统账号</b>。
  509. ///
  510. /// <para>S8-SYSUSER-ONLY-1:原实现是 <c>NotifyAssigneeAsync(employeeRecId)</c> ——
  511. /// 先经 <c>EmployeeMaster.SysUserId</c> 换算成账号才能投递,换不到就打一行
  512. /// <c>s8_assignee_notice_unmapped</c> 然后<b>静默不发</b>。真库 1105 名员工里只有 8 个绑了账号,
  513. /// 也就是说这条路径在绝大多数情况下根本发不出通知,而调用方看到的是"成功"。
  514. /// 现在处理人本身就是账号,桥和它的失败模式一起删除。</para>
  515. /// </summary>
  516. private async Task NotifyUserAsync(AdoS8Exception exception, long sysUserId, string title)
  517. {
  518. if (sysUserId <= 0) return;
  519. var jump = $"/#/aidop/s8/exceptions/{exception.Id}";
  520. var content =
  521. $"<p>{exception.ExceptionCode} {exception.Title}</p>" +
  522. $"<p><a href=\"{jump}\">查看异常详情</a></p>";
  523. try
  524. {
  525. await _noticeService.PublishToUsersAsync(
  526. title,
  527. content,
  528. new[] { sysUserId },
  529. _userManager.UserId,
  530. string.IsNullOrWhiteSpace(_userManager.RealName) ? "S8异常监控" : _userManager.RealName);
  531. }
  532. catch (Exception ex)
  533. {
  534. _logger.LogWarning(ex, "s8_assignee_notice_failed exceptionId={Id} sysUserId={UserId}", exception.Id, sysUserId);
  535. }
  536. }
  537. /// <summary>
  538. /// 鉴权:当前登录账号必须就是单据上记录的那个人。
  539. ///
  540. /// <para>S8-SYSUSER-ONLY-1:原实现要先把 <c>employeeId</c> 经 <c>EmployeeMaster.SysUserId</c>
  541. /// 解析成账号再比对,于是"未绑定员工主数据"和"你不是这个人"共用同一句报错 ——
  542. /// 两种完全不同的问题给出同一个提示。现在两边同为 <c>SysUser.Id</c>,直接比较即可,
  543. /// 不需要查库,也不再有第三种失败模式。</para>
  544. /// </summary>
  545. private static void EnsureCurrentUserIsOperator(long? ownerUserId, long currentUserId, string failMessage)
  546. {
  547. if (ownerUserId != currentUserId)
  548. throw new S8BizException(failMessage);
  549. }
  550. /// <summary>
  551. /// 写时间线。<paramref name="operatorUserId"/> <b>只接受 <c>SysUser.Id</c></b>;
  552. /// 系统自动动作传 null,<b>绝不允许伪造一个 ID</b>(历史上 CLAIM 传的是 EmployeeMaster.RecID,
  553. /// 导致同一列混装两套 ID 空间,见 <see cref="AdoS8ExceptionTimeline.OperatorUserId"/>)。
  554. /// </summary>
  555. private async Task InsertTimelineAsync(long exceptionId, string code, string label, string? from, string? to,
  556. long? operatorUserId, string? operatorName, string? remark) =>
  557. await _timelineRep.InsertAsync(new AdoS8ExceptionTimeline
  558. {
  559. ExceptionId = exceptionId,
  560. ActionCode = code,
  561. ActionLabel = label,
  562. FromStatus = from,
  563. ToStatus = to,
  564. OperatorUserId = operatorUserId,
  565. OperatorName = operatorName,
  566. ActionRemark = remark,
  567. CreatedAt = DateTime.Now
  568. });
  569. }