InventoryConcurrencyTests.cs 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory;
  2. using Xunit;
  3. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  4. /// <summary>
  5. /// C5 并发一致性语义测试(Phase 5C;事务/锁设计语义单测,**非 MySQL 并发实证**——Phase 5B)。
  6. /// 验证「Load(锁内快照)+Guard+Write 同锁边界」修复 -60 竞态,及首建/批次/租户隔离。
  7. /// </summary>
  8. public class InventoryConcurrencyTests
  9. {
  10. private const long T1 = 797403760988229L;
  11. private const long T2 = 824585161322565L;
  12. private static InventoryTransactionCommand Cmd(string tt, decimal qty, long tenant = T1, string item = "IT-1", string loc = "L1", string lot = "", string refs = "")
  13. => new InventoryTransactionCommand
  14. {
  15. TenantId = tenant, DomainCode = "8010", PostingId = 1, TransactionGroupId = 100, Seq = 1,
  16. TransType = tt, ChangeQty = qty, UmConversion = 1m, ItemNum = item, Location = loc, LotSerial = lot, Refs = refs, User = "t",
  17. };
  18. private static InventoryTransactionService Svc(FakeInventoryDatabase db)
  19. => new InventoryTransactionService(new FakeInventoryUnitOfWorkFactory(db));
  20. private static Task<InventoryPostingResult[]> RunConcurrently(InventoryTransactionService svc, params InventoryTransactionCommand[][] batches)
  21. => Task.WhenAll(batches.Select(b => Task.Run(() => svc.PostAsync(b))));
  22. // Case A:库存 100,两并发各出 80 → 一成功一守卫失败,最终 20,不为 -60
  23. [Fact]
  24. public async Task CaseA_TwoConcurrentOut80_From100_OneFails_NoNegative()
  25. {
  26. var db = new FakeInventoryDatabase();
  27. db.SeedStock(T1, "8010", "IT-1", "L1", 100m);
  28. var svc = Svc(db);
  29. var results = await RunConcurrently(svc, new[] { Cmd("iss-x", -80m) }, new[] { Cmd("iss-x", -80m) });
  30. Assert.Equal(1, results.Count(r => r.Outcome == InventoryPostingOutcome.Success));
  31. Assert.Equal(1, results.Count(r => r.Outcome == InventoryPostingOutcome.ValidationFailure));
  32. var det = db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "", "")];
  33. Assert.Equal(20m, det.QtyOnHand); // 只出一次 → 20
  34. Assert.Equal(20m, det.AvailStatusQty);
  35. Assert.True(det.AvailStatusQty >= 0m); // 绝不为 -60
  36. Assert.Equal(20m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand);
  37. }
  38. // Case B:两并发首次 rct-po-ins 同一自然键 → 仅一行、数量正确累计
  39. [Fact]
  40. public async Task CaseB_TwoConcurrentFirstInsert_SameKey_OneRow_SumCorrect()
  41. {
  42. var db = new FakeInventoryDatabase();
  43. db.SeedMaster(T1, "8010", "IT-1", "L1"); // 主数据在,无库存行
  44. var svc = Svc(db);
  45. var results = await RunConcurrently(svc, new[] { Cmd("rct-po-ins", 50m) }, new[] { Cmd("rct-po-ins", 50m) });
  46. Assert.All(results, r => Assert.Equal(InventoryPostingOutcome.Success, r.Outcome));
  47. Assert.Equal(1, db.InvMaster.Count); // 仅一行自然键
  48. Assert.Equal(100m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand); // 50+50
  49. Assert.Equal(100m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].Assay); // rct-po-ins 入质检
  50. Assert.Equal(1, db.LocationDetail.Count);
  51. Assert.Equal(100m, db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "", "")].QtyOnHand);
  52. }
  53. // Case C:同 item/location 不同 lot → 两批次明细独立、互不污染(InvMaster 共享正确累计)
  54. [Fact]
  55. public async Task CaseC_SameItemLoc_DifferentLot_IndependentDetails()
  56. {
  57. var db = new FakeInventoryDatabase();
  58. db.SeedMaster(T1, "8010", "IT-1", "L1");
  59. var svc = Svc(db);
  60. var results = await RunConcurrently(svc,
  61. new[] { Cmd("rct-po-ins", 30m, lot: "LOT-A") },
  62. new[] { Cmd("rct-po-ins", 40m, lot: "LOT-B") });
  63. Assert.All(results, r => Assert.Equal(InventoryPostingOutcome.Success, r.Outcome));
  64. Assert.Equal(2, db.LocationDetail.Count); // 两 lot 各一行
  65. Assert.Equal(30m, db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "LOT-A", "")].QtyOnHand);
  66. Assert.Equal(40m, db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "LOT-B", "")].QtyOnHand);
  67. Assert.Equal(1, db.InvMaster.Count); // InvMaster 不含批次 → 共享一行
  68. Assert.Equal(70m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand); // 30+40
  69. }
  70. // Case D:不同 tenant 同 item/location → 互不污染、各自独立行
  71. [Fact]
  72. public async Task CaseD_DifferentTenant_Isolated()
  73. {
  74. var db = new FakeInventoryDatabase();
  75. db.SeedMaster(T1, "8010", "IT-1", "L1");
  76. db.SeedMaster(T2, "8010", "IT-1", "L1");
  77. var svc = Svc(db);
  78. var results = await RunConcurrently(svc,
  79. new[] { Cmd("rct-po-ins", 50m, tenant: T1) },
  80. new[] { Cmd("rct-po-ins", 50m, tenant: T2) });
  81. Assert.All(results, r => Assert.Equal(InventoryPostingOutcome.Success, r.Outcome));
  82. Assert.Equal(2, db.InvMaster.Count); // 两租户各一行
  83. Assert.Equal(50m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand);
  84. Assert.Equal(50m, db.InvMaster[FakeInventoryDatabase.InvK(T2, "8010", "IT-1", "L1")].QtyOnHand);
  85. }
  86. }