using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.L0; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.L1; using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// 可配置标准层读取(测试用,替代真实 mdp_std_iqc_receipt_state;无 DB)。 public sealed class FakeIqcReceiptStdReader : IIqcReceiptStdReader { public IqcReceiptStdRow Row { get; set; } public Task LoadRowAsync(long tenantId, string domainCode, string fbillno) => Task.FromResult(Row); } /// /// L0:MdpIqcReceiptStateLoader 字段保真(由 mdp_std 事实读时计算布尔)+ freshness 契约 + 双源无关。 /// 全程 Fake std reader,无 DB(态①,实库贴源留 Phase 5B)。 /// public class MdpIqcReceiptStateLoaderTests { private const long Tenant = 797403760988229L; private static IqcReceiptStdRow FullRow(DateTime? synced = null) => new IqcReceiptStdRow { TenantId = Tenant, Domain = "8010", Fbillno = "FB-1", QcNbr = "QC-1", ItemNum = "IT-1", Location = "L1", LotSerial = "", RctNbr = "RCT-1", PurOrd = "PO-1", PurLine = 3, Potype = "po", OrderedQty = 100m, ReceivedCumQty = 30m, ReturnedCumQty = 2m, SampleQty = 5m, ReceiptPendingQty = 8m, ReceivableDetailCount = 4, SourceSyncedAt = synced, }; private static MdpIqcReceiptStateLoader Loader(IqcReceiptStdRow row) => new MdpIqcReceiptStateLoader(new FakeIqcReceiptStdReader { Row = row }); // L01:FBILLNO 找到完整状态 → 字段映射齐全 [Fact] public async Task L01_Found_MapsAllFields() { var st = await Loader(FullRow()).LoadAsync(Tenant, "8010", "FB-1"); Assert.True(st.Found); Assert.True(st.HasPendingReceipt); // pending 8>0 Assert.True(st.HasReceivableDetails); // count 4>0 Assert.Equal("IT-1", st.ItemNum); Assert.Equal("L1", st.Location); Assert.Equal("RCT-1", st.RctNbr); Assert.Equal("PO-1", st.PurOrd); Assert.Equal(3, st.PurLine); Assert.Equal("po", st.Potype); Assert.Equal("", st.Refs); // UNRESOLVED → 恒空 } // L02:找不到 → Found=false(确定性) [Fact] public async Task L02_NotFound_FoundFalse() { var st = await Loader(null).LoadAsync(Tenant, "8010", "NOPE"); Assert.False(st.Found); } // L03:HasPendingReceipt 由 receipt_pending_qty 计算(=0 → false) [Fact] public async Task L03_NoPending_HasPendingFalse() { var row = FullRow(); row.ReceiptPendingQty = 0m; var st = await Loader(row).LoadAsync(Tenant, "8010", "FB-1"); Assert.True(st.Found); Assert.False(st.HasPendingReceipt); } // L04:无可收明细 → HasReceivableDetails=false [Fact] public async Task L04_NoReceivableDetail_False() { var row = FullRow(); row.ReceivableDetailCount = 0; var st = await Loader(row).LoadAsync(Tenant, "8010", "FB-1"); Assert.False(st.HasReceivableDetails); } // L06:批次空 → LotSerial "" 且 Refs "" 保持 [Fact] public async Task L06_EmptyLot_StaysEmpty() { var row = FullRow(); row.LotSerial = ""; var st = await Loader(row).LoadAsync(Tenant, "8010", "FB-1"); Assert.Equal("", st.LotSerial); Assert.Equal("", st.Refs); } // L07:SampleQty 正确(inspbill.lysl) [Fact] public async Task L07_SampleQty_Mapped() { var st = await Loader(FullRow()).LoadAsync(Tenant, "8010", "FB-1"); Assert.Equal(5m, st.SampleQty); } // L08:PO 数量 Ordered/Received(累计)/Returned 正确 [Fact] public async Task L08_PoQuantities_Mapped() { var st = await Loader(FullRow()).LoadAsync(Tenant, "8010", "FB-1"); Assert.Equal(100m, st.OrderedQty); Assert.Equal(30m, st.BeforeReceivedQty); // received_cum_qty Assert.Equal(2m, st.ReturnedQty); } // ── freshness(经消费者,需 event 时刻)── private const string Dom = "8010"; private static readonly DateTime EvtTime = new DateTime(2026, 8, 9); private static (FakeInventoryDatabase db, InMemoryIqcPostingStore store, IqcInventoryEventConsumer consumer) Rig(IqcReceiptStdRow row) { var db = new FakeInventoryDatabase(); db.SeedMaster(Tenant, Dom, "IT-1", "L1"); var store = new InMemoryIqcPostingStore(); var idem = new IqcPostingIdempotencyService(store); var factory = new FakeIqcReceiptUnitOfWorkFactory(db); var loader = new MdpIqcReceiptStateLoader(new FakeIqcReceiptStdReader { Row = row }); var consumer = new IqcInventoryEventConsumer( idem, new InMemoryBusinessCompletionStore(db), loader, new IqcReceiptOrchestrator(factory), new IqcMrbSelectionTaskService(factory)); return (db, store, consumer); } private static IqcInventoryEvent Evt() => new IqcInventoryEvent { TenantId = Tenant, DomainCode = Dom, Fbillno = "FB-1", Receiver = "RCV-1", RctQcNbr = "QC-1", Pd = 0, Clfs = "", Dhsl = 100m, Bhgsl = 0m, User = "t", EventTime = EvtTime, }; // L09:标准层水位落后于事件 → Retryable、0 C2 [Fact] public async Task L09_FreshnessStale_Retryable_NoPosting() { var (db, _, consumer) = Rig(FullRow(synced: EvtTime.AddDays(-1))); // 同步早于事件 var r = await consumer.ConsumeAsync(Evt()); Assert.Equal(IqcConsumeOutcome.Retryable, r.Outcome); Assert.Equal(0, db.TotalPersisted); } // L10:标准层水位不落后 → 允许进入并过账 [Fact] public async Task L10_FreshnessCurrent_Processes() { var (db, _, consumer) = Rig(FullRow(synced: EvtTime.AddDays(1))); // 同步不早于事件 var r = await consumer.ConsumeAsync(Evt()); Assert.Equal(IqcConsumeOutcome.Processed, r.Outcome); Assert.Equal(1, db.InvMaster.Count); } // L11:双源配置契约 —— 切换 mdp source 不改 loader 代码: // 同一 MdpIqcReceiptStateLoader 面对两个不同来源的 std reader,产出等价 IqcReceiptState。 [Fact] public async Task L11_DualSource_LoaderCodeUnchanged() { var sourceA = new MdpIqcReceiptStateLoader(new FakeIqcReceiptStdReader { Row = FullRow() }); // 本库 aidopdev var sourceB = new MdpIqcReceiptStateLoader(new FakeIqcReceiptStdReader { Row = FullRow() }); // dopdemorq SQLSERVER var a = await sourceA.LoadAsync(Tenant, "8010", "FB-1"); var b = await sourceB.LoadAsync(Tenant, "8010", "FB-1"); Assert.Equal(a.PurOrd, b.PurOrd); Assert.Equal(a.OrderedQty, b.OrderedQty); Assert.Equal(a.HasPendingReceipt, b.HasPendingReceipt); Assert.Equal(a.Found, b.Found); // 读端与 source 解耦 } }