using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// C5 并发一致性语义测试(Phase 5C;事务/锁设计语义单测,**非 MySQL 并发实证**——Phase 5B)。 /// 验证「Load(锁内快照)+Guard+Write 同锁边界」修复 -60 竞态,及首建/批次/租户隔离。 /// public class InventoryConcurrencyTests { private const long T1 = 797403760988229L; private const long T2 = 824585161322565L; private static InventoryTransactionCommand Cmd(string tt, decimal qty, long tenant = T1, string item = "IT-1", string loc = "L1", string lot = "", string refs = "") => new InventoryTransactionCommand { TenantId = tenant, DomainCode = "8010", PostingId = 1, TransactionGroupId = 100, Seq = 1, TransType = tt, ChangeQty = qty, UmConversion = 1m, ItemNum = item, Location = loc, LotSerial = lot, Refs = refs, User = "t", }; private static InventoryTransactionService Svc(FakeInventoryDatabase db) => new InventoryTransactionService(new FakeInventoryUnitOfWorkFactory(db)); private static Task RunConcurrently(InventoryTransactionService svc, params InventoryTransactionCommand[][] batches) => Task.WhenAll(batches.Select(b => Task.Run(() => svc.PostAsync(b)))); // Case A:库存 100,两并发各出 80 → 一成功一守卫失败,最终 20,不为 -60 [Fact] public async Task CaseA_TwoConcurrentOut80_From100_OneFails_NoNegative() { var db = new FakeInventoryDatabase(); db.SeedStock(T1, "8010", "IT-1", "L1", 100m); var svc = Svc(db); var results = await RunConcurrently(svc, new[] { Cmd("iss-x", -80m) }, new[] { Cmd("iss-x", -80m) }); Assert.Equal(1, results.Count(r => r.Outcome == InventoryPostingOutcome.Success)); Assert.Equal(1, results.Count(r => r.Outcome == InventoryPostingOutcome.ValidationFailure)); var det = db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "", "")]; Assert.Equal(20m, det.QtyOnHand); // 只出一次 → 20 Assert.Equal(20m, det.AvailStatusQty); Assert.True(det.AvailStatusQty >= 0m); // 绝不为 -60 Assert.Equal(20m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand); } // Case B:两并发首次 rct-po-ins 同一自然键 → 仅一行、数量正确累计 [Fact] public async Task CaseB_TwoConcurrentFirstInsert_SameKey_OneRow_SumCorrect() { var db = new FakeInventoryDatabase(); db.SeedMaster(T1, "8010", "IT-1", "L1"); // 主数据在,无库存行 var svc = Svc(db); var results = await RunConcurrently(svc, new[] { Cmd("rct-po-ins", 50m) }, new[] { Cmd("rct-po-ins", 50m) }); Assert.All(results, r => Assert.Equal(InventoryPostingOutcome.Success, r.Outcome)); Assert.Equal(1, db.InvMaster.Count); // 仅一行自然键 Assert.Equal(100m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand); // 50+50 Assert.Equal(100m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].Assay); // rct-po-ins 入质检 Assert.Equal(1, db.LocationDetail.Count); Assert.Equal(100m, db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "", "")].QtyOnHand); } // Case C:同 item/location 不同 lot → 两批次明细独立、互不污染(InvMaster 共享正确累计) [Fact] public async Task CaseC_SameItemLoc_DifferentLot_IndependentDetails() { var db = new FakeInventoryDatabase(); db.SeedMaster(T1, "8010", "IT-1", "L1"); var svc = Svc(db); var results = await RunConcurrently(svc, new[] { Cmd("rct-po-ins", 30m, lot: "LOT-A") }, new[] { Cmd("rct-po-ins", 40m, lot: "LOT-B") }); Assert.All(results, r => Assert.Equal(InventoryPostingOutcome.Success, r.Outcome)); Assert.Equal(2, db.LocationDetail.Count); // 两 lot 各一行 Assert.Equal(30m, db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "LOT-A", "")].QtyOnHand); Assert.Equal(40m, db.LocationDetail[FakeInventoryDatabase.DetK(T1, "8010", "IT-1", "L1", "LOT-B", "")].QtyOnHand); Assert.Equal(1, db.InvMaster.Count); // InvMaster 不含批次 → 共享一行 Assert.Equal(70m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand); // 30+40 } // Case D:不同 tenant 同 item/location → 互不污染、各自独立行 [Fact] public async Task CaseD_DifferentTenant_Isolated() { var db = new FakeInventoryDatabase(); db.SeedMaster(T1, "8010", "IT-1", "L1"); db.SeedMaster(T2, "8010", "IT-1", "L1"); var svc = Svc(db); var results = await RunConcurrently(svc, new[] { Cmd("rct-po-ins", 50m, tenant: T1) }, new[] { Cmd("rct-po-ins", 50m, tenant: T2) }); Assert.All(results, r => Assert.Equal(InventoryPostingOutcome.Success, r.Outcome)); Assert.Equal(2, db.InvMaster.Count); // 两租户各一行 Assert.Equal(50m, db.InvMaster[FakeInventoryDatabase.InvK(T1, "8010", "IT-1", "L1")].QtyOnHand); Assert.Equal(50m, db.InvMaster[FakeInventoryDatabase.InvK(T2, "8010", "IT-1", "L1")].QtyOnHand); } }