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);
}
}