using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// Phase 6C-1:证明 G1-G9 守卫由 拥有(standalone 与 composed 均由 C5 返回), /// Orchestrator 不构造写集绕过守卫。 /// public class InventoryC5OwnershipTests { private const long Tenant = 797403760988229L; private static InventoryTransactionCommand Out(decimal qty, string item = "IT-1", string loc = "L1") => new InventoryTransactionCommand { TenantId = Tenant, DomainCode = "8010", PostingId = 1, TransactionGroupId = 100, Seq = 1, TransType = "iss-x", ChangeQty = qty, UmConversion = 1m, ItemNum = item, Location = loc, User = "t", }; private static InventoryContext CtxStocked(decimal avail) { var ctx = new InventoryContext(); ctx.Items.Add("IT-1"); ctx.Locations.Add("L1"); ctx.InvMasters[InventoryContext.InvKey("IT-1", "L1")] = new InventoryBalance { QtyOnHand = avail, AvailStatusQty = avail, Assay = avail, FreezeQty = avail }; ctx.LocationDetails[InventoryContext.DetailKey("IT-1", "L1", "", "")] = new InventoryBalance { QtyOnHand = avail, AvailStatusQty = avail, Assay = avail, FreezeQty = avail }; return ctx; } // ① C5 standalone:库存不足 → G9 失败(不经 Orchestrator) [Fact] public async Task C5Standalone_G9_InsufficientStock_Fails() { var db = new FakeInventoryDatabase(); db.SeedStock(Tenant, "8010", "IT-1", "L1", 50m); // avail=50 var svc = new InventoryTransactionService(new FakeInventoryUnitOfWorkFactory(db)); var r = await svc.PostAsync(new[] { Out(-80m) }); // 出 80 > 50 Assert.Equal(InventoryPostingOutcome.ValidationFailure, r.Outcome); Assert.Equal(InventoryGuardCode.IssueExceedsAvail, r.GuardErrors[0].Code); Assert.Equal(50m, db.LocationDetail[FakeInventoryDatabase.DetK(Tenant, "8010", "IT-1", "L1", "", "")].AvailStatusQty); // 未变 } // ② C5 composed(ExecuteWithinAsync):库存不足 → G9 失败,且 writeInventory 未被调用(不写) [Fact] public async Task C5Composed_ExecuteWithin_G9_Fails_NoWriteIssued() { var written = false; var r = await InventoryTransactionService.ExecuteWithinAsync( CtxStocked(50m), new[] { Out(-80m) }, writeInventory: _ => { written = true; return Task.CompletedTask; }); Assert.Equal(InventoryPostingOutcome.ValidationFailure, r.Outcome); Assert.Equal(InventoryGuardCode.IssueExceedsAvail, r.GuardErrors[0].Code); Assert.False(written); // 守卫先失败 → 未落写(不绕过) } // ③ Orchestrator 的守卫失败结果来自 C5(GuardErrors 透传自 ExecuteWithinAsync) [Fact] public async Task Orchestrator_GuardFailure_PropagatesC5GuardErrors() { var db = new FakeInventoryDatabase(); // 无 master → G2(由 C5 的 ExecuteWithinAsync 判定) var orch = new IqcReceiptOrchestrator(new FakeIqcReceiptUnitOfWorkFactory(db)); var receipt = new IqcAcceptedReceipt { TenantId = Tenant, DomainCode = "8010", PostingId = 1, TransactionGroupId = 100, RctNbr = "RCT-1", ItemNum = "IT-1", Location = "L1", AcceptedQty = 100m, UmConversion = 1m, PurOrd = "PO-1", PurLine = 1, User = "t", }; var gate = new PurchaseReceiptGateInput { HasPendingReceipt = true, AcceptedQty = 100m, HasReceivableDetails = true }; var r = await orch.PostAsync(receipt, gate); Assert.Equal(IqcReceiptOutcome.ValidationFailure, r.Outcome); Assert.NotEmpty(r.GuardErrors); // 守卫错误来自 C5 Assert.Equal(InventoryGuardCode.ItemMasterMissing, r.GuardErrors[0].Code); Assert.Equal(0, db.TotalPersisted); // C6 也回滚 } }