IpqcInspectionFlowService.cs 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. using System.Text.Json;
  2. using Admin.NET.Plugin.AiDOP.Entity;
  3. using Admin.NET.Plugin.AiDOP.Entity.DataPlatform;
  4. using Admin.NET.Plugin.AiDOP.Manufacturing.Dto;
  5. using Admin.NET.Plugin.ApprovalFlow;
  6. using Admin.NET.Plugin.ApprovalFlow.Service;
  7. using Yitter.IdGenerator;
  8. namespace Admin.NET.Plugin.AiDOP.Manufacturing;
  9. /// <summary>
  10. /// S6 过程检验单(IPQC)检验员+主管流程服务(v2:含整批判定驱动的质量处置分流)。
  11. ///
  12. /// N1_INSPECT 检验员录入 → 提交 → N2_SUP_REVIEW 主管整批审核:
  13. /// 整批合格(Qualified=true) → GW_RESULT(disposition_required=0) → end;
  14. /// 整批不合格(Qualified=false) → GW_RESULT(disposition_required=1) → N3_QUALITY_DISPOSITION(见 IpqcInspectionDispositionService)。
  15. /// 退回:N2→N1。复用 ApprovalFlow 引擎(StartFlow/Approve/ReturnToPrev + IFlowBizHandler),不改核心。
  16. /// 整批判定 jgpd 写 qms_gcjyd 白名单列(可空、不硬编码枚举);disposition_required 落扩展表作网关判据(与 jgpd 整批判定一致)。
  17. /// 项目级 pd(qms_gcjydzb)仅明细展示辅助,不驱动整批流程。不回写 qms_gcjyd.status;不写库存/生产/仓储。
  18. /// 正常鉴权(非 AllowAnonymous);权限以当前用户 pending task 归属为准。
  19. /// </summary>
  20. [ApiDescriptionSettings(Order = 312, Description = "过程检验单流程")]
  21. [Route("api/IpqcInspectionFlow")]
  22. [NonUnify]
  23. public class IpqcInspectionFlowService : IDynamicApiController, ITransient
  24. {
  25. private readonly ISqlSugarClient _db;
  26. private readonly FlowEngineService _flowEngine;
  27. private readonly UserManager _userManager;
  28. public IpqcInspectionFlowService(ISqlSugarClient db, FlowEngineService flowEngine, UserManager userManager)
  29. {
  30. _db = db;
  31. _flowEngine = flowEngine;
  32. _userManager = userManager;
  33. }
  34. /// <summary>
  35. /// 检验单流程状态(只读,供前端按钮控制)。
  36. /// </summary>
  37. [DisplayName("过程检验单流程状态")]
  38. [HttpGet("state")]
  39. public async Task<IpqcFlowStateOutput> GetState([FromQuery] long id)
  40. {
  41. await EnsureBillExistsAsync(id);
  42. return await BuildStateAsync(id);
  43. }
  44. /// <summary>
  45. /// 检验员提交检验结果(写 qms_gcjyd 白名单列 + 推进 N1→N2)。
  46. /// </summary>
  47. [DisplayName("检验员提交检验结果")]
  48. [HttpPost("submit-inspection")]
  49. public async Task<IpqcFlowStateOutput> SubmitInspection([FromBody] IpqcSubmitInspectionInput input)
  50. {
  51. var billNo = await EnsureBillExistsAsync(input.Id);
  52. // 参数校验(不合格数不得为负、不得大于样本量;合格数不得为负)
  53. if (input.Jyhgsl < 0) throw Oops.Oh("检验合格数量不能为负");
  54. if (input.Jybhgsl < 0) throw Oops.Oh("检验不合格数量不能为负");
  55. if (input.Ybl is < 0) throw Oops.Oh("样本量不能为负");
  56. if (input.Ybl is > 0 && input.Jybhgsl > input.Ybl.Value)
  57. throw Oops.Oh("不合格数量不能大于样本量(检验数)");
  58. var inst = await GetLatestInstanceAsync(input.Id);
  59. if (inst != null && inst.Status != FlowInstanceStatusEnum.Running)
  60. throw Oops.Oh("该检验单流程已结束,无法再提交检验结果");
  61. var now = DateTime.Now;
  62. var inspectorName = _userManager.RealName ?? _userManager.Account;
  63. var tran = await _db.AsTenant().UseTranAsync(async () =>
  64. {
  65. // 无运行中实例 → 起流程(自动生成 N1 任务给检验员角色成员)
  66. long instanceId;
  67. if (inst == null)
  68. {
  69. instanceId = await _flowEngine.StartFlow(new StartFlowInput
  70. {
  71. BizType = IpqcInspectionFlowConst.BizType,
  72. BizId = input.Id,
  73. BizNo = billNo,
  74. Title = $"过程检验单 {billNo}",
  75. Comment = input.Comment,
  76. });
  77. }
  78. else
  79. {
  80. instanceId = inst.Id;
  81. }
  82. var myTask = await GetMyPendingTaskAsync(instanceId);
  83. if (myTask == null)
  84. throw Oops.Oh("当前用户没有待处理的检验任务(需检验员角色成员)");
  85. if (myTask.NodeId != IpqcInspectionFlowConst.NodeInspect)
  86. throw Oops.Oh("当前不在检验录入节点,无法提交检验结果");
  87. // 写检验结果(仅 qms_gcjyd 白名单列 jgpd/jyhgsl/jybhgsl/ybl/jyr/bz;jgpd 可空不强写)
  88. await _db.Ado.ExecuteCommandAsync(
  89. "UPDATE qms_gcjyd SET jgpd=@jgpd, jyhgsl=@jyhgsl, jybhgsl=@jybhgsl, ybl=@ybl, jyr=@jyr, bz=@bz WHERE id=@id AND tenant_id=@tid",
  90. new List<SugarParameter>
  91. {
  92. new("@jgpd", input.Jgpd.HasValue ? input.Jgpd.Value : (object?)null),
  93. new("@jyhgsl", input.Jyhgsl),
  94. new("@jybhgsl", input.Jybhgsl),
  95. new("@ybl", input.Ybl.HasValue ? input.Ybl.Value : (object?)null),
  96. new("@jyr", inspectorName),
  97. new("@bz", input.Bz),
  98. new("@id", input.Id),
  99. new("@tid", _userManager.TenantId),
  100. });
  101. // 落轻量扩展表(提交人/提交时间/业务状态,不回写 qms_gcjyd.status)
  102. await UpsertFlowStateAsync(input.Id, billNo, instanceId, IpqcInspectionFlowConst.StatusPendingReview,
  103. IpqcInspectionFlowConst.NodeSupervisor, now, isSubmit: true, inspectorName: inspectorName);
  104. // 推进 N1 → N2
  105. await _flowEngine.Approve(myTask.Id, input.Comment);
  106. });
  107. if (!tran.IsSuccess) throw tran.ErrorException;
  108. return await BuildStateAsync(input.Id);
  109. }
  110. /// <summary>
  111. /// 检验主管通过(整批判定):Qualified=true 整批合格→end;Qualified=false 整批不合格→N3 质量处置。
  112. /// 写整批 jgpd(可空、不硬编码枚举)+ disposition_required(网关判据),再 Approve→GW_RESULT 分流。
  113. /// </summary>
  114. [DisplayName("检验主管通过")]
  115. [HttpPost("supervisor-approve")]
  116. public async Task<IpqcFlowStateOutput> SupervisorApprove([FromBody] IpqcSupervisorApproveInput input)
  117. {
  118. var billNo = await EnsureBillExistsAsync(input.Id);
  119. var (inst, myTask) = await RequireSupervisorTaskAsync(input.Id);
  120. var now = DateTime.Now;
  121. var supervisorName = _userManager.RealName ?? _userManager.Account;
  122. var dispositionRequired = input.Qualified ? 0 : 1;
  123. var businessStatus = input.Qualified ? IpqcInspectionFlowConst.StatusApproved : IpqcInspectionFlowConst.StatusPendingDisposition;
  124. var nextNode = input.Qualified ? null : IpqcInspectionFlowConst.NodeDisposition;
  125. var tran = await _db.AsTenant().UseTranAsync(async () =>
  126. {
  127. // 整批判定 jgpd 写 qms_gcjyd 白名单列(可空,不硬编码枚举;由前端传测试/已确认合法值)
  128. if (input.Jgpd.HasValue)
  129. await _db.Ado.ExecuteCommandAsync(
  130. "UPDATE qms_gcjyd SET jgpd=@jgpd WHERE id=@id AND tenant_id=@tid",
  131. new List<SugarParameter> { new("@jgpd", input.Jgpd.Value), new("@id", input.Id), new("@tid", _userManager.TenantId) });
  132. // 先写 disposition_required(GW_RESULT 网关判据,Approve 时 GetBizData 读取)再推进
  133. await UpsertFlowStateAsync(input.Id, billNo, inst.Id, businessStatus, nextNode, now,
  134. isSubmit: false, reviewResult: input.Qualified ? "APPROVED" : "TO_DISPOSITION",
  135. supervisorName: supervisorName, dispositionRequired: dispositionRequired);
  136. await _flowEngine.Approve(myTask.Id, input.Comment);
  137. });
  138. if (!tran.IsSuccess) throw tran.ErrorException;
  139. // 双模式出站:整批合格结束时写 Outbox(QMS_API 占位;任务书 action=S6_REPORT_PUSH)
  140. if (input.Qualified)
  141. await TryEnqueueIpqcPassOutboxAsync(input.Id, billNo);
  142. return await BuildStateAsync(input.Id);
  143. }
  144. /// <summary>
  145. /// 检验主管退回(N2 → 回 N1,意见必填)。
  146. /// </summary>
  147. [DisplayName("检验主管退回")]
  148. [HttpPost("supervisor-return")]
  149. public async Task<IpqcFlowStateOutput> SupervisorReturn([FromBody] IpqcSupervisorReturnInput input)
  150. {
  151. if (string.IsNullOrWhiteSpace(input.Comment))
  152. throw Oops.Oh("退回必须填写意见");
  153. var billNo = await EnsureBillExistsAsync(input.Id);
  154. var (inst, myTask) = await RequireSupervisorTaskAsync(input.Id);
  155. var now = DateTime.Now;
  156. var supervisorName = _userManager.RealName ?? _userManager.Account;
  157. var tran = await _db.AsTenant().UseTranAsync(async () =>
  158. {
  159. await UpsertFlowStateAsync(input.Id, billNo, inst.Id, IpqcInspectionFlowConst.StatusReturned,
  160. IpqcInspectionFlowConst.NodeInspect, now, isSubmit: false, reviewResult: "RETURNED",
  161. supervisorName: supervisorName, returnReason: input.Comment);
  162. await _flowEngine.ReturnToPrev(myTask.Id, input.Comment);
  163. });
  164. if (!tran.IsSuccess) throw tran.ErrorException;
  165. return await BuildStateAsync(input.Id);
  166. }
  167. // ───────────────────────── helpers ─────────────────────────
  168. /// <summary>校验检验单存在并返回 djbh(无 djbh 用 id 兜底)。</summary>
  169. private async Task<string> EnsureBillExistsAsync(long id)
  170. {
  171. if (id <= 0) throw Oops.Oh("检验单 id 非法");
  172. // 强制租户隔离:qms_gcjyd 全库共享无框架租户过滤器,裸 SQL 必须显式按当前用户租户校验,
  173. // 防止跨租户读到/操作他租户检验单。
  174. var rows = await _db.Ado.SqlQueryAsync<string>(
  175. "SELECT djbh FROM qms_gcjyd WHERE id=@id AND tenant_id=@tid LIMIT 1",
  176. new List<SugarParameter> { new("@id", id), new("@tid", _userManager.TenantId) });
  177. if (rows.Count == 0) throw Oops.Oh("检验单不存在");
  178. return rows[0] ?? id.ToString();
  179. }
  180. /// <summary>取该检验单最新流程实例(BizType+BizId,跨数据范围)。</summary>
  181. private async Task<ApprovalFlowInstance?> GetLatestInstanceAsync(long bizId)
  182. {
  183. return await _db.Queryable<ApprovalFlowInstance>()
  184. .ClearFilter()
  185. .Where(x => x.BizType == IpqcInspectionFlowConst.BizType && x.BizId == bizId)
  186. .OrderByDescending(x => x.Id)
  187. .FirstAsync();
  188. }
  189. /// <summary>取当前用户在指定实例下的待办任务。</summary>
  190. private async Task<ApprovalFlowTask?> GetMyPendingTaskAsync(long instanceId)
  191. {
  192. var userId = _userManager.UserId;
  193. return await _db.Queryable<ApprovalFlowTask>()
  194. .ClearFilter()
  195. .Where(x => x.InstanceId == instanceId && x.AssigneeId == userId && x.Status == FlowTaskStatusEnum.Pending)
  196. .FirstAsync();
  197. }
  198. /// <summary>校验并返回主管 N2 待办任务。</summary>
  199. private async Task<(ApprovalFlowInstance inst, ApprovalFlowTask task)> RequireSupervisorTaskAsync(long bizId)
  200. {
  201. var inst = await GetLatestInstanceAsync(bizId);
  202. if (inst == null) throw Oops.Oh("该检验单尚未发起流程");
  203. if (inst.Status != FlowInstanceStatusEnum.Running) throw Oops.Oh("该检验单流程已结束");
  204. var myTask = await GetMyPendingTaskAsync(inst.Id);
  205. if (myTask == null) throw Oops.Oh("当前用户没有待处理的审核任务(需检验主管角色成员)");
  206. if (myTask.NodeId != IpqcInspectionFlowConst.NodeSupervisor) throw Oops.Oh("当前不在主管审核节点");
  207. return (inst, myTask);
  208. }
  209. /// <summary>当前用户是否具备指定角色(跨数据范围)。</summary>
  210. private async Task<bool> HasRoleAsync(long roleId)
  211. {
  212. var userId = _userManager.UserId;
  213. return await _db.Queryable<SysUserRole>()
  214. .ClearFilter()
  215. .AnyAsync(x => x.UserId == userId && x.RoleId == roleId);
  216. }
  217. /// <summary>Upsert 流程状态扩展表(不回写 qms_gcjyd.status)。dispositionRequired 非空时更新网关判据。</summary>
  218. private async Task UpsertFlowStateAsync(long inspectionId, string billNo, long instanceId, string businessStatus,
  219. string? currentNode, DateTime now, bool isSubmit, string? inspectorName = null, string? supervisorName = null,
  220. string? reviewResult = null, string? returnReason = null, int? dispositionRequired = null)
  221. {
  222. var existing = await _db.Queryable<AdoS6IpqcInspectionFlowState>()
  223. .ClearFilter()
  224. .Where(x => x.InspectionId == inspectionId)
  225. .FirstAsync();
  226. if (existing == null)
  227. {
  228. var row = new AdoS6IpqcInspectionFlowState
  229. {
  230. Id = YitIdHelper.NextId(),
  231. TenantId = _userManager.TenantId,
  232. InspectionId = inspectionId,
  233. InspectionNo = billNo,
  234. BizType = IpqcInspectionFlowConst.BizType,
  235. FlowInstanceId = instanceId,
  236. BusinessStatus = businessStatus,
  237. CurrentNode = currentNode,
  238. DispositionRequired = dispositionRequired,
  239. RowVersion = 1,
  240. CreateTime = now,
  241. };
  242. if (isSubmit)
  243. {
  244. row.InspectorUserId = _userManager.UserId;
  245. row.InspectorName = inspectorName;
  246. row.SubmittedAt = now;
  247. }
  248. else
  249. {
  250. row.SupervisorUserId = _userManager.UserId;
  251. row.SupervisorName = supervisorName;
  252. row.ReviewedAt = now;
  253. row.ReviewResult = reviewResult;
  254. row.ReturnReason = returnReason;
  255. }
  256. await _db.Insertable(row).ExecuteCommandAsync();
  257. return;
  258. }
  259. existing.FlowInstanceId = instanceId;
  260. existing.BusinessStatus = businessStatus;
  261. existing.CurrentNode = currentNode;
  262. if (dispositionRequired.HasValue) existing.DispositionRequired = dispositionRequired;
  263. existing.RowVersion = (existing.RowVersion ?? 0) + 1;
  264. existing.UpdateTime = now;
  265. if (isSubmit)
  266. {
  267. existing.InspectorUserId = _userManager.UserId;
  268. existing.InspectorName = inspectorName;
  269. existing.SubmittedAt = now;
  270. }
  271. else
  272. {
  273. existing.SupervisorUserId = _userManager.UserId;
  274. existing.SupervisorName = supervisorName;
  275. existing.ReviewedAt = now;
  276. existing.ReviewResult = reviewResult;
  277. existing.ReturnReason = returnReason;
  278. }
  279. await _db.Updateable(existing).ExecuteCommandAsync();
  280. }
  281. /// <summary>组装流程状态输出。</summary>
  282. private async Task<IpqcFlowStateOutput> BuildStateAsync(long bizId)
  283. {
  284. var output = new IpqcFlowStateOutput { BizId = bizId };
  285. var inst = await GetLatestInstanceAsync(bizId);
  286. var state = await _db.Queryable<AdoS6IpqcInspectionFlowState>()
  287. .ClearFilter()
  288. .Where(x => x.InspectionId == bizId)
  289. .FirstAsync();
  290. if (state != null)
  291. {
  292. output.BusinessStatus = state.BusinessStatus;
  293. output.InspectorName = state.InspectorName;
  294. output.SupervisorName = state.SupervisorName;
  295. output.DispositionRequired = state.DispositionRequired;
  296. }
  297. output.HasDisposition = await _db.Queryable<AdoS6IpqcQualityDisposition>()
  298. .ClearFilter().AnyAsync(x => x.InspectionId == bizId && x.Status == 1);
  299. if (inst == null)
  300. {
  301. output.FlowStatus = "NotStarted";
  302. output.CanSubmitResult = await HasRoleAsync(IpqcInspectionFlowConst.RoleInspectorId);
  303. output.Message = output.CanSubmitResult ? "待检验员录入结果" : "尚未发起流程";
  304. return output;
  305. }
  306. output.InstanceId = inst.Id;
  307. output.FlowStatus = inst.Status.ToString();
  308. output.UpdatedAt = inst.EndTime ?? inst.StartTime;
  309. if (inst.Status != FlowInstanceStatusEnum.Running)
  310. {
  311. output.IsCompleted = true;
  312. output.Message = inst.Status == FlowInstanceStatusEnum.Approved ? "流程已完成(主管审核通过)" : $"流程已结束({inst.Status})";
  313. return output;
  314. }
  315. output.CurrentNodeCode = inst.CurrentNodeId;
  316. var myTask = await GetMyPendingTaskAsync(inst.Id);
  317. if (myTask != null)
  318. {
  319. output.CurrentNodeName = myTask.NodeName;
  320. if (myTask.NodeId == IpqcInspectionFlowConst.NodeInspect)
  321. {
  322. output.CurrentAssigneeRole = IpqcInspectionFlowConst.RoleInspectorCode;
  323. output.CanSubmitResult = true;
  324. output.Message = "待当前用户录入检验结果";
  325. }
  326. else if (myTask.NodeId == IpqcInspectionFlowConst.NodeSupervisor)
  327. {
  328. output.CurrentAssigneeRole = IpqcInspectionFlowConst.RoleSupervisorCode;
  329. output.CanSupervisorApprove = true;
  330. output.CanSupervisorReturn = true;
  331. output.Message = "待当前用户主管审核";
  332. }
  333. else if (myTask.NodeId == IpqcInspectionFlowConst.NodeDisposition)
  334. {
  335. output.CurrentAssigneeRole = IpqcInspectionFlowConst.RoleQualityEngineerCode;
  336. output.CanSubmitDisposition = true;
  337. output.Message = "待当前用户提交质量处置";
  338. }
  339. }
  340. else
  341. {
  342. output.CurrentAssigneeRole = inst.CurrentNodeId switch
  343. {
  344. IpqcInspectionFlowConst.NodeInspect => IpqcInspectionFlowConst.RoleInspectorCode,
  345. IpqcInspectionFlowConst.NodeDisposition => IpqcInspectionFlowConst.RoleQualityEngineerCode,
  346. _ => IpqcInspectionFlowConst.RoleSupervisorCode,
  347. };
  348. output.Message = "流程进行中(当前用户无待办)";
  349. }
  350. return output;
  351. }
  352. /// <summary>
  353. /// 整批合格闭环时写 mdp_outbox(幂等);失败不阻断主流程。
  354. /// </summary>
  355. private async Task TryEnqueueIpqcPassOutboxAsync(long billId, string billNo)
  356. {
  357. try
  358. {
  359. var row = await _db.Ado.SqlQuerySingleAsync<IpqcOutboxBillRow>(
  360. "SELECT id AS Id, djbh AS BillNo, jgpd AS Jgpd, jyhgsl AS Jyhgsl, jybhgsl AS Jybhgsl FROM qms_gcjyd WHERE id=@id LIMIT 1",
  361. new List<SugarParameter> { new("@id", billId) });
  362. if (row == null) return;
  363. string idem = string.IsNullOrWhiteSpace(billNo) ? billId.ToString() : billNo;
  364. long tenantId = _userManager.TenantId;
  365. var payload = JsonSerializer.Serialize(new
  366. {
  367. path = "/ipqc/result",
  368. method = "POST",
  369. body = new
  370. {
  371. billId,
  372. billNo = idem,
  373. jgpd = row.Jgpd,
  374. jyhgsl = row.Jyhgsl,
  375. jybhgsl = row.Jybhgsl,
  376. action = "S6_REPORT_PUSH"
  377. }
  378. });
  379. var exists = await _db.Ado.GetIntAsync(
  380. "SELECT COUNT(1) FROM mdp_outbox WHERE tenant_id=@tid AND target_source_code='QMS_API' AND action_code='S6_REPORT_PUSH' AND idem_key=@idem",
  381. new List<SugarParameter>
  382. {
  383. new("@tid", tenantId),
  384. new("@idem", idem)
  385. }) > 0;
  386. if (exists) return;
  387. await _db.Insertable(new MdpOutbox
  388. {
  389. TenantId = tenantId,
  390. TargetSourceCode = "QMS_API",
  391. ActionCode = "S6_REPORT_PUSH",
  392. IdemKey = idem,
  393. PayloadJson = payload,
  394. Status = 0,
  395. RetryCount = 0,
  396. CreateTime = DateTime.Now,
  397. UpdateTime = DateTime.Now
  398. }).ExecuteCommandAsync();
  399. }
  400. catch
  401. {
  402. // Outbox 写入失败不影响检验闭环
  403. }
  404. }
  405. private sealed class IpqcOutboxBillRow
  406. {
  407. public long Id { get; set; }
  408. public string? BillNo { get; set; }
  409. public int? Jgpd { get; set; }
  410. public decimal? Jyhgsl { get; set; }
  411. public decimal? Jybhgsl { get; set; }
  412. }
  413. }