InventoryReconScopeTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. using System.Text.RegularExpressions;
  2. using Admin.NET.Plugin.AiDOP.DataPlatform;
  3. using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
  4. using SqlSugar;
  5. using Xunit;
  6. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  7. /// <summary>
  8. /// S5 库存日终对账(<see cref="InventoryReconService"/>)口径实库测试。
  9. /// <para>
  10. /// 验证的不变量:LIVE 与 STD 必须是**同一集合口径**——同一租户、同一 Domain、
  11. /// 同一份 <see cref="TenantLocationScopeLoader"/> 库位白名单;否则差集里全是
  12. /// 「本租户根本不该有的库位」,产生系统性假差异。
  13. /// </para>
  14. /// <para>
  15. /// **门禁**:仅当 AIDOP_IT=1 时执行;触达 165 源库的用例额外要求 AIDOP_IT_165=1。
  16. /// 全程**只读**:不写 aidopdev、不写 165、不写 ado_inventory_recon_diff、不调用
  17. /// <c>RunDailyReconAsync</c>(它会写差异表)。
  18. /// </para>
  19. /// </summary>
  20. [Trait("Category", "Integration")]
  21. public class InventoryReconScopeTests
  22. {
  23. private const long UatTenant = 838257186181189L;
  24. private const long AidopTenant = 797403760988229L;
  25. private const long DefaultTenant = 1300000000001L;
  26. private const string SourceCode = "DOPDEMORQ_SQLSERVER";
  27. private const string Domain = "8010";
  28. private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  29. private static bool LiveEnabled => Enabled && Environment.GetEnvironmentVariable("AIDOP_IT_165") == "1";
  30. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  31. public InventoryReconScopeTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  32. private static ISqlSugarClient BuildAidopdev()
  33. {
  34. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  35. if (!File.Exists(path)) return null;
  36. var cs = File.ReadLines(path)
  37. .Select(l => l.Trim())
  38. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"") && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  39. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  40. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  41. if (string.IsNullOrEmpty(cs)) return null;
  42. return new SqlSugarScope(new ConnectionConfig
  43. {
  44. ConfigId = "aidopdev-inv-recon-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  45. });
  46. }
  47. /// <summary>对账的租户枚举口径(必须与标准层物化侧一致)。</summary>
  48. private static Task<List<long>> ScopedTenantsAsync(ISqlSugarClient db, string domain) =>
  49. db.Ado.SqlQueryAsync<long>(
  50. """
  51. SELECT DISTINCT lm.tenant_id
  52. FROM LocationMaster lm
  53. JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1
  54. WHERE lm.Domain = @Domain
  55. AND IFNULL(lm.typed,'') <> 'Supp'
  56. AND TRIM(lm.location) <> ''
  57. ORDER BY lm.tenant_id
  58. """,
  59. new List<SugarParameter> { new("@Domain", domain) });
  60. private static async Task<TenantLocationScope> ScopeAsync(ISqlSugarClient db, long tenantId, string domain) =>
  61. TenantLocationScope.FromWhitelist(await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain));
  62. private static async Task<HashSet<string>> SuppLocationsAsync(ISqlSugarClient db, long tenantId, string domain) =>
  63. (await db.Ado.SqlQueryAsync<string>(
  64. "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
  65. new List<SugarParameter> { new("@T", tenantId), new("@D", domain) }))
  66. .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
  67. // ===== Case 1:对账覆盖面 = 标准层物化覆盖面 =====
  68. /// <summary>
  69. /// 对账必须逐「业务租户」做,而不是拿 <c>SourceDomainTenantResolver</c> 解析出的
  70. /// 「源归属租户」当唯一口径——后者只决定 stg 落地区,不代表业务归属。
  71. /// </summary>
  72. [Fact]
  73. public async Task ReconTenantScope_CoversEveryMaterializedTenant_NotOnlySourceOwner()
  74. {
  75. if (!Enabled) return;
  76. var db = BuildAidopdev();
  77. Assert.NotNull(db);
  78. var tenants = await ScopedTenantsAsync(db, Domain);
  79. _out.WriteLine($"domain={Domain} 参与对账的租户={string.Join(",", tenants)}");
  80. Assert.Contains(AidopTenant, tenants);
  81. Assert.Contains(UatTenant, tenants); // 源归属租户之外的租户也必须被对账
  82. Assert.DoesNotContain(DefaultTenant, tenants); // 无任何合法库位 → 不得进入对账
  83. foreach (var t in tenants)
  84. Assert.False((await ScopeAsync(db, t, Domain)).IsEmpty, $"tenant={t} 白名单为空却被枚举");
  85. }
  86. // ===== Case 2:旧口径(全 Domain LIVE vs 单租户 STD)的假差异量化 =====
  87. /// <summary>
  88. /// 用 <c>mdp_stg_inventory</c>(贴源层,按 Domain 全量落地,未按租户收窄)作为 LIVE 的等价代理,
  89. /// 量化「LIVE 不收窄库位」会给每个租户造成多少条 <c>qty_std=0</c> 的假「STD 缺失」差异。
  90. /// 断言 > 0,即缺陷客观存在;修复后这些键在两侧都被同一白名单排除,恒不入差异表。
  91. /// </summary>
  92. [Theory]
  93. [InlineData(AidopTenant)]
  94. [InlineData(UatTenant)]
  95. public async Task OldScope_UnscopedLive_ProducesFalseMissingDiffs_NewScopeProducesNone(long tenantId)
  96. {
  97. if (!Enabled) return;
  98. var db = BuildAidopdev();
  99. Assert.NotNull(db);
  100. var scope = await ScopeAsync(db, tenantId, Domain);
  101. Assert.False(scope.IsEmpty);
  102. var liveProxyKeys = await LiveProxyKeysAsync(db);
  103. Assert.NotEmpty(liveProxyKeys);
  104. var outOfScope = liveProxyKeys.Where(k => !scope.Contains(k.Location)).ToList();
  105. var inScope = liveProxyKeys.Count - outOfScope.Count;
  106. _out.WriteLine(
  107. $"tenant={tenantId} allowedLoc={scope.Count} LIVE代理键={liveProxyKeys.Count} " +
  108. $"在范围内={inScope} 旧口径假差异={outOfScope.Count} " +
  109. $"越界库位={string.Join(",", outOfScope.Select(x => x.Location).Distinct().OrderBy(x => x).Take(20))}");
  110. // 旧口径:这些键 LIVE 有、STD 按租户白名单物化后永远没有 → 落成 qty_std=0 的假差异
  111. Assert.True(outOfScope.Count > 0,
  112. "未发现越界键:本用例的前提(LIVE 全 Domain 大于租户库位范围)不再成立,需重新评估对账口径");
  113. // 新口径:两侧同白名单收窄后仍有可比较的键,说明收窄没有把对账做空
  114. Assert.True(inScope > 0, "收窄后无任何可对账的键,白名单或 Domain 口径有误");
  115. // 越界库位一律不属于该租户:其在 LocationMaster 里要么是 Supp,要么根本不存在
  116. var owned = await db.Ado.SqlQueryAsync<string>(
  117. "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND TRIM(Location)<>''",
  118. new List<SugarParameter> { new("@T", tenantId), new("@D", Domain) });
  119. var ownedSet = owned.Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
  120. var suppSet = await SuppLocationsAsync(db, tenantId, Domain);
  121. Assert.All(outOfScope.Select(x => x.Location).Distinct(StringComparer.OrdinalIgnoreCase),
  122. loc => Assert.True(!ownedSet.Contains(loc?.Trim() ?? "") || suppSet.Contains(loc?.Trim() ?? ""),
  123. $"越界库位 {loc} 既非 Supp 又属于本租户,说明白名单口径可疑"));
  124. }
  125. /// <summary>
  126. /// AIDOP 租户越界的那部分键,必须全部落在它自己 LocationMaster 的 <c>Typed='Supp'</c> 库位上——
  127. /// 即「供应商/寄存库存」,本就不属于 S5 自有库存,两侧同时排除才是正确口径。
  128. /// </summary>
  129. [Fact]
  130. public async Task AidopTenant_OutOfScopeKeys_AreSupplierOwnedLocations_NotOwnStock()
  131. {
  132. if (!Enabled) return;
  133. var db = BuildAidopdev();
  134. Assert.NotNull(db);
  135. var scope = await ScopeAsync(db, AidopTenant, Domain);
  136. var supp = await SuppLocationsAsync(db, AidopTenant, Domain);
  137. var liveProxyKeys = await LiveProxyKeysAsync(db);
  138. var outOfScope = liveProxyKeys.Where(k => !scope.Contains(k.Location)).ToList();
  139. var explainedBySupp = outOfScope.Count(k => supp.Contains(k.Location?.Trim() ?? ""));
  140. var notExplainedBySupp = outOfScope.Where(k => !supp.Contains(k.Location?.Trim() ?? ""))
  141. .Select(k => k.Location).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
  142. _out.WriteLine(
  143. $"AIDOP 越界键={outOfScope.Count} 其中 Supp 库位可解释={explainedBySupp} " +
  144. $"无法解释的库位={string.Join(",", notExplainedBySupp)}");
  145. Assert.Empty(notExplainedBySupp);
  146. }
  147. // ===== Case 3:新口径下 STD 侧查询恒在白名单内(并挡住历史脏快照)=====
  148. [Theory]
  149. [InlineData(AidopTenant)]
  150. [InlineData(UatTenant)]
  151. public async Task NewScope_StdSideQuery_NeverReturnsOutOfScopeOrSuppLocation(long tenantId)
  152. {
  153. if (!Enabled) return;
  154. var db = BuildAidopdev();
  155. Assert.NotNull(db);
  156. var scope = await ScopeAsync(db, tenantId, Domain);
  157. var supp = await SuppLocationsAsync(db, tenantId, Domain);
  158. var (clause, locPars) = scope.BuildInClause("location", "loc");
  159. var pars = new List<SugarParameter>
  160. {
  161. new("@TenantId", tenantId), new("@Domain", Domain), new("@SourceSystem", SourceCode)
  162. };
  163. pars.AddRange(locPars);
  164. var scoped = await db.Ado.SqlQueryAsync<string>(
  165. $"""
  166. SELECT DISTINCT location FROM mdp_std_inventory
  167. WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem
  168. AND {clause}
  169. """, pars);
  170. var unscoped = await db.Ado.SqlQueryAsync<string>(
  171. """
  172. SELECT DISTINCT location FROM mdp_std_inventory
  173. WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem
  174. """,
  175. new List<SugarParameter>
  176. {
  177. new("@TenantId", tenantId), new("@Domain", Domain), new("@SourceSystem", SourceCode)
  178. });
  179. _out.WriteLine($"tenant={tenantId} STD 库位(收窄后)={scoped.Count} STD 库位(不收窄)={unscoped.Count} " +
  180. $"被挡住的历史脏库位={string.Join(",", unscoped.Except(scoped, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Take(20))}");
  181. Assert.All(scoped, loc => Assert.True(scope.Contains(loc), $"STD 侧越界库位:{loc}"));
  182. Assert.All(scoped, loc => Assert.False(supp.Contains(loc?.Trim() ?? ""), $"STD 侧出现 Supp 库位:{loc}"));
  183. Assert.True(scoped.Count <= unscoped.Count);
  184. }
  185. // ===== Case 4:历史差异表证据——旧口径写出的差异确为假差异 =====
  186. [Fact]
  187. public async Task HistoricalDiffRows_WereWrittenAgainstOutOfScopeLocations()
  188. {
  189. if (!Enabled) return;
  190. var db = BuildAidopdev();
  191. Assert.NotNull(db);
  192. var rows = await db.Ado.SqlQueryAsync<DiffStatRow>(
  193. """
  194. SELECT d.tenant_id AS TenantId, COUNT(*) AS Total,
  195. SUM(CASE WHEN d.qty_std=0 AND d.qty_live<>0 THEN 1 ELSE 0 END) AS StdMissing,
  196. SUM(CASE WHEN EXISTS(
  197. SELECT 1 FROM LocationMaster l
  198. WHERE l.tenant_id=d.tenant_id AND l.Domain=d.domain
  199. AND IFNULL(l.Typed,'')<>'Supp' AND l.Location=d.location)
  200. THEN 0 ELSE 1 END) AS OutOfScope
  201. FROM ado_inventory_recon_diff d
  202. GROUP BY d.tenant_id
  203. """);
  204. if (rows.Count == 0)
  205. {
  206. _out.WriteLine("ado_inventory_recon_diff 为空,跳过历史证据断言(本用例只读,不制造数据)");
  207. return;
  208. }
  209. foreach (var r in rows)
  210. _out.WriteLine($"tenant={r.TenantId} 差异行={r.Total} 其中 STD缺失型={r.StdMissing} 越界库位={r.OutOfScope}");
  211. // 历史差异若几乎全部落在「该租户根本没有的库位」上,即为口径错误产生的假差异
  212. var total = rows.Sum(x => x.Total);
  213. var outOfScope = rows.Sum(x => x.OutOfScope);
  214. _out.WriteLine($"合计 差异行={total} 越界占比={(total == 0m ? 0m : outOfScope * 100m / total):F1}%");
  215. Assert.True(outOfScope <= total);
  216. }
  217. // ===== Case 5:165 侧实测(需 AIDOP_IT_165=1)=====
  218. /// <summary>
  219. /// 直接对 165 跑「收窄 / 不收窄」两版 LIVE 查询,实测假差异规模。
  220. /// 只发 SELECT,且库位值全部参数化(复用 <see cref="TenantLocationScope.BuildInClause"/>)。
  221. /// </summary>
  222. [Theory]
  223. [InlineData(AidopTenant)]
  224. [InlineData(UatTenant)]
  225. public async Task Live165_ScopedQuery_ReturnsOnlyWhitelistedLocations(long tenantId)
  226. {
  227. if (!LiveEnabled)
  228. {
  229. _out.WriteLine("AIDOP_IT_165 != 1,跳过 165 实测用例");
  230. return;
  231. }
  232. var db = BuildAidopdev();
  233. Assert.NotNull(db);
  234. var scope = await ScopeAsync(db, tenantId, Domain);
  235. Assert.False(scope.IsEmpty);
  236. var remote = await new MdpSourceScopeFactory(db).GetScopeAsync(SourceCode);
  237. var (clause, locPars) = scope.BuildInClause("Location", "loc");
  238. var pars = new List<SugarParameter> { new("@Domain", Domain) };
  239. pars.AddRange(locPars);
  240. var scopedLocs = await remote.Ado.SqlQueryAsync<string>(
  241. $"""
  242. SELECT DISTINCT Location FROM LocationDetail
  243. WHERE Domain=@Domain AND {clause}
  244. AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0
  245. """, pars);
  246. var allLocs = await remote.Ado.SqlQueryAsync<string>(
  247. """
  248. SELECT DISTINCT Location FROM LocationDetail
  249. WHERE Domain=@Domain
  250. AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0
  251. """,
  252. new List<SugarParameter> { new("@Domain", Domain) });
  253. var falseDiffLocs = allLocs.Except(scopedLocs, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList();
  254. _out.WriteLine($"tenant={tenantId} 165 全 Domain 库位={allLocs.Count} 收窄后={scopedLocs.Count} " +
  255. $"旧口径会产生假差异的库位={falseDiffLocs.Count}:{string.Join(",", falseDiffLocs.Take(30))}");
  256. Assert.All(scopedLocs, loc => Assert.True(scope.Contains(loc), $"LIVE 侧越界库位:{loc}"));
  257. Assert.True(scopedLocs.Count <= allLocs.Count);
  258. }
  259. // ===== 辅助:LIVE 等价代理(贴源层按 Domain 全量落地,未按租户收窄)=====
  260. private static async Task<List<LiveKeyRow>> LiveProxyKeysAsync(ISqlSugarClient db) =>
  261. await db.Ado.SqlQueryAsync<LiveKeyRow>(
  262. """
  263. SELECT DISTINCT
  264. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Location')) AS Location,
  265. JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum')) AS ItemNum,
  266. IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LotSerial')),'') AS LotSerial
  267. FROM mdp_stg_inventory s
  268. WHERE s.source_table='LocationDetail'
  269. AND s.source_system=@SourceSystem
  270. AND JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain'))=@Domain
  271. AND (IFNULL(CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.AvailStatusQty')),'null'),'') AS DECIMAL(18,5)),0)
  272. +IFNULL(CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Assay')),'null'),'') AS DECIMAL(18,5)),0)
  273. +IFNULL(CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.FreezeQty')),'null'),'') AS DECIMAL(18,5)),0)) <> 0
  274. """,
  275. new List<SugarParameter> { new("@SourceSystem", SourceCode), new("@Domain", Domain) });
  276. private sealed class LiveKeyRow
  277. {
  278. public string Location { get; set; }
  279. public string ItemNum { get; set; }
  280. public string LotSerial { get; set; }
  281. }
  282. private sealed class DiffStatRow
  283. {
  284. public long TenantId { get; set; }
  285. // COUNT/SUM 在 MySQL 侧分别是 BIGINT / DECIMAL,统一用 decimal 承接避免映射转换异常
  286. public decimal Total { get; set; }
  287. public decimal StdMissing { get; set; }
  288. public decimal OutOfScope { get; set; }
  289. }
  290. }