| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186 |
- using System.Text;
- using System.Text.RegularExpressions;
- using Admin.NET.Core;
- using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting;
- 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.Numbering;
- using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt;
- using Admin.NET.Plugin.AiDOP.Supply;
- using Microsoft.AspNetCore.Http;
- using SqlSugar;
- using Xunit;
- using Yitter.IdGenerator;
- namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
- /// <summary>
- /// L4 态② Shadow Reconciliation:读**真实历史 IQC 检验单**(qms_qcp_inspbill 的真实 pd/clfs/dhsl/bhgsl) 作路由与数量的
- /// REAL_PARTIAL 对账;经**生产 C#** replay 到隔离 recon tenant 的 DOP Shadow(CONTROLLED_SHADOW),与 LIVE 契约逐字段比较;
- /// 输出 MATCH/DIFFERENCE/UNVERIFIABLE + CSV。**只读 Legacy(qms_/ItemMaster/LocationMaster),不写任何旧表**。门禁 AIDOP_IT=1。
- /// 说明:dev 这些 UAT 单从未过账(0 linked to PurOrdRctDetail),故无运行态 Legacy 下游结果→REAL_COMPLETE=0;
- /// 运行态 Legacy 交易语义另由 InvTransHist(179K 行) 只读对账(见报告)。
- /// </summary>
- [Trait("Category", "Integration")]
- public class IqcL4ReconciliationTests : IDisposable
- {
- private const long T5 = 950000000000005L; // recon 专属 tenant
- private const string DOM = "8010";
- private const string RULE = "P5B3_RCT";
- private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
- static IqcL4ReconciliationTests()
- { try { _ = YitIdHelper.NextId(); } catch { YitIdHelper.SetIdGenerator(new IdGeneratorOptions(1)); } }
- private static int _seq;
- private static ISqlSugarClient NewDb()
- {
- const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
- var cs = File.ReadLines(path).Select(l => l.Trim())
- .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
- .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value).First(v => !string.IsNullOrEmpty(v));
- return new SqlSugarScope(new ConnectionConfig { ConfigId = "l4-" + System.Threading.Interlocked.Increment(ref _seq), DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true });
- }
- private static IqcInventoryEventConsumer Consumer(ISqlSugarClient db)
- {
- var provider = new NumberRuleServiceBusinessNumberProvider(new NumberRuleService(db, new UserManager(new HttpContextAccessor())));
- var factory = new SqlSugarIqcReceiptUnitOfWorkFactory(db);
- return new IqcInventoryEventConsumer(
- new IqcPostingIdempotencyService(new SqlSugarIqcPostingStore(db)),
- new SqlSugarBusinessCompletionStore(db),
- new MdpIqcReceiptStateLoader(new SqlSugarIqcReceiptStdReader(db)),
- new IqcReceiptOrchestrator(factory, provider, RULE),
- new IqcMrbSelectionTaskService(factory), null, new IqcDomainResolver());
- }
- private sealed class Bill { public string Fbillno, Lydjbh, Clfs; public int Pd; public decimal Dhsl, Bhgsl; }
- private static void EnsureRule(ISqlSugarClient db)
- {
- // recon 用隔离取号规则(若被前批清理则重建;cleanup 再删)
- if (db.Ado.GetInt("SELECT COUNT(*) FROM NbrControl WHERE NbrType=@n", new SugarParameter("@n", RULE)) == 0)
- db.Ado.ExecuteCommand("INSERT INTO NbrControl (NbrType,NbrPre1,IniValue,MinValue,`MaxValue`,ResetValue,AllowReset,AllowSkip,AllowManual,IsDateType,IsActive,IsConfirm,Domain,domain_code,company_ref_id,factory_ref_id,tenant_id,CreateTime) VALUES (@n,'RC',1,1,99999999,0,0,0,0,0,1,1,'','',0,0,NULL,NOW())", new SugarParameter("@n", RULE));
- }
- private static void SeedStd(ISqlSugarClient db, string fb, string item, decimal accept, decimal sample)
- {
- db.Ado.ExecuteCommand("DELETE FROM mdp_std_iqc_receipt_state WHERE tenant_id=@t AND fbillno=@fb", new SugarParameter("@t", T5), new SugarParameter("@fb", fb));
- db.Ado.ExecuteCommand(
- @"INSERT INTO mdp_std_iqc_receipt_state (id,tenant_id,domain,fbillno,qc_nbr,item_num,location,lot_serial,rct_nbr,pur_ord,pur_line,potype,
- qty_ordered,received_cum_qty,returned_cum_qty,sample_qty,receipt_pending_qty,receivable_detail_count,source_system,sync_batch_id,sync_time,create_time)
- VALUES (UUID_SHORT(),@t,@d,@fb,@qc,@item,'1000','',CONCAT('EXIST_',@fb),CONCAT('PO_',@fb),1,'po',@acc,0,0,@smp,8,4,'L4','L4','2999-01-01',NOW())",
- new SugarParameter("@t", T5), new SugarParameter("@d", DOM), new SugarParameter("@fb", fb), new SugarParameter("@qc", "QC_" + fb),
- new SugarParameter("@item", item), new SugarParameter("@acc", accept), new SugarParameter("@smp", sample));
- }
- private static IqcInventoryEvent Evt(string fb, int pd, string clfs, decimal dhsl, decimal bhgsl)
- => new IqcInventoryEvent { TenantId = T5, DomainCode = DOM, Fbillno = fb, Receiver = "RCV_" + fb, RctQcNbr = "QC_" + fb, Pd = pd, Clfs = clfs ?? "", Dhsl = dhsl, Bhgsl = bhgsl, User = "l4", EventTime = new DateTime(2026, 8, 8) };
- public void Dispose()
- {
- if (!Enabled) return;
- using var db = NewDb();
- foreach (var t in new[] { "ado_iqc_inventory_posting", "ado_iqc_posting_completion", "ado_purchase_receipt_writeback", "ado_inventory_master", "ado_inventory_location_detail", "ado_inventory_transaction", "ado_item_location_state", "ado_inventory_mobile_task", "mdp_std_iqc_receipt_state" })
- db.Ado.ExecuteCommand($"DELETE FROM {t} WHERE tenant_id=@t", new SugarParameter("@t", T5));
- }
- [Fact]
- public async Task L4_Reconcile_RealInspbills_And_SynthMrb()
- {
- if (!Enabled) return;
- using var db = NewDb();
- EnsureRule(db);
- var csv = new StringBuilder("fbillno,qc_nbr,pd,clfs,sample_type,dop_route,expected_route,dop_accept,expected_accept,dop_qoh_delta,dop_avail_delta,dop_assay_delta,dop_trans,dop_task,verdict,reason\n");
- // 读真实历史 IQC 检验单(REAL 路由输入)
- var dt = db.Ado.GetDataTable("SELECT FBILLNO,lydjbh,pd,clfs,CAST(IFNULL(dhsl,0) AS DECIMAL(18,4)) dhsl,CAST(IFNULL(bhgsl,0) AS DECIMAL(18,4)) bhgsl FROM qms_qcp_inspbill WHERE TRIM(IFNULL(FBILLNO,''))<>'' ORDER BY FBILLNO");
- var bills = new List<Bill>();
- foreach (System.Data.DataRow r in dt.Rows)
- bills.Add(new Bill { Fbillno = Convert.ToString(r["FBILLNO"]), Lydjbh = Convert.ToString(r["lydjbh"]), Pd = Convert.ToInt32(r["pd"]), Clfs = r["clfs"] == DBNull.Value ? "" : Convert.ToString(r["clfs"]), Dhsl = Convert.ToDecimal(r["dhsl"]), Bhgsl = Convert.ToDecimal(r["bhgsl"]) });
- Assert.NotEmpty(bills); // 真实样本存在
- var items = new[] { "112142431", "112142441", "112312432", "112312433", "112312434", "112312435", "112312436", "112312437" };
- var verdicts = new List<string>();
- var idx = 0;
- foreach (var b in bills)
- {
- var fb = "L4_" + Regex.Replace(b.Fbillno, "[^A-Za-z0-9]", "");
- var item = items[idx % items.Length]; idx++;
- var map = IqcResultMapping.Map(new IqcResultMappingInput { Pd = b.Pd, Clfs = b.Clfs, Dhsl = b.Dhsl, Bhgsl = b.Bhgsl });
- var expectedRoute = ExpectRoute(b.Pd, b.Clfs);
- Assert.Equal(expectedRoute, map.RoutingKind.ToString()); // 真实路由对账:DOP == LIVE 契约
- string verdict = "MATCH", reason = "routing==LIVE", dopTask = "-";
- decimal dq = 0, da = 0, das = 0, dopAccept = map.QtyAcc, expAccept = map.QtyAcc; int trans = 0;
- if (map.RoutingKind == IqcRoutingKind.FullReceiptPosting)
- {
- SeedStd(db, fb, item, map.QtyAcc, sample: 0m); // 真实 accept;留样另由 5B-3 + 合成样本证
- var before = Qoh(db, item);
- var r = await Consumer(db).ConsumeAsync(Evt(fb, b.Pd, b.Clfs, b.Dhsl, b.Bhgsl));
- if (r.Outcome != IqcConsumeOutcome.Processed) { verdict = "DIFFERENCE"; reason = "not Processed:" + r.Reason; }
- dq = Qoh(db, item) - before; da = Avail(db, item) - before; das = Assay(db, item);
- trans = TransCount(db, item); dopTask = Task(db, fb);
- if (dq != map.QtyAcc || da != map.QtyAcc || das != 0m) { verdict = "DIFFERENCE"; reason = $"delta qoh={dq}/av={da}/as={das} != accept {map.QtyAcc}/0"; }
- if (dopTask != "InvUpShelf") { verdict = "DIFFERENCE"; reason += "|task!=InvUpShelf:" + dopTask; }
- }
- else // Unmatched (INSP-03 clfs=1) / ReturnOnly
- {
- var r = await Consumer(db).ConsumeAsync(Evt(fb, b.Pd, b.Clfs, b.Dhsl, b.Bhgsl));
- if (r.Outcome != IqcConsumeOutcome.NoAction) { verdict = "DIFFERENCE"; reason = "expected NoAction got " + r.Outcome; }
- reason = "no-op route (LIVE 旧入口不动作)";
- }
- verdicts.Add($"{b.Fbillno}:{map.RoutingKind}:{verdict}");
- csv.Append($"{b.Fbillno},{b.Lydjbh},{b.Pd},{b.Clfs},REAL_PARTIAL,{map.RoutingKind},{expectedRoute},{dopAccept},{expAccept},{dq},{da},{das},{trans},{dopTask},{verdict},{reason}\n");
- Assert.Equal("MATCH", verdict);
- }
- // 合成覆盖:② 挑选(pd0clfs3) + ③ 退货(pd1clfs0)(真实历史无此样本)
- // ② choose
- var fbC = "L4_SYNTH_CHOOSE"; SeedStd(db, fbC, items[0], 100m, 0m);
- var rc = await Consumer(db).ConsumeAsync(Evt(fbC, 0, "3", 97, 3));
- Assert.Equal(IqcConsumeOutcome.Processed, rc.Outcome);
- Assert.Equal("PurOrdRctByMRBChoose", Task(db, fbC)); Assert.Equal(0, TransCount(db, items[0] + "_none"));
- csv.Append($"{fbC},QC_{fbC},0,3,CONTROLLED_SHADOW,MrbChooseMobileTask,MrbChooseMobileTask,0,0,0,0,0,0,PurOrdRctByMRBChoose,MATCH,selection task-only no inventory\n");
- // ③ return
- var fbR = "L4_SYNTH_RETURN"; SeedStd(db, fbR, items[1], 0m, 0m);
- var rr = await Consumer(db).ConsumeAsync(Evt(fbR, 1, "0", 0, 100));
- Assert.Equal(IqcConsumeOutcome.NoAction, rr.Outcome);
- csv.Append($"{fbR},QC_{fbR},1,0,CONTROLLED_SHADOW,ReturnOnly,ReturnOnly,0,0,0,0,0,0,-,MATCH,return-only no inventory/task\n");
- // 留样合成对账(§17 transaction-level + balance-level):accept 100 / sample 10 → rct-po+100 & iss-unp-10 → 净+90,两流水
- var fbS = "L4_SYNTH_SAMPLE"; var itemS = items[6]; // 独立未复用 item,避免叠加真实单流水
- SeedStd(db, fbS, itemS, 100m, 10m);
- var beforeS = Qoh(db, itemS);
- var rs = await Consumer(db).ConsumeAsync(Evt(fbS, 0, "", 100, 0));
- Assert.Equal(IqcConsumeOutcome.Processed, rs.Outcome);
- Assert.Equal(90m, Qoh(db, itemS) - beforeS); // balance-level net = accept - sample
- Assert.Equal(90m, Avail(db, itemS) - beforeS);
- Assert.Equal(0m, Assay(db, itemS));
- Assert.Equal(2, TransCount(db, itemS)); // transaction-level: rct-po + iss-unp(非单条 +90)
- csv.Append($"{fbS},QC_{fbS},0,,CONTROLLED_SHADOW,FullReceiptPosting,FullReceiptPosting,100,100,90,90,0,2,InvUpShelf,MATCH,sampling rct-po+100 iss-unp-10 two-trans net90\n");
- File.WriteAllText("/home/yy968/work/New9S/lwb/modules/S5/L4-IQC-Shadow-Reconciliation.csv", csv.ToString());
- Assert.All(verdicts, v => Assert.EndsWith(":MATCH", v));
- }
- private static string ExpectRoute(int pd, string clfs)
- {
- clfs ??= "";
- if (pd == 0 && clfs == "") return "FullReceiptPosting";
- if (pd == 0 && clfs == "3") return "MrbChooseMobileTask";
- if (pd == 1 && clfs == "0") return "ReturnOnly";
- if (pd == 1 && clfs == "2") return "FullReceiptPosting";
- if (pd == 1 && clfs == "3") return "MrbChooseMobileTask";
- return "Unmatched";
- }
- private static decimal Qoh(ISqlSugarClient db, string item) => db.Ado.GetDecimal("SELECT IFNULL(MAX(qty_on_hand),0) FROM ado_inventory_master WHERE tenant_id=@t AND item_num=@i AND location='1000'", new SugarParameter("@t", T5), new SugarParameter("@i", item));
- private static decimal Avail(ISqlSugarClient db, string item) => db.Ado.GetDecimal("SELECT IFNULL(MAX(avail_status_qty),0) FROM ado_inventory_master WHERE tenant_id=@t AND item_num=@i AND location='1000'", new SugarParameter("@t", T5), new SugarParameter("@i", item));
- private static decimal Assay(ISqlSugarClient db, string item) => db.Ado.GetDecimal("SELECT IFNULL(MAX(assay_qty),0) FROM ado_inventory_master WHERE tenant_id=@t AND item_num=@i AND location='1000'", new SugarParameter("@t", T5), new SugarParameter("@i", item));
- private static int TransCount(ISqlSugarClient db, string item) => db.Ado.GetInt("SELECT COUNT(*) FROM ado_inventory_transaction WHERE tenant_id=@t AND item_num=@i AND location='1000'", new SugarParameter("@t", T5), new SugarParameter("@i", item));
- private static string Task(ISqlSugarClient db, string fb) => db.Ado.GetString("SELECT IFNULL(MAX(name),'-') FROM ado_inventory_mobile_task WHERE tenant_id=@t AND rct_qc_nbr=@qc", new SugarParameter("@t", T5), new SugarParameter("@qc", "QC_" + fb));
- }
|