using System.Text.RegularExpressions;
using SqlSugar;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
///
/// FUNC-S5-011 进出存流水 · 逐租户物化守卫。
///
/// 源码断言无门禁;数据面断言需 AIDOP_IT=1 且**全程只读**(不写任何表、不触发同步)。
///
///
/// 背景:ado_source_domain_tenant_map 把 DOPDEMORQ_SQLSERVER/8010 登记在 797 名下,
/// 修复前 UpsertInvTransStdAsync 只按这个「源归属租户」写一次标准层,
/// 于是 UAT(838257186181189) 名下恒为 0 —— 而正式贴源层里落在 UAT 18 个合法库位上的流水
/// 实测有 188,419 行。余额腿早已是「逐租户 + 库位投影」,流水腿此前漏了这一步。
///
///
public class InvTransTenantMaterializationTests
{
private const long TenantUat = 838257186181189L;
private const long TenantAidop = 797403760988229L; // 同时也是 DOPDEMORQ_SQLSERVER/8010 的源归属租户
private const string FormalSource = "DOPDEMORQ_SQLSERVER";
private const string FixtureSource = "UAT_GENERATOR";
private static bool ItEnabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
private readonly Xunit.Abstractions.ITestOutputHelper _out;
public InvTransTenantMaterializationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private const string SyncSrc = "MaterialWarehouse/InventoryMdpSyncService.cs";
private const string ReaderSrc = "MaterialWarehouse/StdInventoryTransactionReader.cs";
private static string ReadSource(string relativePath)
{
var full = Path.Combine(PluginRoot, relativePath);
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
private static List CodeLines(string relativePath) =>
ReadSource(relativePath)
.Split('\n')
.Select(l => l.Trim())
.Where(l => !l.StartsWith("///", StringComparison.Ordinal)
&& !l.StartsWith("//", StringComparison.Ordinal)
&& !l.StartsWith("--", StringComparison.Ordinal))
.ToList();
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-invtrans-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
});
}
// ===================== 1/2:逐租户物化 + source≠target =====================
/// 两条腿都必须逐目标租户循环,流水腿不得再只调一次。
[Fact]
public void InvTrans_MustLoopTargetTenants()
{
var src = ReadSource(SyncSrc);
// 旧方法名必须彻底消失(它的签名把 source 当 business tenant)
Assert.DoesNotContain("UpsertInvTransStdAsync", src, StringComparison.Ordinal);
// 1 处定义 + 2 处调用(RunAsync 的 bootstrap/reconcile 与增量分支、transform-std)
var calls = Regex.Matches(src, @"MaterializeInvTransStdAsync\(").Count;
Assert.True(calls >= 3, $"应有 1 处定义 + 至少 2 处调用,实际 {calls}");
// 3 处逐租户循环:余额腿 1 + 流水腿 2(RunAsync、transform-std)
var loops = Regex.Matches(src, @"foreach \(var targetTenantId in targetTenants\)").Count;
Assert.True(loops >= 3, $"逐租户循环应至少 3 处(余额 1 + 流水 2),实际 {loops}");
// 每一处 MaterializeInvTransStdAsync 的调用点之前,最近的循环必须是 targetTenants 循环
foreach (Match m in Regex.Matches(src, @"await MaterializeInvTransStdAsync\("))
{
var before = src[..m.Index];
var lastLoop = before.LastIndexOf("foreach (var targetTenantId in targetTenants)", StringComparison.Ordinal);
Assert.True(lastLoop >= 0, "存在不在逐租户循环内的流水物化调用");
}
}
/// 方法签名必须显式区分源归属租户与业务租户,不能再合并成一个 tenantId。
[Fact]
public void InvTrans_MustDistinguishSourceAndTargetTenant()
{
var src = ReadSource(SyncSrc);
Assert.Contains("long sourceTenantId, long targetTenantId", src, StringComparison.Ordinal);
Assert.Contains("new(\"@SourceTenantId\", sourceTenantId)", src, StringComparison.Ordinal);
Assert.Contains("new(\"@TargetTenantId\", targetTenantId)", src, StringComparison.Ordinal);
// std.tenant_id 必须写目标租户,不得再从 stg 反推
Assert.Contains("var sTenant = \"@TargetTenantId\";", src, StringComparison.Ordinal);
}
/// 目标租户集合必须复用余额腿同一个枚举,不得另写一套。
[Fact]
public void InvTrans_MustReuseScopedTenantEnumeration()
{
var lines = CodeLines(SyncSrc);
var enumerators = lines.Count(l => l.Contains("ListInventoryScopedTenantsAsync(domain", StringComparison.Ordinal));
Assert.True(enumerators >= 2, $"RunAsync 与 transform-std 都应复用该枚举,实际 {enumerators} 处");
Assert.Equal(1, Regex.Matches(ReadSource(SyncSrc), @"private async Task> ListInventoryScopedTenantsAsync").Count);
}
// ===================== 3/4:库位投影 + Supp 排除 =====================
///
/// 库位投影必须只有一个实现口径(余额腿与流水腿共用),只有贴源 JSON 字段名不同。
///
[Fact]
public void LocationScopeJoin_HasSingleImplementation()
{
var src = ReadSource(SyncSrc);
Assert.Equal(1, Regex.Matches(src, @"private static string TenantScopeJoinSql\(").Count);
Assert.Contains("TenantScopeJoinSql(\"Location\")", src, StringComparison.Ordinal);
Assert.Contains("TenantScopeJoinSql(\"Loc\")", src, StringComparison.Ordinal);
// 全文只允许这一处 LocationMaster 投影 JOIN
Assert.Equal(1, Regex.Matches(src, @"INNER JOIN LocationMaster lm").Count);
}
[Fact]
public void LocationScopeJoin_ExcludesSuppAndEmptyLocation()
{
var src = ReadSource(SyncSrc);
var stmt = src[src.IndexOf("INNER JOIN LocationMaster lm", StringComparison.Ordinal)..];
stmt = stmt[..stmt.IndexOf("\"\"\"", StringComparison.Ordinal)];
Assert.Contains("lm.tenant_id = @TargetTenantId", stmt, StringComparison.Ordinal);
Assert.Contains("IFNULL(lm.typed, '') <> 'Supp'", stmt, StringComparison.Ordinal);
Assert.Contains("TRIM(lm.location) <> ''", stmt, StringComparison.Ordinal);
}
/// 流水物化必须用上该投影,且来源侧仍限定源归属租户与正式贴源表。
[Fact]
public void InvTrans_Materialization_AppliesScopeProjection()
{
var src = ReadSource(SyncSrc);
var i = src.IndexOf("FROM mdp_stg_inv_trans s", StringComparison.Ordinal);
Assert.True(i >= 0);
var stmt = src[i..(i + 400)];
Assert.Contains("{TransTenantScopeJoin}", stmt, StringComparison.Ordinal);
Assert.Contains("s.tenant_id=@SourceTenantId", stmt, StringComparison.Ordinal);
Assert.Contains("s.source_table='InvTransHist'", stmt, StringComparison.Ordinal);
}
// ===================== 5:空 scope fail closed =====================
[Fact]
public void EmptyTenantScope_FailsClosed_NoFallbackToSourceOwner()
{
var src = ReadSource(SyncSrc);
Assert.Contains("no scoped tenant", src, StringComparison.Ordinal);
// 绝不能存在「没有目标租户就按源归属租户写一份」的回落分支
Assert.DoesNotContain(CodeLines(SyncSrc),
l => l.Contains("MaterializeInvTransStdAsync(tenantId, tenantId", StringComparison.Ordinal));
}
// ===================== 6/7:FullReplace 只删正式切片,保留 fixture =====================
///
/// FULL Replace 的删除范围必须带 source_system + domain:
/// 只按 tenant_id 删会连带删掉 UAT_GENERATOR fixture。
///
[Fact]
public void FullReplace_OnlyDeletesFormalSlice()
{
var src = ReadSource(SyncSrc);
Assert.Contains("FormalSliceWhere(sourceCode, domain)", src, StringComparison.Ordinal);
Assert.Contains("source_system='{sourceSystem}' AND domain='{domain}'", src, StringComparison.Ordinal);
// 两处 mdp_std_inv_trans 的 FullReplace 都必须传 extraWhere,不得传 null
foreach (Match m in Regex.Matches(src, @"ReplaceAsync\(\s*_db,\s*""mdp_std_inv_trans"",\s*targetTenantId,\s*([^,]+),"))
Assert.Contains("FormalSliceWhere", m.Groups[1].Value, StringComparison.Ordinal);
}
/// 白名单校验:source_system / domain 虽来自配置,仍不得被拼接注入。
[Fact]
public void FormalSliceWhere_ValidatesIdentifiers()
{
var src = ReadSource(SyncSrc);
Assert.Contains("非法 source_system", src, StringComparison.Ordinal);
Assert.Contains("非法 domain", src, StringComparison.Ordinal);
}
// ===================== 9:ItemMaster tenant-aware =====================
[Fact]
public void Reader_ItemMasterJoin_MustBeTenantAware()
{
var src = ReadSource(ReaderSrc);
Assert.Contains("ON i.tenant_id = a.tenant_id AND i.ItemNum = a.item_num", src, StringComparison.Ordinal);
Assert.DoesNotContain(CodeLines(ReaderSrc),
l => l.Contains("ON a.domain = i.Domain AND a.item_num = i.ItemNum", StringComparison.Ordinal));
}
/// ORDER BY 必须有唯一 tiebreaker,否则 OFFSET 分页会跨页重复/漏行。
[Fact]
public void Reader_OrderBy_HasUniqueTiebreaker()
{
var src = ReadSource(ReaderSrc);
Assert.Contains("ORDER BY a.trans_time DESC, CAST(a.src_rec_id AS UNSIGNED) DESC",
src, StringComparison.Ordinal);
}
// ===================== 数据面(AIDOP_IT=1,只读) =====================
/// 8:同一源流水必须能分别物化给两个业务租户 —— 唯一键须以 tenant_id 打头。
[Fact]
public async Task Schema_UniqueKey_AllowsPerTenantMaterialization()
{
if (!ItEnabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var cols = await db!.Ado.SqlQueryAsync(
"""
SELECT COLUMN_NAME FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_inv_trans'
AND INDEX_NAME='uk_std_inv_trans_src' ORDER BY SEQ_IN_INDEX
""");
_out.WriteLine($"uk_std_inv_trans_src = ({string.Join(", ", cols)})");
Assert.Equal(new[] { "tenant_id", "source_system", "src_rec_id" }, cols);
}
/// 10:COUNT 基集与 JOIN 后集合必须同基数(ItemMaster 不放大)。
[Fact]
public async Task Reader_CountAndJoin_SameCardinality()
{
if (!ItEnabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var pars = new List { new("@T", TenantAidop), new("@S", FormalSource) };
var baseRows = await db!.Ado.GetIntAsync(
"SELECT COUNT(1) FROM mdp_std_inv_trans a WHERE a.tenant_id=@T AND a.source_system=@S", pars);
if (baseRows == 0) return; // 该租户尚无正式数据时本用例无意义
var oldJoin = await db.Ado.GetIntAsync(
"""
SELECT COUNT(1) FROM mdp_std_inv_trans a
LEFT JOIN ItemMaster i ON a.domain=i.Domain AND a.item_num=i.ItemNum
WHERE a.tenant_id=@T AND a.source_system=@S
""", pars);
var newJoin = await db.Ado.GetIntAsync(
"""
SELECT COUNT(1) FROM mdp_std_inv_trans a
LEFT JOIN ItemMaster i ON i.tenant_id=a.tenant_id AND i.ItemNum=a.item_num AND i.Domain=a.domain
WHERE a.tenant_id=@T AND a.source_system=@S
""", pars);
_out.WriteLine($"base={baseRows} oldJoin={oldJoin} newJoin={newJoin}");
Assert.True(oldJoin > baseRows, "旧写法应当放大,否则本用例失去意义");
Assert.Equal(baseRows, newJoin);
}
/// ItemMaster 租户内必须唯一,否则加了 tenant_id 也不能断言 1:1。
[Fact]
public async Task ItemMaster_UniqueWithinTenant()
{
if (!ItEnabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var dup = await db!.Ado.GetIntAsync(
"SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
_out.WriteLine($"(tenant_id, ItemNum) 重复组={dup}");
Assert.Equal(0, dup);
}
/// 11:UAT 正式切片必须全部落在其合法库位内、无 Supp、domain 一致。
[Fact]
public async Task Uat_FormalSlice_WithinLocationScope()
{
if (!ItEnabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var pars = new List { new("@T", TenantUat), new("@S", FormalSource) };
var formal = await db!.Ado.GetIntAsync(
"SELECT COUNT(1) FROM mdp_std_inv_trans WHERE tenant_id=@T AND source_system=@S", pars);
_out.WriteLine($"UAT 正式行数={formal}");
if (formal == 0) return; // 尚未物化时跳过(物化后本用例即生效)
var outside = await db.Ado.GetIntAsync(
"""
SELECT COUNT(1) FROM mdp_std_inv_trans a
LEFT JOIN LocationMaster lm
ON lm.tenant_id=a.tenant_id AND lm.Domain=a.domain AND lm.location=a.location
AND IFNULL(lm.typed,'')<>'Supp'
WHERE a.tenant_id=@T AND a.source_system=@S AND lm.location IS NULL
""", pars);
var badDomain = await db.Ado.GetIntAsync(
"SELECT COUNT(1) FROM mdp_std_inv_trans WHERE tenant_id=@T AND source_system=@S AND domain<>'8010'", pars);
_out.WriteLine($"越界库位行={outside} 异常domain行={badDomain}");
Assert.Equal(0, outside);
Assert.Equal(0, badDomain);
}
/// 12:fixture 切片必须原样保留,且与正式切片互不混淆。
[Fact]
public async Task Fixture_Slice_Preserved_AndIsolated()
{
if (!ItEnabled) return;
var db = BuildAidopdev();
Assert.NotNull(db);
var fixture = await db!.Ado.GetIntAsync(
"SELECT COUNT(1) FROM mdp_std_inv_trans WHERE tenant_id=@T AND source_system=@S",
new List { new("@T", TenantUat), new("@S", FixtureSource) });
// fixture 的库位全部在 UAT 合法范围之外 —— 这是它「不是正式数据」的实证
var fixtureOutside = await db.Ado.GetIntAsync(
"""
SELECT COUNT(1) FROM mdp_std_inv_trans a
WHERE a.tenant_id=@T AND a.source_system=@S
AND a.location NOT IN (SELECT location FROM LocationMaster
WHERE tenant_id=@T AND Domain='8010' AND IFNULL(typed,'')<>'Supp')
""",
new List { new("@T", TenantUat), new("@S", FixtureSource) });
_out.WriteLine($"UAT_GENERATOR 行数={fixture},其中越界库位={fixtureOutside}");
Assert.Equal(168, fixture); // 本批不做 fixture 治理,必须原样保留
Assert.Equal(fixture, fixtureOutside); // 全部越界 → 确为 fixture,不可混入正式口径
}
}