using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// 故障注入阶段(事务语义单测;任一阶段→WriteAsync 抛错→全回滚)。 public enum FakeInventoryFailPhase { None, InvMaster, ItemLocation, LocationDetail, Transaction } /// /// 测试用共享库:backing 按 tenant|domain|… 键隔离;每键一把 SemaphoreSlim 模拟 FOR UPDATE 行锁串行。 /// 用于「事务/锁设计语义单测」——非 MySQL 并发实证(Phase 5B)。 /// public sealed class FakeInventoryDatabase { public Dictionary InvMaster { get; } = new(StringComparer.Ordinal); public Dictionary LocationDetail { get; } = new(StringComparer.Ordinal); public Dictionary ItemLocation { get; } = new(StringComparer.Ordinal); public List Transactions { get; } = new(); public HashSet Items { get; } = new(StringComparer.Ordinal); public HashSet Locations { get; } = new(StringComparer.Ordinal); /// C6 采购收货回写 shadow(键 tenant|domain|posting|purord|line)。 public Dictionary ReceiptWriteback { get; } = new(StringComparer.Ordinal); /// C7 移动任务 shadow(键 tenant|domain|task_id,模拟 TaskID upsert 幂等)。 public Dictionary MobileTasks { get; } = new(StringComparer.Ordinal); /// Phase 7C 完成锚 shadow(键 tenant|domain|fbillno;业务事务同提交,UNIQUE 幂等)。 public Dictionary Completions { get; } = new(StringComparer.Ordinal); /// C3 排除锁:IQC 流程绝不写条码卡,本计数恒为 0。 public int BarcodeCardWrites { get; set; } /// 外层 UoW 调用顺序(断言 CheckInv→C6→C5:记 "C6"/"C5")。 public List CallOrder { get; } = new(); private readonly object _gate = new(); private readonly Dictionary _locks = new(StringComparer.Ordinal); public SemaphoreSlim LockFor(string key) { lock (_gate) { if (!_locks.TryGetValue(key, out var s)) { s = new SemaphoreSlim(1, 1); _locks[key] = s; } return s; } } public int TotalPersisted => InvMaster.Count + LocationDetail.Count + ItemLocation.Count + Transactions.Count + ReceiptWriteback.Count + MobileTasks.Count; public static string ItemK(long t, string d, string item) => $"{t}|{d}|{item}"; public static string LocK(long t, string d, string loc) => $"{t}|{d}|{loc}"; public static string InvK(long t, string d, string item, string loc) => $"{t}|{d}|{item}|{loc}"; public static string DetK(long t, string d, string item, string loc, string lot, string refs) => $"{t}|{d}|{item}|{loc}|{lot}|{refs}"; public void SeedMaster(long t, string d, string item, string loc) { Items.Add(ItemK(t, d, item)); Locations.Add(LocK(t, d, loc)); } public void SeedStock(long t, string d, string item, string loc, decimal qty, string lot = "", string refs = "") { SeedMaster(t, d, item, loc); var bal = new InventoryBalance { QtyOnHand = qty, AvailStatusQty = qty, Assay = qty, FreezeQty = qty }; InvMaster[InvK(t, d, item, loc)] = new InventoryBalance { QtyOnHand = qty, AvailStatusQty = qty, Assay = qty, FreezeQty = qty }; LocationDetail[DetK(t, d, item, loc, lot, refs)] = bal; ItemLocation[ItemK(t, d, item)] = loc; } } /// /// 测试用库存工作单元:LockAndLoad 对本次命令涉及的自然键**加锁并读锁内最新值**(模拟 BeginTran+FOR UPDATE); /// 锁持有至 Commit/Rollback。Write 缓冲,Commit 才落 backing(全有或全无)。 /// public sealed class FakeInventoryUnitOfWork : IInventoryUnitOfWork { private readonly FakeInventoryDatabase _db; private readonly FakeInventoryFailPhase _failAt; private readonly List _held = new(); private InventoryWriteSet _pending; private long _t; private string _d; private bool _completed; public FakeInventoryUnitOfWork(FakeInventoryDatabase db, FakeInventoryFailPhase failAt) { _db = db; _failAt = failAt; } public async Task LockAndLoadAsync(IReadOnlyList cmds) { _t = cmds[0].TenantId; _d = cmds[0].DomainCode ?? string.Empty; // 按排序键加锁(一致顺序防死锁),锁住至 Commit/Rollback → 与真正 Write 同锁边界 var keys = new SortedSet(StringComparer.Ordinal); foreach (var c in cmds) { 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); } // 锁内快照(读 backing 最新值) var ctx = new InventoryContext(); foreach (var item in cmds.Select(c => c.ItemNum).Distinct(StringComparer.Ordinal)) if (_db.Items.Contains(FakeInventoryDatabase.ItemK(_t, _d, item))) ctx.Items.Add(item); foreach (var loc in cmds.Select(c => c.Location).Distinct(StringComparer.Ordinal)) if (_db.Locations.Contains(FakeInventoryDatabase.LocK(_t, _d, loc))) ctx.Locations.Add(loc); foreach (var c in cmds) { 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 d)) ctx.LocationDetails[InventoryContext.DetailKey(c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? "")] = Clone(d); if (_db.ItemLocation.TryGetValue(FakeInventoryDatabase.ItemK(_t, _d, c.ItemNum), out var loc)) ctx.ItemDefaultLocation[c.ItemNum] = loc; } return ctx; } public Task WriteAsync(InventoryWriteSet writeSet) { if (_failAt != FakeInventoryFailPhase.None) throw new InvalidOperationException("inject-fail:" + _failAt); _pending = writeSet; return Task.CompletedTask; } public Task CommitAsync() { if (_completed) return Task.CompletedTask; _completed = true; if (_pending != null) Apply(_pending); ReleaseLocks(); return Task.CompletedTask; } public Task RollbackAsync() { if (_completed) return Task.CompletedTask; _completed = true; // 丢弃 _pending ReleaseLocks(); return Task.CompletedTask; } public ValueTask DisposeAsync() { if (!_completed) { _completed = true; ReleaseLocks(); } return default; } private void Apply(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 }; } /// 测试用工厂:每次 Create 返回新 UoW(共享同一 FakeInventoryDatabase)。 public sealed class FakeInventoryUnitOfWorkFactory : IInventoryUnitOfWorkFactory { private readonly FakeInventoryDatabase _db; private readonly FakeInventoryFailPhase _failAt; public FakeInventoryUnitOfWorkFactory(FakeInventoryDatabase db, FakeInventoryFailPhase failAt = FakeInventoryFailPhase.None) { _db = db; _failAt = failAt; } public IInventoryUnitOfWork Create() => new FakeInventoryUnitOfWork(_db, _failAt); }