S0DimContractTests.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. using System;
  2. using System.IO;
  3. using System.Linq;
  4. using Admin.NET.Plugin.AiDOP.DataPlatform;
  5. using Admin.NET.Plugin.AiDOP.DataPlatform.S0Dim;
  6. using Xunit;
  7. namespace Admin.NET.Plugin.AiDOP.Tests.S0.Dim;
  8. /// <summary>
  9. /// S0 主数据维度的**声明与 SQL 生成**契约测试(纯函数,不接触数据库)。
  10. ///
  11. /// 这些断言把两类曾经真实踩过的坑钉在编译/测试期:
  12. /// ① LocationMaster 是 dual-column 复刻表,snake_case 一侧是**填了假值的死列**
  13. /// (rec_id 恒 0 / domain_code 恒空串 / is_active 恒 0),取错不报错、静默返回垃圾;
  14. /// ② 租户隔离必须由 SQL 谓词保证,不能依赖调用方自觉。
  15. /// </summary>
  16. public class S0DimContractTests
  17. {
  18. private const StringComparison Ord = StringComparison.Ordinal;
  19. [Fact]
  20. public void Catalog_should_be_self_consistent()
  21. {
  22. S0DimCatalog.ValidateAll();
  23. // 断言内容而非数量:数量断言每加一个维度就要改一次,且改完什么也没守住。
  24. // 这里守的是「目录里恰好是这些维度、每个都能被 Find 命中、源系统统一」。
  25. Assert.Equal(
  26. ["WORK_CENTER", "DEPARTMENT", "LOCATION", "LOCATION_SHELF", "EMPLOYEE", "LINE", "CUSTOMER", "PERSON_SKILL"],
  27. S0DimCatalog.All.Select(d => d.Key).ToArray());
  28. Assert.All(S0DimCatalog.All, d => Assert.Same(d, S0DimCatalog.Find(d.Key)));
  29. Assert.All(S0DimCatalog.All, d => Assert.Equal(S0DimCatalog.LocalSourceSystem, d.SourceSystem));
  30. }
  31. [Fact]
  32. public void Catalog_find_should_be_case_insensitive_and_null_safe()
  33. {
  34. Assert.Same(S0DimCatalog.WorkCenter, S0DimCatalog.Find("work_center"));
  35. Assert.Same(S0DimCatalog.LocationShelf, S0DimCatalog.Find(" LOCATION_SHELF "));
  36. Assert.Null(S0DimCatalog.Find("NOT_A_KEY"));
  37. Assert.Null(S0DimCatalog.Find(null));
  38. }
  39. [Fact]
  40. public void Every_definition_must_declare_explicit_json_path()
  41. {
  42. foreach (var def in S0DimCatalog.All)
  43. foreach (var col in def.Columns.Where(c => c.Kind != S0DimValueKind.TenantIdColumn))
  44. Assert.False(string.IsNullOrWhiteSpace(col.JsonPath),
  45. $"{def.Key}.{col.TargetColumn} 必须显式声明 JsonPath");
  46. }
  47. [Fact]
  48. public void Definition_without_json_path_should_be_rejected()
  49. {
  50. var bad = S0DimCatalog.WorkCenter with
  51. {
  52. Columns =
  53. [
  54. new S0DimColumn("tenant_id", S0DimValueKind.TenantIdColumn),
  55. new S0DimColumn("domain_code", S0DimValueKind.Str, null, Required: true),
  56. new S0DimColumn("work_center_code", S0DimValueKind.Str, "WorkCtr", Required: true)
  57. ]
  58. };
  59. var ex = Assert.Throws<InvalidOperationException>(() => bad.Validate());
  60. Assert.Contains("JsonPath", ex.Message, Ord);
  61. }
  62. [Fact]
  63. public void Business_key_order_must_match_source_biz_key_expr()
  64. {
  65. // dim 业务键顺序与 mdp_entity.biz_key_expr 不一致时,stg.source_biz_key 无法与 dim 业务键对账
  66. var bad = S0DimCatalog.WorkCenter with { SourceBizKeyColumns = ["WorkCtr", "Domain"] };
  67. var ex = Assert.Throws<InvalidOperationException>(() => bad.Validate());
  68. Assert.Contains("业务键顺序不一致", ex.Message, Ord);
  69. }
  70. [Fact]
  71. public void Business_key_columns_must_be_required()
  72. {
  73. var cols = S0DimCatalog.WorkCenter.Columns
  74. .Select(c => c.TargetColumn == "domain_code" ? c with { Required = false } : c).ToList();
  75. var bad = S0DimCatalog.WorkCenter with { Columns = cols };
  76. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  77. }
  78. [Fact]
  79. public void Business_key_must_start_with_tenant_id()
  80. {
  81. var bad = S0DimCatalog.WorkCenter with
  82. {
  83. BusinessKeyColumns = ["domain_code", "work_center_code"],
  84. SourceBizKeyColumns = ["WorkCtr"]
  85. };
  86. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  87. }
  88. [Fact]
  89. public void Identifiers_must_be_whitelisted()
  90. {
  91. var bad = S0DimCatalog.WorkCenter with { DimTable = "dim_work_center; DROP TABLE x" };
  92. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  93. }
  94. // ────────────────────────────────────────────────────────────────
  95. // LocationMaster 影子列黑名单(🔴 本仓最容易静默出错的一处)
  96. // ────────────────────────────────────────────────────────────────
  97. public static TheoryData<string> LocationShadowPaths() =>
  98. ["$.domain_code", "$.rec_id", "$.is_active", "$.physical_address", "$.create_user"];
  99. [Theory]
  100. [MemberData(nameof(LocationShadowPaths))]
  101. public void Location_insert_sql_must_never_read_shadow_columns(string shadowPath)
  102. {
  103. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Location);
  104. Assert.DoesNotContain(shadowPath, sql, Ord);
  105. }
  106. /// <summary>
  107. /// LocationMaster 影子列的**裸列名**(不带 <c>$.</c> 前缀)。
  108. ///
  109. /// <para>源侧校验和 / 业务键 SQL 把 <c>JsonPath</c> 当作**源表物理列名**用
  110. /// (<c>S0DimSqlBuilder.BuildSourceChecksumSql</c>:<c>a.`Domain`</c>),**整条语句里没有任何 <c>$.</c>**。
  111. /// 因此对这两个生成器断言 <c>DoesNotContain("$.domain_code")</c> 是**恒真**的,零保护 ——
  112. /// 必须断言反引号形式。</para>
  113. /// </summary>
  114. public static TheoryData<string> LocationShadowColumns() =>
  115. ["domain_code", "rec_id", "is_active", "physical_address", "create_user"];
  116. [Theory]
  117. [MemberData(nameof(LocationShadowColumns))]
  118. public void Location_source_side_sql_must_never_read_shadow_columns(string shadowColumn)
  119. {
  120. // 这是本批**唯一能静默通过全部对账**的缺陷类:
  121. // JsonPath 若误写成影子列,dim 侧读 staging 的 $.is_active(=0),
  122. // 源侧校验和读源表的 a.`is_active`(同样 =0)→ 两侧自洽 → checksum 相等 → PASS,
  123. // 而真实的 IsActive=1 被静默翻成 0。影子列存 falsy 而非 NULL,不会抛任何异常。
  124. var needle = $"{S0DimSqlBuilder.SrcAlias}.`{shadowColumn}`";
  125. Assert.DoesNotContain(needle, S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Location), Ord);
  126. Assert.DoesNotContain(needle, S0DimSqlBuilder.SourceBizKeySetSql(S0DimCatalog.Location), Ord);
  127. }
  128. [Theory]
  129. [InlineData("Domain")]
  130. [InlineData("IsActive")]
  131. [InlineData("PhysicalAddress")]
  132. [InlineData("UpdateTime")]
  133. public void Location_source_checksum_must_read_real_columns(string realColumn)
  134. {
  135. // 正向断言:光有 DoesNotContain 不够 —— 列被整个删掉时它也通过。
  136. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`{realColumn}`",
  137. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Location), Ord);
  138. }
  139. [Fact]
  140. public void Insert_and_source_checksum_must_read_the_same_declared_path()
  141. {
  142. // 通用护栏(不限 Location):dim 物化走 staging 的 $.JsonPath,
  143. // 源侧校验和走源表的 a.`JsonPath`。两者必须来自**同一个**声明,
  144. // 否则「写进 dim 的值」与「用来验证 dim 的值」根本不是一回事。
  145. foreach (var def in S0DimCatalog.All)
  146. {
  147. var insert = S0DimSqlBuilder.BuildInsertSql(def);
  148. var srcChk = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  149. foreach (var c in def.Columns)
  150. {
  151. if (c.Kind == S0DimValueKind.TenantIdColumn) continue;
  152. Assert.Contains($"$.{c.JsonPath}", insert, Ord);
  153. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`{c.JsonPath}`", srcChk, Ord);
  154. }
  155. }
  156. }
  157. [Fact]
  158. public void Source_and_dim_checksum_must_have_identical_segment_count()
  159. {
  160. // 位置对称性:两侧 CONCAT_WS 的段数/顺序错位会产出**完全自洽的错值**。
  161. // 段数相等是最容易机械校验的必要条件(顺序由 ChecksumColumns 同一函数保证)。
  162. foreach (var def in S0DimCatalog.All)
  163. {
  164. var src = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  165. var dim = S0DimSqlBuilder.BuildDimChecksumSql(def);
  166. Assert.Equal(Occurrences(src, "IFNULL("), Occurrences(dim, "IFNULL("));
  167. Assert.Equal(Occurrences(src, "CONCAT_WS("), Occurrences(dim, "CONCAT_WS("));
  168. }
  169. }
  170. private static int Occurrences(string haystack, string needle)
  171. {
  172. var n = 0;
  173. for (var i = haystack.IndexOf(needle, Ord); i >= 0; i = haystack.IndexOf(needle, i + needle.Length, Ord))
  174. n++;
  175. return n;
  176. }
  177. [Fact]
  178. public void Required_string_columns_must_also_reject_empty_string()
  179. {
  180. // IS NOT NULL 挡不住空串,而 CONCAT_WS 会保留空段:
  181. // Domain='' 会拼出 '#LOC01' 这种「形似合法」的业务键,且不会被 blank_cnt 探针捕获
  182. // (它只捕获整串全空)。故 Required 的 Str 列必须同时有 <> '' 守卫。
  183. foreach (var def in S0DimCatalog.All)
  184. {
  185. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  186. foreach (var c in def.Columns)
  187. {
  188. if (c.Kind != S0DimValueKind.Str || !c.Required) continue;
  189. // 必须逐列绑定:整串级的 Assert.Contains("<> ''") 在「5 个 Required 列只有 1 个带守卫」时照样通过。
  190. var expr = MdpJsonSql.Str(S0DimSqlBuilder.StgAlias, c.JsonPath!);
  191. Assert.Contains($"({expr}) IS NOT NULL", sql, Ord);
  192. Assert.Contains($"({expr}) <> ''", sql, Ord);
  193. }
  194. }
  195. }
  196. [Fact]
  197. public void Every_business_key_component_must_be_required_and_of_key_safe_kind()
  198. {
  199. // 守住上一条测试的前提:若将来把某个业务键分量改成非 Required,
  200. // 空串守卫会连同 NULL 守卫一起消失,而上一条测试仍会通过(它只遍历 Required 列)。
  201. foreach (var def in S0DimCatalog.All)
  202. {
  203. foreach (var bk in def.BusinessKeyColumns.Skip(1))
  204. {
  205. var col = def.Columns.First(x => x.TargetColumn == bk);
  206. Assert.True(col.Required, $"[{def.Key}] 业务键分量 {bk} 必须 Required");
  207. Assert.True(col.Kind is S0DimValueKind.Str or S0DimValueKind.Int,
  208. $"[{def.Key}] 业务键分量 {bk} 的 Kind={col.Kind} 不适合做键" +
  209. "(Dec 有精度格式化差异、DateTimeSec 截秒、BoolTrue 基数过低)");
  210. }
  211. }
  212. }
  213. public static TheoryData<string> LocationRealPaths() =>
  214. ["$.Domain", "$.IsActive", "$.PhysicalAddress", "$.location", "$.descr", "$.typed", "$.storer", "$.UpdateTime"];
  215. [Theory]
  216. [MemberData(nameof(LocationRealPaths))]
  217. public void Location_insert_sql_must_read_real_columns(string realPath)
  218. {
  219. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Location);
  220. Assert.Contains(realPath, sql, Ord);
  221. }
  222. [Fact]
  223. public void Location_shadow_blacklist_must_not_be_applied_to_shelf()
  224. {
  225. // LocationShelfMaster 本身就是 snake_case、无影子列:
  226. // 这里的 $.domain_code / $.update_time 是**正确**的物理列名,不适用父表的禁用规则
  227. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.LocationShelf);
  228. Assert.Contains("$.domain_code", sql, Ord);
  229. Assert.Contains("$.location", sql, Ord);
  230. Assert.Contains("$.inv_shelf", sql, Ord);
  231. Assert.Contains("$.update_time", sql, Ord);
  232. Assert.DoesNotContain("$.Domain'", sql, Ord);
  233. Assert.DoesNotContain("$.InvShelf", sql, Ord);
  234. }
  235. // ────────────────────────────────────────────────────────────────
  236. // 租户隔离:所有生成的 SQL 都必须带租户谓词
  237. // ────────────────────────────────────────────────────────────────
  238. [Fact]
  239. public void Insert_sql_must_filter_tenant_source_table_and_batch()
  240. {
  241. foreach (var def in S0DimCatalog.All)
  242. {
  243. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  244. Assert.Contains("s.`tenant_id` = @TenantId", sql, Ord);
  245. Assert.Contains("s.`source_system` = @SourceSystem", sql, Ord);
  246. Assert.Contains("s.`source_table` = @SourceTable", sql, Ord);
  247. Assert.Contains("s.`sync_batch_id` = @BatchId", sql, Ord);
  248. }
  249. }
  250. [Fact]
  251. public void Insert_sql_must_not_use_on_duplicate_key_update()
  252. {
  253. // 源侧业务键重复必须撞 dim 唯一键并回滚(transform fail),绝不 last-wins
  254. foreach (var def in S0DimCatalog.All)
  255. Assert.DoesNotContain("ON DUPLICATE KEY", S0DimSqlBuilder.BuildInsertSql(def), StringComparison.OrdinalIgnoreCase);
  256. }
  257. [Fact]
  258. public void Purge_sql_must_carry_all_three_predicates()
  259. {
  260. foreach (var def in S0DimCatalog.All)
  261. {
  262. var sql = S0DimSqlBuilder.BuildPurgeStagingSql(def);
  263. Assert.Contains("s.`tenant_id` = @TenantId", sql, Ord);
  264. Assert.Contains("s.`source_system` = @SourceSystem", sql, Ord);
  265. Assert.Contains("s.`source_table` = @SourceTable", sql, Ord);
  266. // purge 不限批次:本轮之前的残留也要清掉
  267. Assert.DoesNotContain("sync_batch_id", sql, Ord);
  268. }
  269. }
  270. [Fact]
  271. public void All_count_and_set_sql_must_be_tenant_scoped()
  272. {
  273. foreach (var def in S0DimCatalog.All)
  274. {
  275. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildSourceCountSql(def), Ord);
  276. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildStagingCountSql(def), Ord);
  277. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimCountSql(def), Ord);
  278. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimBatchImpuritySql(def), Ord);
  279. Assert.Contains("@TenantId", S0DimSqlBuilder.SourceBizKeySetSql(def), Ord);
  280. Assert.Contains("@TenantId", S0DimSqlBuilder.StagingBizKeySetSql(def), Ord);
  281. Assert.Contains("@TenantId", S0DimSqlBuilder.DimBizKeySetSql(def), Ord);
  282. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildSourceChecksumSql(def), Ord);
  283. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimChecksumSql(def), Ord);
  284. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimDuplicateBizKeySql(def), Ord);
  285. }
  286. }
  287. [Fact]
  288. public void Bool_columns_must_carry_recognizability_guard()
  289. {
  290. // MdpJsonSql.BoolTrue 对 null / 不可识别值静默返回 0;必须有守卫把这类行挡在 dim 之外。
  291. // 遍历全部维度并逐列绑定:只测 WorkCenter 会漏掉 Location / Department 的 BoolTrue 列。
  292. var covered = 0;
  293. foreach (var def in S0DimCatalog.All)
  294. {
  295. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  296. foreach (var c in def.Columns)
  297. {
  298. if (c.Kind != S0DimValueKind.BoolTrue) continue;
  299. var raw = MdpJsonSql.Raw(S0DimSqlBuilder.StgAlias, c.JsonPath!);
  300. Assert.Contains($"LOWER({raw}) IN ('1','0','true','false')", sql, Ord);
  301. covered++;
  302. }
  303. }
  304. Assert.True(covered > 0, "目录里一个 BoolTrue 列都没有,本测试已失去意义");
  305. }
  306. [Fact]
  307. public void Checksum_must_use_exact_integer_summation_not_double()
  308. {
  309. // 回归守卫(2026-09-07 Batch 2 实测缺陷):CONV() 返回字符串,SUM(字符串) 在 MySQL 里按 DOUBLE 累加,
  310. // 只有约 16 位有效数字;两侧扫描顺序不同会产生不同舍入,导致完全相同的数据被误判为 checksum 不一致
  311. // (租户 838257186181189:source=…980000 / dim=…980700,count 相同)。
  312. // 必须先 CAST 成 UNSIGNED,SUM 才走 DECIMAL 精确累加、与顺序无关。
  313. foreach (var def in S0DimCatalog.All)
  314. {
  315. foreach (var sql in new[] { S0DimSqlBuilder.BuildSourceChecksumSql(def), S0DimSqlBuilder.BuildDimChecksumSql(def) })
  316. {
  317. Assert.Contains("SUM(CAST(CONV(", sql, Ord);
  318. Assert.Contains("AS UNSIGNED))", sql, Ord);
  319. Assert.DoesNotContain("SUM(CONV(", sql, Ord);
  320. }
  321. }
  322. }
  323. [Fact]
  324. public void Shelf_definition_declares_parent_and_location_declares_mirror_unique()
  325. {
  326. Assert.Equal("dim_location", S0DimCatalog.LocationShelf.ParentDimTable);
  327. Assert.Equal(["domain_code", "location_code"], S0DimCatalog.LocationShelf.ParentKeyColumns);
  328. Assert.Equal(["tenant_id", "location_code"], S0DimCatalog.Location.MirrorUniqueColumns);
  329. var orphanSql = S0DimSqlBuilder.BuildOrphanChildSql(S0DimCatalog.LocationShelf, 10);
  330. Assert.Contains("dim_location", orphanSql, Ord);
  331. Assert.Contains("@TenantId", orphanSql, Ord);
  332. }
  333. [Fact]
  334. public void Work_center_dim_must_not_invent_company_or_factory_columns()
  335. {
  336. // 源 WorkCtrMaster 没有 company_ref_id / factory_ref_id,dim 不得凭空补
  337. var targets = S0DimCatalog.WorkCenter.Columns.Select(c => c.TargetColumn).ToList();
  338. Assert.DoesNotContain("company_id", targets);
  339. Assert.DoesNotContain("factory_id", targets);
  340. }
  341. [Fact]
  342. public void Shelf_dim_must_not_invent_is_active()
  343. {
  344. // 源 LocationShelfMaster 没有 IsActive 列,不发明状态语义
  345. var targets = S0DimCatalog.LocationShelf.Columns.Select(c => c.TargetColumn).ToList();
  346. Assert.DoesNotContain("is_active", targets);
  347. }
  348. [Fact]
  349. public void Two_definitions_may_share_staging_but_never_share_dim()
  350. {
  351. Assert.Equal(S0DimCatalog.Location.StagingTable, S0DimCatalog.LocationShelf.StagingTable);
  352. Assert.NotEqual(S0DimCatalog.Location.SourceTable, S0DimCatalog.LocationShelf.SourceTable);
  353. // 与维度数量解耦:新增维度不该让这条断言失效,它要守的是「dim 表不共用」这个不变量
  354. Assert.Equal(S0DimCatalog.All.Count, S0DimCatalog.All.Select(d => d.DimTable).Distinct().Count());
  355. }
  356. // ────────────────────────────────────────────────────────────────
  357. // Phase 2 · Batch 4:EmployeeMaster / LineMaster
  358. // ────────────────────────────────────────────────────────────────
  359. [Fact]
  360. public void Employee_identity_must_be_tenant_domain_employee()
  361. {
  362. var d = S0DimCatalog.Employee;
  363. Assert.Equal(["tenant_id", "employee_code"], d.BusinessKeyColumns);
  364. Assert.Equal(["Employee"], d.SourceBizKeyColumns);
  365. // legacy scope 不得混进身份
  366. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  367. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  368. Assert.Equal("EmployeeMaster", d.SourceTable);
  369. Assert.Equal("mdp_stg_s0_employee", d.StagingTable);
  370. Assert.Equal("dim_employee", d.DimTable);
  371. }
  372. [Fact]
  373. public void Employee_sys_user_id_must_be_attribute_only_never_business_key()
  374. {
  375. // 源实测 1105 行中仅 7 行非空(0.6%):把账号 id 当员工身份会让 99.4% 的员工无法标识。
  376. var d = S0DimCatalog.Employee;
  377. Assert.DoesNotContain("sys_user_id", d.BusinessKeyColumns);
  378. Assert.DoesNotContain("sys_user_id", d.SourceBizKeyColumns);
  379. Assert.DoesNotContain("sys_user_id", d.ParentKeyColumns!);
  380. var col = d.Columns.Single(c => c.TargetColumn == "sys_user_id");
  381. Assert.False(col.Required); // 必须可空,否则 1098 行会被 RowGuards 过滤掉
  382. Assert.Equal("sys_user_id", col.JsonPath);
  383. }
  384. [Fact]
  385. public void Employee_parent_is_department_by_domain_and_department_code()
  386. {
  387. var d = S0DimCatalog.Employee;
  388. Assert.Equal("dim_department", d.ParentDimTable);
  389. Assert.Equal(["domain_code", "department_code"], d.ParentKeyColumns);
  390. // 父键分量不得 Required:源侧 292 行未分配部门,Required 会把它们整行过滤掉
  391. Assert.False(d.Columns.Single(c => c.TargetColumn == "department_code").Required);
  392. // 依赖顺序:父必须排在子之前,否则孤儿判定时父维度还没就绪
  393. Assert.True(S0DimCatalog.All.ToList().IndexOf(S0DimCatalog.Department)
  394. < S0DimCatalog.All.ToList().IndexOf(d));
  395. }
  396. [Fact]
  397. public void Orphan_sql_must_not_report_unassigned_parent_key_as_orphan()
  398. {
  399. // 「未分配部门」(department_code IS NULL) 不是孤儿,「部门码找不到」才是。
  400. // <=> 是 NULL 安全等值,而 dim_department.department_code 为 NOT NULL,
  401. // 不加 IS NOT NULL 过滤会把 292 行未分配全部误报成孤儿,淹没 97 个真悬挂。
  402. foreach (var sql in new[]
  403. {
  404. S0DimSqlBuilder.BuildOrphanChildSql(S0DimCatalog.Employee, 10),
  405. S0DimSqlBuilder.BuildOrphanChildCountSql(S0DimCatalog.Employee)
  406. })
  407. {
  408. Assert.Contains("d.`domain_code` IS NOT NULL", sql, Ord);
  409. Assert.Contains("d.`department_code` IS NOT NULL", sql, Ord);
  410. Assert.Contains("p.`id` IS NULL", sql, Ord);
  411. Assert.Contains("d.`tenant_id` = @TenantId", sql, Ord);
  412. }
  413. }
  414. [Fact]
  415. public void Orphan_count_sql_must_not_be_truncated_by_sample_limit()
  416. {
  417. // 告警数字必须来自不截断的 COUNT,而不是被 OrphanSampleLimit 砍过的样本条数。
  418. var count = S0DimSqlBuilder.BuildOrphanChildCountSql(S0DimCatalog.Employee);
  419. Assert.Contains("SELECT COUNT(*)", count, Ord);
  420. Assert.DoesNotContain("LIMIT", count, Ord);
  421. }
  422. [Fact]
  423. public void Line_identity_must_be_tenant_and_line_only_no_legacy_scope()
  424. {
  425. // 按「当前业务证据优先」重判:CRUD 无查重、consumer 只用 (TenantId, Line)、
  426. // 且每租户在 LineMaster 只有 1 个 Domain/company/factory(租户内常量,零区分力)。
  427. // 旧物理 UK 含 company+factory,属 legacy scope,不予继承。
  428. var d = S0DimCatalog.Line;
  429. Assert.Equal(["tenant_id", "line_code"], d.BusinessKeyColumns);
  430. Assert.Equal(["Line"], d.SourceBizKeyColumns);
  431. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  432. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  433. Assert.Null(d.ParentDimTable);
  434. }
  435. [Fact]
  436. public void Line_must_read_real_describe_column_not_shadow()
  437. {
  438. // Describe 169/169 填充;line_describe 0/169——本仓第三例恒空影子列。
  439. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Line);
  440. Assert.Contains("$.Describe", sql, Ord);
  441. Assert.DoesNotContain("$.line_describe", sql, Ord);
  442. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`Describe`",
  443. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Line), Ord);
  444. Assert.DoesNotContain($"{S0DimSqlBuilder.SrcAlias}.`line_describe`",
  445. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Line), Ord);
  446. }
  447. [Theory]
  448. [InlineData("EMPLOYEE")]
  449. [InlineData("LINE")]
  450. [InlineData("CUSTOMER")]
  451. [InlineData("PERSON_SKILL")]
  452. public void New_dims_must_carry_tenant_predicate_and_no_upsert(string key)
  453. {
  454. var d = S0DimCatalog.Find(key)!;
  455. var insert = S0DimSqlBuilder.BuildInsertSql(d);
  456. Assert.Contains("s.`tenant_id` = @TenantId", insert, Ord);
  457. Assert.Contains("s.`source_system` = @SourceSystem", insert, Ord);
  458. Assert.Contains("s.`source_table` = @SourceTable", insert, Ord);
  459. Assert.Contains("s.`sync_batch_id` = @BatchId", insert, Ord);
  460. Assert.DoesNotContain("ON DUPLICATE KEY", insert, Ord);
  461. var purge = S0DimSqlBuilder.BuildPurgeStagingSql(d);
  462. Assert.Contains("s.`tenant_id` = @TenantId", purge, Ord);
  463. Assert.DoesNotContain("sync_batch_id", purge, Ord);
  464. }
  465. [Fact]
  466. public void PullContext_must_never_set_FactoryId_or_CompanyId()
  467. {
  468. // I-015:MdpStagingWriter 只识别 factory_id / FactoryId 两个列名。
  469. // LineMaster / EmployeeMaster 用的是 factory_ref_id → 解析为 null →
  470. // 一旦 ctx.FactoryId > 0,比较基准退化成 1,整批行被 `return 0` 静默丢弃。
  471. // 因此 S0 的 pullCtx 必须只按租户取范围,company/factory 只能是源侧身份字段。
  472. var src = File.ReadAllText(FindSourceFile("S0DimMaterializer.cs"));
  473. var ctxBlock = src[src.IndexOf("new MdpPullContext", Ord)..];
  474. ctxBlock = ctxBlock[..ctxBlock.IndexOf("};", Ord)];
  475. Assert.DoesNotContain("FactoryId", ctxBlock, Ord);
  476. Assert.DoesNotContain("CompanyId", ctxBlock, Ord);
  477. Assert.Contains("TenantId = tenantId", ctxBlock, Ord);
  478. Assert.Contains("RequireMatchingSourceTenant = true", ctxBlock, Ord);
  479. }
  480. private static string FindSourceFile(string fileName)
  481. {
  482. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  483. while (dir != null)
  484. {
  485. var candidate = Path.Combine(dir.FullName,
  486. "Admin.NET.Plugin.AiDOP", "DataPlatform", "S0Dim", fileName);
  487. if (File.Exists(candidate)) return candidate;
  488. dir = dir.Parent;
  489. }
  490. throw new FileNotFoundException($"未找到 {fileName}");
  491. }
  492. // ────────────────────────────────────────────────────────────────
  493. // Phase 2 · Batch 5:CustMaster / PersonSkill
  494. // ────────────────────────────────────────────────────────────────
  495. [Theory]
  496. [InlineData("CUSTOMER", "customer_code", "Cust")]
  497. [InlineData("PERSON_SKILL", "skill_code", "Code")]
  498. public void Batch5_identity_must_be_tenant_plus_code_only(string key, string dimCol, string srcCol)
  499. {
  500. // 身份按当前业务证据判定,不继承旧物理唯一键:
  501. // · Customer CRUD 查重 = tenant + FactoryRefId(=f(tenant)) + Cust ⇒ 等价 tenant + Cust
  502. // · PersonSkill 现有消费方已按 (租户, Code) 解析,并把同租户跨厂重码当歧义报错
  503. var d = S0DimCatalog.Find(key)!;
  504. Assert.Equal(["tenant_id", dimCol], d.BusinessKeyColumns);
  505. Assert.Equal([srcCol], d.SourceBizKeyColumns);
  506. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  507. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  508. }
  509. [Fact]
  510. public void PersonSkill_must_not_declare_fake_parent_to_employee_or_skill_bridge()
  511. {
  512. // EmpSkills.SkillNo 与 PersonSkill.Code 同租户实测 650 行中 632 行对不上,
  513. // 不能因为都叫 Skill 就建立虚假父子关系。
  514. Assert.Null(S0DimCatalog.PersonSkill.ParentDimTable);
  515. Assert.Null(S0DimCatalog.PersonSkill.ParentKeyColumns);
  516. }
  517. [Fact]
  518. public void PersonSkill_watermark_must_map_updated_at_not_created_at()
  519. {
  520. // UpdatedAt 是维护时间(源 5/46 非空 = 只有改过的行才有值);
  521. // 不得用 CreatedAt 冒充「最近更新」,那会让每行都显得刚被维护过。
  522. var col = S0DimCatalog.PersonSkill.Columns.Single(c => c.TargetColumn == "source_updated_at");
  523. Assert.Equal("UpdatedAt", col.JsonPath);
  524. Assert.False(col.Required);
  525. Assert.DoesNotContain("CreatedAt",
  526. S0DimCatalog.PersonSkill.Columns.Select(c => c.JsonPath ?? ""));
  527. }
  528. [Fact]
  529. public void Batch4_must_not_reuse_dopdemorq_staging_tables()
  530. {
  531. // mdp_stg_employee / mdp_stg_line 属 DOPDEMORQ(当前 0 行),不是 S0 的 authority
  532. foreach (var d in S0DimCatalog.All)
  533. {
  534. Assert.NotEqual("mdp_stg_employee", d.StagingTable);
  535. Assert.NotEqual("mdp_stg_line", d.StagingTable);
  536. Assert.NotEqual("mdp_stg_location", d.StagingTable);
  537. Assert.NotEqual("mdp_stg_customer", d.StagingTable);
  538. }
  539. }
  540. }