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;
}
}
}