using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// C5 InventoryTransactionService 编排测试(Phase 5A/5C):写集路由 + 守卫 0 写 + 故障注入全回滚(事务语义单测)。 /// 单事务/锁边界经 UnitOfWork;故障注入为事务语义单测,非 MySQL rollback 实证(Phase 5B)。 /// public class InventoryTransactionServiceTests { private const long Tenant = 797403760988229L; private static InventoryTransactionCommand Cmd(string tt, decimal qty, 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", }; // ── InventoryContext(供 BuildWriteSet 纯路由测试)── private static InventoryContext EmptyCtxWithMaster(string item = "IT-1", string loc = "L1") { var ctx = new InventoryContext(); ctx.Items.Add(item); ctx.Locations.Add(loc); return ctx; } private static InventoryContext StockedCtx(string item = "IT-1", string loc = "L1", decimal stock = 500m) { var ctx = new InventoryContext(); ctx.Items.Add(item); ctx.Locations.Add(loc); ctx.InvMasters[InventoryContext.InvKey(item, loc)] = new InventoryBalance { QtyOnHand = stock, AvailStatusQty = stock, Assay = stock, FreezeQty = stock }; ctx.LocationDetails[InventoryContext.DetailKey(item, loc, "", "")] = new InventoryBalance { QtyOnHand = stock, AvailStatusQty = stock, Assay = stock, FreezeQty = stock }; ctx.ItemDefaultLocation[item] = loc; return ctx; } // ── FakeInventoryDatabase(供 PostAsync 编排测试)── private static FakeInventoryDatabase DbWithMaster() { var db = new FakeInventoryDatabase(); db.SeedMaster(Tenant, "8010", "IT-1", "L1"); return db; } private static InventoryTransactionService Svc(FakeInventoryDatabase db, FakeInventoryFailPhase fail = FakeInventoryFailPhase.None) => new InventoryTransactionService(new FakeInventoryUnitOfWorkFactory(db, fail)); // ═══ 路由:INSERT vs UPDATE(BuildWriteSet 纯计算)═══ [Fact] public void BuildWriteSet_InboundToEmpty_RoutesInsert() { var ws = InventoryTransactionService.BuildWriteSet(new[] { Cmd("rct-po-ins", 100m) }, EmptyCtxWithMaster()); Assert.True(Assert.Single(ws.InvMasterWrites).InsertIfMissing); Assert.True(Assert.Single(ws.LocationDetailWrites).InsertIfMissing); Assert.Single(ws.ItemLocationWrites); Assert.Single(ws.TransactionRows); } [Fact] public void BuildWriteSet_OutboundToExisting_RoutesUpdateOnly() { var ws = InventoryTransactionService.BuildWriteSet(new[] { Cmd("iss-x", -50m) }, StockedCtx()); Assert.False(Assert.Single(ws.InvMasterWrites).InsertIfMissing); Assert.False(Assert.Single(ws.LocationDetailWrites).InsertIfMissing); Assert.Empty(ws.ItemLocationWrites); } [Fact] public void BuildWriteSet_RctPoIns_AfterBalances_MatchLive() { var ws = InventoryTransactionService.BuildWriteSet(new[] { Cmd("rct-po-ins", 40m) }, StockedCtx(stock: 100m)); var inv = Assert.Single(ws.InvMasterWrites); Assert.Equal(140m, inv.After.QtyOnHand); Assert.Equal(100m, inv.After.AvailStatusQty); Assert.Equal(140m, inv.After.Assay); } [Fact] public void BuildWriteSet_NonLot_LotSerialEmpty() => Assert.Equal("", Assert.Single( InventoryTransactionService.BuildWriteSet(new[] { Cmd("rct-po-ins", 10m) }, EmptyCtxWithMaster()).LocationDetailWrites).LotSerial); [Fact] public void BuildWriteSet_Lot_PreservesLotSerial() => Assert.Equal("LOT-A", Assert.Single( InventoryTransactionService.BuildWriteSet(new[] { Cmd("rct-po-ins", 10m, lot: "LOT-A") }, EmptyCtxWithMaster()).LocationDetailWrites).LotSerial); // ═══ PostAsync 编排 ═══ [Fact] public async Task Post_GuardFailure_ZeroWrites() { var db = new FakeInventoryDatabase(); // 无 master → G2 var r = await Svc(db).PostAsync(new[] { Cmd("rct-po-ins", 10m) }); Assert.Equal(InventoryPostingOutcome.ValidationFailure, r.Outcome); Assert.Equal(0, r.WrittenRows); Assert.Equal(0, db.TotalPersisted); Assert.NotEmpty(r.GuardErrors); } [Fact] public async Task Post_Success_PersistsAllFour() { var db = DbWithMaster(); var r = await Svc(db).PostAsync(new[] { Cmd("rct-po-ins", 100m) }); Assert.Equal(InventoryPostingOutcome.Success, r.Outcome); Assert.Equal("保存成功", r.LegacyReturnMsg); Assert.Equal(1, db.InvMaster.Count); Assert.Equal(1, db.LocationDetail.Count); Assert.Equal(1, db.ItemLocation.Count); Assert.Equal(1, db.Transactions.Count); } [Theory] [InlineData(FakeInventoryFailPhase.InvMaster)] [InlineData(FakeInventoryFailPhase.ItemLocation)] [InlineData(FakeInventoryFailPhase.LocationDetail)] [InlineData(FakeInventoryFailPhase.Transaction)] public async Task Post_FailureInjection_AllRollBack(FakeInventoryFailPhase phase) { var db = DbWithMaster(); var r = await Svc(db, phase).PostAsync(new[] { Cmd("rct-po-ins", 100m) }); Assert.Equal(InventoryPostingOutcome.Error, r.Outcome); Assert.Equal(0, r.WrittenRows); Assert.Equal(0, db.TotalPersisted); Assert.StartsWith("保存失败,", r.LegacyReturnMsg); } [Fact] public async Task Post_EmptyCommands_SuccessZero() { var r = await Svc(new FakeInventoryDatabase()).PostAsync(System.Array.Empty()); Assert.Equal(InventoryPostingOutcome.Success, r.Outcome); Assert.Equal(0, r.WrittenRows); } }