using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// IQC 收货外层事务故障注入阶段(事务组合语义单测)。 public enum FakeReceiptFailPhase { None, C6, C5, C7 } /// /// 测试用 IQC 收货外层工作单元:共享 FakeInventoryDatabase(每键 SemaphoreSlim 串行模拟 FOR UPDATE); /// C6/C5 写缓冲,Commit 才落 backing(全有或全无);可在 C6/C5 阶段注入失败。 /// **事务组合语义单测,非 MySQL 实证(Phase 5B)。** /// public sealed class FakeIqcReceiptUnitOfWork : IIqcReceiptUnitOfWork { private readonly FakeInventoryDatabase _db; private readonly FakeReceiptFailPhase _failAt; private readonly List _held = new(); private long _t; private string _d; private PurchaseReceiptWriteback _pendingC6; private InventoryWriteSet _pendingWs; private MobileTaskCommand _pendingTask; private IqcCompletionMarker _pendingCompletion; private bool _completed; public FakeIqcReceiptUnitOfWork(FakeInventoryDatabase db, FakeReceiptFailPhase failAt) { _db = db; _failAt = failAt; } public async Task BeginAndLockAsync(IReadOnlyList invCmds) { _t = invCmds[0].TenantId; _d = invCmds[0].DomainCode ?? string.Empty; var keys = new SortedSet(StringComparer.Ordinal); foreach (var c in invCmds) { keys.Add("INV:" + FakeInventoryDatabase.InvK(_t, _d, c.ItemNum, c.Location)); keys.Add("DET:" + FakeInventoryDatabase.DetK(_t, _d, c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? "")); } foreach (var k in keys) { var s = _db.LockFor(k); await s.WaitAsync(); _held.Add(s); } var ctx = new InventoryContext(); foreach (var item in invCmds.Select(c => c.ItemNum).Distinct(StringComparer.Ordinal)) if (_db.Items.Contains(FakeInventoryDatabase.ItemK(_t, _d, item))) ctx.Items.Add(item); foreach (var loc in invCmds.Select(c => c.Location).Distinct(StringComparer.Ordinal)) if (_db.Locations.Contains(FakeInventoryDatabase.LocK(_t, _d, loc))) ctx.Locations.Add(loc); foreach (var c in invCmds) { if (_db.InvMaster.TryGetValue(FakeInventoryDatabase.InvK(_t, _d, c.ItemNum, c.Location), out var b)) ctx.InvMasters[InventoryContext.InvKey(c.ItemNum, c.Location)] = Clone(b); if (_db.LocationDetail.TryGetValue(FakeInventoryDatabase.DetK(_t, _d, c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? ""), out var dd)) ctx.LocationDetails[InventoryContext.DetailKey(c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? "")] = Clone(dd); if (_db.ItemLocation.TryGetValue(FakeInventoryDatabase.ItemK(_t, _d, c.ItemNum), out var loc)) ctx.ItemDefaultLocation[c.ItemNum] = loc; } return ctx; } public Task BeginAsync(long tenantId, string domainCode) { _t = tenantId; _d = domainCode ?? string.Empty; return Task.CompletedTask; // 任务-only:仅开事务,无库存锁 } public Task WritePurchaseReceiptAsync(PurchaseReceiptWriteback c6) { _db.CallOrder.Add("C6"); if (_failAt == FakeReceiptFailPhase.C6) throw new InvalidOperationException("inject-fail:C6"); _pendingC6 = c6; return Task.CompletedTask; } public Task WriteMobileTaskAsync(MobileTaskCommand task) { _db.CallOrder.Add("C7"); if (_failAt == FakeReceiptFailPhase.C7) throw new InvalidOperationException("inject-fail:C7"); _pendingTask = task; return Task.CompletedTask; } public Task WriteInventoryAsync(InventoryWriteSet writeSet) { _db.CallOrder.Add("C5"); if (_failAt == FakeReceiptFailPhase.C5) throw new InvalidOperationException("inject-fail:C5"); _pendingWs = writeSet; return Task.CompletedTask; } public Task WriteCompletionAsync(IqcCompletionMarker marker) { _db.CallOrder.Add("COMPLETE"); _pendingCompletion = marker; // 缓冲,Commit 才落(与业务写同成同败) return Task.CompletedTask; } public Task CommitAsync() { if (_completed) return Task.CompletedTask; _completed = true; if (_pendingC6 != null) { var key = $"{_pendingC6.TenantId}|{_pendingC6.DomainCode}|{_pendingC6.PostingId}|{_pendingC6.PurOrd}|{_pendingC6.PurLine}"; _db.ReceiptWriteback[key] = _pendingC6; } if (_pendingWs != null) ApplyInventory(_pendingWs); if (_pendingTask != null) _db.MobileTasks[$"{_pendingTask.TenantId}|{_pendingTask.DomainCode}|{_pendingTask.TaskId}"] = _pendingTask; if (_pendingCompletion != null) _db.Completions[$"{_pendingCompletion.TenantId}|{_pendingCompletion.DomainCode}|{_pendingCompletion.FbillNo}"] = _pendingCompletion; ReleaseLocks(); return Task.CompletedTask; } public Task RollbackAsync() { if (_completed) return Task.CompletedTask; _completed = true; // 丢弃 _pendingC6 / _pendingWs ReleaseLocks(); return Task.CompletedTask; } public ValueTask DisposeAsync() { if (!_completed) { _completed = true; ReleaseLocks(); } return default; } private void ApplyInventory(InventoryWriteSet ws) { foreach (var w in ws.InvMasterWrites) _db.InvMaster[FakeInventoryDatabase.InvK(w.TenantId, w.DomainCode ?? "", w.ItemNum, w.Location)] = w.After; foreach (var w in ws.ItemLocationWrites) _db.ItemLocation[FakeInventoryDatabase.ItemK(w.TenantId, w.DomainCode ?? "", w.ItemNum)] = w.DefaultLocation; foreach (var w in ws.LocationDetailWrites) _db.LocationDetail[FakeInventoryDatabase.DetK(w.TenantId, w.DomainCode ?? "", w.ItemNum, w.Location, w.LotSerial ?? "", w.Refs ?? "")] = w.After; foreach (var r in ws.TransactionRows) _db.Transactions.Add(r); } private void ReleaseLocks() { for (var i = _held.Count - 1; i >= 0; i--) _held[i].Release(); _held.Clear(); } private static InventoryBalance Clone(InventoryBalance b) => new InventoryBalance { QtyOnHand = b.QtyOnHand, AvailStatusQty = b.AvailStatusQty, Assay = b.Assay, FreezeQty = b.FreezeQty, QtyOnOrd = b.QtyOnOrd }; } public sealed class FakeIqcReceiptUnitOfWorkFactory : IIqcReceiptUnitOfWorkFactory { private readonly FakeInventoryDatabase _db; private readonly FakeReceiptFailPhase _failAt; public FakeIqcReceiptUnitOfWorkFactory(FakeInventoryDatabase db, FakeReceiptFailPhase failAt = FakeReceiptFailPhase.None) { _db = db; _failAt = failAt; } public IIqcReceiptUnitOfWork Create() => new FakeIqcReceiptUnitOfWork(_db, _failAt); }