S1RequirementExamineDwdContractTests.cs 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  3. /// <summary>
  4. /// dwd_requirement_examine_detail 作为「工单当前齐套/缺料」中台权威表的契约。
  5. ///
  6. /// 为什么是源文本契约测试而不是跑库集成测试:
  7. /// 本表的 schema 与全部投影逻辑都以 SQL 字符串形式内联在 S1MdpSyncTransformService.cs 里
  8. /// (没有 SqlSugar 实体、没有 UpdateScripts 的 CREATE),
  9. /// 本仓库 DataPlatform/ 下既有测试(MdpStandardUpsertContractTests / T8KpiTenantIsolationContractTests)
  10. /// 也一律用这种方式守护数据中台 SQL。这里沿用同一范式,逐条钉死会被悄悄改坏的不变量。
  11. /// </summary>
  12. public class S1RequirementExamineDwdContractTests
  13. {
  14. private static string Service() => File.ReadAllText(FindFile(
  15. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Order", "S1MdpSyncTransformService.cs"));
  16. /// <summary>
  17. /// 按<b>内容</b>而不是按文件名定位本批迁移。
  18. ///
  19. /// <para>本仓的版本号是「提交那一刻才取号」的(多实例并行,谁先 push 谁定号),
  20. /// 所以迁移文件名在合并前后都可能顺延。写死 <c>1.0.NNN.sql</c> 的测试
  21. /// 会在每一次顺延时集体变红,而红的原因与被测契约毫无关系 —— 实测发生过一次。</para>
  22. /// </summary>
  23. private static string MigrationPath(string suffix)
  24. {
  25. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  26. while (dir != null)
  27. {
  28. var scripts = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts");
  29. if (Directory.Exists(scripts))
  30. {
  31. // 注意:通配 "1.0.*.sql" 也会命中 "1.0.NNN.verify.sql"(它同样以 .sql 结尾)。
  32. // 必须按文件名精确收口,否则 Migration() 会拿到 verify 脚本 —— 实测踩过。
  33. var hit = Directory.GetFiles(scripts, "1.0.*.sql")
  34. .Where(f => Path.GetFileName(f).EndsWith(suffix, StringComparison.Ordinal)
  35. && (suffix != ".sql" || !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.Ordinal)))
  36. .Where(f => File.ReadAllText(f).Contains("dwd_requirement_examine_detail", StringComparison.Ordinal)
  37. && File.ReadAllText(f).Contains("material_role", StringComparison.Ordinal))
  38. .OrderBy(f => f, StringComparer.Ordinal)
  39. .LastOrDefault();
  40. if (hit != null) return hit;
  41. }
  42. dir = dir.Parent;
  43. }
  44. throw new FileNotFoundException($"找不到含 material_role 的 dwd_requirement_examine_detail 迁移脚本 (*{suffix})");
  45. }
  46. private static string Migration() => File.ReadAllText(MigrationPath(".sql"));
  47. // ── B1 · 新列必须「内联 DDL」与「迁移脚本」两处同时到位 ──────────────────────────
  48. // 这是本表最容易出事的地方:新库只走内联 CREATE TABLE IF NOT EXISTS,旧库只走 ALTER,
  49. // 任一处漏改都会让两种环境的 schema 分叉,且分叉在运行期才暴露。
  50. [Fact]
  51. public void NewColumns_ExistInBothInlineDdlAndMigration()
  52. {
  53. var service = Service();
  54. var migration = Migration();
  55. Assert.Contains("bom_level INT NULL", service);
  56. Assert.Contains("material_role VARCHAR(32) NULL", service);
  57. Assert.Contains("is_current_flag TINYINT NOT NULL DEFAULT 0", service);
  58. Assert.Contains("ADD COLUMN `bom_level` INT NULL", migration);
  59. Assert.Contains("ADD COLUMN `material_role` VARCHAR(32) NULL", migration);
  60. Assert.Contains("ADD COLUMN `is_current_flag` TINYINT NOT NULL DEFAULT 0", migration);
  61. }
  62. [Fact]
  63. public void CurrentSnapshotIndex_ExistsInBothInlineDdlAndMigration()
  64. {
  65. Assert.Contains("KEY idx_req_exam_current (tenant_id, is_current_flag, morder_no)", Service());
  66. Assert.Contains("ADD KEY `idx_req_exam_current` (`tenant_id`,`is_current_flag`,`morder_no`)", Migration());
  67. }
  68. // 仓库约定:布尔一律 <noun>_flag TINYINT,不用 bit、不用 is_* 裸名(is_current_flag 本身是既定命名例外)。
  69. [Fact]
  70. public void BooleanColumn_FollowsRepoFlagConvention_NotBit()
  71. {
  72. var service = Service();
  73. var ddl = Slice(service, "CREATE TABLE IF NOT EXISTS dwd_requirement_examine_detail", ") ENGINE=InnoDB");
  74. Assert.Contains("is_current_flag TINYINT", ddl);
  75. Assert.DoesNotContain("is_current_flag BIT", ddl);
  76. Assert.DoesNotContain("bom_level BIT", ddl);
  77. }
  78. // ── B2 · 角色投影:level=1 → 根,其余(含脏值/NULL)→ 投入料 ────────────────────
  79. [Fact]
  80. public void MaterialRole_ProjectsLevelOneAsFinishedGoodRoot()
  81. {
  82. var service = Service();
  83. Assert.Contains("CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.level')) = '1'", service);
  84. Assert.Contains("THEN 'FINISHED_GOOD_ROOT' ELSE 'INPUT_MATERIAL' END", service);
  85. }
  86. /// <summary>
  87. /// 失败方向必须保守:脏值 / NULL / 空串一律落 INPUT_MATERIAL,绝不能落成根、更不能整行丢掉。
  88. /// 反向断言(ELSE 不得是 FINISHED_GOOD_ROOT)才是真正防回归的那一条 ——
  89. /// 如果哪天有人把三元反过来写成「非 1 就是根」,正向断言仍会通过,只有这条会挂。
  90. /// </summary>
  91. [Fact]
  92. public void MaterialRole_MalformedOrNullLevel_FallsBackToInputMaterial()
  93. {
  94. var service = Service();
  95. Assert.DoesNotContain("ELSE 'FINISHED_GOOD_ROOT'", service);
  96. // 角色列不得出现 NULL 分支:CASE 必须有 ELSE,否则脏值会变成 NULL
  97. Assert.DoesNotContain("THEN 'FINISHED_GOOD_ROOT' END", service);
  98. // level 不得被提升为过滤条件——那会把脏值行直接丢掉(漏报缺料)
  99. Assert.DoesNotContain("AND JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.level'))", service);
  100. }
  101. /// <summary>bom_level 只在纯数字时物化,不猜层级;下游(含 S8)只需读 material_role。</summary>
  102. [Fact]
  103. public void BomLevel_OnlyMaterializedWhenNumeric()
  104. {
  105. Assert.Contains(
  106. "CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.level')) REGEXP '^[0-9]+$'",
  107. Service());
  108. }
  109. // ── B4 · 当前快照发布必须是「一条」原子语句 ──────────────────────────────────────
  110. [Fact]
  111. public void CurrentSnapshotPublication_IsSingleAtomicStatement()
  112. {
  113. var service = Service();
  114. var publish = Slice(service, "UPDATE dwd_requirement_examine_detail", "\"\"\",");
  115. Assert.Contains("SET is_current_flag = CASE WHEN calc_batch_id=@BatchId THEN 1 ELSE 0 END", publish);
  116. Assert.Contains("AND (calc_batch_id=@BatchId OR is_current_flag=1)", publish);
  117. // 单条语句:整段里不得出现第二个分号分隔的语句
  118. Assert.DoesNotContain(";", publish);
  119. }
  120. /// <summary>
  121. /// 禁止「先清后置」/「先置后清」两段式:BuildDwdAsync 没有事务,
  122. /// 两段式必然留下「零个当前批次」(漏报)或「两个当前批次」(重复计数)的可见窗口。
  123. /// </summary>
  124. [Fact]
  125. public void CurrentSnapshotPublication_ForbidsTwoStatementClearThenSet()
  126. {
  127. var service = Service();
  128. var sqlOnly = StripComments(service);
  129. Assert.DoesNotContain("SET is_current_flag = 0", sqlOnly);
  130. Assert.DoesNotContain("SET is_current_flag = 1", sqlOnly);
  131. Assert.DoesNotContain("SET is_current_flag=0", sqlOnly);
  132. Assert.DoesNotContain("SET is_current_flag=1", sqlOnly);
  133. // 只能有一处写 is_current_flag 的 UPDATE
  134. Assert.Equal(1, Count(sqlOnly, "SET is_current_flag"));
  135. }
  136. /// <summary>
  137. /// factory_id 必须归一后再圈作用域。实测存量 296189/1019060 行 factory_id IS NULL,
  138. /// 且 594/3439 个批次同时含 factory_id=1 与 NULL;
  139. /// 若写成 factory_id=@FactoryId,NULL 行永不被 = 匹配,旧批次会永久残留 is_current_flag=1 → 双 current。
  140. /// </summary>
  141. [Fact]
  142. public void CurrentSnapshotPublication_NormalizesNullFactoryScope()
  143. {
  144. var publish = Slice(Service(), "UPDATE dwd_requirement_examine_detail", "\"\"\",");
  145. Assert.Contains("AND COALESCE(NULLIF(factory_id, 0), 1)=@FactoryId", publish);
  146. Assert.DoesNotContain("AND factory_id=@FactoryId", publish);
  147. }
  148. /// <summary>发布必须是 DWD 阶段的最后一步:在所有 DWD 写入命令跑完之后。</summary>
  149. [Fact]
  150. public void CurrentSnapshotPublication_RunsAfterAllDwdCommands()
  151. {
  152. var service = Service();
  153. var loop = service.IndexOf("foreach (var command in BuildDwdCommands(", StringComparison.Ordinal);
  154. var publish = service.IndexOf("await PublishCurrentSnapshotAsync(db, scope, batchId);", StringComparison.Ordinal);
  155. Assert.True(loop > 0 && publish > loop, "发布语句必须位于 BuildDwdCommands 循环之后");
  156. }
  157. /// <summary>ON DUPLICATE KEY UPDATE 不得回写 is_current_flag,否则同批次重跑会把已发布状态打回 0。</summary>
  158. [Fact]
  159. public void UpsertClause_DoesNotResetCurrentFlag()
  160. {
  161. Assert.DoesNotContain("is_current_flag=VALUES(is_current_flag)", Service());
  162. Assert.Contains("bom_level=VALUES(bom_level), material_role=VALUES(material_role)", Service());
  163. }
  164. // ── B5 · 表头软删必须被排除 ──────────────────────────────────────────────────────
  165. [Fact]
  166. public void DwdBuild_ExcludesDeletedExamineHeaders()
  167. {
  168. Assert.Contains(
  169. "AND JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.IsDeleted')) IN ('0', 'false', 'False', 'base64:type16:AA==')",
  170. Service());
  171. }
  172. /// <summary>
  173. /// bit(1) 源列经贴源后两种编码并存('1'/'0' 与 base64:type16:AQ==/AA==),
  174. /// 任何布尔过滤都必须同时认这两种,只认一种会静默漏掉整批数据。
  175. /// </summary>
  176. [Fact]
  177. public void BooleanFilters_HandleBothBitEncodings()
  178. {
  179. var service = Service();
  180. // 存活表头:明文 '0' 与 base64 AA== 都要认
  181. Assert.Contains("'0', 'false', 'False', 'base64:type16:AA=='", service);
  182. // 启用明细:明文 '1' 与 base64 AQ== 都要认
  183. Assert.Contains("'1', 'true', 'True', 'base64:type16:AQ=='", service);
  184. // base64 编码不得只出现在其中一侧
  185. Assert.True(Count(service, "base64:type16:AA==") >= 1);
  186. Assert.True(Count(service, "base64:type16:AQ==") >= 2);
  187. }
  188. // ── B6 · FULL = 镜像(DWD 侧权宜实现)───────────────────────────────────────────
  189. /// <summary>
  190. /// 只取每个 (tenant_id, source_table) 的最新 sync_batch_id。
  191. /// 明细与表头两侧都必须过滤,且父行(成品根)查找子查询同样要过滤,
  192. /// 否则会拿一个旧批次的根去挂今天的子件。
  193. /// </summary>
  194. [Fact]
  195. public void DwdBuild_UsesOnlyLatestSyncBatchPerSourceTable()
  196. {
  197. var service = Service();
  198. Assert.Contains(
  199. "AND d.sync_batch_id = (SELECT lb.sync_batch_id FROM mdp_stg_so lb",
  200. service);
  201. Assert.Contains(
  202. "AND r.sync_batch_id = (SELECT lb.sync_batch_id FROM mdp_stg_so lb",
  203. service);
  204. Assert.Contains("ORDER BY lb.sync_time DESC, lb.id DESC LIMIT 1)", service);
  205. // 明细侧 + 表头侧 + 父行子查询 = 至少 3 处
  206. Assert.True(Count(service, "ORDER BY lb.sync_time DESC, lb.id DESC LIMIT 1)") >= 3);
  207. }
  208. /// <summary>权宜实现必须写明是 STOPGAP 及其失效前提(entity 改成 INCREMENTAL 就会错杀存量行)。</summary>
  209. [Fact]
  210. public void LatestBatchStopgap_IsDocumentedAsStopgap()
  211. {
  212. var service = Service();
  213. Assert.Contains("STOPGAP", service);
  214. Assert.Contains("INCREMENTAL", service);
  215. }
  216. // ── B3 · 齐套三量 ────────────────────────────────────────────────────────────────
  217. /// <summary>不得新增别名列,也不得重算出第二套数字。</summary>
  218. [Fact]
  219. public void QuantityColumns_NoAliasAndNoRecomputation()
  220. {
  221. // 别名词本身会合法出现在「不要新增 xxx 别名」的列注释与说明文字里,
  222. // 所以不能整文件盲搜,必须针对「列声明 / 列清单 / 回写」三个真正会引入别名列的位置断言。
  223. var service = Service();
  224. var migration = Migration();
  225. var insertColumns = Slice(service, "INSERT INTO dwd_requirement_examine_detail", "SELECT");
  226. foreach (var alias in new[] { "required_qty", "satisfied_qty", "shortage_qty" })
  227. {
  228. Assert.DoesNotContain(alias + " DECIMAL", service); // 内联 DDL 里的列声明
  229. Assert.DoesNotContain(alias + "=VALUES(", service); // ON DUPLICATE KEY UPDATE 回写
  230. Assert.DoesNotContain(alias, insertColumns); // INSERT 列清单
  231. Assert.DoesNotContain("ADD COLUMN `" + alias + "`", migration);
  232. }
  233. // 三个标准列必须是直取源值,不得由其它列相减/相加得出
  234. Assert.DoesNotContain("need_count - use_qty", service);
  235. Assert.DoesNotContain("need_count-use_qty", service);
  236. }
  237. [Fact]
  238. public void QuantityColumns_DocumentedAsStandardNames()
  239. {
  240. Assert.Contains("标准列·需求量", Service());
  241. Assert.Contains("标准列·已满足量", Service());
  242. Assert.Contains("标准列·缺口量", Service());
  243. }
  244. /// <summary>恒等式 lack + use = need 必须被 verify 脚本守住(实测 1019060/1019060 行成立)。</summary>
  245. [Fact]
  246. public void QuantityIdentity_IsAssertedByVerifyScript()
  247. {
  248. var verify = File.ReadAllText(MigrationPath(".verify.sql"));
  249. Assert.Contains("ABS(IFNULL(`lack_qty`,0) + IFNULL(`use_qty`,0) - IFNULL(`need_count`,0)) > 0.0001", verify);
  250. }
  251. // ── B7 · 前修数据清理 ────────────────────────────────────────────────────────────
  252. /// <summary>
  253. /// 前修行以 material_role IS NULL 精确识别(修复后的投影恒不产生 NULL)。
  254. /// 该谓词幂等、可重跑,且不会误删迁移期间新管线产生的正确行。
  255. /// </summary>
  256. [Fact]
  257. public void LegacyRows_PurgedByNullRolePredicate()
  258. {
  259. var migration = Migration();
  260. Assert.Contains("DELETE FROM `dwd_requirement_examine_detail` WHERE `material_role` IS NULL", migration);
  261. // 不得无条件清表:那样会连迁移期间新产生的正确行一起删掉
  262. Assert.DoesNotContain("TRUNCATE", migration);
  263. }
  264. /// <summary>迁移必须是幂等的:所有 DDL 都要有存在性守卫。</summary>
  265. [Fact]
  266. public void Migration_IsIdempotent()
  267. {
  268. var migration = Migration();
  269. Assert.True(Count(migration, "PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;") >= 6);
  270. // 表不存在时(全新库尚未跑过 S1)必须整体跳过,不能 ALTER 一张不存在的表
  271. Assert.Contains("information_schema.TABLES", migration);
  272. Assert.True(Count(migration, "@tbl = 1") >= 6);
  273. }
  274. // ── 通用护栏 ─────────────────────────────────────────────────────────────────────
  275. /// <summary>本表是通用中台权威表,不得为单一消费方(S8)开专属列。</summary>
  276. [Fact]
  277. public void Table_HasNoS8SpecificColumns()
  278. {
  279. var ddl = Slice(Service(),
  280. "CREATE TABLE IF NOT EXISTS dwd_requirement_examine_detail", ") ENGINE=InnoDB");
  281. Assert.DoesNotContain("s8_", ddl);
  282. Assert.DoesNotContain("exception_id", ddl);
  283. Assert.DoesNotContain("rule_code", ddl);
  284. }
  285. private static string Slice(string source, string start, string end)
  286. {
  287. var from = source.IndexOf(start, StringComparison.Ordinal);
  288. Assert.True(from >= 0, $"未找到起始锚点:{start}");
  289. var to = source.IndexOf(end, from, StringComparison.Ordinal);
  290. Assert.True(to > from, $"未找到结束锚点:{end}");
  291. return source.Substring(from, to - from);
  292. }
  293. /// <summary>去掉 C# 行注释与 SQL 行注释,避免注释里的示例文本污染「禁止出现」类断言。</summary>
  294. private static string StripComments(string source)
  295. {
  296. var lines = source.Split('\n');
  297. var kept = lines.Where(l =>
  298. {
  299. var t = l.TrimStart();
  300. return !t.StartsWith("//", StringComparison.Ordinal)
  301. && !t.StartsWith("--", StringComparison.Ordinal)
  302. && !t.StartsWith("///", StringComparison.Ordinal)
  303. && !t.StartsWith("*", StringComparison.Ordinal);
  304. });
  305. return string.Join("\n", kept);
  306. }
  307. private static int Count(string source, string token)
  308. {
  309. var count = 0;
  310. for (var index = 0; (index = source.IndexOf(token, index, StringComparison.Ordinal)) >= 0; index += token.Length)
  311. count++;
  312. return count;
  313. }
  314. private static string FindFile(params string[] parts)
  315. {
  316. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  317. while (dir != null)
  318. {
  319. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  320. if (File.Exists(candidate)) return candidate;
  321. dir = dir.Parent;
  322. }
  323. throw new FileNotFoundException(string.Join("/", parts));
  324. }
  325. }