FakeInventoryUnitOfWork.cs 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory;
  2. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  4. /// <summary>故障注入阶段(事务语义单测;任一阶段→WriteAsync 抛错→全回滚)。</summary>
  5. public enum FakeInventoryFailPhase { None, InvMaster, ItemLocation, LocationDetail, Transaction }
  6. /// <summary>
  7. /// 测试用共享库:backing 按 tenant|domain|… 键隔离;每键一把 SemaphoreSlim 模拟 FOR UPDATE 行锁串行。
  8. /// 用于「事务/锁设计语义单测」——非 MySQL 并发实证(Phase 5B)。
  9. /// </summary>
  10. public sealed class FakeInventoryDatabase
  11. {
  12. public Dictionary<string, InventoryBalance> InvMaster { get; } = new(StringComparer.Ordinal);
  13. public Dictionary<string, InventoryBalance> LocationDetail { get; } = new(StringComparer.Ordinal);
  14. public Dictionary<string, string> ItemLocation { get; } = new(StringComparer.Ordinal);
  15. public List<InventoryTransactionRow> Transactions { get; } = new();
  16. public HashSet<string> Items { get; } = new(StringComparer.Ordinal);
  17. public HashSet<string> Locations { get; } = new(StringComparer.Ordinal);
  18. /// <summary>C6 采购收货回写 shadow(键 tenant|domain|posting|purord|line)。</summary>
  19. public Dictionary<string, PurchaseReceiptWriteback> ReceiptWriteback { get; } = new(StringComparer.Ordinal);
  20. /// <summary>C7 移动任务 shadow(键 tenant|domain|task_id,模拟 TaskID upsert 幂等)。</summary>
  21. public Dictionary<string, MobileTaskCommand> MobileTasks { get; } = new(StringComparer.Ordinal);
  22. /// <summary>Phase 7C 完成锚 shadow(键 tenant|domain|fbillno;业务事务同提交,UNIQUE 幂等)。</summary>
  23. public Dictionary<string, IqcCompletionMarker> Completions { get; } = new(StringComparer.Ordinal);
  24. /// <summary>C3 排除锁:IQC 流程绝不写条码卡,本计数恒为 0。</summary>
  25. public int BarcodeCardWrites { get; set; }
  26. /// <summary>外层 UoW 调用顺序(断言 CheckInv→C6→C5:记 "C6"/"C5")。</summary>
  27. public List<string> CallOrder { get; } = new();
  28. private readonly object _gate = new();
  29. private readonly Dictionary<string, SemaphoreSlim> _locks = new(StringComparer.Ordinal);
  30. public SemaphoreSlim LockFor(string key)
  31. {
  32. lock (_gate)
  33. {
  34. if (!_locks.TryGetValue(key, out var s)) { s = new SemaphoreSlim(1, 1); _locks[key] = s; }
  35. return s;
  36. }
  37. }
  38. public int TotalPersisted => InvMaster.Count + LocationDetail.Count + ItemLocation.Count + Transactions.Count + ReceiptWriteback.Count + MobileTasks.Count;
  39. public static string ItemK(long t, string d, string item) => $"{t}|{d}|{item}";
  40. public static string LocK(long t, string d, string loc) => $"{t}|{d}|{loc}";
  41. public static string InvK(long t, string d, string item, string loc) => $"{t}|{d}|{item}|{loc}";
  42. public static string DetK(long t, string d, string item, string loc, string lot, string refs) => $"{t}|{d}|{item}|{loc}|{lot}|{refs}";
  43. public void SeedMaster(long t, string d, string item, string loc) { Items.Add(ItemK(t, d, item)); Locations.Add(LocK(t, d, loc)); }
  44. public void SeedStock(long t, string d, string item, string loc, decimal qty, string lot = "", string refs = "")
  45. {
  46. SeedMaster(t, d, item, loc);
  47. var bal = new InventoryBalance { QtyOnHand = qty, AvailStatusQty = qty, Assay = qty, FreezeQty = qty };
  48. InvMaster[InvK(t, d, item, loc)] = new InventoryBalance { QtyOnHand = qty, AvailStatusQty = qty, Assay = qty, FreezeQty = qty };
  49. LocationDetail[DetK(t, d, item, loc, lot, refs)] = bal;
  50. ItemLocation[ItemK(t, d, item)] = loc;
  51. }
  52. }
  53. /// <summary>
  54. /// 测试用库存工作单元:LockAndLoad 对本次命令涉及的自然键**加锁并读锁内最新值**(模拟 BeginTran+FOR UPDATE);
  55. /// 锁持有至 Commit/Rollback。Write 缓冲,Commit 才落 backing(全有或全无)。
  56. /// </summary>
  57. public sealed class FakeInventoryUnitOfWork : IInventoryUnitOfWork
  58. {
  59. private readonly FakeInventoryDatabase _db;
  60. private readonly FakeInventoryFailPhase _failAt;
  61. private readonly List<SemaphoreSlim> _held = new();
  62. private InventoryWriteSet _pending;
  63. private long _t;
  64. private string _d;
  65. private bool _completed;
  66. public FakeInventoryUnitOfWork(FakeInventoryDatabase db, FakeInventoryFailPhase failAt)
  67. {
  68. _db = db;
  69. _failAt = failAt;
  70. }
  71. public async Task<InventoryContext> LockAndLoadAsync(IReadOnlyList<InventoryTransactionCommand> cmds)
  72. {
  73. _t = cmds[0].TenantId;
  74. _d = cmds[0].DomainCode ?? string.Empty;
  75. // 按排序键加锁(一致顺序防死锁),锁住至 Commit/Rollback → 与真正 Write 同锁边界
  76. var keys = new SortedSet<string>(StringComparer.Ordinal);
  77. foreach (var c in cmds)
  78. {
  79. keys.Add("INV:" + FakeInventoryDatabase.InvK(_t, _d, c.ItemNum, c.Location));
  80. keys.Add("DET:" + FakeInventoryDatabase.DetK(_t, _d, c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? ""));
  81. }
  82. foreach (var k in keys)
  83. {
  84. var s = _db.LockFor(k);
  85. await s.WaitAsync();
  86. _held.Add(s);
  87. }
  88. // 锁内快照(读 backing 最新值)
  89. var ctx = new InventoryContext();
  90. foreach (var item in cmds.Select(c => c.ItemNum).Distinct(StringComparer.Ordinal))
  91. if (_db.Items.Contains(FakeInventoryDatabase.ItemK(_t, _d, item))) ctx.Items.Add(item);
  92. foreach (var loc in cmds.Select(c => c.Location).Distinct(StringComparer.Ordinal))
  93. if (_db.Locations.Contains(FakeInventoryDatabase.LocK(_t, _d, loc))) ctx.Locations.Add(loc);
  94. foreach (var c in cmds)
  95. {
  96. if (_db.InvMaster.TryGetValue(FakeInventoryDatabase.InvK(_t, _d, c.ItemNum, c.Location), out var b))
  97. ctx.InvMasters[InventoryContext.InvKey(c.ItemNum, c.Location)] = Clone(b);
  98. if (_db.LocationDetail.TryGetValue(FakeInventoryDatabase.DetK(_t, _d, c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? ""), out var d))
  99. ctx.LocationDetails[InventoryContext.DetailKey(c.ItemNum, c.Location, c.LotSerial ?? "", c.Refs ?? "")] = Clone(d);
  100. if (_db.ItemLocation.TryGetValue(FakeInventoryDatabase.ItemK(_t, _d, c.ItemNum), out var loc))
  101. ctx.ItemDefaultLocation[c.ItemNum] = loc;
  102. }
  103. return ctx;
  104. }
  105. public Task WriteAsync(InventoryWriteSet writeSet)
  106. {
  107. if (_failAt != FakeInventoryFailPhase.None)
  108. throw new InvalidOperationException("inject-fail:" + _failAt);
  109. _pending = writeSet;
  110. return Task.CompletedTask;
  111. }
  112. public Task CommitAsync()
  113. {
  114. if (_completed) return Task.CompletedTask;
  115. _completed = true;
  116. if (_pending != null) Apply(_pending);
  117. ReleaseLocks();
  118. return Task.CompletedTask;
  119. }
  120. public Task RollbackAsync()
  121. {
  122. if (_completed) return Task.CompletedTask;
  123. _completed = true; // 丢弃 _pending
  124. ReleaseLocks();
  125. return Task.CompletedTask;
  126. }
  127. public ValueTask DisposeAsync()
  128. {
  129. if (!_completed) { _completed = true; ReleaseLocks(); }
  130. return default;
  131. }
  132. private void Apply(InventoryWriteSet ws)
  133. {
  134. foreach (var w in ws.InvMasterWrites)
  135. _db.InvMaster[FakeInventoryDatabase.InvK(w.TenantId, w.DomainCode ?? "", w.ItemNum, w.Location)] = w.After;
  136. foreach (var w in ws.ItemLocationWrites)
  137. _db.ItemLocation[FakeInventoryDatabase.ItemK(w.TenantId, w.DomainCode ?? "", w.ItemNum)] = w.DefaultLocation;
  138. foreach (var w in ws.LocationDetailWrites)
  139. _db.LocationDetail[FakeInventoryDatabase.DetK(w.TenantId, w.DomainCode ?? "", w.ItemNum, w.Location, w.LotSerial ?? "", w.Refs ?? "")] = w.After;
  140. foreach (var r in ws.TransactionRows)
  141. _db.Transactions.Add(r);
  142. }
  143. private void ReleaseLocks()
  144. {
  145. for (var i = _held.Count - 1; i >= 0; i--) _held[i].Release();
  146. _held.Clear();
  147. }
  148. private static InventoryBalance Clone(InventoryBalance b)
  149. => new InventoryBalance { QtyOnHand = b.QtyOnHand, AvailStatusQty = b.AvailStatusQty, Assay = b.Assay, FreezeQty = b.FreezeQty, QtyOnOrd = b.QtyOnOrd };
  150. }
  151. /// <summary>测试用工厂:每次 Create 返回新 UoW(共享同一 FakeInventoryDatabase)。</summary>
  152. public sealed class FakeInventoryUnitOfWorkFactory : IInventoryUnitOfWorkFactory
  153. {
  154. private readonly FakeInventoryDatabase _db;
  155. private readonly FakeInventoryFailPhase _failAt;
  156. public FakeInventoryUnitOfWorkFactory(FakeInventoryDatabase db, FakeInventoryFailPhase failAt = FakeInventoryFailPhase.None)
  157. { _db = db; _failAt = failAt; }
  158. public IInventoryUnitOfWork Create() => new FakeInventoryUnitOfWork(_db, _failAt);
  159. }