using System.Text.RegularExpressions; using Admin.NET.Plugin.AiDOP.DataPlatform; using Admin.NET.Plugin.AiDOP.MaterialWarehouse; using SqlSugar; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// S5 库存日终对账()口径实库测试。 /// /// 验证的不变量:LIVE 与 STD 必须是**同一集合口径**——同一租户、同一 Domain、 /// 同一份 库位白名单;否则差集里全是 /// 「本租户根本不该有的库位」,产生系统性假差异。 /// /// /// **门禁**:仅当 AIDOP_IT=1 时执行;触达 165 源库的用例额外要求 AIDOP_IT_165=1。 /// 全程**只读**:不写 aidopdev、不写 165、不写 ado_inventory_recon_diff、不调用 /// RunDailyReconAsync(它会写差异表)。 /// /// [Trait("Category", "Integration")] public class InventoryReconScopeTests { private const long UatTenant = 838257186181189L; private const long AidopTenant = 797403760988229L; private const long DefaultTenant = 1300000000001L; private const string SourceCode = "DOPDEMORQ_SQLSERVER"; private const string Domain = "8010"; private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1"; private static bool LiveEnabled => Enabled && Environment.GetEnvironmentVariable("AIDOP_IT_165") == "1"; private readonly Xunit.Abstractions.ITestOutputHelper _out; public InventoryReconScopeTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output; private static ISqlSugarClient BuildAidopdev() { const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json"; if (!File.Exists(path)) return null; 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) .FirstOrDefault(v => !string.IsNullOrEmpty(v)); if (string.IsNullOrEmpty(cs)) return null; return new SqlSugarScope(new ConnectionConfig { ConfigId = "aidopdev-inv-recon-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true, }); } /// 对账的租户枚举口径(必须与标准层物化侧一致)。 private static Task> ScopedTenantsAsync(ISqlSugarClient db, string domain) => db.Ado.SqlQueryAsync( """ SELECT DISTINCT lm.tenant_id FROM LocationMaster lm JOIN SysTenant t ON t.Id = lm.tenant_id AND t.Status = 1 WHERE lm.Domain = @Domain AND IFNULL(lm.typed,'') <> 'Supp' AND TRIM(lm.location) <> '' ORDER BY lm.tenant_id """, new List { new("@Domain", domain) }); private static async Task ScopeAsync(ISqlSugarClient db, long tenantId, string domain) => TenantLocationScope.FromWhitelist(await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain)); private static async Task> SuppLocationsAsync(ISqlSugarClient db, long tenantId, string domain) => (await db.Ado.SqlQueryAsync( "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''", new List { new("@T", tenantId), new("@D", domain) })) .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); // ===== Case 1:对账覆盖面 = 标准层物化覆盖面 ===== /// /// 对账必须逐「业务租户」做,而不是拿 SourceDomainTenantResolver 解析出的 /// 「源归属租户」当唯一口径——后者只决定 stg 落地区,不代表业务归属。 /// [Fact] public async Task ReconTenantScope_CoversEveryMaterializedTenant_NotOnlySourceOwner() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var tenants = await ScopedTenantsAsync(db, Domain); _out.WriteLine($"domain={Domain} 参与对账的租户={string.Join(",", tenants)}"); Assert.Contains(AidopTenant, tenants); Assert.Contains(UatTenant, tenants); // 源归属租户之外的租户也必须被对账 Assert.DoesNotContain(DefaultTenant, tenants); // 无任何合法库位 → 不得进入对账 foreach (var t in tenants) Assert.False((await ScopeAsync(db, t, Domain)).IsEmpty, $"tenant={t} 白名单为空却被枚举"); } // ===== Case 2:旧口径(全 Domain LIVE vs 单租户 STD)的假差异量化 ===== /// /// 用 mdp_stg_inventory(贴源层,按 Domain 全量落地,未按租户收窄)作为 LIVE 的等价代理, /// 量化「LIVE 不收窄库位」会给每个租户造成多少条 qty_std=0 的假「STD 缺失」差异。 /// 断言 > 0,即缺陷客观存在;修复后这些键在两侧都被同一白名单排除,恒不入差异表。 /// [Theory] [InlineData(AidopTenant)] [InlineData(UatTenant)] public async Task OldScope_UnscopedLive_ProducesFalseMissingDiffs_NewScopeProducesNone(long tenantId) { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var scope = await ScopeAsync(db, tenantId, Domain); Assert.False(scope.IsEmpty); var liveProxyKeys = await LiveProxyKeysAsync(db); Assert.NotEmpty(liveProxyKeys); var outOfScope = liveProxyKeys.Where(k => !scope.Contains(k.Location)).ToList(); var inScope = liveProxyKeys.Count - outOfScope.Count; _out.WriteLine( $"tenant={tenantId} allowedLoc={scope.Count} LIVE代理键={liveProxyKeys.Count} " + $"在范围内={inScope} 旧口径假差异={outOfScope.Count} " + $"越界库位={string.Join(",", outOfScope.Select(x => x.Location).Distinct().OrderBy(x => x).Take(20))}"); // 旧口径:这些键 LIVE 有、STD 按租户白名单物化后永远没有 → 落成 qty_std=0 的假差异 Assert.True(outOfScope.Count > 0, "未发现越界键:本用例的前提(LIVE 全 Domain 大于租户库位范围)不再成立,需重新评估对账口径"); // 新口径:两侧同白名单收窄后仍有可比较的键,说明收窄没有把对账做空 Assert.True(inScope > 0, "收窄后无任何可对账的键,白名单或 Domain 口径有误"); // 越界库位一律不属于该租户:其在 LocationMaster 里要么是 Supp,要么根本不存在 var owned = await db.Ado.SqlQueryAsync( "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND TRIM(Location)<>''", new List { new("@T", tenantId), new("@D", Domain) }); var ownedSet = owned.Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase); var suppSet = await SuppLocationsAsync(db, tenantId, Domain); Assert.All(outOfScope.Select(x => x.Location).Distinct(StringComparer.OrdinalIgnoreCase), loc => Assert.True(!ownedSet.Contains(loc?.Trim() ?? "") || suppSet.Contains(loc?.Trim() ?? ""), $"越界库位 {loc} 既非 Supp 又属于本租户,说明白名单口径可疑")); } /// /// AIDOP 租户越界的那部分键,必须全部落在它自己 LocationMaster 的 Typed='Supp' 库位上—— /// 即「供应商/寄存库存」,本就不属于 S5 自有库存,两侧同时排除才是正确口径。 /// [Fact] public async Task AidopTenant_OutOfScopeKeys_AreSupplierOwnedLocations_NotOwnStock() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var scope = await ScopeAsync(db, AidopTenant, Domain); var supp = await SuppLocationsAsync(db, AidopTenant, Domain); var liveProxyKeys = await LiveProxyKeysAsync(db); var outOfScope = liveProxyKeys.Where(k => !scope.Contains(k.Location)).ToList(); var explainedBySupp = outOfScope.Count(k => supp.Contains(k.Location?.Trim() ?? "")); var notExplainedBySupp = outOfScope.Where(k => !supp.Contains(k.Location?.Trim() ?? "")) .Select(k => k.Location).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); _out.WriteLine( $"AIDOP 越界键={outOfScope.Count} 其中 Supp 库位可解释={explainedBySupp} " + $"无法解释的库位={string.Join(",", notExplainedBySupp)}"); Assert.Empty(notExplainedBySupp); } // ===== Case 3:新口径下 STD 侧查询恒在白名单内(并挡住历史脏快照)===== [Theory] [InlineData(AidopTenant)] [InlineData(UatTenant)] public async Task NewScope_StdSideQuery_NeverReturnsOutOfScopeOrSuppLocation(long tenantId) { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var scope = await ScopeAsync(db, tenantId, Domain); var supp = await SuppLocationsAsync(db, tenantId, Domain); var (clause, locPars) = scope.BuildInClause("location", "loc"); var pars = new List { new("@TenantId", tenantId), new("@Domain", Domain), new("@SourceSystem", SourceCode) }; pars.AddRange(locPars); var scoped = await db.Ado.SqlQueryAsync( $""" SELECT DISTINCT location FROM mdp_std_inventory WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem AND {clause} """, pars); var unscoped = await db.Ado.SqlQueryAsync( """ SELECT DISTINCT location FROM mdp_std_inventory WHERE tenant_id=@TenantId AND domain=@Domain AND source_system=@SourceSystem """, new List { new("@TenantId", tenantId), new("@Domain", Domain), new("@SourceSystem", SourceCode) }); _out.WriteLine($"tenant={tenantId} STD 库位(收窄后)={scoped.Count} STD 库位(不收窄)={unscoped.Count} " + $"被挡住的历史脏库位={string.Join(",", unscoped.Except(scoped, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Take(20))}"); Assert.All(scoped, loc => Assert.True(scope.Contains(loc), $"STD 侧越界库位:{loc}")); Assert.All(scoped, loc => Assert.False(supp.Contains(loc?.Trim() ?? ""), $"STD 侧出现 Supp 库位:{loc}")); Assert.True(scoped.Count <= unscoped.Count); } // ===== Case 4:历史差异表证据——旧口径写出的差异确为假差异 ===== [Fact] public async Task HistoricalDiffRows_WereWrittenAgainstOutOfScopeLocations() { if (!Enabled) return; var db = BuildAidopdev(); Assert.NotNull(db); var rows = await db.Ado.SqlQueryAsync( """ SELECT d.tenant_id AS TenantId, COUNT(*) AS Total, SUM(CASE WHEN d.qty_std=0 AND d.qty_live<>0 THEN 1 ELSE 0 END) AS StdMissing, SUM(CASE WHEN EXISTS( SELECT 1 FROM LocationMaster l WHERE l.tenant_id=d.tenant_id AND l.Domain=d.domain AND IFNULL(l.Typed,'')<>'Supp' AND l.Location=d.location) THEN 0 ELSE 1 END) AS OutOfScope FROM ado_inventory_recon_diff d GROUP BY d.tenant_id """); if (rows.Count == 0) { _out.WriteLine("ado_inventory_recon_diff 为空,跳过历史证据断言(本用例只读,不制造数据)"); return; } foreach (var r in rows) _out.WriteLine($"tenant={r.TenantId} 差异行={r.Total} 其中 STD缺失型={r.StdMissing} 越界库位={r.OutOfScope}"); // 历史差异若几乎全部落在「该租户根本没有的库位」上,即为口径错误产生的假差异 var total = rows.Sum(x => x.Total); var outOfScope = rows.Sum(x => x.OutOfScope); _out.WriteLine($"合计 差异行={total} 越界占比={(total == 0m ? 0m : outOfScope * 100m / total):F1}%"); Assert.True(outOfScope <= total); } // ===== Case 5:165 侧实测(需 AIDOP_IT_165=1)===== /// /// 直接对 165 跑「收窄 / 不收窄」两版 LIVE 查询,实测假差异规模。 /// 只发 SELECT,且库位值全部参数化(复用 )。 /// [Theory] [InlineData(AidopTenant)] [InlineData(UatTenant)] public async Task Live165_ScopedQuery_ReturnsOnlyWhitelistedLocations(long tenantId) { if (!LiveEnabled) { _out.WriteLine("AIDOP_IT_165 != 1,跳过 165 实测用例"); return; } var db = BuildAidopdev(); Assert.NotNull(db); var scope = await ScopeAsync(db, tenantId, Domain); Assert.False(scope.IsEmpty); var remote = await new MdpSourceScopeFactory(db).GetScopeAsync(SourceCode); var (clause, locPars) = scope.BuildInClause("Location", "loc"); var pars = new List { new("@Domain", Domain) }; pars.AddRange(locPars); var scopedLocs = await remote.Ado.SqlQueryAsync( $""" SELECT DISTINCT Location FROM LocationDetail WHERE Domain=@Domain AND {clause} AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0 """, pars); var allLocs = await remote.Ado.SqlQueryAsync( """ SELECT DISTINCT Location FROM LocationDetail WHERE Domain=@Domain AND (ISNULL(AvailStatusQty,0)+ISNULL(Assay,0)+ISNULL(FreezeQty,0)) <> 0 """, new List { new("@Domain", Domain) }); var falseDiffLocs = allLocs.Except(scopedLocs, StringComparer.OrdinalIgnoreCase).OrderBy(x => x).ToList(); _out.WriteLine($"tenant={tenantId} 165 全 Domain 库位={allLocs.Count} 收窄后={scopedLocs.Count} " + $"旧口径会产生假差异的库位={falseDiffLocs.Count}:{string.Join(",", falseDiffLocs.Take(30))}"); Assert.All(scopedLocs, loc => Assert.True(scope.Contains(loc), $"LIVE 侧越界库位:{loc}")); Assert.True(scopedLocs.Count <= allLocs.Count); } // ===== 辅助:LIVE 等价代理(贴源层按 Domain 全量落地,未按租户收窄)===== private static async Task> LiveProxyKeysAsync(ISqlSugarClient db) => await db.Ado.SqlQueryAsync( """ SELECT DISTINCT JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Location')) AS Location, JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.ItemNum')) AS ItemNum, IFNULL(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.LotSerial')),'') AS LotSerial FROM mdp_stg_inventory s WHERE s.source_table='LocationDetail' AND s.source_system=@SourceSystem AND JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Domain'))=@Domain AND (IFNULL(CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.AvailStatusQty')),'null'),'') AS DECIMAL(18,5)),0) +IFNULL(CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.Assay')),'null'),'') AS DECIMAL(18,5)),0) +IFNULL(CAST(NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(s.raw_data,'$.FreezeQty')),'null'),'') AS DECIMAL(18,5)),0)) <> 0 """, new List { new("@SourceSystem", SourceCode), new("@Domain", Domain) }); private sealed class LiveKeyRow { public string Location { get; set; } public string ItemNum { get; set; } public string LotSerial { get; set; } } private sealed class DiffStatRow { public long TenantId { get; set; } // COUNT/SUM 在 MySQL 侧分别是 BIGINT / DECIMAL,统一用 decimal 承接避免映射转换异常 public decimal Total { get; set; } public decimal StdMissing { get; set; } public decimal OutOfScope { get; set; } } }