IqcPostingIdempotencyService.cs 3.8 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677
  1. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Entity;
  2. namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency;
  3. /// <summary>
  4. /// IQC 入库过账幂等服务。幂等键 = tenant_id + domain_code + FBILLNO(DB UNIQUE 兜底,见 <see cref="AdoIqcInventoryPosting"/>)。
  5. /// **禁止**用 FINSPECTSTATUS='检验完成' 判断是否已入库;**禁止**先查后插(有并发窗口)。
  6. /// 认领语义:INSERT 原子占位(成功=FirstProcess);撞唯一约束→回读既有→按 posting_status 分类。
  7. /// 注意:Phase 4 仅建立幂等认领;真正的库存过账动作在 Phase 5。
  8. /// </summary>
  9. public sealed class IqcPostingIdempotencyService
  10. {
  11. private readonly IIqcPostingStore _store;
  12. public IqcPostingIdempotencyService(IIqcPostingStore store)
  13. {
  14. _store = store ?? throw new ArgumentNullException(nameof(store));
  15. }
  16. /// <summary>
  17. /// 认领一次 IQC 入库过账。<paramref name="init"/> 可在插入前补齐过账头字段(receiver/rct_qc_nbr/branch_no/mrb_status 等)。
  18. /// </summary>
  19. public async Task<IqcPostingClaimResult> ClaimAsync(
  20. long tenantId, string domainCode, string fbillNo, Action<AdoIqcInventoryPosting> init = null)
  21. {
  22. if (string.IsNullOrWhiteSpace(fbillNo))
  23. throw new ArgumentException("FBILLNO 不能为空", nameof(fbillNo));
  24. var posting = new AdoIqcInventoryPosting
  25. {
  26. TenantId = tenantId,
  27. DomainCode = domainCode ?? string.Empty,
  28. FbillNo = fbillNo,
  29. PostingStatus = "PROCESSING",
  30. PostingMode = "SHADOW",
  31. CreateTime = DateTime.Now,
  32. };
  33. posting.UpdateTime = posting.CreateTime; // 认领即起租(lease 心跳基准,供 stale 恢复)
  34. init?.Invoke(posting);
  35. var ins = await _store.TryInsertAsync(posting);
  36. if (ins.Inserted)
  37. return new IqcPostingClaimResult { Outcome = IqcPostingClaimOutcome.FirstProcess, Posting = posting, IsNew = true };
  38. var existing = ins.Existing ?? await _store.GetAsync(tenantId, domainCode ?? string.Empty, fbillNo);
  39. return new IqcPostingClaimResult { Outcome = Classify(existing?.PostingStatus), Posting = existing, IsNew = false };
  40. }
  41. /// <summary>业务提交成功后标记过账完成(POSTED)——L1 consume 端在业务事务 Commit 成功后调用(ack)。</summary>
  42. public Task MarkPostedAsync(AdoIqcInventoryPosting posting) => _store.UpdateStatusAsync(posting, "POSTED");
  43. /// <summary>业务失败后标记 FAILED(可重试)——保持事件 retryable,不 ack。</summary>
  44. public Task MarkFailedAsync(AdoIqcInventoryPosting posting) => _store.UpdateStatusAsync(posting, "FAILED");
  45. /// <summary>
  46. /// 尝试原子抢占 stale PROCESSING(Phase 7C):lease 已过期才抢占成功(rows=1→true)。
  47. /// 并发多路仅一路 true,用于 stale Processing 恢复(避免永久 skip)。
  48. /// </summary>
  49. public Task<bool> TryRecoverStaleAsync(long tenantId, string domainCode, string fbillNo, TimeSpan lease)
  50. {
  51. var now = DateTime.Now;
  52. return _store.TryRecoverStaleAsync(tenantId, domainCode ?? string.Empty, fbillNo, now - lease, now);
  53. }
  54. /// <summary>按既有过账头状态分类认领结果。未知态保守视为 Processing(不重复过账)。</summary>
  55. public static IqcPostingClaimOutcome Classify(string postingStatus)
  56. {
  57. switch ((postingStatus ?? string.Empty).Trim().ToUpperInvariant())
  58. {
  59. case "POSTED": return IqcPostingClaimOutcome.AlreadyProcessed;
  60. case "FAILED": return IqcPostingClaimOutcome.FailedRetryable;
  61. case "PROCESSING":
  62. case "PENDING": return IqcPostingClaimOutcome.Processing;
  63. default: return IqcPostingClaimOutcome.Processing;
  64. }
  65. }
  66. }