InvTransTenantMaterializationTests.cs 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. using System.Text.RegularExpressions;
  2. using SqlSugar;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  5. /// <summary>
  6. /// FUNC-S5-011 进出存流水 · 逐租户物化守卫。
  7. /// <para>
  8. /// 源码断言无门禁;数据面断言需 AIDOP_IT=1 且**全程只读**(不写任何表、不触发同步)。
  9. /// </para>
  10. /// <para>
  11. /// 背景:<c>ado_source_domain_tenant_map</c> 把 DOPDEMORQ_SQLSERVER/8010 登记在 797 名下,
  12. /// 修复前 <c>UpsertInvTransStdAsync</c> 只按这个「源归属租户」写一次标准层,
  13. /// 于是 UAT(838257186181189) 名下恒为 0 —— 而正式贴源层里落在 UAT 18 个合法库位上的流水
  14. /// 实测有 188,419 行。余额腿早已是「逐租户 + 库位投影」,流水腿此前漏了这一步。
  15. /// </para>
  16. /// </summary>
  17. public class InvTransTenantMaterializationTests
  18. {
  19. private const long TenantUat = 838257186181189L;
  20. private const long TenantAidop = 797403760988229L; // 同时也是 DOPDEMORQ_SQLSERVER/8010 的源归属租户
  21. private const string FormalSource = "DOPDEMORQ_SQLSERVER";
  22. private const string FixtureSource = "UAT_GENERATOR";
  23. private static bool ItEnabled => Environment.GetEnvironmentVariable("AIDOP_IT") == "1";
  24. private readonly Xunit.Abstractions.ITestOutputHelper _out;
  25. public InvTransTenantMaterializationTests(Xunit.Abstractions.ITestOutputHelper output) => _out = output;
  26. private static readonly string PluginRoot =
  27. Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
  28. private const string SyncSrc = "MaterialWarehouse/InventoryMdpSyncService.cs";
  29. private const string ReaderSrc = "MaterialWarehouse/StdInventoryTransactionReader.cs";
  30. private static string ReadSource(string relativePath)
  31. {
  32. var full = Path.Combine(PluginRoot, relativePath);
  33. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  34. return File.ReadAllText(full);
  35. }
  36. private static List<string> CodeLines(string relativePath) =>
  37. ReadSource(relativePath)
  38. .Split('\n')
  39. .Select(l => l.Trim())
  40. .Where(l => !l.StartsWith("///", StringComparison.Ordinal)
  41. && !l.StartsWith("//", StringComparison.Ordinal)
  42. && !l.StartsWith("--", StringComparison.Ordinal))
  43. .ToList();
  44. private static ISqlSugarClient? BuildAidopdev()
  45. {
  46. const string path = "/home/yy968/work/New9S/AiDOPWarehouse/server/Admin.NET.Application/Configuration/Database.json";
  47. if (!File.Exists(path)) return null;
  48. var cs = File.ReadLines(path)
  49. .Select(l => l.Trim())
  50. .Where(l => !l.StartsWith("//") && l.Contains("\"ConnectionString\"")
  51. && l.Contains("Database=aidopdev") && l.Contains("123.60.180.165"))
  52. .Select(l => Regex.Match(l, "\"ConnectionString\"\\s*:\\s*\"([^\"]+)\"").Groups[1].Value)
  53. .FirstOrDefault(v => !string.IsNullOrEmpty(v));
  54. if (string.IsNullOrEmpty(cs)) return null;
  55. return new SqlSugarScope(new ConnectionConfig
  56. {
  57. ConfigId = "aidopdev-invtrans-it", DbType = DbType.MySql, ConnectionString = cs, IsAutoCloseConnection = true,
  58. });
  59. }
  60. // ===================== 1/2:逐租户物化 + source≠target =====================
  61. /// <summary>两条腿都必须逐目标租户循环,流水腿不得再只调一次。</summary>
  62. [Fact]
  63. public void InvTrans_MustLoopTargetTenants()
  64. {
  65. var src = ReadSource(SyncSrc);
  66. // 旧方法名必须彻底消失(它的签名把 source 当 business tenant)
  67. Assert.DoesNotContain("UpsertInvTransStdAsync", src, StringComparison.Ordinal);
  68. // 1 处定义 + 2 处调用(RunAsync 的 bootstrap/reconcile 与增量分支、transform-std)
  69. var calls = Regex.Matches(src, @"MaterializeInvTransStdAsync\(").Count;
  70. Assert.True(calls >= 3, $"应有 1 处定义 + 至少 2 处调用,实际 {calls}");
  71. // 3 处逐租户循环:余额腿 1 + 流水腿 2(RunAsync、transform-std)
  72. var loops = Regex.Matches(src, @"foreach \(var targetTenantId in targetTenants\)").Count;
  73. Assert.True(loops >= 3, $"逐租户循环应至少 3 处(余额 1 + 流水 2),实际 {loops}");
  74. // 每一处 MaterializeInvTransStdAsync 的调用点之前,最近的循环必须是 targetTenants 循环
  75. foreach (Match m in Regex.Matches(src, @"await MaterializeInvTransStdAsync\("))
  76. {
  77. var before = src[..m.Index];
  78. var lastLoop = before.LastIndexOf("foreach (var targetTenantId in targetTenants)", StringComparison.Ordinal);
  79. Assert.True(lastLoop >= 0, "存在不在逐租户循环内的流水物化调用");
  80. }
  81. }
  82. /// <summary>方法签名必须显式区分源归属租户与业务租户,不能再合并成一个 tenantId。</summary>
  83. [Fact]
  84. public void InvTrans_MustDistinguishSourceAndTargetTenant()
  85. {
  86. var src = ReadSource(SyncSrc);
  87. Assert.Contains("long sourceTenantId, long targetTenantId", src, StringComparison.Ordinal);
  88. Assert.Contains("new(\"@SourceTenantId\", sourceTenantId)", src, StringComparison.Ordinal);
  89. Assert.Contains("new(\"@TargetTenantId\", targetTenantId)", src, StringComparison.Ordinal);
  90. // std.tenant_id 必须写目标租户,不得再从 stg 反推
  91. Assert.Contains("var sTenant = \"@TargetTenantId\";", src, StringComparison.Ordinal);
  92. }
  93. /// <summary>目标租户集合必须复用余额腿同一个枚举,不得另写一套。</summary>
  94. [Fact]
  95. public void InvTrans_MustReuseScopedTenantEnumeration()
  96. {
  97. var lines = CodeLines(SyncSrc);
  98. var enumerators = lines.Count(l => l.Contains("ListInventoryScopedTenantsAsync(domain", StringComparison.Ordinal));
  99. Assert.True(enumerators >= 2, $"RunAsync 与 transform-std 都应复用该枚举,实际 {enumerators} 处");
  100. Assert.Equal(1, Regex.Matches(ReadSource(SyncSrc), @"private async Task<List<long>> ListInventoryScopedTenantsAsync").Count);
  101. }
  102. // ===================== 3/4:库位投影 + Supp 排除 =====================
  103. /// <summary>
  104. /// 库位投影必须只有一个实现口径(余额腿与流水腿共用),只有贴源 JSON 字段名不同。
  105. /// </summary>
  106. [Fact]
  107. public void LocationScopeJoin_HasSingleImplementation()
  108. {
  109. var src = ReadSource(SyncSrc);
  110. Assert.Equal(1, Regex.Matches(src, @"private static string TenantScopeJoinSql\(").Count);
  111. Assert.Contains("TenantScopeJoinSql(\"Location\")", src, StringComparison.Ordinal);
  112. Assert.Contains("TenantScopeJoinSql(\"Loc\")", src, StringComparison.Ordinal);
  113. // 全文只允许这一处 LocationMaster 投影 JOIN
  114. Assert.Equal(1, Regex.Matches(src, @"INNER JOIN LocationMaster lm").Count);
  115. }
  116. [Fact]
  117. public void LocationScopeJoin_ExcludesSuppAndEmptyLocation()
  118. {
  119. var src = ReadSource(SyncSrc);
  120. var stmt = src[src.IndexOf("INNER JOIN LocationMaster lm", StringComparison.Ordinal)..];
  121. stmt = stmt[..stmt.IndexOf("\"\"\"", StringComparison.Ordinal)];
  122. Assert.Contains("lm.tenant_id = @TargetTenantId", stmt, StringComparison.Ordinal);
  123. Assert.Contains("IFNULL(lm.typed, '') <> 'Supp'", stmt, StringComparison.Ordinal);
  124. Assert.Contains("TRIM(lm.location) <> ''", stmt, StringComparison.Ordinal);
  125. }
  126. /// <summary>流水物化必须用上该投影,且来源侧仍限定源归属租户与正式贴源表。</summary>
  127. [Fact]
  128. public void InvTrans_Materialization_AppliesScopeProjection()
  129. {
  130. var src = ReadSource(SyncSrc);
  131. var i = src.IndexOf("FROM mdp_stg_inv_trans s", StringComparison.Ordinal);
  132. Assert.True(i >= 0);
  133. var stmt = src[i..(i + 400)];
  134. Assert.Contains("{TransTenantScopeJoin}", stmt, StringComparison.Ordinal);
  135. Assert.Contains("s.tenant_id=@SourceTenantId", stmt, StringComparison.Ordinal);
  136. Assert.Contains("s.source_table='InvTransHist'", stmt, StringComparison.Ordinal);
  137. }
  138. // ===================== 5:空 scope fail closed =====================
  139. [Fact]
  140. public void EmptyTenantScope_FailsClosed_NoFallbackToSourceOwner()
  141. {
  142. var src = ReadSource(SyncSrc);
  143. Assert.Contains("no scoped tenant", src, StringComparison.Ordinal);
  144. // 绝不能存在「没有目标租户就按源归属租户写一份」的回落分支
  145. Assert.DoesNotContain(CodeLines(SyncSrc),
  146. l => l.Contains("MaterializeInvTransStdAsync(tenantId, tenantId", StringComparison.Ordinal));
  147. }
  148. // ===================== 6/7:FullReplace 只删正式切片,保留 fixture =====================
  149. /// <summary>
  150. /// FULL Replace 的删除范围必须带 source_system + domain:
  151. /// 只按 tenant_id 删会连带删掉 UAT_GENERATOR fixture。
  152. /// </summary>
  153. [Fact]
  154. public void FullReplace_OnlyDeletesFormalSlice()
  155. {
  156. var src = ReadSource(SyncSrc);
  157. Assert.Contains("FormalSliceWhere(sourceCode, domain)", src, StringComparison.Ordinal);
  158. Assert.Contains("source_system='{sourceSystem}' AND domain='{domain}'", src, StringComparison.Ordinal);
  159. // 两处 mdp_std_inv_trans 的 FullReplace 都必须传 extraWhere,不得传 null
  160. foreach (Match m in Regex.Matches(src, @"ReplaceAsync\(\s*_db,\s*""mdp_std_inv_trans"",\s*targetTenantId,\s*([^,]+),"))
  161. Assert.Contains("FormalSliceWhere", m.Groups[1].Value, StringComparison.Ordinal);
  162. }
  163. /// <summary>白名单校验:source_system / domain 虽来自配置,仍不得被拼接注入。</summary>
  164. [Fact]
  165. public void FormalSliceWhere_ValidatesIdentifiers()
  166. {
  167. var src = ReadSource(SyncSrc);
  168. Assert.Contains("非法 source_system", src, StringComparison.Ordinal);
  169. Assert.Contains("非法 domain", src, StringComparison.Ordinal);
  170. }
  171. // ===================== 9:ItemMaster tenant-aware =====================
  172. [Fact]
  173. public void Reader_ItemMasterJoin_MustBeTenantAware()
  174. {
  175. var src = ReadSource(ReaderSrc);
  176. Assert.Contains("ON i.tenant_id = a.tenant_id AND i.ItemNum = a.item_num", src, StringComparison.Ordinal);
  177. Assert.DoesNotContain(CodeLines(ReaderSrc),
  178. l => l.Contains("ON a.domain = i.Domain AND a.item_num = i.ItemNum", StringComparison.Ordinal));
  179. }
  180. /// <summary>ORDER BY 必须有唯一 tiebreaker,否则 OFFSET 分页会跨页重复/漏行。</summary>
  181. [Fact]
  182. public void Reader_OrderBy_HasUniqueTiebreaker()
  183. {
  184. var src = ReadSource(ReaderSrc);
  185. Assert.Contains("ORDER BY a.trans_time DESC, CAST(a.src_rec_id AS UNSIGNED) DESC",
  186. src, StringComparison.Ordinal);
  187. }
  188. // ===================== 数据面(AIDOP_IT=1,只读) =====================
  189. /// <summary>8:同一源流水必须能分别物化给两个业务租户 —— 唯一键须以 tenant_id 打头。</summary>
  190. [Fact]
  191. public async Task Schema_UniqueKey_AllowsPerTenantMaterialization()
  192. {
  193. if (!ItEnabled) return;
  194. var db = BuildAidopdev();
  195. Assert.NotNull(db);
  196. var cols = await db!.Ado.SqlQueryAsync<string>(
  197. """
  198. SELECT COLUMN_NAME FROM information_schema.STATISTICS
  199. WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='mdp_std_inv_trans'
  200. AND INDEX_NAME='uk_std_inv_trans_src' ORDER BY SEQ_IN_INDEX
  201. """);
  202. _out.WriteLine($"uk_std_inv_trans_src = ({string.Join(", ", cols)})");
  203. Assert.Equal(new[] { "tenant_id", "source_system", "src_rec_id" }, cols);
  204. }
  205. /// <summary>10:COUNT 基集与 JOIN 后集合必须同基数(ItemMaster 不放大)。</summary>
  206. [Fact]
  207. public async Task Reader_CountAndJoin_SameCardinality()
  208. {
  209. if (!ItEnabled) return;
  210. var db = BuildAidopdev();
  211. Assert.NotNull(db);
  212. var pars = new List<SugarParameter> { new("@T", TenantAidop), new("@S", FormalSource) };
  213. var baseRows = await db!.Ado.GetIntAsync(
  214. "SELECT COUNT(1) FROM mdp_std_inv_trans a WHERE a.tenant_id=@T AND a.source_system=@S", pars);
  215. if (baseRows == 0) return; // 该租户尚无正式数据时本用例无意义
  216. var oldJoin = await db.Ado.GetIntAsync(
  217. """
  218. SELECT COUNT(1) FROM mdp_std_inv_trans a
  219. LEFT JOIN ItemMaster i ON a.domain=i.Domain AND a.item_num=i.ItemNum
  220. WHERE a.tenant_id=@T AND a.source_system=@S
  221. """, pars);
  222. var newJoin = await db.Ado.GetIntAsync(
  223. """
  224. SELECT COUNT(1) FROM mdp_std_inv_trans a
  225. LEFT JOIN ItemMaster i ON i.tenant_id=a.tenant_id AND i.ItemNum=a.item_num AND i.Domain=a.domain
  226. WHERE a.tenant_id=@T AND a.source_system=@S
  227. """, pars);
  228. _out.WriteLine($"base={baseRows} oldJoin={oldJoin} newJoin={newJoin}");
  229. Assert.True(oldJoin > baseRows, "旧写法应当放大,否则本用例失去意义");
  230. Assert.Equal(baseRows, newJoin);
  231. }
  232. /// <summary>ItemMaster 租户内必须唯一,否则加了 tenant_id 也不能断言 1:1。</summary>
  233. [Fact]
  234. public async Task ItemMaster_UniqueWithinTenant()
  235. {
  236. if (!ItEnabled) return;
  237. var db = BuildAidopdev();
  238. Assert.NotNull(db);
  239. var dup = await db!.Ado.GetIntAsync(
  240. "SELECT COUNT(1) FROM (SELECT tenant_id,ItemNum FROM ItemMaster GROUP BY tenant_id,ItemNum HAVING COUNT(*)>1) x");
  241. _out.WriteLine($"(tenant_id, ItemNum) 重复组={dup}");
  242. Assert.Equal(0, dup);
  243. }
  244. /// <summary>11:UAT 正式切片必须全部落在其合法库位内、无 Supp、domain 一致。</summary>
  245. [Fact]
  246. public async Task Uat_FormalSlice_WithinLocationScope()
  247. {
  248. if (!ItEnabled) return;
  249. var db = BuildAidopdev();
  250. Assert.NotNull(db);
  251. var pars = new List<SugarParameter> { new("@T", TenantUat), new("@S", FormalSource) };
  252. var formal = await db!.Ado.GetIntAsync(
  253. "SELECT COUNT(1) FROM mdp_std_inv_trans WHERE tenant_id=@T AND source_system=@S", pars);
  254. _out.WriteLine($"UAT 正式行数={formal}");
  255. if (formal == 0) return; // 尚未物化时跳过(物化后本用例即生效)
  256. var outside = await db.Ado.GetIntAsync(
  257. """
  258. SELECT COUNT(1) FROM mdp_std_inv_trans a
  259. LEFT JOIN LocationMaster lm
  260. ON lm.tenant_id=a.tenant_id AND lm.Domain=a.domain AND lm.location=a.location
  261. AND IFNULL(lm.typed,'')<>'Supp'
  262. WHERE a.tenant_id=@T AND a.source_system=@S AND lm.location IS NULL
  263. """, pars);
  264. var badDomain = await db.Ado.GetIntAsync(
  265. "SELECT COUNT(1) FROM mdp_std_inv_trans WHERE tenant_id=@T AND source_system=@S AND domain<>'8010'", pars);
  266. _out.WriteLine($"越界库位行={outside} 异常domain行={badDomain}");
  267. Assert.Equal(0, outside);
  268. Assert.Equal(0, badDomain);
  269. }
  270. /// <summary>12:fixture 切片必须原样保留,且与正式切片互不混淆。</summary>
  271. [Fact]
  272. public async Task Fixture_Slice_Preserved_AndIsolated()
  273. {
  274. if (!ItEnabled) return;
  275. var db = BuildAidopdev();
  276. Assert.NotNull(db);
  277. var fixture = await db!.Ado.GetIntAsync(
  278. "SELECT COUNT(1) FROM mdp_std_inv_trans WHERE tenant_id=@T AND source_system=@S",
  279. new List<SugarParameter> { new("@T", TenantUat), new("@S", FixtureSource) });
  280. // fixture 的库位全部在 UAT 合法范围之外 —— 这是它「不是正式数据」的实证
  281. var fixtureOutside = await db.Ado.GetIntAsync(
  282. """
  283. SELECT COUNT(1) FROM mdp_std_inv_trans a
  284. WHERE a.tenant_id=@T AND a.source_system=@S
  285. AND a.location NOT IN (SELECT location FROM LocationMaster
  286. WHERE tenant_id=@T AND Domain='8010' AND IFNULL(typed,'')<>'Supp')
  287. """,
  288. new List<SugarParameter> { new("@T", TenantUat), new("@S", FixtureSource) });
  289. _out.WriteLine($"UAT_GENERATOR 行数={fixture},其中越界库位={fixtureOutside}");
  290. Assert.Equal(168, fixture); // 本批不做 fixture 治理,必须原样保留
  291. Assert.Equal(fixture, fixtureOutside); // 全部越界 → 确为 fixture,不可混入正式口径
  292. }
  293. }