MissedPrintConsumerScopeTests.cs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. using System.Text.RegularExpressions;
  2. using SqlSugar;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  5. /// <summary>
  6. /// S5 MissedPrint 剩余消费点 P1/P2 收口守卫(FUNC-S5-009 / IqcWmsResultPush / InventoryBalanceReader)。
  7. /// <para>
  8. /// 源码断言部分无门禁,普通 CI 也会执行;数据面断言需 AIDOP_IT=1,且**全程只读**,
  9. /// 不写任何业务表、不触发 165 推送。
  10. /// </para>
  11. /// <para>
  12. /// 背景:MissedPrint.tenant_id 已是正式租户列(生成标签写入 + 1.0.442 回填,152 行 0 NULL)。
  13. /// 但 797 的 104 行 Domain='8010'、UAT 的 48 行 Domain='',而 SourceDomainTenantResolver
  14. /// 对两者都解析出 '8010' —— 因此凡是按 Domain 过滤 MissedPrint 的路径,对 UAT 而言候选宇宙
  15. /// 100% 由 797 构成,方向是反的。本组把「一律直接用 tenant_id」钉死。
  16. /// </para>
  17. /// </summary>
  18. public class MissedPrintConsumerScopeTests
  19. {
  20. private const long TenantUat = 838257186181189L;
  21. private const long TenantAidop = 797403760988229L;
  22. private static bool ItEnabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  23. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  24. public MissedPrintConsumerScopeTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  25. private static readonly string PluginRoot =
  26. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  27. private const string PendingSrc = "MaterialWarehouse/PendingInspectionService.cs";
  28. private const string IqcPushSrc = "MaterialWarehouse/IqcWmsResultPushService.cs";
  29. private const string BalanceSrc = "MaterialWarehouse/InventoryBalanceReader.cs";
  30. private const string StocktakeSrc = "MaterialWarehouse/StocktakeResultService.cs";
  31. private static string ReadSource(string relativePath)
  32. {
  33. var full = Path.Combine(PluginRoot, relativePath);
  34. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  35. return File.ReadAllText(full);
  36. }
  37. /// <summary>只取可执行代码行:注释里为留档会提到旧写法,不应算违规。</summary>
  38. private static List<string> CodeLines(string relativePath) =>
  39. ReadSource(relativePath)
  40. .Split('\n')
  41. .Select(l => l.Trim())
  42. .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
  43. && !l.StartsWith("//", StringComparison.Ordinal)
  44. && !l.StartsWith("--", StringComparison.Ordinal))
  45. .ToList();
  46. private static ISqlSugarClient? BuildAidopdev()
  47. {
  48. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  49. if (!File.Exists(path)) return null;
  50. var cs = File.ReadLines(path)
  51. .Select(l => l.Trim())
  52. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
  53. && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  54. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  55. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  56. if (string.IsNullOrEmpty(cs)) return null;
  57. return new SqlSugarScope(new ConnectionConfig
  58. {
  59. ConfigId = "aidopdev-mp-consumer-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  60. });
  61. }
  62. /// <summary>取源码中某条 SQL 片段。</summary>
  63. private static string Slice(string src, string from, string to)
  64. {
  65. var i = src.IndexOf(from, StringComparison.Ordinal);
  66. Assert.True(i >= 0, $"源码中找不到锚点:{from}");
  67. var j = src.IndexOf(to, i + from.Length, StringComparison.Ordinal);
  68. Assert.True(j >= 0, $"源码中找不到结束锚点:{to}");
  69. return src.Substring(i, j - i);
  70. }
  71. // ===================== FUNC-S5-009 PendingInspection =====================
  72. [Fact]
  73. public void F009_Anchor_MustFilterByTenantId()
  74. {
  75. var src = ReadSource(PendingSrc);
  76. Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
  77. }
  78. /// <summary>
  79. /// 锚表不得再带 Domain:它对租户内零过滤价值,却会把整个 UAT 清零。
  80. /// </summary>
  81. [Fact]
  82. public void F009_Anchor_MustNotFilterByDomain()
  83. {
  84. var offenders = CodeLines(PendingSrc)
  85. .Where(l => l.Contains("m.Domain", StringComparison.Ordinal)
  86. || l.Contains("\"@Domain\"", StringComparison.Ordinal))
  87. .ToList();
  88. Assert.True(offenders.Count == 0, $"009 仍在按 Domain 过滤:{string.Join(" | ", offenders)}");
  89. }
  90. [Fact]
  91. public void F009_SupplierOptions_MustBeTenantScoped()
  92. {
  93. var src = ReadSource(PendingSrc);
  94. var stmt = Slice(src, "SELECT DISTINCT m.Supply", "ORDER BY m.Supply");
  95. Assert.Contains("m.tenant_id = @TenantId", stmt, StringComparison.Ordinal);
  96. Assert.Contains("s.tenant_id = @TenantId", stmt, StringComparison.Ordinal);
  97. Assert.DoesNotContain("@Domain", stmt, StringComparison.Ordinal);
  98. }
  99. /// <summary>主数据关联改按租户:(Domain, ItemNum) / (Domain, Supp) 都不唯一,会一对多放大。</summary>
  100. [Fact]
  101. public void F009_MasterDataJoins_MustBeTenantScoped()
  102. {
  103. var src = ReadSource(PendingSrc);
  104. Assert.Contains("LEFT JOIN ItemMaster i ON i.tenant_id = @TenantId", src, StringComparison.Ordinal);
  105. Assert.Contains("LEFT JOIN SuppMaster s ON s.tenant_id = @TenantId", src, StringComparison.Ordinal);
  106. }
  107. // ===================== IqcWmsResultPushService =====================
  108. [Fact]
  109. public void IqcPush_MissedPrint_MustBeTenantScoped()
  110. {
  111. var src = ReadSource(IqcPushSrc);
  112. var stmt = Slice(src, "FROM MissedPrint", "IFNULL(Type,'') <> 'Card'");
  113. Assert.Contains("tenant_id = @TenantId", stmt, StringComparison.Ordinal);
  114. }
  115. /// <summary>
  116. /// <c>IFNULL(Domain,'') IN ('', @Domain)</c> 会同时命中 UAT(Domain='')与 797(Domain='8010')
  117. /// 两个分区,是本仓唯一一处结构性合并两租户的 MissedPrint 查询,必须彻底消失。
  118. /// </summary>
  119. [Fact]
  120. public void IqcPush_MustNotUseCrossPartitionDomainPredicate()
  121. {
  122. var offenders = CodeLines(IqcPushSrc)
  123. .Where(l => l.Contains("IN ('', @Domain)", StringComparison.Ordinal))
  124. .ToList();
  125. Assert.True(offenders.Count == 0, $"仍存在跨分区 Domain 谓词:{string.Join(" | ", offenders)}");
  126. }
  127. [Fact]
  128. public void IqcPush_MustNotHardcodeDomain()
  129. {
  130. var offenders = CodeLines(IqcPushSrc)
  131. .Where(l => l.Contains("\"8010\"", StringComparison.Ordinal))
  132. .ToList();
  133. Assert.True(offenders.Count == 0, $"仍硬编码 Domain:{string.Join(" | ", offenders)}");
  134. // 必须改走统一 resolver 并按当前租户解析
  135. Assert.Contains(CodeLines(IqcPushSrc),
  136. l => l.Contains("_domainTenant.ResolveDomainAsync(SourceCode, tenantId", StringComparison.Ordinal));
  137. }
  138. /// <summary>推送链租户一致性:入队的 TenantId 必须就是取数用的 tenantId,不得另起来源。</summary>
  139. [Fact]
  140. public void IqcPush_OutboxTenant_MatchesSourceTenant()
  141. {
  142. var lines = CodeLines(IqcPushSrc);
  143. var outboxTenants = lines.Where(l => l.Contains("TenantId =", StringComparison.Ordinal)).ToList();
  144. Assert.NotEmpty(outboxTenants);
  145. Assert.All(outboxTenants, l => Assert.Contains("TenantId = tenantId", l, StringComparison.Ordinal));
  146. }
  147. // ===================== InventoryBalanceReader(010 富化) =====================
  148. /// <summary>Live165 与 STD 两处标签富化都必须按租户,且不得退回 Domain-only。</summary>
  149. [Fact]
  150. public void Balance_BothEnrichments_MustBeTenantScoped()
  151. {
  152. var src = ReadSource(BalanceSrc);
  153. var occurrences = Regex.Matches(src, @"FROM MissedPrint\s*\r?\n\s*WHERE\s+tenant_id=@TenantId").Count;
  154. Assert.Equal(2, occurrences);
  155. var offenders = CodeLines(BalanceSrc)
  156. .Where(l => l.Contains("WHERE Domain=@Domain AND IFNULL(Status", StringComparison.Ordinal))
  157. .ToList();
  158. Assert.True(offenders.Count == 0, $"仍存在 Domain-only 的标签富化:{string.Join(" | ", offenders)}");
  159. }
  160. /// <summary>富化只能影响展示列,绝不能碰数量列 —— 这是 010 保持 CLOSED 的前提。</summary>
  161. [Fact]
  162. public void Balance_Enrichment_TouchesOnlyDisplayColumns()
  163. {
  164. var src = ReadSource(BalanceSrc);
  165. foreach (var stmt in new[]
  166. {
  167. Slice(src, "SELECT Location, ItemNum, IFNULL(LotSerial,'') AS LotSerial", "GROUP BY Location"),
  168. })
  169. {
  170. Assert.Contains("MAX(Shelf)", stmt, StringComparison.Ordinal);
  171. Assert.Contains("MAX(LevelChar)", stmt, StringComparison.Ordinal);
  172. Assert.Contains("MAX(Supply)", stmt, StringComparison.Ordinal);
  173. Assert.DoesNotContain("QtyOnHand", stmt, StringComparison.Ordinal);
  174. Assert.DoesNotContain("AvailStatusQty", stmt, StringComparison.Ordinal);
  175. }
  176. }
  177. // ===================== FUNC-S5-013 StocktakeResult =====================
  178. [Fact]
  179. public void F013_MissedPrint_MustFilterByTenantId()
  180. {
  181. var src = ReadSource(StocktakeSrc);
  182. Assert.Contains("\"m.tenant_id = @TenantId\"", src, StringComparison.Ordinal);
  183. }
  184. /// <summary>
  185. /// 013 不得再把 Domain 当租户边界:UAT 的 48 行 Domain 为空串,
  186. /// 而 resolver 给出 '8010',叠加后会把本租户数据整体清零(单向误杀)。
  187. /// </summary>
  188. [Fact]
  189. public void F013_MustNotUseDomainAsTenantBoundary()
  190. {
  191. var offenders = CodeLines(StocktakeSrc)
  192. .Where(l => l.Contains("m.Domain", StringComparison.Ordinal)
  193. || l.Contains("\"@Domain\"", StringComparison.Ordinal)
  194. || l.Contains("ResolveDomainAsync", StringComparison.Ordinal))
  195. .ToList();
  196. Assert.True(offenders.Count == 0, $"013 仍在按 Domain 过滤/解析:{string.Join(" | ", offenders)}");
  197. }
  198. /// <summary>013 的 ItemMaster 关联必须 tenant-aware,且不得回落到 Domain-only。</summary>
  199. [Fact]
  200. public void F013_ItemMasterJoin_MustBeTenantAware()
  201. {
  202. var src = ReadSource(StocktakeSrc);
  203. Assert.Contains("LEFT JOIN ItemMaster i ON i.tenant_id = m.tenant_id AND i.ItemNum = m.ItemNum",
  204. src, StringComparison.Ordinal);
  205. Assert.DoesNotContain(CodeLines(StocktakeSrc),
  206. l => l.Contains("ON m.Domain = i.Domain", StringComparison.Ordinal));
  207. }
  208. /// <summary>
  209. /// COUNT 与 SELECT 必须建立在同一业务集合上:COUNT 只查 MissedPrint,
  210. /// SELECT 多一个 LEFT JOIN —— 该 JOIN 必须恒为 1:1,否则分页取回行数会大于 total。
  211. /// </summary>
  212. [Fact]
  213. public void F013_CountAndSelect_ShareSameWhereClause()
  214. {
  215. var src = ReadSource(StocktakeSrc);
  216. Assert.Contains("SELECT COUNT(1) FROM MissedPrint m WHERE {whereSql}", src, StringComparison.Ordinal);
  217. // SELECT 侧复用同一个 whereSql,不得另起条件
  218. var select = Slice(src, "FROM MissedPrint m", "ORDER BY m.UpdateTime");
  219. Assert.Contains("WHERE {whereSql}", select, StringComparison.Ordinal);
  220. }
  221. /// <summary>013 仍须保持只读,且 IsTag=1 业务语义不得被改动。</summary>
  222. [Fact]
  223. public void F013_MustRemainReadOnly_AndKeepIsTagSemantics()
  224. {
  225. var src = ReadSource(StocktakeSrc);
  226. Assert.Contains("\"m.IsTag = 1\"", src, StringComparison.Ordinal);
  227. foreach (var verb in new[] { "HttpPost", "HttpPut", "HttpDelete", "HttpPatch" })
  228. Assert.DoesNotContain(verb, src, StringComparison.Ordinal);
  229. }
  230. // ===================== 数据面(AIDOP_IT=1,只读) =====================
  231. /// <summary>
  232. /// §3.2 的判据固化:Domain 在 MissedPrint 层对租户内零过滤价值(每租户单一 Domain 值),
  233. /// 却会整租户清零 —— UAT 就是被清零的那一个。
  234. /// </summary>
  235. [Fact]
  236. public async Task Data_DomainIsUselessAndHarmfulOnMissedPrint()
  237. {
  238. if (!ItEnabled) return;
  239. var db = BuildAidopdev();
  240. Assert.NotNull(db);
  241. var multiDomainTenants = await db!.Ado.GetIntAsync(
  242. "SELECT COUNT(1) FROM (SELECT tenant_id FROM MissedPrint GROUP BY tenant_id HAVING COUNT(DISTINCT IFNULL(Domain,''))>1) x");
  243. var uatUnderDomain8010 = await db.Ado.GetIntAsync(
  244. "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND Domain='8010'",
  245. new List<SugarParameter> { new("@T", TenantUat) });
  246. var uatTotal = await db.Ado.GetIntAsync(
  247. "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T", new List<SugarParameter> { new("@T", TenantUat) });
  248. _out.WriteLine($"多 Domain 租户数={multiDomainTenants};UAT 总数={uatTotal},其中 Domain='8010' 的={uatUnderDomain8010}");
  249. Assert.Equal(0, multiDomainTenants); // 每租户单一 Domain → Domain 无租户内过滤价值
  250. Assert.True(uatTotal > 0);
  251. Assert.Equal(0, uatUnderDomain8010); // 而 resolver 给 UAT 的正是 '8010' → 旧写法整租户清零
  252. }
  253. /// <summary>§4.5:IQC 推送候选集,修复前跨两租户 130 行,修复后按租户切分且互不重叠。</summary>
  254. [Fact]
  255. public async Task Data_IqcPushCandidates_SplitByTenant()
  256. {
  257. if (!ItEnabled) return;
  258. var db = BuildAidopdev();
  259. Assert.NotNull(db);
  260. const string tail = "IFNULL(Status,'')<>'C' AND IFNULL(Type,'')<>'Card'";
  261. var legacy = await db!.Ado.GetIntAsync(
  262. $"SELECT COUNT(1) FROM MissedPrint WHERE IFNULL(Domain,'') IN ('', '8010') AND {tail}");
  263. var uat = await db.Ado.GetIntAsync(
  264. $"SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND {tail}",
  265. new List<SugarParameter> { new("@T", TenantUat) });
  266. var aidop = await db.Ado.GetIntAsync(
  267. $"SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND {tail}",
  268. new List<SugarParameter> { new("@T", TenantAidop) });
  269. _out.WriteLine($"旧写法候选={legacy}(跨两租户);按租户切分 UAT={uat} / 797={aidop}");
  270. Assert.True(legacy > uat && legacy > aidop, "旧写法应当同时并入两个租户,否则用例失去意义");
  271. Assert.Equal(legacy, uat + aidop); // 不重不漏
  272. }
  273. /// <summary>§5.3:富化 JOIN 键在租户内唯一,加 tenant_id 后不会放大分页。</summary>
  274. [Fact]
  275. public async Task Data_EnrichmentJoinKey_UniqueWithinTenant()
  276. {
  277. if (!ItEnabled) return;
  278. var db = BuildAidopdev();
  279. Assert.NotNull(db);
  280. var dupWithinTenant = await db!.Ado.GetIntAsync(
  281. """
  282. SELECT COUNT(1) FROM (
  283. SELECT tenant_id, Location, ItemNum, IFNULL(LotSerial,'') l
  284. FROM MissedPrint WHERE IFNULL(Location,'')<>''
  285. GROUP BY tenant_id, Location, ItemNum, l HAVING COUNT(*)>1) x
  286. """);
  287. // 负向对照:不带租户时,(Location,ItemNum,LotSerial) 在 mdp_std_inventory 已跨租户碰撞
  288. var stdCollision = await db.Ado.GetIntAsync(
  289. """
  290. SELECT COUNT(1) FROM (
  291. SELECT location, item_num, IFNULL(lot_serial,'') l FROM mdp_std_inventory
  292. WHERE (location, item_num, IFNULL(lot_serial,'')) IN (
  293. SELECT Location, ItemNum, IFNULL(LotSerial,'') FROM MissedPrint
  294. WHERE IFNULL(Status,'') NOT IN ('U','P','C') AND IFNULL(Location,'')<>'' AND Qty>0)
  295. GROUP BY location, item_num, l HAVING COUNT(DISTINCT tenant_id)>1) x
  296. """);
  297. _out.WriteLine($"MissedPrint 租户内键重复={dupWithinTenant};标签键在 STD 里跨租户碰撞={stdCollision}");
  298. Assert.Equal(0, dupWithinTenant);
  299. Assert.True(stdCollision > 0, "碰撞若已消失,需重新评估本条 HARDEN 的必要性");
  300. }
  301. /// <summary>§3.4:主数据关联改按租户后是 1:1;按 Domain 则会放大。</summary>
  302. [Fact]
  303. public async Task Data_MasterDataJoin_TenantScopedIsOneToOne()
  304. {
  305. if (!ItEnabled) return;
  306. var db = BuildAidopdev();
  307. Assert.NotNull(db);
  308. var itemByTenant = await db!.Ado.GetIntAsync(
  309. "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
  310. var itemByDomain = await db.Ado.GetIntAsync(
  311. "SELECT COUNT(1) FROM (SELECT Domain,ItemNum FROM ItemMaster GROUP BY Domain,ItemNum HAVING COUNT(*)>1) x");
  312. var suppByTenant = await db.Ado.GetIntAsync(
  313. "SELECT COUNT(1) FROM (SELECT tenant_id,Supp FROM SuppMaster GROUP BY tenant_id,Supp HAVING COUNT(*)>1) x");
  314. var suppByDomain = await db.Ado.GetIntAsync(
  315. "SELECT COUNT(1) FROM (SELECT Domain,Supp FROM SuppMaster GROUP BY Domain,Supp HAVING COUNT(*)>1) x");
  316. _out.WriteLine($"ItemMaster 重复:按租户={itemByTenant} 按Domain={itemByDomain};" +
  317. $"SuppMaster 重复:按租户={suppByTenant} 按Domain={suppByDomain}");
  318. Assert.Equal(0, itemByTenant);
  319. Assert.Equal(0, suppByTenant);
  320. Assert.True(itemByDomain > 0 && suppByDomain > 0, "Domain-only 若已唯一,本条放大论证需重做");
  321. }
  322. /// <summary>
  323. /// §三 dry-run 固化:013 的 Domain 条件会单向清零 UAT。
  324. /// 当前 IsTag=1 全库 0,故放宽 IsTag 做候选集模拟(不造数据)。
  325. /// </summary>
  326. [Fact]
  327. public async Task Data_F013_DomainCondition_WipesOutUatOnly()
  328. {
  329. if (!ItEnabled) return;
  330. var db = BuildAidopdev();
  331. Assert.NotNull(db);
  332. async Task<int> Cnt(long t, bool withDomain) => await db!.Ado.GetIntAsync(
  333. withDomain
  334. ? "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T AND Domain='8010'"
  335. : "SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
  336. new List<SugarParameter> { new("@T", t) });
  337. var uatOnly = await Cnt(TenantUat, false);
  338. var uatWithDomain = await Cnt(TenantUat, true);
  339. var aidopOnly = await Cnt(TenantAidop, false);
  340. var aidopWithDomain = await Cnt(TenantAidop, true);
  341. _out.WriteLine($"UAT tenant-only={uatOnly} / tenant+Domain={uatWithDomain};" +
  342. $"797 tenant-only={aidopOnly} / tenant+Domain={aidopWithDomain}");
  343. Assert.True(uatOnly > 0);
  344. Assert.Equal(0, uatWithDomain); // Domain 把 UAT 自己的数据整体清零
  345. Assert.Equal(aidopOnly, aidopWithDomain); // 对 797 无影响 —— 单向误杀
  346. }
  347. /// <summary>
  348. /// §六/§七 JOIN 基数:租户内 ItemNum 唯一 → 1:1 不放大;
  349. /// 且按租户关联能取到的物料主数据严格多于按 Domain(今日 104 → 152)。
  350. /// </summary>
  351. [Fact]
  352. public async Task Data_F013_ItemMasterJoin_OneToOne_AndMoreComplete()
  353. {
  354. if (!ItEnabled) return;
  355. var db = BuildAidopdev();
  356. Assert.NotNull(db);
  357. var dupByTenant = await db!.Ado.GetIntAsync(
  358. "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
  359. var dupByDomain = await db.Ado.GetIntAsync(
  360. "SELECT COUNT(1) FROM (SELECT Domain,ItemNum FROM ItemMaster GROUP BY Domain,ItemNum HAVING COUNT(*)>1) x");
  361. var baseRows = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
  362. var newJoinRows = await db.Ado.GetIntAsync(
  363. "SELECT COUNT(1) FROM MissedPrint m LEFT JOIN ItemMaster i ON i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum");
  364. var newWorst = await db.Ado.GetIntAsync(
  365. """
  366. SELECT IFNULL(MAX(c),0) FROM (
  367. SELECT m.RecID, COUNT(i.ItemNum) c FROM MissedPrint m
  368. LEFT JOIN ItemMaster i ON i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum
  369. GROUP BY m.RecID) x
  370. """);
  371. var oldMatched = await db.Ado.GetIntAsync(
  372. "SELECT COUNT(1) FROM MissedPrint m JOIN ItemMaster i ON m.Domain=i.Domain AND m.ItemNum=i.ItemNum");
  373. var newMatched = await db.Ado.GetIntAsync(
  374. "SELECT COUNT(1) FROM MissedPrint m JOIN ItemMaster i ON i.tenant_id=m.tenant_id AND i.ItemNum=m.ItemNum");
  375. _out.WriteLine($"重复组:按租户={dupByTenant} 按Domain={dupByDomain};" +
  376. $"base={baseRows} newJoin={newJoinRows} 单行最大命中={newWorst};" +
  377. $"能取到物料名:old={oldMatched} new={newMatched}");
  378. Assert.Equal(0, dupByTenant); // 租户内唯一 → 可直接收口
  379. Assert.True(dupByDomain > 0, "Domain-only 若已唯一,放大论证需重做");
  380. Assert.Equal(baseRows, newJoinRows); // 不放大:SELECT 行数 == COUNT 基集
  381. Assert.True(newWorst <= 1);
  382. Assert.True(newMatched > oldMatched, "按租户关联应当严格覆盖更多行(UAT 原本一条都取不到)");
  383. }
  384. /// <summary>回归:MissedPrint 租户归属未被本批破坏。</summary>
  385. [Fact]
  386. public async Task Data_Regression_TenantSplitUnchanged()
  387. {
  388. if (!ItEnabled) return;
  389. var db = BuildAidopdev();
  390. Assert.NotNull(db);
  391. var total = await db!.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint");
  392. var nullTenant = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id IS NULL");
  393. var uat = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
  394. new List<SugarParameter> { new("@T", TenantUat) });
  395. var aidop = await db.Ado.GetIntAsync("SELECT COUNT(1) FROM MissedPrint WHERE tenant_id=@T",
  396. new List<SugarParameter> { new("@T", TenantAidop) });
  397. _out.WriteLine($"total={total} null={nullTenant} UAT={uat} 797={aidop}");
  398. Assert.Equal(0, nullTenant);
  399. Assert.Equal(total, uat + aidop);
  400. }
  401. }