S0DimContractTests.cs 59 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167
  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. [
  27. // C1
  28. "WORK_CENTER", "DEPARTMENT", "LOCATION", "LOCATION_SHELF",
  29. "EMPLOYEE", "LINE", "CUSTOMER", "PERSON_SKILL",
  30. // C2
  31. "LABEL_TYPE", "SOP_FILE_TYPE", "QUALITY_BASE_TYPE",
  32. "SAMPLING_SCHEME", "INSPECTION_ITEM", "INSPECTION_METHOD",
  33. "INSPECTION_FREQUENCY", "INSPECTION_INSTRUMENT",
  34. // C1 扩展
  35. "ITEM",
  36. // C3
  37. "EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL",
  38. // C3-A/C/B 物料桥(全部父 = dim_item)
  39. "ITEM_OP_LINE", "ITEM_ROUTING_OP",
  40. "ITEM_OP_CONDITION", "ITEM_OP_IMAGE",
  41. "ITEM_BOM", "ITEM_SUBSTITUTE"
  42. ],
  43. S0DimCatalog.All.Select(d => d.Key).ToArray());
  44. Assert.All(S0DimCatalog.All, d => Assert.Same(d, S0DimCatalog.Find(d.Key)));
  45. Assert.All(S0DimCatalog.All, d => Assert.Equal(S0DimCatalog.LocalSourceSystem, d.SourceSystem));
  46. }
  47. [Fact]
  48. public void Catalog_find_should_be_case_insensitive_and_null_safe()
  49. {
  50. Assert.Same(S0DimCatalog.WorkCenter, S0DimCatalog.Find("work_center"));
  51. Assert.Same(S0DimCatalog.LocationShelf, S0DimCatalog.Find(" LOCATION_SHELF "));
  52. Assert.Null(S0DimCatalog.Find("NOT_A_KEY"));
  53. Assert.Null(S0DimCatalog.Find(null));
  54. }
  55. [Fact]
  56. public void Every_definition_must_declare_explicit_json_path()
  57. {
  58. foreach (var def in S0DimCatalog.All)
  59. foreach (var col in def.Columns.Where(c => c.Kind != S0DimValueKind.TenantIdColumn))
  60. Assert.False(string.IsNullOrWhiteSpace(col.JsonPath),
  61. $"{def.Key}.{col.TargetColumn} 必须显式声明 JsonPath");
  62. }
  63. [Fact]
  64. public void Definition_without_json_path_should_be_rejected()
  65. {
  66. var bad = S0DimCatalog.WorkCenter with
  67. {
  68. Columns =
  69. [
  70. new S0DimColumn("tenant_id", S0DimValueKind.TenantIdColumn),
  71. new S0DimColumn("domain_code", S0DimValueKind.Str, null, Required: true),
  72. new S0DimColumn("work_center_code", S0DimValueKind.Str, "WorkCtr", Required: true)
  73. ]
  74. };
  75. var ex = Assert.Throws<InvalidOperationException>(() => bad.Validate());
  76. Assert.Contains("JsonPath", ex.Message, Ord);
  77. }
  78. [Fact]
  79. public void Business_key_order_must_match_source_biz_key_expr()
  80. {
  81. // dim 业务键顺序与 mdp_entity.biz_key_expr 不一致时,stg.source_biz_key 无法与 dim 业务键对账
  82. var bad = S0DimCatalog.WorkCenter with { SourceBizKeyColumns = ["WorkCtr", "Domain"] };
  83. var ex = Assert.Throws<InvalidOperationException>(() => bad.Validate());
  84. Assert.Contains("业务键顺序不一致", ex.Message, Ord);
  85. }
  86. [Fact]
  87. public void Business_key_columns_must_be_required()
  88. {
  89. var cols = S0DimCatalog.WorkCenter.Columns
  90. .Select(c => c.TargetColumn == "domain_code" ? c with { Required = false } : c).ToList();
  91. var bad = S0DimCatalog.WorkCenter with { Columns = cols };
  92. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  93. }
  94. [Fact]
  95. public void Business_key_must_start_with_tenant_id()
  96. {
  97. var bad = S0DimCatalog.WorkCenter with
  98. {
  99. BusinessKeyColumns = ["domain_code", "work_center_code"],
  100. SourceBizKeyColumns = ["WorkCtr"]
  101. };
  102. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  103. }
  104. [Fact]
  105. public void Identifiers_must_be_whitelisted()
  106. {
  107. var bad = S0DimCatalog.WorkCenter with { DimTable = "dim_work_center; DROP TABLE x" };
  108. Assert.Throws<InvalidOperationException>(() => bad.Validate());
  109. }
  110. // ────────────────────────────────────────────────────────────────
  111. // LocationMaster 影子列黑名单(🔴 本仓最容易静默出错的一处)
  112. // ────────────────────────────────────────────────────────────────
  113. public static TheoryData<string> LocationShadowPaths() =>
  114. ["$.domain_code", "$.rec_id", "$.is_active", "$.physical_address", "$.create_user"];
  115. [Theory]
  116. [MemberData(nameof(LocationShadowPaths))]
  117. public void Location_insert_sql_must_never_read_shadow_columns(string shadowPath)
  118. {
  119. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Location);
  120. Assert.DoesNotContain(shadowPath, sql, Ord);
  121. }
  122. /// <summary>
  123. /// LocationMaster 影子列的**裸列名**(不带 <c>$.</c> 前缀)。
  124. ///
  125. /// <para>源侧校验和 / 业务键 SQL 把 <c>JsonPath</c> 当作**源表物理列名**用
  126. /// (<c>S0DimSqlBuilder.BuildSourceChecksumSql</c>:<c>a.`Domain`</c>),**整条语句里没有任何 <c>$.</c>**。
  127. /// 因此对这两个生成器断言 <c>DoesNotContain("$.domain_code")</c> 是**恒真**的,零保护 ——
  128. /// 必须断言反引号形式。</para>
  129. /// </summary>
  130. public static TheoryData<string> LocationShadowColumns() =>
  131. ["domain_code", "rec_id", "is_active", "physical_address", "create_user"];
  132. [Theory]
  133. [MemberData(nameof(LocationShadowColumns))]
  134. public void Location_source_side_sql_must_never_read_shadow_columns(string shadowColumn)
  135. {
  136. // 这是本批**唯一能静默通过全部对账**的缺陷类:
  137. // JsonPath 若误写成影子列,dim 侧读 staging 的 $.is_active(=0),
  138. // 源侧校验和读源表的 a.`is_active`(同样 =0)→ 两侧自洽 → checksum 相等 → PASS,
  139. // 而真实的 IsActive=1 被静默翻成 0。影子列存 falsy 而非 NULL,不会抛任何异常。
  140. var needle = $"{S0DimSqlBuilder.SrcAlias}.`{shadowColumn}`";
  141. Assert.DoesNotContain(needle, S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Location), Ord);
  142. Assert.DoesNotContain(needle, S0DimSqlBuilder.SourceBizKeySetSql(S0DimCatalog.Location), Ord);
  143. }
  144. [Theory]
  145. [InlineData("Domain")]
  146. [InlineData("IsActive")]
  147. [InlineData("PhysicalAddress")]
  148. [InlineData("UpdateTime")]
  149. public void Location_source_checksum_must_read_real_columns(string realColumn)
  150. {
  151. // 正向断言:光有 DoesNotContain 不够 —— 列被整个删掉时它也通过。
  152. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`{realColumn}`",
  153. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Location), Ord);
  154. }
  155. [Fact]
  156. public void Insert_and_source_checksum_must_read_the_same_declared_path()
  157. {
  158. // 通用护栏(不限 Location):dim 物化走 staging 的 $.JsonPath,
  159. // 源侧校验和走源表的 a.`JsonPath`。两者必须来自**同一个**声明,
  160. // 否则「写进 dim 的值」与「用来验证 dim 的值」根本不是一回事。
  161. foreach (var def in S0DimCatalog.All)
  162. {
  163. var insert = S0DimSqlBuilder.BuildInsertSql(def);
  164. var srcChk = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  165. foreach (var c in def.Columns)
  166. {
  167. if (c.Kind == S0DimValueKind.TenantIdColumn) continue;
  168. Assert.Contains($"$.{c.JsonPath}", insert, Ord);
  169. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`{c.JsonPath}`", srcChk, Ord);
  170. }
  171. }
  172. }
  173. [Fact]
  174. public void Source_and_dim_checksum_must_have_identical_segment_count()
  175. {
  176. // 位置对称性:两侧 CONCAT_WS 的段数/顺序错位会产出**完全自洽的错值**。
  177. // 段数相等是最容易机械校验的必要条件(顺序由 ChecksumColumns 同一函数保证)。
  178. foreach (var def in S0DimCatalog.All)
  179. {
  180. var src = S0DimSqlBuilder.BuildSourceChecksumSql(def);
  181. var dim = S0DimSqlBuilder.BuildDimChecksumSql(def);
  182. Assert.Equal(Occurrences(src, "IFNULL("), Occurrences(dim, "IFNULL("));
  183. Assert.Equal(Occurrences(src, "CONCAT_WS("), Occurrences(dim, "CONCAT_WS("));
  184. }
  185. }
  186. private static int Occurrences(string haystack, string needle)
  187. {
  188. var n = 0;
  189. for (var i = haystack.IndexOf(needle, Ord); i >= 0; i = haystack.IndexOf(needle, i + needle.Length, Ord))
  190. n++;
  191. return n;
  192. }
  193. [Fact]
  194. public void Required_string_columns_must_also_reject_empty_string()
  195. {
  196. // IS NOT NULL 挡不住空串,而 CONCAT_WS 会保留空段:
  197. // Domain='' 会拼出 '#LOC01' 这种「形似合法」的业务键,且不会被 blank_cnt 探针捕获
  198. // (它只捕获整串全空)。故 Required 的 Str 列必须同时有 <> '' 守卫。
  199. foreach (var def in S0DimCatalog.All)
  200. {
  201. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  202. foreach (var c in def.Columns)
  203. {
  204. if (c.Kind != S0DimValueKind.Str || !c.Required) continue;
  205. // 必须逐列绑定:整串级的 Assert.Contains("<> ''") 在「5 个 Required 列只有 1 个带守卫」时照样通过。
  206. var expr = MdpJsonSql.Str(S0DimSqlBuilder.StgAlias, c.JsonPath!);
  207. Assert.Contains($"({expr}) IS NOT NULL", sql, Ord);
  208. Assert.Contains($"({expr}) <> ''", sql, Ord);
  209. }
  210. }
  211. }
  212. [Fact]
  213. public void Every_business_key_component_must_be_required_and_of_key_safe_kind()
  214. {
  215. // 守住上一条测试的前提:若将来把某个业务键分量改成非 Required,
  216. // 空串守卫会连同 NULL 守卫一起消失,而上一条测试仍会通过(它只遍历 Required 列)。
  217. foreach (var def in S0DimCatalog.All)
  218. {
  219. foreach (var bk in def.BusinessKeyColumns.Skip(1))
  220. {
  221. var col = def.Columns.First(x => x.TargetColumn == bk);
  222. Assert.True(col.Required, $"[{def.Key}] 业务键分量 {bk} 必须 Required");
  223. Assert.True(col.Kind is S0DimValueKind.Str or S0DimValueKind.Int,
  224. $"[{def.Key}] 业务键分量 {bk} 的 Kind={col.Kind} 不适合做键" +
  225. "(Dec 有精度格式化差异、DateTimeSec 截秒、BoolTrue 基数过低)");
  226. }
  227. }
  228. }
  229. public static TheoryData<string> LocationRealPaths() =>
  230. ["$.Domain", "$.IsActive", "$.PhysicalAddress", "$.location", "$.descr", "$.typed", "$.storer", "$.UpdateTime"];
  231. [Theory]
  232. [MemberData(nameof(LocationRealPaths))]
  233. public void Location_insert_sql_must_read_real_columns(string realPath)
  234. {
  235. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Location);
  236. Assert.Contains(realPath, sql, Ord);
  237. }
  238. [Fact]
  239. public void Location_shadow_blacklist_must_not_be_applied_to_shelf()
  240. {
  241. // LocationShelfMaster 本身就是 snake_case、无影子列:
  242. // 这里的 $.domain_code / $.update_time 是**正确**的物理列名,不适用父表的禁用规则
  243. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.LocationShelf);
  244. Assert.Contains("$.domain_code", sql, Ord);
  245. Assert.Contains("$.location", sql, Ord);
  246. Assert.Contains("$.inv_shelf", sql, Ord);
  247. Assert.Contains("$.update_time", sql, Ord);
  248. Assert.DoesNotContain("$.Domain'", sql, Ord);
  249. Assert.DoesNotContain("$.InvShelf", sql, Ord);
  250. }
  251. // ────────────────────────────────────────────────────────────────
  252. // 租户隔离:所有生成的 SQL 都必须带租户谓词
  253. // ────────────────────────────────────────────────────────────────
  254. [Fact]
  255. public void Insert_sql_must_filter_tenant_source_table_and_batch()
  256. {
  257. foreach (var def in S0DimCatalog.All)
  258. {
  259. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  260. Assert.Contains("s.`tenant_id` = @TenantId", sql, Ord);
  261. Assert.Contains("s.`source_system` = @SourceSystem", sql, Ord);
  262. Assert.Contains("s.`source_table` = @SourceTable", sql, Ord);
  263. Assert.Contains("s.`sync_batch_id` = @BatchId", sql, Ord);
  264. }
  265. }
  266. [Fact]
  267. public void Insert_sql_must_not_use_on_duplicate_key_update()
  268. {
  269. // 源侧业务键重复必须撞 dim 唯一键并回滚(transform fail),绝不 last-wins
  270. foreach (var def in S0DimCatalog.All)
  271. Assert.DoesNotContain("ON DUPLICATE KEY", S0DimSqlBuilder.BuildInsertSql(def), StringComparison.OrdinalIgnoreCase);
  272. }
  273. [Fact]
  274. public void Purge_sql_must_carry_all_three_predicates()
  275. {
  276. foreach (var def in S0DimCatalog.All)
  277. {
  278. var sql = S0DimSqlBuilder.BuildPurgeStagingSql(def);
  279. Assert.Contains("s.`tenant_id` = @TenantId", sql, Ord);
  280. Assert.Contains("s.`source_system` = @SourceSystem", sql, Ord);
  281. Assert.Contains("s.`source_table` = @SourceTable", sql, Ord);
  282. // purge 不限批次:本轮之前的残留也要清掉
  283. Assert.DoesNotContain("sync_batch_id", sql, Ord);
  284. }
  285. }
  286. [Fact]
  287. public void All_count_and_set_sql_must_be_tenant_scoped()
  288. {
  289. foreach (var def in S0DimCatalog.All)
  290. {
  291. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildSourceCountSql(def), Ord);
  292. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildStagingCountSql(def), Ord);
  293. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimCountSql(def), Ord);
  294. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimBatchImpuritySql(def), Ord);
  295. Assert.Contains("@TenantId", S0DimSqlBuilder.SourceBizKeySetSql(def), Ord);
  296. Assert.Contains("@TenantId", S0DimSqlBuilder.StagingBizKeySetSql(def), Ord);
  297. Assert.Contains("@TenantId", S0DimSqlBuilder.DimBizKeySetSql(def), Ord);
  298. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildSourceChecksumSql(def), Ord);
  299. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimChecksumSql(def), Ord);
  300. Assert.Contains("@TenantId", S0DimSqlBuilder.BuildDimDuplicateBizKeySql(def), Ord);
  301. }
  302. }
  303. [Fact]
  304. public void Bool_columns_must_carry_recognizability_guard()
  305. {
  306. // MdpJsonSql.BoolTrue 对 null / 不可识别值静默返回 0;必须有守卫把这类行挡在 dim 之外。
  307. // 遍历全部维度并逐列绑定:只测 WorkCenter 会漏掉 Location / Department 的 BoolTrue 列。
  308. var covered = 0;
  309. foreach (var def in S0DimCatalog.All)
  310. {
  311. var sql = S0DimSqlBuilder.BuildInsertSql(def);
  312. foreach (var c in def.Columns)
  313. {
  314. if (c.Kind != S0DimValueKind.BoolTrue) continue;
  315. var raw = MdpJsonSql.Raw(S0DimSqlBuilder.StgAlias, c.JsonPath!);
  316. Assert.Contains($"LOWER({raw}) IN ('1','0','true','false')", sql, Ord);
  317. covered++;
  318. }
  319. }
  320. Assert.True(covered > 0, "目录里一个 BoolTrue 列都没有,本测试已失去意义");
  321. }
  322. [Fact]
  323. public void Checksum_must_use_exact_integer_summation_not_double()
  324. {
  325. // 回归守卫(2026-09-07 Batch 2 实测缺陷):CONV() 返回字符串,SUM(字符串) 在 MySQL 里按 DOUBLE 累加,
  326. // 只有约 16 位有效数字;两侧扫描顺序不同会产生不同舍入,导致完全相同的数据被误判为 checksum 不一致
  327. // (租户 838257186181189:source=…980000 / dim=…980700,count 相同)。
  328. // 必须先 CAST 成 UNSIGNED,SUM 才走 DECIMAL 精确累加、与顺序无关。
  329. foreach (var def in S0DimCatalog.All)
  330. {
  331. foreach (var sql in new[] { S0DimSqlBuilder.BuildSourceChecksumSql(def), S0DimSqlBuilder.BuildDimChecksumSql(def) })
  332. {
  333. Assert.Contains("SUM(CAST(CONV(", sql, Ord);
  334. Assert.Contains("AS UNSIGNED))", sql, Ord);
  335. Assert.DoesNotContain("SUM(CONV(", sql, Ord);
  336. }
  337. }
  338. }
  339. [Fact]
  340. public void Shelf_definition_declares_parent_and_location_declares_mirror_unique()
  341. {
  342. Assert.Equal("dim_location", S0DimCatalog.LocationShelf.ParentDimTable);
  343. Assert.Equal(["domain_code", "location_code"], S0DimCatalog.LocationShelf.ParentKeyColumns);
  344. Assert.Equal(["tenant_id", "location_code"], S0DimCatalog.Location.MirrorUniqueColumns);
  345. var orphanSql = S0DimSqlBuilder.BuildOrphanChildSql(S0DimCatalog.LocationShelf, 10);
  346. Assert.Contains("dim_location", orphanSql, Ord);
  347. Assert.Contains("@TenantId", orphanSql, Ord);
  348. }
  349. [Fact]
  350. public void Work_center_dim_must_not_invent_company_or_factory_columns()
  351. {
  352. // 源 WorkCtrMaster 没有 company_ref_id / factory_ref_id,dim 不得凭空补
  353. var targets = S0DimCatalog.WorkCenter.Columns.Select(c => c.TargetColumn).ToList();
  354. Assert.DoesNotContain("company_id", targets);
  355. Assert.DoesNotContain("factory_id", targets);
  356. }
  357. [Fact]
  358. public void Shelf_dim_must_not_invent_is_active()
  359. {
  360. // 源 LocationShelfMaster 没有 IsActive 列,不发明状态语义
  361. var targets = S0DimCatalog.LocationShelf.Columns.Select(c => c.TargetColumn).ToList();
  362. Assert.DoesNotContain("is_active", targets);
  363. }
  364. [Fact]
  365. public void Two_definitions_may_share_staging_but_never_share_dim()
  366. {
  367. Assert.Equal(S0DimCatalog.Location.StagingTable, S0DimCatalog.LocationShelf.StagingTable);
  368. Assert.NotEqual(S0DimCatalog.Location.SourceTable, S0DimCatalog.LocationShelf.SourceTable);
  369. // 与维度数量解耦:新增维度不该让这条断言失效,它要守的是「dim 表不共用」这个不变量
  370. Assert.Equal(S0DimCatalog.All.Count, S0DimCatalog.All.Select(d => d.DimTable).Distinct().Count());
  371. }
  372. // ────────────────────────────────────────────────────────────────
  373. // Phase 2 · Batch 4:EmployeeMaster / LineMaster
  374. // ────────────────────────────────────────────────────────────────
  375. [Fact]
  376. public void Employee_identity_must_be_tenant_domain_employee()
  377. {
  378. var d = S0DimCatalog.Employee;
  379. Assert.Equal(["tenant_id", "employee_code"], d.BusinessKeyColumns);
  380. Assert.Equal(["Employee"], d.SourceBizKeyColumns);
  381. // legacy scope 不得混进身份
  382. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  383. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  384. Assert.Equal("EmployeeMaster", d.SourceTable);
  385. Assert.Equal("mdp_stg_s0_employee", d.StagingTable);
  386. Assert.Equal("dim_employee", d.DimTable);
  387. }
  388. [Fact]
  389. public void Employee_sys_user_id_must_be_attribute_only_never_business_key()
  390. {
  391. // 源实测 1105 行中仅 7 行非空(0.6%):把账号 id 当员工身份会让 99.4% 的员工无法标识。
  392. var d = S0DimCatalog.Employee;
  393. Assert.DoesNotContain("sys_user_id", d.BusinessKeyColumns);
  394. Assert.DoesNotContain("sys_user_id", d.SourceBizKeyColumns);
  395. Assert.DoesNotContain("sys_user_id", d.ParentKeyColumns!);
  396. var col = d.Columns.Single(c => c.TargetColumn == "sys_user_id");
  397. Assert.False(col.Required); // 必须可空,否则 1098 行会被 RowGuards 过滤掉
  398. Assert.Equal("sys_user_id", col.JsonPath);
  399. }
  400. [Fact]
  401. public void Employee_parent_is_department_by_domain_and_department_code()
  402. {
  403. var d = S0DimCatalog.Employee;
  404. Assert.Equal("dim_department", d.ParentDimTable);
  405. Assert.Equal(["domain_code", "department_code"], d.ParentKeyColumns);
  406. // 父键分量不得 Required:源侧 292 行未分配部门,Required 会把它们整行过滤掉
  407. Assert.False(d.Columns.Single(c => c.TargetColumn == "department_code").Required);
  408. // 依赖顺序:父必须排在子之前,否则孤儿判定时父维度还没就绪
  409. Assert.True(S0DimCatalog.All.ToList().IndexOf(S0DimCatalog.Department)
  410. < S0DimCatalog.All.ToList().IndexOf(d));
  411. }
  412. [Fact]
  413. public void Orphan_sql_must_not_report_unassigned_parent_key_as_orphan()
  414. {
  415. // 「未分配部门」(department_code IS NULL) 不是孤儿,「部门码找不到」才是。
  416. // <=> 是 NULL 安全等值,而 dim_department.department_code 为 NOT NULL,
  417. // 不加 IS NOT NULL 过滤会把 292 行未分配全部误报成孤儿,淹没 97 个真悬挂。
  418. foreach (var sql in new[]
  419. {
  420. S0DimSqlBuilder.BuildOrphanChildSql(S0DimCatalog.Employee, 10),
  421. S0DimSqlBuilder.BuildOrphanChildCountSql(S0DimCatalog.Employee)
  422. })
  423. {
  424. Assert.Contains("d.`domain_code` IS NOT NULL", sql, Ord);
  425. Assert.Contains("d.`department_code` IS NOT NULL", sql, Ord);
  426. Assert.Contains("p.`id` IS NULL", sql, Ord);
  427. Assert.Contains("d.`tenant_id` = @TenantId", sql, Ord);
  428. }
  429. }
  430. [Fact]
  431. public void Orphan_count_sql_must_not_be_truncated_by_sample_limit()
  432. {
  433. // 告警数字必须来自不截断的 COUNT,而不是被 OrphanSampleLimit 砍过的样本条数。
  434. var count = S0DimSqlBuilder.BuildOrphanChildCountSql(S0DimCatalog.Employee);
  435. Assert.Contains("SELECT COUNT(*)", count, Ord);
  436. Assert.DoesNotContain("LIMIT", count, Ord);
  437. }
  438. [Fact]
  439. public void Line_identity_must_be_tenant_and_line_only_no_legacy_scope()
  440. {
  441. // 按「当前业务证据优先」重判:CRUD 无查重、consumer 只用 (TenantId, Line)、
  442. // 且每租户在 LineMaster 只有 1 个 Domain/company/factory(租户内常量,零区分力)。
  443. // 旧物理 UK 含 company+factory,属 legacy scope,不予继承。
  444. var d = S0DimCatalog.Line;
  445. Assert.Equal(["tenant_id", "line_code"], d.BusinessKeyColumns);
  446. Assert.Equal(["Line"], d.SourceBizKeyColumns);
  447. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  448. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  449. Assert.Null(d.ParentDimTable);
  450. }
  451. [Fact]
  452. public void Line_must_read_real_describe_column_not_shadow()
  453. {
  454. // Describe 169/169 填充;line_describe 0/169——本仓第三例恒空影子列。
  455. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.Line);
  456. Assert.Contains("$.Describe", sql, Ord);
  457. Assert.DoesNotContain("$.line_describe", sql, Ord);
  458. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`Describe`",
  459. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Line), Ord);
  460. Assert.DoesNotContain($"{S0DimSqlBuilder.SrcAlias}.`line_describe`",
  461. S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.Line), Ord);
  462. }
  463. [Theory]
  464. [InlineData("EMPLOYEE")]
  465. [InlineData("LINE")]
  466. [InlineData("CUSTOMER")]
  467. [InlineData("PERSON_SKILL")]
  468. public void New_dims_must_carry_tenant_predicate_and_no_upsert(string key)
  469. {
  470. var d = S0DimCatalog.Find(key)!;
  471. var insert = S0DimSqlBuilder.BuildInsertSql(d);
  472. Assert.Contains("s.`tenant_id` = @TenantId", insert, Ord);
  473. Assert.Contains("s.`source_system` = @SourceSystem", insert, Ord);
  474. Assert.Contains("s.`source_table` = @SourceTable", insert, Ord);
  475. Assert.Contains("s.`sync_batch_id` = @BatchId", insert, Ord);
  476. Assert.DoesNotContain("ON DUPLICATE KEY", insert, Ord);
  477. var purge = S0DimSqlBuilder.BuildPurgeStagingSql(d);
  478. Assert.Contains("s.`tenant_id` = @TenantId", purge, Ord);
  479. Assert.DoesNotContain("sync_batch_id", purge, Ord);
  480. }
  481. [Fact]
  482. public void PullContext_must_never_set_FactoryId_or_CompanyId()
  483. {
  484. // I-015:MdpStagingWriter 只识别 factory_id / FactoryId 两个列名。
  485. // LineMaster / EmployeeMaster 用的是 factory_ref_id → 解析为 null →
  486. // 一旦 ctx.FactoryId > 0,比较基准退化成 1,整批行被 `return 0` 静默丢弃。
  487. // 因此 S0 的 pullCtx 必须只按租户取范围,company/factory 只能是源侧身份字段。
  488. var src = File.ReadAllText(FindSourceFile("S0DimMaterializer.cs"));
  489. var ctxBlock = src[src.IndexOf("new MdpPullContext", Ord)..];
  490. ctxBlock = ctxBlock[..ctxBlock.IndexOf("};", Ord)];
  491. Assert.DoesNotContain("FactoryId", ctxBlock, Ord);
  492. Assert.DoesNotContain("CompanyId", ctxBlock, Ord);
  493. Assert.Contains("TenantId = tenantId", ctxBlock, Ord);
  494. Assert.Contains("RequireMatchingSourceTenant = true", ctxBlock, Ord);
  495. }
  496. private static string FindSourceFile(string fileName)
  497. {
  498. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  499. while (dir != null)
  500. {
  501. var candidate = Path.Combine(dir.FullName,
  502. "Admin.NET.Plugin.AiDOP", "DataPlatform", "S0Dim", fileName);
  503. if (File.Exists(candidate)) return candidate;
  504. dir = dir.Parent;
  505. }
  506. throw new FileNotFoundException($"未找到 {fileName}");
  507. }
  508. // ────────────────────────────────────────────────────────────────
  509. // Phase 2 · Batch 5:CustMaster / PersonSkill
  510. // ────────────────────────────────────────────────────────────────
  511. [Theory]
  512. [InlineData("CUSTOMER", "customer_code", "Cust")]
  513. [InlineData("PERSON_SKILL", "skill_code", "Code")]
  514. public void Batch5_identity_must_be_tenant_plus_code_only(string key, string dimCol, string srcCol)
  515. {
  516. // 身份按当前业务证据判定,不继承旧物理唯一键:
  517. // · Customer CRUD 查重 = tenant + FactoryRefId(=f(tenant)) + Cust ⇒ 等价 tenant + Cust
  518. // · PersonSkill 现有消费方已按 (租户, Code) 解析,并把同租户跨厂重码当歧义报错
  519. var d = S0DimCatalog.Find(key)!;
  520. Assert.Equal(["tenant_id", dimCol], d.BusinessKeyColumns);
  521. Assert.Equal([srcCol], d.SourceBizKeyColumns);
  522. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  523. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  524. }
  525. [Fact]
  526. public void PersonSkill_must_not_declare_fake_parent_to_employee_or_skill_bridge()
  527. {
  528. // EmpSkills.SkillNo 与 PersonSkill.Code 同租户实测 650 行中 632 行对不上,
  529. // 不能因为都叫 Skill 就建立虚假父子关系。
  530. Assert.Null(S0DimCatalog.PersonSkill.ParentDimTable);
  531. Assert.Null(S0DimCatalog.PersonSkill.ParentKeyColumns);
  532. }
  533. [Fact]
  534. public void PersonSkill_watermark_must_map_updated_at_not_created_at()
  535. {
  536. // UpdatedAt 是维护时间(源 5/46 非空 = 只有改过的行才有值);
  537. // 不得用 CreatedAt 冒充「最近更新」,那会让每行都显得刚被维护过。
  538. var col = S0DimCatalog.PersonSkill.Columns.Single(c => c.TargetColumn == "source_updated_at");
  539. Assert.Equal("UpdatedAt", col.JsonPath);
  540. Assert.False(col.Required);
  541. Assert.DoesNotContain("CreatedAt",
  542. S0DimCatalog.PersonSkill.Columns.Select(c => c.JsonPath ?? ""));
  543. }
  544. // ────────────────────────────────────────────────────────────────
  545. // C2 / C3:本轮实测出的框架地雷,钉在测试里
  546. // ────────────────────────────────────────────────────────────────
  547. [Fact]
  548. public void Qms_status_columns_must_be_str_never_booltrue()
  549. {
  550. // 实测值域含 `已审核` / `已启用` / `A` / `ENABLED` / `INVALID_STATUS`。
  551. // 用 BoolTrue 会命中 RowGuards 的可辨识性守卫:4 张表整表归零,
  552. // 而 qms_inspectionfreq.FENABLE 恰好存活 8/13 —— 部分静默丢失,最难发现。
  553. foreach (var key in new[] { "SAMPLING_SCHEME", "INSPECTION_ITEM", "INSPECTION_METHOD",
  554. "INSPECTION_FREQUENCY", "INSPECTION_INSTRUMENT" })
  555. {
  556. var d = S0DimCatalog.Find(key)!;
  557. foreach (var name in new[] { "status_code", "enable_code" })
  558. {
  559. var col = d.Columns.FirstOrDefault(c => c.TargetColumn == name);
  560. if (col is null) continue;
  561. Assert.Equal(S0DimValueKind.Str, col.Kind);
  562. }
  563. }
  564. }
  565. [Fact]
  566. public void InspectionFrequency_remark_must_map_FREMARK_not_FCOMMENT()
  567. {
  568. // 兄弟表都叫 FCOMMENT,唯独本表叫 FREMARK。照抄会取到不存在的列。
  569. var col = S0DimCatalog.InspectionFrequency.Columns.Single(c => c.TargetColumn == "remark");
  570. Assert.Equal("FREMARK", col.JsonPath);
  571. }
  572. [Fact]
  573. public void SopFileType_must_not_declare_watermark_it_has_no_time_column()
  574. {
  575. // 源 ImageType 只有 5 列、无任何时间列。不得为"标准字段齐整"而回填。
  576. Assert.DoesNotContain("source_updated_at",
  577. S0DimCatalog.SopFileType.Columns.Select(c => c.TargetColumn));
  578. }
  579. [Fact]
  580. public void LinePostSkill_effective_date_must_read_StartDate_not_the_shadow_column()
  581. {
  582. // 🔴 目标列名 effective_date 与源表那个 0/261 的影子列同名,是本批最易取错的一处。
  583. var col = S0DimCatalog.LinePostSkill.Columns.Single(c => c.TargetColumn == "effective_date");
  584. Assert.Equal("StartDate", col.JsonPath);
  585. var insert = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.LinePostSkill);
  586. Assert.Contains("$.StartDate", insert, Ord);
  587. Assert.DoesNotContain("$.effective_date", insert, Ord);
  588. var srcChk = S0DimSqlBuilder.BuildSourceChecksumSql(S0DimCatalog.LinePostSkill);
  589. Assert.Contains($"{S0DimSqlBuilder.SrcAlias}.`StartDate`", srcChk, Ord);
  590. Assert.DoesNotContain($"{S0DimSqlBuilder.SrcAlias}.`effective_date`", srcChk, Ord);
  591. // 另三对发散型影子列同样禁用
  592. foreach (var shadow in new[] { "id", "line_skill_master_id", "person_skill_id" })
  593. Assert.DoesNotContain($"{S0DimSqlBuilder.SrcAlias}.`{shadow}`", srcChk, Ord);
  594. }
  595. [Fact]
  596. public void LinePostSkill_parent_must_use_natural_key_not_surrogate()
  597. {
  598. // 代理键已污染:LineSkillRecID 同租户孤儿 210/261,PersonSkillId 250/261 跨租户。
  599. var d = S0DimCatalog.LinePostSkill;
  600. Assert.Equal("dim_line_post", d.ParentDimTable);
  601. Assert.Equal(["line_code", "job_no"], d.ParentKeyColumns);
  602. // 父键必须与 dim_line_post 业务键(去 tenant)逐字一致
  603. Assert.Equal(S0DimCatalog.LinePost.BusinessKeyColumns.Skip(1), d.ParentKeyColumns);
  604. // legacy id 只能是普通属性,绝不入键
  605. foreach (var legacy in new[] { "legacy_line_skill_rec_id", "legacy_person_skill_id" })
  606. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  607. }
  608. [Fact]
  609. public void EmpWorkDuty_identity_must_keep_location()
  610. {
  611. // 实测 tenant+Employee+Duty 有 1 组重复(G00308 同 Duty 下 Location 1001 vs 1000)。
  612. // location 是真实区分维,不是 legacy scope,不可简化掉。
  613. var d = S0DimCatalog.EmpWorkDuty;
  614. Assert.Equal(["tenant_id", "employee_code", "duty", "location_code"], d.BusinessKeyColumns);
  615. Assert.Equal(["Employee", "Duty", "Location"], d.SourceBizKeyColumns);
  616. Assert.Equal("dim_employee", d.ParentDimTable);
  617. Assert.Equal(["employee_code"], d.ParentKeyColumns);
  618. }
  619. [Fact]
  620. public void LinePost_run_crew_precision_must_match_source_decimal_10_3()
  621. {
  622. // 对账两侧都走 CAST(expr AS CHAR):scale 不同会让 '0.000' 与 '0.0' 不等 → 校验和 FAIL。
  623. var col = S0DimCatalog.LinePost.Columns.Single(c => c.TargetColumn == "run_crew");
  624. Assert.Equal(S0DimValueKind.Dec, col.Kind);
  625. Assert.Equal(10, col.Precision);
  626. Assert.Equal(3, col.Scale);
  627. }
  628. [Fact]
  629. public void C2_and_C3_identities_must_not_contain_legacy_scope()
  630. {
  631. foreach (var key in new[] { "LABEL_TYPE", "SOP_FILE_TYPE", "QUALITY_BASE_TYPE",
  632. "SAMPLING_SCHEME", "INSPECTION_ITEM", "INSPECTION_METHOD",
  633. "INSPECTION_FREQUENCY", "INSPECTION_INSTRUMENT",
  634. "LINE_POST", "LINE_POST_SKILL" })
  635. {
  636. var d = S0DimCatalog.Find(key)!;
  637. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id" })
  638. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  639. }
  640. }
  641. // ════════════════════════════════════════════════════════════════════════════
  642. // 同库集合式贴源快路径(S0DimSameDbStagingLoader / BuildSameDbStagingInsertSql)
  643. //
  644. // 这些断言钉死的是「快路径与旧逐行 MdpStagingWriter 的语义等价」。每一条对应一个
  645. // 在 aidopdev 上实测过的差异点;改动这些表达式前先看注释里的实测值。
  646. // ════════════════════════════════════════════════════════════════════════════
  647. /// <summary>构造一列源列描述,便于各用例复用。</summary>
  648. private static S0DimSourceColumn Col(string name, string dataType, string? columnType = null, bool nullable = true)
  649. => new(name, dataType, columnType ?? dataType, nullable);
  650. private static readonly IReadOnlySet<string> StgCols =
  651. new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  652. { "tenant_id", "factory_id", "source_system", "source_table", "source_row_id",
  653. "source_biz_key", "raw_data", "sync_batch_id", "sync_time", "process_status", "create_time", "update_time" };
  654. private static string SameDbSql(params S0DimSourceColumn[] cols) =>
  655. S0DimSqlBuilder.BuildSameDbStagingInsertSql(
  656. S0DimCatalog.Item, cols, StgCols, ["RecID"]);
  657. [Fact]
  658. public void SameDb_unsigned_integer_json_type_label_is_downstream_irrelevant()
  659. {
  660. // 已知差异(如实登记,非缺陷):bit(1) 与 `tinyint(1) unsigned zerofill` 在
  661. // 旧路径的 raw_data 里 JSON_TYPE = INTEGER,快路径则是 UNSIGNED INTEGER。
  662. //
  663. // 为什么不去消除它:downstream 只经 MdpJsonSql 读值,而它一律走
  664. // JSON_UNQUOTE(JSON_EXTRACT(...)) 再 CAST —— 两种标签下取出的文本完全相同。
  665. // 实证:同一批 16150 行真实数据,旧路径与快路径产出的 dim 校验和**逐位相同**
  666. // (9215906568012255809101),即该标签差异对 dim 契约零影响。
  667. //
  668. // 本断言钉死「MdpJsonSql 不依赖 JSON_TYPE」这一前提 —— 一旦有人改成按类型分支,
  669. // 上述等价性即不再成立,此测试会失败并把人引到这段注释。
  670. foreach (var expr in new[]
  671. {
  672. MdpJsonSql.Str("s", "X"), MdpJsonSql.Int("s", "X"),
  673. MdpJsonSql.Dec("s", "X", 18, 5), MdpJsonSql.DateTimeSec("s", "X"),
  674. MdpJsonSql.BoolTrue("s", "X"), MdpJsonSql.Raw("s", "X")
  675. })
  676. Assert.DoesNotContain("JSON_TYPE", expr, Ord);
  677. }
  678. // ════════════════════════════════════════════════════════════════════════════
  679. // C3-A 物料桥(ProdLineDetail / RoutingOpDetail)
  680. // 每条断言都对应一个实测过的取错点;改这些映射前先看 S0DimCatalog 里的类型注释。
  681. // ════════════════════════════════════════════════════════════════════════════
  682. /// <summary>全部 6 张 C3 物料桥 —— 通则断言对每一张都必须成立。</summary>
  683. public static TheoryData<string> C3aItemBridges() =>
  684. [
  685. "ITEM_OP_LINE", "ITEM_ROUTING_OP",
  686. "ITEM_OP_CONDITION", "ITEM_OP_IMAGE",
  687. "ITEM_BOM", "ITEM_SUBSTITUTE"
  688. ];
  689. [Theory][MemberData(nameof(C3aItemBridges))]
  690. public void C3a_bridges_must_parent_dim_item_by_natural_key(string key)
  691. {
  692. // 父连接走 p.<col> <=> d.<col> **同名列** join(S0DimSqlBuilder.OrphanFromWhere),
  693. // 而 dim_item 的身份列叫 item_code —— 子表若命名为 item_num,Validate() 会过、
  694. // 运行时 join 报未知列。
  695. var d = S0DimCatalog.All.Single(x => x.Key == key);
  696. Assert.Equal("dim_item", d.ParentDimTable);
  697. Assert.Equal(["item_code"], d.ParentKeyColumns);
  698. Assert.Contains("item_code", d.BusinessKeyColumns);
  699. // 绝不能用 dim 代理 id 建关系:每轮 FULL REPLACE 后 dim_item.id 都会变
  700. Assert.DoesNotContain("dim_item_id", d.Columns.Select(c => c.TargetColumn));
  701. }
  702. [Theory][MemberData(nameof(C3aItemBridges))]
  703. public void C3a_identities_must_not_contain_legacy_scope(string key)
  704. {
  705. var d = S0DimCatalog.All.Single(x => x.Key == key);
  706. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id", "work_center_code", "route_code" })
  707. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  708. Assert.Equal("tenant_id", d.BusinessKeyColumns[0]);
  709. }
  710. [Fact]
  711. public void ItemOpLine_must_read_real_equipment_column_not_the_dead_shadow()
  712. {
  713. // 真列 InternalEquipmentCode 有 2430/10646 行有值;
  714. // 同表 internal_equipment_type_code 是 0/10646 的死影子列,取错会全 NULL 且不报错。
  715. var paths = S0DimCatalog.ItemOpLine.Columns.Select(c => c.JsonPath ?? "").ToList();
  716. Assert.Contains("InternalEquipmentCode", paths);
  717. Assert.DoesNotContain("internal_equipment_type_code", paths);
  718. var sql = S0DimSqlBuilder.BuildInsertSql(S0DimCatalog.ItemOpLine);
  719. Assert.DoesNotContain("internal_equipment_type_code", sql, Ord);
  720. }
  721. [Fact]
  722. public void ItemOpLine_run_crew_must_be_string_not_decimal()
  723. {
  724. // 🔴 ProdLineDetail.RunCrew 是 varchar(8);LinePost 的同名列源自 LineSkillMaster
  725. // 是 decimal(10,3)。同名不同型,照抄会当场类型出错。
  726. Assert.Equal(S0DimValueKind.Str,
  727. S0DimCatalog.ItemOpLine.Columns.Single(c => c.TargetColumn == "run_crew").Kind);
  728. Assert.Equal(S0DimValueKind.Dec,
  729. S0DimCatalog.LinePost.Columns.Single(c => c.TargetColumn == "run_crew").Kind);
  730. }
  731. [Fact]
  732. public void ItemOpLine_line_code_must_stay_in_identity()
  733. {
  734. // 去掉 Line 后 tenant+Part+Op 有 197 组 / 394 行重复 —— 不可逆的键收缩
  735. Assert.Equal(["tenant_id", "item_code", "operation_no", "line_code"],
  736. S0DimCatalog.ItemOpLine.BusinessKeyColumns);
  737. Assert.Equal(["Part", "Op", "Line"], S0DimCatalog.ItemOpLine.SourceBizKeyColumns);
  738. }
  739. [Fact]
  740. public void ItemRoutingOp_watermark_must_read_UpdatedAt_not_UpdateTime()
  741. {
  742. // 🔴 本表时间列叫 UpdatedAt/CreatedAt。写成全仓通用的 UpdateTime 不会报错,
  743. // JSON 键不存在 → JSON_EXTRACT 返回 SQL NULL → 该列恒 NULL,静默失真。
  744. Assert.Equal("UpdatedAt",
  745. S0DimCatalog.ItemRoutingOp.Columns.Single(c => c.TargetColumn == "source_updated_at").JsonPath);
  746. Assert.DoesNotContain("UpdateTime", S0DimCatalog.ItemRoutingOp.Columns.Select(c => c.JsonPath ?? ""));
  747. }
  748. public static TheoryData<string> RoutingOpForbiddenColumns() =>
  749. // 六组新旧列近重复 + 两类不可物化的布尔。取错得到的不是 NULL 而是「看起来合法的错值」。
  750. ["RoutingCode", "Descr", "MilestoneOperation", "MilestoneOp", "IsActive", "IsEnabled", "UpdateTime"];
  751. [Theory][MemberData(nameof(RoutingOpForbiddenColumns))]
  752. public void ItemRoutingOp_must_never_read_legacy_or_unmaterializable_columns(string forbidden)
  753. {
  754. Assert.DoesNotContain(forbidden, S0DimCatalog.ItemRoutingOp.Columns.Select(c => c.JsonPath ?? ""));
  755. }
  756. [Fact]
  757. public void ItemRoutingOp_std_op_must_be_attribute_with_no_parent()
  758. {
  759. // StdOp 对 StdOpMaster 孤儿 37112/38746,CRUD 不写、UI 无下拉、码空间不同构。
  760. // 它与 item 孤儿(仅 44)是两回事,勿混淆。框架只能挂一个父,父是 dim_item。
  761. var d = S0DimCatalog.ItemRoutingOp;
  762. Assert.Contains("StdOp", d.Columns.Select(c => c.JsonPath ?? ""));
  763. Assert.DoesNotContain("std_op", d.BusinessKeyColumns);
  764. Assert.Equal("dim_item", d.ParentDimTable);
  765. }
  766. [Fact]
  767. public void ItemRoutingOp_decimal_scales_must_match_source_exactly()
  768. {
  769. // UDeci1..5 源 decimal(18,5),RunTime 源 decimal(18,6) —— 不得统一,
  770. // 精度不一致会让 CAST AS CHAR 后的 checksum 两侧不等。
  771. var d = S0DimCatalog.ItemRoutingOp;
  772. foreach (var c in new[] { "udeci1", "udeci2", "udeci3", "udeci4", "udeci5" })
  773. {
  774. var col = d.Columns.Single(x => x.TargetColumn == c);
  775. Assert.Equal((18, 5), (col.Precision, col.Scale));
  776. }
  777. var rt = d.Columns.Single(x => x.TargetColumn == "run_time");
  778. Assert.Equal((18, 6), (rt.Precision, rt.Scale));
  779. }
  780. [Fact]
  781. public void ItemOpLine_decimal_scales_must_all_be_18_6()
  782. {
  783. foreach (var c in new[] { "rate", "setup_time", "overlap_time", "sequence_no", "standard_staff_count" })
  784. {
  785. var col = S0DimCatalog.ItemOpLine.Columns.Single(x => x.TargetColumn == c);
  786. Assert.Equal((18, 6), (col.Precision, col.Scale));
  787. }
  788. }
  789. [Fact]
  790. public void ItemRoutingOp_domain_must_not_be_required()
  791. {
  792. // RecID=106435 那行 TEST 数据的 Domain 是 NULL;设成 Required 会让该行被 RowGuards
  793. // 整行滤掉 → dim_count < stg_count → 对账 FAIL。
  794. Assert.False(S0DimCatalog.ItemRoutingOp.Columns.Single(c => c.TargetColumn == "domain_code").Required);
  795. }
  796. [Fact]
  797. public void ItemOpCondition_is_main_must_be_booltrue_attribute_never_key()
  798. {
  799. // 源 tinyint(1) 在 raw_data 里是 JSON BOOLEAN。Int 会把 true/false 双双静默压成 0
  800. // (比 bit(1) 地雷更毒:不报错、不为 NULL);Str 会得到 'true' 而源侧 checksum 是 '1'。
  801. var d = S0DimCatalog.ItemOpCondition;
  802. Assert.Equal(S0DimValueKind.BoolTrue, d.Columns.Single(c => c.TargetColumn == "is_main").Kind);
  803. Assert.DoesNotContain("is_main", d.BusinessKeyColumns);
  804. Assert.DoesNotContain("IsMain", d.SourceBizKeyColumns);
  805. }
  806. [Fact]
  807. public void ItemOpCondition_element_code_must_not_be_confused_with_item_code()
  808. {
  809. // $.ItemCode 是「要素编码」,$.ItemNum 是「物料编码」,实测两列 0 行相等、前者 5/10 为空。
  810. var d = S0DimCatalog.ItemOpCondition;
  811. Assert.Equal("ItemCode", d.Columns.Single(c => c.TargetColumn == "element_code").JsonPath);
  812. Assert.Equal("ItemNum", d.Columns.Single(c => c.TargetColumn == "item_code").JsonPath);
  813. }
  814. [Fact]
  815. public void ItemOpImage_must_not_declare_watermark_because_source_has_no_time_column()
  816. {
  817. // QualitySegmentImage 11 列里没有任何时间列(实测)。为字段齐整回填会得到恒 NULL 的假列。
  818. Assert.DoesNotContain("source_updated_at",
  819. S0DimCatalog.ItemOpImage.Columns.Select(c => c.TargetColumn));
  820. // 对照:ItemOpCondition 的源有 UpdateTime,故必须有
  821. Assert.Contains("source_updated_at",
  822. S0DimCatalog.ItemOpCondition.Columns.Select(c => c.TargetColumn));
  823. }
  824. [Fact]
  825. public void ItemBom_legacy_surrogate_ids_must_stay_out_of_identity()
  826. {
  827. // 🔴 实测跨租户泄漏向量:ComponentMaterialId → ItemMaster.RecID
  828. // 同租户失败 170 / 忽略租户失败 0,而那 170 行恰恰全挂到别的租户;
  829. // ParentMaterialId 同款(同租户失败 2060)。自然键则 0/62036 零孤儿且 61866/61866 一致。
  830. var d = S0DimCatalog.ItemBom;
  831. foreach (var legacy in new[] { "legacy_parent_item_id", "legacy_component_item_id", "company_id", "factory_id" })
  832. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  833. Assert.Equal(["ParentItem", "ComponentItem"], d.SourceBizKeyColumns);
  834. // 但必须留痕,供 legacy 对账
  835. Assert.Contains("legacy_component_item_id", d.Columns.Select(c => c.TargetColumn));
  836. }
  837. [Fact]
  838. public void ItemBom_must_not_materialize_nullable_bit_or_zero_information_columns()
  839. {
  840. // IsActive 是 bit(1) 且含 1 行 NULL —— Int 让对账必挂、BoolTrue 会滤掉那行。
  841. // IsEnabled/SequenceNum/StructureType distinct=1;LineOp/Remark/StartEff/EndEff 全库 0 填充。
  842. var paths = S0DimCatalog.ItemBom.Columns.Select(c => c.JsonPath ?? "").ToList();
  843. foreach (var forbidden in new[]
  844. { "IsActive", "IsEnabled", "SequenceNum", "StructureType", "LineOp", "Remark", "StartEff", "EndEff" })
  845. Assert.DoesNotContain(forbidden, paths);
  846. }
  847. [Fact]
  848. public void ItemSubstitute_all_three_item_refs_must_target_dim_item_naturally()
  849. {
  850. // 三个 item 引用全部统一到 dim_item 的自然键;框架单父,取被替代的 ItemNum 作 item_code。
  851. var d = S0DimCatalog.ItemSubstitute;
  852. var paths = d.Columns.ToDictionary(c => c.TargetColumn, c => c.JsonPath);
  853. Assert.Equal("ParentItem", paths["parent_item_code"]);
  854. Assert.Equal("ItemNum", paths["item_code"]);
  855. Assert.Equal("SubstituteItem", paths["substitute_item_code"]);
  856. Assert.Equal("dim_item", d.ParentDimTable);
  857. Assert.Equal(["item_code"], d.ParentKeyColumns);
  858. }
  859. [Fact]
  860. public void ItemSubstitute_parent_item_must_stay_in_identity()
  861. {
  862. // ParentItem 与 ItemNum 实测 16 行无一相等(distinct 9 vs 14),是真实不同信息。
  863. // 全表仅 16 行,「去掉后仍唯一」证明不了可去 —— 去掉即不可逆的键收缩。
  864. Assert.Contains("parent_item_code", S0DimCatalog.ItemSubstitute.BusinessKeyColumns);
  865. Assert.Equal(["ParentItem", "ItemNum", "SubstituteItem"], S0DimCatalog.ItemSubstitute.SourceBizKeyColumns);
  866. }
  867. [Fact]
  868. public void SameDb_source_column_must_be_sqlsugar_mappable()
  869. {
  870. // 🔴 SqlSugar 的 IDataReaderEntityBuilder.CreateBuilder 走反射构造,
  871. // 对只有主构造函数的 positional record 会抛 ArgumentNullException (Parameter 'con')。
  872. // 该异常会被 S0DimSameDbStagingLoader 吞掉并静默回退逐行路径 ——
  873. // **表现为「变慢」而不是「报错」**,实测踩过一次。此断言让回归变成编译期/单测期可见。
  874. var t = typeof(S0DimSourceColumn);
  875. Assert.NotNull(t.GetConstructor(Type.EmptyTypes));
  876. foreach (var prop in new[] { "Name", "DataType", "ColumnType", "IsNullable" })
  877. {
  878. var p = t.GetProperty(prop);
  879. Assert.NotNull(p);
  880. Assert.True(p!.CanWrite, $"{prop} 必须可写,否则 SqlSugar 无法回填");
  881. }
  882. }
  883. [Fact]
  884. public void SameDb_tinyint1_must_emit_json_boolean_not_integer()
  885. {
  886. // 旧路径:MySqlConnector 的 TreatTinyAsBoolean 把 tinyint(1) 变成 CLR bool
  887. // → JsonSerializer 产出 JSON BOOLEAN(实测 mdp_stg_s0_item.$.IsActive = BOOLEAN "true")。
  888. // JSON_OBJECT 直出会是 INTEGER 1 —— 必须用 CAST(... AS JSON) 纠正。
  889. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("IsActive", "tinyint", "tinyint(1)"));
  890. Assert.Contains("CAST(IF(a.`IsActive` IS NULL, NULL, IF(a.`IsActive` <> 0, 'true', 'false')) AS JSON)", sql, Ord);
  891. }
  892. [Fact]
  893. public void SameDb_zerofill_tinyint1_must_stay_integer()
  894. {
  895. // 🔴 TreatTinyAsBoolean 对 `tinyint(1) unsigned zerofill` **不生效**
  896. // (实测 $.enable_bom_version = INTEGER 0,而 $.IsActive = BOOLEAN)。
  897. // 判定必须比对 COLUMN_TYPE 全串而非 DATA_TYPE,否则这一列会被错误地转成布尔。
  898. var sql = SameDbSql(Col("RecID", "bigint", nullable: false),
  899. Col("enable_bom_version", "tinyint", "tinyint(1) unsigned zerofill"));
  900. Assert.DoesNotContain("`enable_bom_version` IS NULL, NULL, IF(", sql, Ord);
  901. Assert.Contains("'enable_bom_version', a.`enable_bom_version`", sql, Ord);
  902. }
  903. [Fact]
  904. public void SameDb_bit_must_cast_unsigned_never_char()
  905. {
  906. // bit(1) 在旧路径是 INTEGER(实测 $.SuppWarranty = INTEGER 0)。
  907. // 若直出,JSON_OBJECT 会得到裸字节;CAST AS CHAR 更糟(0x01 而非 '1',见 S0DimValueKind.Int 注释)。
  908. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("SuppWarranty", "bit", "bit(1)"));
  909. Assert.Contains("'SuppWarranty', CAST(a.`SuppWarranty` AS UNSIGNED)", sql, Ord);
  910. }
  911. [Fact]
  912. public void SameDb_decimal_must_emit_double_not_decimal()
  913. {
  914. // 旧路径 decimal → CLR decimal → JSON DOUBLE «0.0»;JSON_OBJECT 直出是 DECIMAL «0.00000»。
  915. // JSON_UNQUOTE 后字符串不同 → staging 侧任何按原文比对都会差异。
  916. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("NetWeight", "decimal", "decimal(18,5)"));
  917. Assert.Contains("'NetWeight', CAST(a.`NetWeight` AS DOUBLE)", sql, Ord);
  918. }
  919. [Fact]
  920. public void SameDb_datetime_must_emit_mysql_literal_string()
  921. {
  922. // 旧路径用 MysqlLiteralDateTimeConverter 写成 STRING "yyyy-MM-dd HH:mm:ss.ffffff"
  923. // (实测 $.UpdateTime = STRING "2023-12-18 13:57:30.000000");JSON_OBJECT 直出是 JSON DATETIME。
  924. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("UpdateTime", "datetime"));
  925. Assert.Contains("'UpdateTime', DATE_FORMAT(a.`UpdateTime`, '%Y-%m-%d %H:%i:%s.%f')", sql, Ord);
  926. }
  927. [Fact]
  928. public void SameDb_string_columns_must_pass_through_untouched()
  929. {
  930. // 空串必须保持空串(不能被当成 NULL);字符类直出即 JSON STRING,与旧路径一致。
  931. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("Descr", "varchar", "varchar(200)"));
  932. Assert.Contains("'Descr', a.`Descr`", sql, Ord);
  933. }
  934. [Fact]
  935. public void SameDb_biz_key_must_fall_back_to_row_id_when_any_part_is_null_or_blank()
  936. {
  937. // 🔴 MdpStagingWriter.BuildBizKey:任一分量 null/空串 → 整个 biz key 作废 → 回落 source_row_id。
  938. // 直接写 CONCAT_WS 会**跳过** NULL 分量而产出「看起来合法」的短键,与旧路径不等价,
  939. // 且要到下一轮四向集合比对才炸。
  940. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
  941. Assert.Contains("CASE WHEN (a.`ItemNum` IS NOT NULL AND CAST(a.`ItemNum` AS CHAR) <> '')", sql, Ord);
  942. Assert.Contains("ELSE IF(a.`RecID` IS NULL OR TRIM(CAST(a.`RecID` AS CHAR)) = '', NULL, CAST(a.`RecID` AS CHAR)) END", sql, Ord);
  943. }
  944. [Fact]
  945. public void SameDb_row_id_candidate_order_must_match_shared_pull_executor()
  946. {
  947. // 顺序与 MdpDbPullExecutor.ResolveSourceRowId 逐字一致;错序会让同一源行在两条路径下
  948. // 得到不同 source_row_id,贴源唯一键随之漂移。
  949. Assert.Equal(
  950. ["RecID", "RecId", "recid", "Recid", "id", "Id", "ID", "noid", "billno", "BillNo", "djbh", "Djbh"],
  951. S0DimSqlBuilder.SourceRowIdCandidates);
  952. }
  953. [Fact]
  954. public void SameDb_sql_must_never_contain_nondeterministic_uuid()
  955. {
  956. // 旧路径在全部候选都空白时兜底 Guid.NewGuid()。快路径不复刻该分支,
  957. // 而是由 Loader 要求「首个候选是 NOT NULL 整数」后判定适用,从源头排除。
  958. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
  959. Assert.DoesNotContain("UUID(", sql, StringComparison.OrdinalIgnoreCase);
  960. }
  961. [Fact]
  962. public void SameDb_sql_must_carry_structural_tenant_gate()
  963. {
  964. // 租户硬门必须结构性存在,且绝不允许出现 ctx 兜底常量
  965. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
  966. Assert.Contains("WHERE a.`tenant_id` = @TenantId", sql, Ord);
  967. Assert.Contains("`tenant_id`", sql, Ord);
  968. Assert.DoesNotContain("tenant_id = 0", sql, Ord);
  969. Assert.DoesNotContain("IFNULL(a.`tenant_id`", sql, Ord);
  970. }
  971. [Fact]
  972. public void SameDb_supported_types_must_be_an_allowlist_excluding_binary_and_json()
  973. {
  974. // 白名单而非黑名单:未知类型一律回退旧路径,不猜其序列化形态。
  975. // blob 在旧路径是 byte[]→base64,JSON_OBJECT 会直出原始字节,静默失真。
  976. foreach (var ok in new[] { "varchar", "bigint", "datetime", "tinyint", "decimal", "int", "bit" })
  977. Assert.Contains(ok, S0DimSqlBuilder.SameDbSupportedDataTypes);
  978. foreach (var no in new[] { "blob", "varbinary", "binary", "json", "enum", "set", "time", "geometry" })
  979. Assert.DoesNotContain(no, S0DimSqlBuilder.SameDbSupportedDataTypes);
  980. }
  981. [Fact]
  982. public void SameDb_must_preserve_full_semantics_with_upsert()
  983. {
  984. // 旧 MdpStagingWriter 是 upsert;保留 ON DUPLICATE KEY UPDATE 才能在源侧出现重复
  985. // source_row_id 这类异常数据下两条路径同构,而不是一条报错一条 last-wins。
  986. var sql = SameDbSql(Col("RecID", "bigint", nullable: false), Col("ItemNum", "varchar", "varchar(100)"));
  987. Assert.Contains("ON DUPLICATE KEY UPDATE", sql, Ord);
  988. Assert.Contains("`process_status`='PENDING'", sql, Ord);
  989. }
  990. [Fact]
  991. public void AntiJoin_must_not_use_nested_loop_left_join_shape()
  992. {
  993. // 🔴 LEFT JOIN ... ON y.bk <=> x.bk 在「右侧是 CONCAT_WS 计算列」的方向上退化成 nested loop:
  994. // 16150 行的 stg\src 实测 130.3s(rows=32,762,720),撞穿 CommandTimeOut=30;
  995. // 而对称的 src\stg 只要 0.5s —— 缺陷只在一半方向显形,小表永远发现不了。
  996. var sql = S0DimSqlBuilder.BuildAntiJoinCountSql("SELECT 1 AS bk", "SELECT 2 AS bk");
  997. Assert.DoesNotContain("LEFT JOIN", sql, Ord);
  998. Assert.Contains("UNION ALL", sql, Ord);
  999. Assert.Contains("GROUP BY u.bk", sql, Ord);
  1000. // 必须按左行计数(SUM(l_cnt))而非去重键数,才与旧 COUNT(*) 语义一致
  1001. Assert.Contains("SUM(CASE WHEN g.r_cnt = 0 THEN g.l_cnt ELSE 0 END)", sql, Ord);
  1002. }
  1003. [Fact]
  1004. public void Item_identity_must_be_tenant_and_item_code_only()
  1005. {
  1006. // 旧 UK uk_ItemMaster_factory_item 含 factory,不予继承:factory_ref_id 连 f(tenant) 都不是
  1007. // (838257186181189 有 3 个取值含 0),且 8010 被三个租户共用。
  1008. var d = S0DimCatalog.Item;
  1009. Assert.Equal(["tenant_id", "item_code"], d.BusinessKeyColumns);
  1010. Assert.Equal(["ItemNum"], d.SourceBizKeyColumns);
  1011. foreach (var legacy in new[] { "domain_code", "company_id", "factory_id", "legacy_source_id" })
  1012. Assert.DoesNotContain(legacy, d.BusinessKeyColumns);
  1013. Assert.Null(d.ParentDimTable);
  1014. }
  1015. [Fact]
  1016. public void Item_legacy_source_id_is_technical_attribute_only()
  1017. {
  1018. // 供 C3 的 ComponentMaterialId 这类 legacy 代理键做对账解析;绝不是身份。
  1019. // 也绝不能用 dim 的代理 id 建外键 —— 每轮 FULL REPLACE 后 dim_item.id 都会变。
  1020. var col = S0DimCatalog.Item.Columns.Single(c => c.TargetColumn == "legacy_source_id");
  1021. Assert.Equal("RecID", col.JsonPath);
  1022. Assert.Equal(S0DimValueKind.Int, col.Kind);
  1023. }
  1024. [Fact]
  1025. public void Item_must_not_materialize_bit_or_nullable_bool_or_shadow_columns()
  1026. {
  1027. // SuppWarranty 是 bit(1) 且含 2 行 NULL —— Int 会让对账必挂、BoolTrue 会滤掉那 2 行。
  1028. // DEAControlled 是可空 tinyint(1),同样会被 BoolTrue 守卫滤行。
  1029. // expire_alarm_day 是 0 填充的影子列,真列为 ExpireAlarmDays。
  1030. var paths = S0DimCatalog.Item.Columns.Select(c => c.JsonPath ?? "").ToList();
  1031. foreach (var forbidden in new[] { "SuppWarranty", "DEAControlled", "expire_alarm_day" })
  1032. Assert.DoesNotContain(forbidden, paths);
  1033. Assert.Contains("ExpireAlarmDays", paths);
  1034. }
  1035. [Fact]
  1036. public void Item_must_precede_c3_definitions_in_catalog_order()
  1037. {
  1038. // 后续 6 张 C3 都要挂 dim_item 作父,父必须先就绪
  1039. var keys = S0DimCatalog.All.Select(d => d.Key).ToList();
  1040. var item = keys.IndexOf("ITEM");
  1041. Assert.True(item >= 0);
  1042. foreach (var child in new[] { "EMP_WORK_DUTY", "LINE_POST", "LINE_POST_SKILL" })
  1043. Assert.True(item < keys.IndexOf(child), $"ITEM 必须排在 {child} 之前");
  1044. }
  1045. [Fact]
  1046. public void Batch4_must_not_reuse_dopdemorq_staging_tables()
  1047. {
  1048. // mdp_stg_employee / mdp_stg_line 属 DOPDEMORQ(当前 0 行),不是 S0 的 authority
  1049. foreach (var d in S0DimCatalog.All)
  1050. {
  1051. Assert.NotEqual("mdp_stg_employee", d.StagingTable);
  1052. Assert.NotEqual("mdp_stg_line", d.StagingTable);
  1053. Assert.NotEqual("mdp_stg_location", d.StagingTable);
  1054. Assert.NotEqual("mdp_stg_customer", d.StagingTable);
  1055. }
  1056. }
  1057. }