S8TaskFlowService.cs 34 KB

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