| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103 |
- using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
- /// <summary>
- /// C5 并发一致性语义测试(Phase 5C;事务/锁设计语义单测,**非 MySQL 并发实证**——Phase 5B)。
- /// 验证「Load(锁内快照)+Guard+Write 同锁边界」修复 -60 竞态,及首建/批次/租户隔离。
- /// </summary>
- 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<InventoryPostingResult[]> 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);
- }
- }
|