using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Entity; namespace Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency; /// /// IQC 入库过账幂等服务。幂等键 = tenant_id + domain_code + FBILLNO(DB UNIQUE 兜底,见 )。 /// **禁止**用 FINSPECTSTATUS='检验完成' 判断是否已入库;**禁止**先查后插(有并发窗口)。 /// 认领语义:INSERT 原子占位(成功=FirstProcess);撞唯一约束→回读既有→按 posting_status 分类。 /// 注意:Phase 4 仅建立幂等认领;真正的库存过账动作在 Phase 5。 /// public sealed class IqcPostingIdempotencyService { private readonly IIqcPostingStore _store; public IqcPostingIdempotencyService(IIqcPostingStore store) { _store = store ?? throw new ArgumentNullException(nameof(store)); } /// /// 认领一次 IQC 入库过账。 可在插入前补齐过账头字段(receiver/rct_qc_nbr/branch_no/mrb_status 等)。 /// public async Task ClaimAsync( long tenantId, string domainCode, string fbillNo, Action init = null) { if (string.IsNullOrWhiteSpace(fbillNo)) throw new ArgumentException("FBILLNO 不能为空", nameof(fbillNo)); var posting = new AdoIqcInventoryPosting { TenantId = tenantId, DomainCode = domainCode ?? string.Empty, FbillNo = fbillNo, PostingStatus = "PROCESSING", PostingMode = "SHADOW", CreateTime = DateTime.Now, }; posting.UpdateTime = posting.CreateTime; // 认领即起租(lease 心跳基准,供 stale 恢复) init?.Invoke(posting); var ins = await _store.TryInsertAsync(posting); if (ins.Inserted) return new IqcPostingClaimResult { Outcome = IqcPostingClaimOutcome.FirstProcess, Posting = posting, IsNew = true }; var existing = ins.Existing ?? await _store.GetAsync(tenantId, domainCode ?? string.Empty, fbillNo); return new IqcPostingClaimResult { Outcome = Classify(existing?.PostingStatus), Posting = existing, IsNew = false }; } /// 业务提交成功后标记过账完成(POSTED)——L1 consume 端在业务事务 Commit 成功后调用(ack)。 public Task MarkPostedAsync(AdoIqcInventoryPosting posting) => _store.UpdateStatusAsync(posting, "POSTED"); /// 业务失败后标记 FAILED(可重试)——保持事件 retryable,不 ack。 public Task MarkFailedAsync(AdoIqcInventoryPosting posting) => _store.UpdateStatusAsync(posting, "FAILED"); /// /// 尝试原子抢占 stale PROCESSING(Phase 7C):lease 已过期才抢占成功(rows=1→true)。 /// 并发多路仅一路 true,用于 stale Processing 恢复(避免永久 skip)。 /// public Task TryRecoverStaleAsync(long tenantId, string domainCode, string fbillNo, TimeSpan lease) { var now = DateTime.Now; return _store.TryRecoverStaleAsync(tenantId, domainCode ?? string.Empty, fbillNo, now - lease, now); } /// 按既有过账头状态分类认领结果。未知态保守视为 Processing(不重复过账)。 public static IqcPostingClaimOutcome Classify(string postingStatus) { switch ((postingStatus ?? string.Empty).Trim().ToUpperInvariant()) { case "POSTED": return IqcPostingClaimOutcome.AlreadyProcessed; case "FAILED": return IqcPostingClaimOutcome.FailedRetryable; case "PROCESSING": case "PENDING": return IqcPostingClaimOutcome.Processing; default: return IqcPostingClaimOutcome.Processing; } } }