IqcInspBillFlowService.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. using Admin.NET.Plugin.AiDOP.Entity;
  2. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.Dto;
  3. using Admin.NET.Plugin.ApprovalFlow;
  4. using Admin.NET.Plugin.ApprovalFlow.Service;
  5. using Yitter.IdGenerator;
  6. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  7. /// <summary>
  8. /// S5 来料检验单 检验员+主管最小审批流服务(S5-IQC-INSPBILL-INSPECTOR-SUPERVISOR-FLOW-1)。
  9. ///
  10. /// N1_INSPECT 检验员录入 pd/dhsl/bhgsl/clfs → 提交 → N2_SUP_REVIEW 检验主管审核(通过=CLOSED / 退回=回 N1)。
  11. /// 复用 ApprovalFlow 引擎扩展点(StartFlow/Approve/ReturnToPrev + IFlowBizHandler),不改 ApprovalFlow 核心。
  12. /// BizType=S5_IQC_INSPBILL,BizId=qms_qcp_inspbill.id,BizCode=FBILLNO。反查用 ApprovalFlowInstance(BizType,BizId),inspbill 不加列。
  13. /// submit-result 写 qms_qcp_inspbill.pd/dhsl/bhgsl/clfs 并推进 N1→N2;
  14. /// Q5:提交结果即推 165(IqcWmsResultPushService),主管审核不再碰 165。
  15. /// approve/reject 不写 qms。不写库存、不入库。
  16. /// </summary>
  17. [ApiDescriptionSettings(Order = 310, Description = "来料检验单流程")]
  18. [Route("api/S5IqcInspBillFlow")]
  19. [NonUnify]
  20. public class IqcInspBillFlowService : IDynamicApiController, ITransient
  21. {
  22. /// <summary>主/系统租户哨兵:超管未选择目标租户时其 JWT TenantId 即此值,拒绝作为业务租户。</summary>
  23. private const long MainTenantId = 1300000000001L;
  24. private readonly ISqlSugarClient _db;
  25. private readonly FlowEngineService _flowEngine;
  26. private readonly UserManager _userManager;
  27. private readonly IqcWmsResultPushService _wmsPush;
  28. /// <summary>Batch 3:项目级结果读取器,仅用于 submit-result 的只读前置校验。</summary>
  29. private readonly IqcInspBillItemReader _itemReader;
  30. public IqcInspBillFlowService(
  31. ISqlSugarClient db,
  32. FlowEngineService flowEngine,
  33. UserManager userManager,
  34. IqcWmsResultPushService wmsPush,
  35. IqcInspBillItemReader itemReader)
  36. {
  37. _db = db;
  38. _flowEngine = flowEngine;
  39. _userManager = userManager;
  40. _wmsPush = wmsPush;
  41. _itemReader = itemReader;
  42. }
  43. /// <summary>严格可信租户解析:只来自认证后 JWT;无 Token(&lt;=0) 或超管未选主租户 → 拒绝;不读前端 tenantId、无默认回退。</summary>
  44. private long ResolveTenantOrThrow()
  45. {
  46. var tid = _userManager.TenantId;
  47. if (tid <= 0)
  48. throw Oops.Oh("无法确定当前租户,请重新登录或选择目标租户");
  49. if (_userManager.SuperAdmin && tid == MainTenantId)
  50. throw Oops.Oh("超级管理员操作前必须选择目标租户");
  51. return tid;
  52. }
  53. /// <summary>
  54. /// 检验单流程状态(只读,供前端按钮控制)。
  55. /// </summary>
  56. [DisplayName("来料检验单流程状态")]
  57. [HttpGet("state")]
  58. public async Task<IqcFlowStateOutput> GetState([FromQuery] long id)
  59. {
  60. await EnsureBillExistsAsync(id);
  61. return await BuildStateAsync(id);
  62. }
  63. /// <summary>
  64. /// 检验员提交检验结果(写 pd/dhsl/bhgsl/clfs + 推进 N1→N2)。
  65. /// </summary>
  66. [DisplayName("检验员提交检验结果")]
  67. [HttpPost("submit-result")]
  68. public async Task<IqcFlowStateOutput> SubmitResult([FromBody] IqcSubmitResultInput input)
  69. {
  70. var billNo = await EnsureBillExistsAsync(input.Id);
  71. // 参数校验
  72. if (input.Pd != 0 && input.Pd != 1) throw Oops.Oh("判定 pd 只能为 0(合格) 或 1(不合格)");
  73. if (input.Dhsl < 0) throw Oops.Oh("合格数量不能为负");
  74. if (input.Bhgsl < 0) throw Oops.Oh("不合格数量不能为负");
  75. if (input.Pd == 0)
  76. {
  77. if (input.Bhgsl != 0) throw Oops.Oh("判定合格时不合格数量必须为 0");
  78. }
  79. else // pd == 1
  80. {
  81. if (input.Bhgsl <= 0) throw Oops.Oh("判定不合格时不合格数量必须大于 0");
  82. if (input.Clfs is not (0 or 1 or 2)) throw Oops.Oh("判定不合格时处理方式 clfs 必填且只能为 0(让步接收)/1(挑选)/2(报废)");
  83. }
  84. // Batch 3:项目级前置校验。刻意放在事务之前、Header 写入与流程推进之前 ——
  85. // 纯只读 SELECT,失败直接抛,零写入残留、无需回滚。
  86. await ValidateItemResultsBeforeSubmitAsync(input.Id, input.Pd);
  87. var inst = await GetLatestInstanceAsync(input.Id);
  88. if (inst != null && inst.Status != FlowInstanceStatusEnum.Running)
  89. throw Oops.Oh("该检验单流程已结束,无法再提交检验结果");
  90. var tran = await _db.AsTenant().UseTranAsync(async () =>
  91. {
  92. // 无运行中实例 → 起流程(自动生成 N1 任务给检验员角色成员)
  93. long instanceId;
  94. if (inst == null)
  95. {
  96. instanceId = await _flowEngine.StartFlow(new StartFlowInput
  97. {
  98. BizType = IqcInspBillFlowConst.BizType,
  99. BizId = input.Id,
  100. BizNo = billNo,
  101. Title = $"来料检验单 {billNo}",
  102. Comment = input.Comment,
  103. });
  104. }
  105. else
  106. {
  107. instanceId = inst.Id;
  108. }
  109. var myTask = await GetMyPendingTaskAsync(instanceId);
  110. if (myTask == null)
  111. throw Oops.Oh("当前用户没有待处理的检验任务(需检验员角色成员)");
  112. if (myTask.NodeId != IqcInspBillFlowConst.NodeInspect)
  113. throw Oops.Oh("当前不在检验录入节点,无法提交检验结果");
  114. // 写库前严格解析可信租户(无 Token / 超管未选主租户 → 抛错,不落库)
  115. var tid = ResolveTenantOrThrow();
  116. // 写检验结果(仅 pd/dhsl/bhgsl/clfs)
  117. await _db.Ado.ExecuteCommandAsync(
  118. "UPDATE qms_qcp_inspbill SET pd=@pd, dhsl=@dhsl, bhgsl=@bhgsl, clfs=@clfs WHERE id=@id AND tenant_id=@TenantId",
  119. new List<SugarParameter>
  120. {
  121. new("@pd", input.Pd),
  122. new("@dhsl", input.Dhsl),
  123. new("@bhgsl", input.Bhgsl),
  124. new("@clfs", input.Pd == 1 ? input.Clfs : null),
  125. new("@id", input.Id),
  126. new("@TenantId", tid),
  127. });
  128. // 推进 N1 → N2
  129. await _flowEngine.Approve(myTask.Id, input.Comment);
  130. });
  131. if (!tran.IsSuccess) throw tran.ErrorException;
  132. // Q5:检验员提交即推 165(对齐旧 DOP submitAfter);失败不阻断主流程
  133. try
  134. {
  135. var tid = ResolveTenantOrThrow();
  136. await _wmsPush.TryEnqueueAfterSubmitAsync(input.Id, tid);
  137. }
  138. catch
  139. {
  140. // 推送失败不影响提交结果本身
  141. }
  142. return await BuildStateAsync(input.Id);
  143. }
  144. /// <summary>
  145. /// 检验主管通过(N2 → 完成)。仅走 Ai-DOP 内部审批,不再写 165。
  146. /// </summary>
  147. [DisplayName("检验主管通过")]
  148. [HttpPost("supervisor-approve")]
  149. public async Task<IqcFlowStateOutput> SupervisorApprove([FromBody] IqcSupervisorApproveInput input)
  150. {
  151. await EnsureBillExistsAsync(input.Id);
  152. var (inst, myTask) = await RequireSupervisorTaskAsync(input.Id);
  153. var tran = await _db.AsTenant().UseTranAsync(async () =>
  154. {
  155. await _flowEngine.Approve(myTask.Id, input.Comment);
  156. });
  157. if (!tran.IsSuccess) throw tran.ErrorException;
  158. return await BuildStateAsync(input.Id);
  159. }
  160. /// <summary>
  161. /// 检验主管退回(N2 → 回 N1,意见必填)。
  162. /// </summary>
  163. [DisplayName("检验主管退回")]
  164. [HttpPost("supervisor-reject")]
  165. public async Task<IqcFlowStateOutput> SupervisorReject([FromBody] IqcSupervisorRejectInput input)
  166. {
  167. if (string.IsNullOrWhiteSpace(input.Comment))
  168. throw Oops.Oh("退回必须填写意见");
  169. await EnsureBillExistsAsync(input.Id);
  170. var (inst, myTask) = await RequireSupervisorTaskAsync(input.Id);
  171. var tran = await _db.AsTenant().UseTranAsync(async () =>
  172. {
  173. await _flowEngine.ReturnToPrev(myTask.Id, input.Comment);
  174. });
  175. if (!tran.IsSuccess) throw tran.ErrorException;
  176. return await BuildStateAsync(input.Id);
  177. }
  178. /// <summary>
  179. /// SQE 提交处置方案(N3 → 完成)。写 ado_s5_iqc_sqe_disposition + 推进 N3→end。不写 qms.clfs/库存/仓储。
  180. /// </summary>
  181. [DisplayName("SQE提交处置方案")]
  182. [HttpPost("sqe-submit-disposition")]
  183. public async Task<IqcFlowStateOutput> SqeSubmitDisposition([FromBody] IqcSqeSubmitDispositionInput input)
  184. {
  185. var billNo = await EnsureBillExistsAsync(input.Id);
  186. // 参数校验
  187. if (input.DispositionType is not (0 or 1 or 2)) throw Oops.Oh("处置方式只能为 0(让步接收)/1(挑选)/2(报废)");
  188. if (string.IsNullOrWhiteSpace(input.DispositionOpinion)) throw Oops.Oh("处置意见必填");
  189. foreach (var q in new[] { input.DispositionQty, input.ConcessionQty, input.SelectionQty, input.ScrapQty })
  190. if (q.HasValue && q.Value < 0) throw Oops.Oh("处置数量不能为负");
  191. // 写库前严格解析可信租户(无 Token / 超管未选主租户 → 抛错,不落库)
  192. var tid = ResolveTenantOrThrow();
  193. // 检验单必须不合格(合格单无 SQE 节点)
  194. var pdRows = await _db.Ado.SqlQueryAsync<int?>(
  195. "SELECT pd FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId LIMIT 1",
  196. new List<SugarParameter> { new("@id", input.Id), new("@TenantId", tid) });
  197. if (pdRows.FirstOrDefault() != 1) throw Oops.Oh("仅不合格检验单(pd=1)可做 SQE 处置");
  198. var inst = await GetLatestInstanceAsync(input.Id);
  199. if (inst == null) throw Oops.Oh("该检验单尚未发起流程");
  200. if (inst.Status != FlowInstanceStatusEnum.Running) throw Oops.Oh("该检验单流程已结束");
  201. var myTask = await GetMyPendingTaskAsync(inst.Id);
  202. if (myTask == null) throw Oops.Oh("当前用户没有待处理的 SQE 处置任务(需 SQE 角色成员)");
  203. if (myTask.NodeId != IqcInspBillFlowConst.NodeSqe) throw Oops.Oh("当前不在 SQE 处置节点");
  204. // 一单一有效处置
  205. var hasEffective = await _db.Queryable<AdoS5IqcSqeDisposition>()
  206. .Where(x => x.InspbillId == input.Id && x.Status == 1)
  207. .AnyAsync();
  208. if (hasEffective) throw Oops.Oh("该检验单已有有效 SQE 处置,不可重复提交");
  209. // 来源单号
  210. var srcRows = await _db.Ado.SqlQueryAsync<string>(
  211. "SELECT lydjbh FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId LIMIT 1",
  212. new List<SugarParameter> { new("@id", input.Id), new("@TenantId", tid) });
  213. var now = DateTime.Now;
  214. var disposition = new AdoS5IqcSqeDisposition
  215. {
  216. Id = YitIdHelper.NextId(),
  217. TenantId = tid,
  218. OrgId = inst.OrgId,
  219. InspbillId = input.Id,
  220. InspbillNo = billNo,
  221. SourceBillNo = srcRows.FirstOrDefault(),
  222. BizType = IqcInspBillFlowConst.BizType,
  223. FlowInstanceId = inst.Id,
  224. DispositionNo = $"SQE-{billNo}-{now:yyyyMMddHHmmss}",
  225. DispositionType = input.DispositionType,
  226. DispositionQty = input.DispositionQty,
  227. ConcessionQty = input.ConcessionQty,
  228. SelectionQty = input.SelectionQty,
  229. ScrapQty = input.ScrapQty,
  230. SupplierResponse = input.SupplierResponse,
  231. DispositionOpinion = input.DispositionOpinion,
  232. AttachmentJson = input.AttachmentJson,
  233. SqeUserId = _userManager.UserId,
  234. SqeUserName = _userManager.RealName ?? _userManager.Account,
  235. Status = 1,
  236. SubmittedAt = now,
  237. CreateTime = now,
  238. };
  239. var tran = await _db.AsTenant().UseTranAsync(async () =>
  240. {
  241. await _db.Insertable(disposition).ExecuteCommandAsync();
  242. // 推进 N3 → end
  243. await _flowEngine.Approve(myTask.Id, input.Comment);
  244. });
  245. if (!tran.IsSuccess) throw tran.ErrorException;
  246. return await BuildStateAsync(input.Id);
  247. }
  248. // ───────────────────────── helpers ─────────────────────────
  249. /// <summary>
  250. /// Batch 3:item-mode 提交前置校验(**纯只读**,不写任何表、不推进流程)。
  251. ///
  252. /// 【LEGACY MODE · itemCount = 0】
  253. /// 涵盖历史单、NO_SPEC 新单、命中检规但该检规 0 明细的新单 —— 三者同一语义。
  254. /// 立即短路返回,**不追加任何项目/样本/类型校验**,行为与 Batch 3 之前完全一致。
  255. /// 判据只看数据(COUNT),不看日期 / 单号 / 版本。
  256. ///
  257. /// 【ITEM MODE · itemCount &gt; 0】两条硬规则:
  258. /// ① 每个项目必须已给出 PASS / FAIL —— NULL(未录)与 INVALID(系统无法判定)均阻断提交;
  259. /// ② 存在 FAIL 项目时,整单判定不得为「合格」(pd=0) —— HARD ERROR,不是 warning,
  260. /// 杜绝「项目不合格 + 整单合格」流入主管审核。
  261. ///
  262. /// 【刻意不做】不由项目结果反推 pd / dhsl / bhgsl / clfs —— Header 语义保持既有,本方法只校验。
  263. /// 全部项目 PASS 而用户提交 pd=1(不合格)本批**不反向强制纠正**,按既有 Header 语义放行。
  264. /// </summary>
  265. private async Task ValidateItemResultsBeforeSubmitAsync(long billId, int pd)
  266. {
  267. var tid = ResolveTenantOrThrow();
  268. var itemCount = await _itemReader.CountItemsAsync(billId, tid);
  269. if (itemCount == 0) return; // LEGACY MODE:原样放行
  270. var lines = await _itemReader.LoadLinesAsync(billId, tid);
  271. var unready = lines.Where(l => !IqcInspectionJudge.IsSubmittable(l.ItemJudgement)).ToList();
  272. if (unready.Count > 0)
  273. {
  274. var names = string.Join("、", unready.Take(5).Select(l => l.InspectionItem ?? $"#{l.Seq}"));
  275. throw Oops.Oh($"INSPECTION_INCOMPLETE:仍有 {unready.Count} 个检验项目未给出有效判定({names}),无法提交");
  276. }
  277. var failed = lines.Where(l => l.ItemJudgement == IqcJudgement.Fail).ToList();
  278. if (pd == 0 && failed.Count > 0)
  279. {
  280. var names = string.Join("、", failed.Take(5).Select(l => l.InspectionItem ?? $"#{l.Seq}"));
  281. throw Oops.Oh($"存在 {failed.Count} 个不合格检验项目({names}),整单判定不能为合格");
  282. }
  283. }
  284. /// <summary>校验检验单存在并返回 FBILLNO。</summary>
  285. private async Task<string> EnsureBillExistsAsync(long id)
  286. {
  287. if (id <= 0) throw Oops.Oh("检验单 id 非法");
  288. var tid = ResolveTenantOrThrow();
  289. var rows = await _db.Ado.SqlQueryAsync<string>(
  290. "SELECT FBILLNO FROM qms_qcp_inspbill WHERE id=@id AND tenant_id=@TenantId LIMIT 1",
  291. new List<SugarParameter> { new("@id", id), new("@TenantId", tid) });
  292. if (rows.Count == 0) throw Oops.Oh("检验单不存在");
  293. return rows[0] ?? id.ToString();
  294. }
  295. /// <summary>取该检验单最新流程实例(BizType+BizId,跨数据范围)。</summary>
  296. private async Task<ApprovalFlowInstance?> GetLatestInstanceAsync(long bizId)
  297. {
  298. return await _db.Queryable<ApprovalFlowInstance>()
  299. .ClearFilter()
  300. .Where(x => x.BizType == IqcInspBillFlowConst.BizType && x.BizId == bizId)
  301. .OrderByDescending(x => x.Id)
  302. .FirstAsync();
  303. }
  304. /// <summary>取当前用户在指定实例下的待办任务。</summary>
  305. private async Task<ApprovalFlowTask?> GetMyPendingTaskAsync(long instanceId)
  306. {
  307. var userId = _userManager.UserId;
  308. return await _db.Queryable<ApprovalFlowTask>()
  309. .ClearFilter()
  310. .Where(x => x.InstanceId == instanceId && x.AssigneeId == userId && x.Status == FlowTaskStatusEnum.Pending)
  311. .FirstAsync();
  312. }
  313. /// <summary>校验并返回主管 N2 待办任务。</summary>
  314. private async Task<(ApprovalFlowInstance inst, ApprovalFlowTask task)> RequireSupervisorTaskAsync(long bizId)
  315. {
  316. var inst = await GetLatestInstanceAsync(bizId);
  317. if (inst == null) throw Oops.Oh("该检验单尚未发起流程");
  318. if (inst.Status != FlowInstanceStatusEnum.Running) throw Oops.Oh("该检验单流程已结束");
  319. var myTask = await GetMyPendingTaskAsync(inst.Id);
  320. if (myTask == null) throw Oops.Oh("当前用户没有待处理的审核任务(需检验主管角色成员)");
  321. if (myTask.NodeId != IqcInspBillFlowConst.NodeSupervisor) throw Oops.Oh("当前不在主管审核节点");
  322. return (inst, myTask);
  323. }
  324. /// <summary>当前用户是否具备指定角色(跨数据范围)。</summary>
  325. private async Task<bool> HasRoleAsync(long roleId)
  326. {
  327. var userId = _userManager.UserId;
  328. return await _db.Queryable<SysUserRole>()
  329. .ClearFilter()
  330. .AnyAsync(x => x.UserId == userId && x.RoleId == roleId);
  331. }
  332. /// <summary>组装流程状态输出。</summary>
  333. private async Task<IqcFlowStateOutput> BuildStateAsync(long bizId)
  334. {
  335. var output = new IqcFlowStateOutput { BizId = bizId };
  336. var inst = await GetLatestInstanceAsync(bizId);
  337. if (inst == null)
  338. {
  339. output.FlowStatus = "NotStarted";
  340. output.CanSubmitResult = await HasRoleAsync(IqcInspBillFlowConst.RoleInspectorId);
  341. output.Message = output.CanSubmitResult ? "待检验员录入结果" : "尚未发起流程";
  342. return output;
  343. }
  344. output.InstanceId = inst.Id;
  345. output.FlowStatus = inst.Status.ToString();
  346. output.UpdatedAt = inst.EndTime ?? inst.StartTime;
  347. // SQE 处置摘要(不合格单可能已有处置记录,任何状态都可展示)
  348. var disp = await _db.Queryable<AdoS5IqcSqeDisposition>()
  349. .Where(x => x.InspbillId == bizId)
  350. .OrderByDescending(x => x.Id)
  351. .FirstAsync();
  352. if (disp != null)
  353. {
  354. output.HasSqeDisposition = true;
  355. output.SqeDispositionId = disp.Id;
  356. output.SqeDispositionStatus = disp.Status;
  357. output.SqeDispositionType = disp.DispositionType;
  358. }
  359. if (inst.Status != FlowInstanceStatusEnum.Running)
  360. {
  361. output.IsCompleted = true;
  362. output.Message = inst.Status == FlowInstanceStatusEnum.Approved ? "流程已完成(主管审核通过)" : $"流程已结束({inst.Status})";
  363. return output;
  364. }
  365. output.CurrentNodeCode = inst.CurrentNodeId;
  366. output.IsWaitingSqe = inst.CurrentNodeId == IqcInspBillFlowConst.NodeSqe;
  367. var myTask = await GetMyPendingTaskAsync(inst.Id);
  368. if (myTask != null)
  369. {
  370. output.CurrentNodeName = myTask.NodeName;
  371. if (myTask.NodeId == IqcInspBillFlowConst.NodeInspect)
  372. {
  373. output.CurrentAssigneeRole = IqcInspBillFlowConst.RoleInspectorCode;
  374. output.CanSubmitResult = true;
  375. output.Message = "待当前用户录入检验结果";
  376. }
  377. else if (myTask.NodeId == IqcInspBillFlowConst.NodeSupervisor)
  378. {
  379. output.CurrentAssigneeRole = IqcInspBillFlowConst.RoleSupervisorCode;
  380. output.CanSupervisorApprove = true;
  381. output.CanSupervisorReject = true;
  382. output.Message = "待当前用户主管审核";
  383. }
  384. else if (myTask.NodeId == IqcInspBillFlowConst.NodeSqe)
  385. {
  386. output.CurrentAssigneeRole = IqcInspBillFlowConst.RoleSqeCode;
  387. output.CanSqeSubmit = true;
  388. output.Message = "待当前用户提交 SQE 处置";
  389. }
  390. }
  391. else
  392. {
  393. output.CurrentAssigneeRole = inst.CurrentNodeId switch
  394. {
  395. IqcInspBillFlowConst.NodeInspect => IqcInspBillFlowConst.RoleInspectorCode,
  396. IqcInspBillFlowConst.NodeSqe => IqcInspBillFlowConst.RoleSqeCode,
  397. _ => IqcInspBillFlowConst.RoleSupervisorCode,
  398. };
  399. output.Message = "流程进行中(当前用户无待办)";
  400. }
  401. return output;
  402. }
  403. }