|
|
@@ -0,0 +1,272 @@
|
|
|
+using System.Text.RegularExpressions;
|
|
|
+using Admin.NET.Plugin.AiDOP.DataPlatform;
|
|
|
+using Admin.NET.Plugin.AiDOP.Infrastructure;
|
|
|
+using Admin.NET.Plugin.AiDOP.MaterialWarehouse;
|
|
|
+using Microsoft.Extensions.Logging.Abstractions;
|
|
|
+using Microsoft.Extensions.Options;
|
|
|
+using SqlSugar;
|
|
|
+using Xunit;
|
|
|
+
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
|
|
|
+
|
|
|
+/// <summary>
|
|
|
+/// S5 库存查询页面收口:分页稳定性 · 库位下拉口径 · 数据来源三态语义。
|
|
|
+/// **门禁**:仅当环境变量 AIDOP_IT=1 时执行。全程只读。
|
|
|
+/// </summary>
|
|
|
+[Trait("Category", "Integration")]
|
|
|
+public class StockQueryUiAndPaginationTests
|
|
|
+{
|
|
|
+ private const long UatTenant = 838257186181189L;
|
|
|
+ private const long AidopTenant = 797403760988229L;
|
|
|
+ private const string SourceCode = "DOPDEMORQ_SQLSERVER";
|
|
|
+
|
|
|
+ private static bool Enabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
|
|
|
+
|
|
|
+ private readonly Xunit.Abstractions.ITestOutputHelper _out;
|
|
|
+ public StockQueryUiAndPaginationTests(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-stockquery-ui-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private static AidopInventoryOptions Opt(string sourceCode = SourceCode) => new() { SourceCode = sourceCode };
|
|
|
+ private static SourceDomainTenantResolver Resolver(ISqlSugarClient db) => new(db);
|
|
|
+ private static StdInventorySource Std(ISqlSugarClient db) => new(db, Options.Create(Opt()));
|
|
|
+ private static Live165InventorySource Live(ISqlSugarClient db, string sourceCode = SourceCode) =>
|
|
|
+ new(new MdpSourceScopeFactory(db), db, Options.Create(Opt(sourceCode)));
|
|
|
+
|
|
|
+ private static InventoryBalanceQuery Q(long tenantId, string domain, int page, int pageSize,
|
|
|
+ string location = null, string materialCode = null) => new()
|
|
|
+ {
|
|
|
+ TenantId = tenantId, Domain = domain, Location = location, MaterialCode = materialCode,
|
|
|
+ Page = page, PageSize = pageSize
|
|
|
+ };
|
|
|
+
|
|
|
+ private static string RowKey(InventoryBalanceRow r) =>
|
|
|
+ $"{r.Location}|{r.MaterialCode}|{r.BatchNo}|{r.UnrestrictedStock}|{r.QcStock}|{r.FrozenStock}|{r.QtyOnHand}";
|
|
|
+
|
|
|
+ /// <summary>按 pageSize 抓完整分页,返回 (total, 抓到的全部行)。</summary>
|
|
|
+ private static async Task<(int Total, List<InventoryBalanceRow> Rows)> FetchAllAsync(
|
|
|
+ StdInventorySource std, InventoryBalanceQuery seed, int pageSize)
|
|
|
+ {
|
|
|
+ var rows = new List<InventoryBalanceRow>();
|
|
|
+ var total = 0;
|
|
|
+ for (var page = 1; page <= 500; page++)
|
|
|
+ {
|
|
|
+ var p = await std.QueryPageAsync(Q(seed.TenantId, seed.Domain, page, pageSize, seed.Location, seed.MaterialCode));
|
|
|
+ total = p.Total;
|
|
|
+ rows.AddRange(p.List);
|
|
|
+ if (page * pageSize >= p.Total) break;
|
|
|
+ }
|
|
|
+ return (total, rows);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 任务 2 · Case 1 / 2:抓取行数 == total,且去重后 == total =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case1_2_StdPagination_FetchedAndUniqueEqualTotal()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+
|
|
|
+ var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50);
|
|
|
+ var unique = rows.Select(RowKey).Distinct().Count();
|
|
|
+
|
|
|
+ _out.WriteLine($"pageSize=50 total={total} fetched={rows.Count} unique={unique}");
|
|
|
+ Assert.True(total > 0, "STD 应有数据,否则本用例无意义");
|
|
|
+ Assert.Equal(total, rows.Count);
|
|
|
+ Assert.Equal(total, unique);
|
|
|
+ }
|
|
|
+
|
|
|
+ [Theory]
|
|
|
+ [InlineData(10)]
|
|
|
+ [InlineData(50)]
|
|
|
+ [InlineData(200)]
|
|
|
+ public async Task Case1_StdPagination_StableAcrossPageSizes(int pageSize)
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+
|
|
|
+ var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, pageSize), pageSize);
|
|
|
+ _out.WriteLine($"pageSize={pageSize} total={total} fetched={rows.Count}");
|
|
|
+ Assert.Equal(total, rows.Count);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 任务 2 · Case 3:重复 3 次结果集合一致 =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case3_StdPagination_RepeatableAcrossThreeRuns()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+
|
|
|
+ List<string> first = null;
|
|
|
+ for (var run = 1; run <= 3; run++)
|
|
|
+ {
|
|
|
+ var (_, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 50), 50);
|
|
|
+ var keys = rows.Select(RowKey).OrderBy(x => x, StringComparer.Ordinal).ToList();
|
|
|
+ if (first is null) first = keys;
|
|
|
+ else Assert.Equal(first, keys);
|
|
|
+ }
|
|
|
+ _out.WriteLine($"3 轮抓取结果集合一致,rows={first!.Count}");
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 任务 2 · Case 4:合法 Location 筛选下同样稳定 =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task Case4_StdPagination_StableUnderLocationFilter()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+ var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
|
|
|
+
|
|
|
+ var hit = 0;
|
|
|
+ foreach (var loc in allowed)
|
|
|
+ {
|
|
|
+ var (total, rows) = await FetchAllAsync(Std(db), Q(UatTenant, domain, 1, 10, location: loc), 10);
|
|
|
+ Assert.Equal(total, rows.Count);
|
|
|
+ Assert.Equal(total, rows.Select(RowKey).Distinct().Count());
|
|
|
+ if (total > 0) hit++;
|
|
|
+ }
|
|
|
+ _out.WriteLine($"按库位逐个校验分页稳定,有数据的库位数={hit}/{allowed.Count}");
|
|
|
+ Assert.True(hit > 0);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 任务 3:库位下拉必须与查询层同口径(排除 Supp、去重)=====
|
|
|
+
|
|
|
+ [Theory]
|
|
|
+ [InlineData(UatTenant)]
|
|
|
+ [InlineData(AidopTenant)]
|
|
|
+ public async Task LocationOptions_MatchesQueryScope_AndExcludesSupp(long tenantId)
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, tenantId);
|
|
|
+
|
|
|
+ var options = await TenantLocationScopeLoader.LoadOptionsAsync(db, tenantId, domain);
|
|
|
+ var scope = await TenantLocationScopeLoader.LoadAsync(db, tenantId, domain);
|
|
|
+
|
|
|
+ var supp = (await db.Ado.SqlQueryAsync<string>(
|
|
|
+ "SELECT DISTINCT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>''",
|
|
|
+ new List<SugarParameter> { new("@T", tenantId), new("@D", domain) }))
|
|
|
+ .Select(x => x.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
+
|
|
|
+ var optionVals = options.Select(x => x.Val?.Trim()).Where(x => !string.IsNullOrEmpty(x)).ToList();
|
|
|
+
|
|
|
+ _out.WriteLine($"tenant={tenantId} domain={domain} options={optionVals.Count} scope={scope.Count} suppInMaster={supp.Count}");
|
|
|
+
|
|
|
+ // ① 下拉集合 == 查询层白名单集合
|
|
|
+ Assert.Equal(
|
|
|
+ scope.Select(x => x.Trim()).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(),
|
|
|
+ optionVals.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList());
|
|
|
+ // ② 无 Supp
|
|
|
+ Assert.All(optionVals, v => Assert.False(supp.Contains(v!), $"Supp 库位 {v} 不得出现在下拉"));
|
|
|
+ // ③ 无重复
|
|
|
+ Assert.Equal(optionVals.Count, optionVals.Distinct(StringComparer.OrdinalIgnoreCase).Count());
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task LocationOptions_SuppLocationExcluded_NormalLocationPresent()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, AidopTenant);
|
|
|
+
|
|
|
+ var supp = (await db.Ado.SqlQueryAsync<string>(
|
|
|
+ "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND Typed='Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
|
|
|
+ new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
|
|
|
+ var normal = (await db.Ado.SqlQueryAsync<string>(
|
|
|
+ "SELECT Location FROM LocationMaster WHERE tenant_id=@T AND Domain=@D AND IFNULL(Typed,'')<>'Supp' AND TRIM(Location)<>'' ORDER BY Location LIMIT 1",
|
|
|
+ new List<SugarParameter> { new("@T", AidopTenant), new("@D", domain) })).Single();
|
|
|
+
|
|
|
+ var vals = (await TenantLocationScopeLoader.LoadOptionsAsync(db, AidopTenant, domain))
|
|
|
+ .Select(x => x.Val?.Trim()).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
|
+
|
|
|
+ _out.WriteLine($"Supp 样例={supp}(应缺席)· 正常样例={normal}(应存在)");
|
|
|
+ Assert.DoesNotContain(supp.Trim(), vals, StringComparer.OrdinalIgnoreCase);
|
|
|
+ Assert.Contains(normal.Trim(), vals, StringComparer.OrdinalIgnoreCase);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ===== 任务 1:数据来源三态语义 =====
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task SourceState_NormalStd_IsNotMarkedAsLiveFallback()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+
|
|
|
+ var reader = new InventoryBalanceReader(
|
|
|
+ Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
|
|
|
+
|
|
|
+ // 无选择性筛选 → 按策略走 STD,未尝试实时源 → 不得标记为降级
|
|
|
+ var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10));
|
|
|
+ Assert.Equal(InventoryDataSources.Std, page.DataSource);
|
|
|
+ Assert.False(page.IsRealtime);
|
|
|
+ Assert.False(page.FallbackFromLive);
|
|
|
+ _out.WriteLine($"STD_NORMAL: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}");
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task SourceState_LiveSuccess_IsNotMarkedAsFallback()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+ var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
|
|
|
+
|
|
|
+ var reader = new InventoryBalanceReader(
|
|
|
+ Live(db), Std(db), Resolver(db), Options.Create(Opt()), NullLoggerFactory.Instance);
|
|
|
+
|
|
|
+ var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First()));
|
|
|
+ Assert.Equal(InventoryDataSources.Live165, page.DataSource);
|
|
|
+ Assert.True(page.IsRealtime);
|
|
|
+ Assert.False(page.FallbackFromLive);
|
|
|
+ _out.WriteLine($"LIVE: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive}");
|
|
|
+ }
|
|
|
+
|
|
|
+ [Fact]
|
|
|
+ public async Task SourceState_LiveFailure_IsMarkedAsFallback()
|
|
|
+ {
|
|
|
+ if (!Enabled) return;
|
|
|
+ var db = BuildAidopdev();
|
|
|
+ Assert.NotNull(db);
|
|
|
+ var domain = await Resolver(db).ResolveDomainAsync(SourceCode, UatTenant);
|
|
|
+ var allowed = await TenantLocationScopeLoader.LoadAsync(db, UatTenant, domain);
|
|
|
+
|
|
|
+ // 故障注入:给 LIVE 一个 mdp_source 中不存在的源码 → 必然抛错 → 走 fallback
|
|
|
+ var reader = new InventoryBalanceReader(
|
|
|
+ Live(db, "NO_SUCH_SOURCE_FOR_FAULT_INJECTION"), Std(db), Resolver(db),
|
|
|
+ Options.Create(Opt()), NullLoggerFactory.Instance);
|
|
|
+
|
|
|
+ var page = await reader.QueryPageAsync(Q(UatTenant, domain, 1, 10, location: allowed.First()));
|
|
|
+ Assert.Equal(InventoryDataSources.Std, page.DataSource);
|
|
|
+ Assert.False(page.IsRealtime);
|
|
|
+ Assert.True(page.FallbackFromLive); // 只有这种情况前端才提示「实时库不可用」
|
|
|
+ _out.WriteLine($"STD_FALLBACK: dataSource={page.DataSource} fallbackFromLive={page.FallbackFromLive} total={page.Total}");
|
|
|
+ }
|
|
|
+}
|