InventoryC5OwnershipTests.cs 4.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
  1. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Inventory;
  2. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  5. /// <summary>
  6. /// Phase 6C-1:证明 G1-G9 守卫由 <see cref="InventoryTransactionService"/> 拥有(standalone 与 composed 均由 C5 返回),
  7. /// Orchestrator 不构造写集绕过守卫。
  8. /// </summary>
  9. public class InventoryC5OwnershipTests
  10. {
  11. private const long Tenant = 797403760988229L;
  12. private static InventoryTransactionCommand Out(decimal qty, string item = "IT-1", string loc = "L1")
  13. => new InventoryTransactionCommand
  14. {
  15. TenantId = Tenant, DomainCode = "8010", PostingId = 1, TransactionGroupId = 100, Seq = 1,
  16. TransType = "iss-x", ChangeQty = qty, UmConversion = 1m, ItemNum = item, Location = loc, User = "t",
  17. };
  18. private static InventoryContext CtxStocked(decimal avail)
  19. {
  20. var ctx = new InventoryContext();
  21. ctx.Items.Add("IT-1");
  22. ctx.Locations.Add("L1");
  23. ctx.InvMasters[InventoryContext.InvKey("IT-1", "L1")] = new InventoryBalance { QtyOnHand = avail, AvailStatusQty = avail, Assay = avail, FreezeQty = avail };
  24. ctx.LocationDetails[InventoryContext.DetailKey("IT-1", "L1", "", "")] = new InventoryBalance { QtyOnHand = avail, AvailStatusQty = avail, Assay = avail, FreezeQty = avail };
  25. return ctx;
  26. }
  27. // ① C5 standalone:库存不足 → G9 失败(不经 Orchestrator)
  28. [Fact]
  29. public async Task C5Standalone_G9_InsufficientStock_Fails()
  30. {
  31. var db = new FakeInventoryDatabase();
  32. db.SeedStock(Tenant, "8010", "IT-1", "L1", 50m); // avail=50
  33. var svc = new InventoryTransactionService(new FakeInventoryUnitOfWorkFactory(db));
  34. var r = await svc.PostAsync(new[] { Out(-80m) }); // 出 80 > 50
  35. Assert.Equal(InventoryPostingOutcome.ValidationFailure, r.Outcome);
  36. Assert.Equal(InventoryGuardCode.IssueExceedsAvail, r.GuardErrors[0].Code);
  37. Assert.Equal(50m, db.LocationDetail[FakeInventoryDatabase.DetK(Tenant, "8010", "IT-1", "L1", "", "")].AvailStatusQty); // 未变
  38. }
  39. // ② C5 composed(ExecuteWithinAsync):库存不足 → G9 失败,且 writeInventory 未被调用(不写)
  40. [Fact]
  41. public async Task C5Composed_ExecuteWithin_G9_Fails_NoWriteIssued()
  42. {
  43. var written = false;
  44. var r = await InventoryTransactionService.ExecuteWithinAsync(
  45. CtxStocked(50m), new[] { Out(-80m) },
  46. writeInventory: _ => { written = true; return Task.CompletedTask; });
  47. Assert.Equal(InventoryPostingOutcome.ValidationFailure, r.Outcome);
  48. Assert.Equal(InventoryGuardCode.IssueExceedsAvail, r.GuardErrors[0].Code);
  49. Assert.False(written); // 守卫先失败 → 未落写(不绕过)
  50. }
  51. // ③ Orchestrator 的守卫失败结果来自 C5(GuardErrors 透传自 ExecuteWithinAsync)
  52. [Fact]
  53. public async Task Orchestrator_GuardFailure_PropagatesC5GuardErrors()
  54. {
  55. var db = new FakeInventoryDatabase(); // 无 master → G2(由 C5 的 ExecuteWithinAsync 判定)
  56. var orch = new IqcReceiptOrchestrator(new FakeIqcReceiptUnitOfWorkFactory(db));
  57. var receipt = new IqcAcceptedReceipt
  58. {
  59. TenantId = Tenant, DomainCode = "8010", PostingId = 1, TransactionGroupId = 100, RctNbr = "RCT-1",
  60. ItemNum = "IT-1", Location = "L1", AcceptedQty = 100m, UmConversion = 1m, PurOrd = "PO-1", PurLine = 1, User = "t",
  61. };
  62. var gate = new PurchaseReceiptGateInput { HasPendingReceipt = true, AcceptedQty = 100m, HasReceivableDetails = true };
  63. var r = await orch.PostAsync(receipt, gate);
  64. Assert.Equal(IqcReceiptOutcome.ValidationFailure, r.Outcome);
  65. Assert.NotEmpty(r.GuardErrors); // 守卫错误来自 C5
  66. Assert.Equal(InventoryGuardCode.ItemMasterMissing, r.GuardErrors[0].Code);
  67. Assert.Equal(0, db.TotalPersisted); // C6 也回滚
  68. }
  69. }