IqcL4ReconciliationTests.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. using System.Text;
  2. using System.Text.RegularExpressions;
  3. using Admin.NET.Core;
  4. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting;
  5. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Idempotency;
  6. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.L0;
  7. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.L1;
  8. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Numbering;
  9. using Admin.NET.Plugin.AiDOP.MaterialWarehouse.InventoryPosting.Receipt;
  10. using Admin.NET.Plugin.AiDOP.Supply;
  11. using Microsoft.AspNetCore.Http;
  12. using SqlSugar;
  13. using Xunit;
  14. using Yitter.IdGenerator;
  15. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  16. /// <summary>
  17. /// L4 态② Shadow Reconciliation:读**真实历史 IQC 检验单**(qms_qcp_inspbill 的真实 pd/clfs/dhsl/bhgsl) 作路由与数量的
  18. /// REAL_PARTIAL 对账;经**生产 C#** replay 到隔离 recon tenant 的 DOP Shadow(CONTROLLED_SHADOW),与 LIVE 契约逐字段比较;
  19. /// 输出 MATCH/DIFFERENCE/UNVERIFIABLE + CSV。**只读 Legacy(qms_/ItemMaster/LocationMaster),不写任何旧表**。门禁 AIDOP_IT=1。
  20. /// 说明:dev 这些 UAT 单从未过账(0 linked to PurOrdRctDetail),故无运行态 Legacy 下游结果→REAL_COMPLETE=0;
  21. /// 运行态 Legacy 交易语义另由 InvTransHist(179K 行) 只读对账(见报告)。
  22. /// </summary>
  23. [Trait("Category", "Integration")]
  24. public class IqcL4ReconciliationTests : IDisposable
  25. {
  26. private const long T5 = 950000000000005L; // recon 专属 tenant
  27. private const string DOM = "8010";
  28. private const string RULE = "P5B3_RCT";
  29. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  30. static IqcL4ReconciliationTests()
  31. { try { _ = YitIdHelper.NextId(); } catch { YitIdHelper.SetIdGenerator(new IdGeneratorOptions(1)); } }
  32. private static int _seq;
  33. private static ISqlSugarClient NewDb()
  34. {
  35. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  36. var cs = File.ReadLines(path).Select(l => l.Trim())
  37. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  38. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value).First(v => !string.IsNullOrEmpty(v));
  39. return new SqlSugarScope(new ConnectionConfig { ConfigId = "l4-" + System.Threading.Interlocked.Increment(ref _seq), DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true });
  40. }
  41. private static IqcInventoryEventConsumer Consumer(ISqlSugarClient db)
  42. {
  43. var provider = new NumberRuleServiceBusinessNumberProvider(new NumberRuleService(db, new UserManager(new HttpContextAccessor())));
  44. var factory = new SqlSugarIqcReceiptUnitOfWorkFactory(db);
  45. return new IqcInventoryEventConsumer(
  46. new IqcPostingIdempotencyService(new SqlSugarIqcPostingStore(db)),
  47. new SqlSugarBusinessCompletionStore(db),
  48. new MdpIqcReceiptStateLoader(new SqlSugarIqcReceiptStdReader(db)),
  49. new IqcReceiptOrchestrator(factory, provider, RULE),
  50. new IqcMrbSelectionTaskService(factory), null, new IqcDomainResolver());
  51. }
  52. private sealed class Bill { public string Fbillno, Lydjbh, Clfs; public int Pd; public decimal Dhsl, Bhgsl; }
  53. private static void EnsureRule(ISqlSugarClient db)
  54. {
  55. // recon 用隔离取号规则(若被前批清理则重建;cleanup 再删)
  56. if (db.Ado.GetInt("SELECT COUNT(*) FROM NbrControl WHERE NbrType=@n", new SugarParameter("@n", RULE)) == 0)
  57. 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));
  58. }
  59. private static void SeedStd(ISqlSugarClient db, string fb, string item, decimal accept, decimal sample)
  60. {
  61. 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));
  62. db.Ado.ExecuteCommand(
  63. @"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,
  64. 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)
  65. 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())",
  66. new SugarParameter("@t", T5), new SugarParameter("@d", DOM), new SugarParameter("@fb", fb), new SugarParameter("@qc", "QC_" + fb),
  67. new SugarParameter("@item", item), new SugarParameter("@acc", accept), new SugarParameter("@smp", sample));
  68. }
  69. private static IqcInventoryEvent Evt(string fb, int pd, string clfs, decimal dhsl, decimal bhgsl)
  70. => 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) };
  71. public void Dispose()
  72. {
  73. if (!Enabled) return;
  74. using var db = NewDb();
  75. 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" })
  76. db.Ado.ExecuteCommand($"DELETE FROM {t} WHERE tenant_id=@t", new SugarParameter("@t", T5));
  77. }
  78. [Fact]
  79. public async Task L4_Reconcile_RealInspbills_And_SynthMrb()
  80. {
  81. if (!Enabled) return;
  82. using var db = NewDb();
  83. EnsureRule(db);
  84. 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");
  85. // 读真实历史 IQC 检验单(REAL 路由输入)
  86. 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");
  87. var bills = new List<Bill>();
  88. foreach (System.Data.DataRow r in dt.Rows)
  89. 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"]) });
  90. Assert.NotEmpty(bills); // 真实样本存在
  91. var items = new[] { "112142431", "112142441", "112312432", "112312433", "112312434", "112312435", "112312436", "112312437" };
  92. var verdicts = new List<string>();
  93. var idx = 0;
  94. foreach (var b in bills)
  95. {
  96. var fb = "L4_" + Regex.Replace(b.Fbillno, "[^A-Za-z0-9]", "");
  97. var item = items[idx % items.Length]; idx++;
  98. var map = IqcResultMapping.Map(new IqcResultMappingInput { Pd = b.Pd, Clfs = b.Clfs, Dhsl = b.Dhsl, Bhgsl = b.Bhgsl });
  99. var expectedRoute = ExpectRoute(b.Pd, b.Clfs);
  100. Assert.Equal(expectedRoute, map.RoutingKind.ToString()); // 真实路由对账:DOP == LIVE 契约
  101. string verdict = "MATCH", reason = "routing==LIVE", dopTask = "-";
  102. decimal dq = 0, da = 0, das = 0, dopAccept = map.QtyAcc, expAccept = map.QtyAcc; int trans = 0;
  103. if (map.RoutingKind == IqcRoutingKind.FullReceiptPosting)
  104. {
  105. SeedStd(db, fb, item, map.QtyAcc, sample: 0m); // 真实 accept;留样另由 5B-3 + 合成样本证
  106. var before = Qoh(db, item);
  107. var r = await Consumer(db).ConsumeAsync(Evt(fb, b.Pd, b.Clfs, b.Dhsl, b.Bhgsl));
  108. if (r.Outcome != IqcConsumeOutcome.Processed) { verdict = "DIFFERENCE"; reason = "not Processed:" + r.Reason; }
  109. dq = Qoh(db, item) - before; da = Avail(db, item) - before; das = Assay(db, item);
  110. trans = TransCount(db, item); dopTask = Task(db, fb);
  111. if (dq != map.QtyAcc || da != map.QtyAcc || das != 0m) { verdict = "DIFFERENCE"; reason = $"delta qoh={dq}/av={da}/as={das} != accept {map.QtyAcc}/0"; }
  112. if (dopTask != "InvUpShelf") { verdict = "DIFFERENCE"; reason += "|task!=InvUpShelf:" + dopTask; }
  113. }
  114. else // Unmatched (INSP-03 clfs=1) / ReturnOnly
  115. {
  116. var r = await Consumer(db).ConsumeAsync(Evt(fb, b.Pd, b.Clfs, b.Dhsl, b.Bhgsl));
  117. if (r.Outcome != IqcConsumeOutcome.NoAction) { verdict = "DIFFERENCE"; reason = "expected NoAction got " + r.Outcome; }
  118. reason = "no-op route (LIVE 旧入口不动作)";
  119. }
  120. verdicts.Add($"{b.Fbillno}:{map.RoutingKind}:{verdict}");
  121. 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");
  122. Assert.Equal("MATCH", verdict);
  123. }
  124. // 合成覆盖:② 挑选(pd0clfs3) + ③ 退货(pd1clfs0)(真实历史无此样本)
  125. // ② choose
  126. var fbC = "L4_SYNTH_CHOOSE"; SeedStd(db, fbC, items[0], 100m, 0m);
  127. var rc = await Consumer(db).ConsumeAsync(Evt(fbC, 0, "3", 97, 3));
  128. Assert.Equal(IqcConsumeOutcome.Processed, rc.Outcome);
  129. Assert.Equal("PurOrdRctByMRBChoose", Task(db, fbC)); Assert.Equal(0, TransCount(db, items[0] + "_none"));
  130. 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");
  131. // ③ return
  132. var fbR = "L4_SYNTH_RETURN"; SeedStd(db, fbR, items[1], 0m, 0m);
  133. var rr = await Consumer(db).ConsumeAsync(Evt(fbR, 1, "0", 0, 100));
  134. Assert.Equal(IqcConsumeOutcome.NoAction, rr.Outcome);
  135. csv.Append($"{fbR},QC_{fbR},1,0,CONTROLLED_SHADOW,ReturnOnly,ReturnOnly,0,0,0,0,0,0,-,MATCH,return-only no inventory/task\n");
  136. // 留样合成对账(§17 transaction-level + balance-level):accept 100 / sample 10 → rct-po+100 & iss-unp-10 → 净+90,两流水
  137. var fbS = "L4_SYNTH_SAMPLE"; var itemS = items[6]; // 独立未复用 item,避免叠加真实单流水
  138. SeedStd(db, fbS, itemS, 100m, 10m);
  139. var beforeS = Qoh(db, itemS);
  140. var rs = await Consumer(db).ConsumeAsync(Evt(fbS, 0, "", 100, 0));
  141. Assert.Equal(IqcConsumeOutcome.Processed, rs.Outcome);
  142. Assert.Equal(90m, Qoh(db, itemS) - beforeS); // balance-level net = accept - sample
  143. Assert.Equal(90m, Avail(db, itemS) - beforeS);
  144. Assert.Equal(0m, Assay(db, itemS));
  145. Assert.Equal(2, TransCount(db, itemS)); // transaction-level: rct-po + iss-unp(非单条 +90)
  146. 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");
  147. File.WriteAllText("/home/yy968/work/New9S/lwb/modules/S5/L4-IQC-Shadow-Reconciliation.csv", csv.ToString());
  148. Assert.All(verdicts, v => Assert.EndsWith(":MATCH", v));
  149. }
  150. private static string ExpectRoute(int pd, string clfs)
  151. {
  152. clfs ??= "";
  153. if (pd == 0 && clfs == "") return "FullReceiptPosting";
  154. if (pd == 0 && clfs == "3") return "MrbChooseMobileTask";
  155. if (pd == 1 && clfs == "0") return "ReturnOnly";
  156. if (pd == 1 && clfs == "2") return "FullReceiptPosting";
  157. if (pd == 1 && clfs == "3") return "MrbChooseMobileTask";
  158. return "Unmatched";
  159. }
  160. 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));
  161. 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));
  162. 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));
  163. 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));
  164. 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));
  165. }