PurchaseOrderCompletionContractTests.cs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512
  1. using Admin.NET.Plugin.AiDOP;
  2. using Admin.NET.Plugin.AiDOP.Supply;
  3. using Xunit;
  4. namespace Admin.NET.Plugin.AiDOP.Tests.S8.Supply;
  5. /// <summary>
  6. /// S8 Stage-3 采购完成态事实(<c>mdp_std_purchase_order_completion</c>,1.0.544)的生产契约。
  7. ///
  8. /// <para>这条链让 S8 在<b>不覆盖</b> <c>mdp_std_purchase_order.status</c> 的前提下拿到 WMS 侧
  9. /// 「这条采购行是否收货关闭」。一旦被"顺手优化"破坏,S8 会在毫无征兆的情况下
  10. /// 把未知当成未完成、把已回退的行当成仍然完成,或者把事实写到别的租户名下。</para>
  11. ///
  12. /// <para>本文件守住六条最易被破坏的边界:
  13. /// ① 三态映射 —— NULL / 未知值绝不能滑成 NOT_COMPLETED;
  14. /// ② 可逆 —— UPSERT 必须无条件覆盖结论,不许有"一旦完成永远完成"的守卫;
  15. /// ③ 租户 —— 唯一才用,歧义与未解析一律跳过,绝不"取第一个",绝不用 Domain 推租户;
  16. /// ④ 淘汰 —— 只在源侧读取成功后执行,且必须 tenant-safe;
  17. /// ⑤ 源绑定 —— 配置化 + 非开发环境 fail-closed,且必须挡住"回落主库";
  18. /// ⑥ 边界 —— 不读 IQC、不用数量推完成、不写任何源表。</para>
  19. /// </summary>
  20. public class PurchaseOrderCompletionContractTests
  21. {
  22. private static string Source(params string[] parts)
  23. {
  24. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  25. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
  26. dir = dir.Parent;
  27. Assert.NotNull(dir);
  28. var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
  29. Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
  30. return File.ReadAllText(full);
  31. }
  32. private static string Service() => Source("Supply", "PurchaseOrderCompletionMdpSyncService.cs");
  33. /// <summary>
  34. /// 去掉 XML 文档注释后的代码体。禁止类断言必须扫这个 —— 文档注释需要点名
  35. /// 「不读 IQC」「回到 NOT_OBSERVED」这些概念来说明边界,那是说明而不是违规。
  36. /// </summary>
  37. private static string ServiceBody()
  38. => string.Join('\n', Service().Split('\n').Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
  39. private static string Migration(string name)
  40. {
  41. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  42. while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
  43. dir = dir.Parent;
  44. Assert.NotNull(dir);
  45. var full = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", name);
  46. Assert.True(File.Exists(full), $"迁移脚本不存在:{full}");
  47. return File.ReadAllText(full);
  48. }
  49. private static int At(string code, string anchor)
  50. {
  51. var i = code.IndexOf(anchor, StringComparison.Ordinal);
  52. Assert.True(i >= 0, $"未找到锚点:{anchor}");
  53. return i;
  54. }
  55. private static string Between(string code, string startAnchor, string endAnchor)
  56. {
  57. var s = At(code, startAnchor);
  58. var e = At(code, endAnchor);
  59. Assert.True(e > s, $"锚点顺序不符:{startAnchor} 应在 {endAnchor} 之前");
  60. return code[s..e];
  61. }
  62. // ── ① 三态映射 ───────────────────────────────────────────────────────────────
  63. [Theory]
  64. [InlineData("C")]
  65. [InlineData("c")]
  66. [InlineData(" C ")]
  67. public void 状态C一律映射为COMPLETED(string raw)
  68. => Assert.Equal("COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
  69. [Theory]
  70. [InlineData("")]
  71. [InlineData(" ")]
  72. public void 空串映射为NOT_COMPLETED(string raw)
  73. => Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
  74. [Fact]
  75. public void NULL绝不能滑成NOT_COMPLETED()
  76. {
  77. // 「源侧没给值」不等于「已确认尚未完成」,下游会把它当确定结论用
  78. Assert.Equal("UNKNOWN", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(null));
  79. }
  80. [Theory]
  81. [InlineData("R")]
  82. [InlineData("O")]
  83. [InlineData("S")]
  84. [InlineData("X")]
  85. public void 未知非空值一律UNKNOWN(string raw)
  86. => Assert.Equal("UNKNOWN", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
  87. [Fact]
  88. public void 映射取值域封闭()
  89. {
  90. var allowed = new[] { "COMPLETED", "NOT_COMPLETED", "UNKNOWN" };
  91. foreach (var raw in new string?[] { null, "", " ", "C", "c", "R", "O", "Z", "完成" })
  92. Assert.Contains(PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw), allowed);
  93. }
  94. [Fact]
  95. public void raw_status归一保留NULL()
  96. {
  97. Assert.Null(PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(null));
  98. Assert.Equal("", PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(" "));
  99. Assert.Equal("C", PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(" C "));
  100. }
  101. [Fact]
  102. public void 禁止用IFNULL把NULL和空串合并()
  103. {
  104. var code = Service();
  105. var mapper = Between(code, "public static string MapCompletionStatus", "public static string? NormalizeRawStatus");
  106. // 出现 ?? "" 或 IFNULL 语义即意味着 NULL 被当成空串处理
  107. Assert.DoesNotContain("?? \"\"", mapper, StringComparison.Ordinal);
  108. Assert.DoesNotContain("?? string.Empty", mapper, StringComparison.Ordinal);
  109. Assert.Contains("if (rawStatus is null) return StatusUnknown;", mapper, StringComparison.Ordinal);
  110. }
  111. // ── ② 可逆:COMPLETED → NOT_COMPLETED 必须能回退 ──────────────────────────────
  112. [Fact]
  113. public void 回退序列_空到C再回空()
  114. {
  115. // 同一 Grain 连续三次观测,结论必须跟着源侧走
  116. Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(""));
  117. Assert.Equal("COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus("C"));
  118. Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(""));
  119. }
  120. [Fact]
  121. public void UPSERT必须无条件覆盖结论_不得有一旦完成永远完成的守卫()
  122. {
  123. var block = Between(Service(), "ON DUPLICATE KEY UPDATE", "await _db.Ado.ExecuteCommandAsync(sb.ToString()");
  124. Assert.Contains("raw_status=VALUES(raw_status)", block, StringComparison.Ordinal);
  125. Assert.Contains("completion_status=VALUES(completion_status)", block, StringComparison.Ordinal);
  126. // 任何 IF(...) / CASE / COALESCE 形式的保留旧值写法都会让回退静默失效
  127. foreach (var forbidden in new[]
  128. {
  129. "completion_status = IF(", "completion_status=IF(",
  130. "raw_status = IF(", "raw_status=IF(",
  131. "CASE WHEN completion_status", "COALESCE(completion_status",
  132. })
  133. Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal);
  134. }
  135. [Fact]
  136. public void 同一Grain只能一行_唯一键就位()
  137. {
  138. var sql = Migration("1.0.544.sql");
  139. Assert.Contains(
  140. "UNIQUE KEY `uk_mdp_std_po_completion` (`tenant_id`, `po_no`, `po_line`)",
  141. sql, StringComparison.Ordinal);
  142. }
  143. [Fact]
  144. public void provenance列不得混进业务唯一键()
  145. {
  146. var sql = Migration("1.0.544.sql");
  147. foreach (var forbidden in new[]
  148. {
  149. "uk_mdp_std_po_completion` (`tenant_id`, `domain`",
  150. "uk_mdp_std_po_completion` (`tenant_id`, `source_row_id`",
  151. })
  152. Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
  153. }
  154. // ── ③ 租户归属:唯一才用 ─────────────────────────────────────────────────────
  155. private static List<PoTenantCandidate> Candidates(params (string po, string dom, long tid)[] rows)
  156. => rows.Select(r => new PoTenantCandidate { PoNo = r.po, Domain = r.dom, TenantId = r.tid }).ToList();
  157. [Fact]
  158. public void 租户唯一时使用()
  159. {
  160. var c = Candidates(("PO1", "", 797L), ("PO2", "", 838L));
  161. Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
  162. }
  163. [Fact]
  164. public void 租户无候选时返回0_不得臆造()
  165. {
  166. var c = Candidates(("PO1", "", 797L));
  167. Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO-NOT-EXIST", "8010"));
  168. }
  169. [Fact]
  170. public void 租户歧义时返回0_绝不取第一个()
  171. {
  172. // 同一采购单号在两个租户下都存在 —— 必须整体跳过,不能任选
  173. var c = Candidates(("PO1", "", 797L), ("PO1", "", 838L));
  174. Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
  175. }
  176. [Fact]
  177. public void 空Domain视为通配_自建单可命中()
  178. {
  179. var c = Candidates(("PO1", "", 797L));
  180. Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
  181. }
  182. [Fact]
  183. public void Domain不一致的候选被排除()
  184. {
  185. var c = Candidates(("PO1", "9999", 797L));
  186. Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
  187. }
  188. [Fact]
  189. public void 相同Domain不会把不同租户混成一个()
  190. {
  191. // Domain 8010 横跨多个租户,绝不能因为 Domain 相同就认为是同一租户
  192. var c = Candidates(("PO1", "8010", 797L), ("PO1", "8010", 838L));
  193. Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
  194. }
  195. [Fact]
  196. public void 同一租户多行不算歧义()
  197. {
  198. var c = Candidates(("PO1", "", 797L), ("PO1", "8010", 797L));
  199. Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
  200. }
  201. [Fact]
  202. public void 禁止用Domain映射表反推租户()
  203. {
  204. var code = ServiceBody();
  205. // ado_source_domain_tenant_map 全表只有一行,用它会把 838 的数据错记到 797 名下
  206. Assert.DoesNotContain("ado_source_domain_tenant_map", code, StringComparison.Ordinal);
  207. Assert.DoesNotContain("ResolveDomainAsync", code, StringComparison.Ordinal);
  208. }
  209. [Fact]
  210. public void 租户解析不到必须跳过_不得落无主Fact()
  211. {
  212. var run = Between(Service(), "var resolved = ResolveTenant(", "// 单租户刷新");
  213. Assert.Contains("if (resolved <= 0)", run, StringComparison.Ordinal);
  214. Assert.Contains("continue;", run, StringComparison.Ordinal);
  215. }
  216. // ── ④ 本地采购行校验 ─────────────────────────────────────────────────────────
  217. [Fact]
  218. public void 多命中必须硬失败_不得取第一条()
  219. {
  220. var run = Between(Service(), "var matches = poLineIndex.TryGetValue", "result.MatchedRows++");
  221. Assert.Contains("if (matches > 1)", run, StringComparison.Ordinal);
  222. Assert.Contains("throw new InvalidOperationException", run, StringComparison.Ordinal);
  223. Assert.Contains("if (matches == 0)", run, StringComparison.Ordinal);
  224. foreach (var forbidden in new[] { "LIMIT 1", ".First()", ".Min()", ".Max()" })
  225. Assert.DoesNotContain(forbidden, run, StringComparison.Ordinal);
  226. }
  227. // ── ⑤ 淘汰:只在成功后、且 tenant-safe ───────────────────────────────────────
  228. [Fact]
  229. public void 淘汰必须在源读取与写入成功之后()
  230. {
  231. var code = Service();
  232. var upsert = At(code, "result.WrittenRows = await UpsertAsync");
  233. var retire = At(code, "result.StaleRemoved = await RetireStaleAsync");
  234. var read = At(code, "var sourceRows = await ReadSourceRowsAsync");
  235. Assert.True(read < upsert, "必须先读源再写入");
  236. Assert.True(upsert < retire, "必须先写入再淘汰,读取失败时异常会跳过淘汰");
  237. }
  238. [Fact]
  239. public void 异常分支绝不淘汰()
  240. {
  241. var catchBlock = Between(Service(), "catch (Exception ex)", "// ── 源绑定与 fail-closed");
  242. Assert.DoesNotContain("RetireStaleAsync", catchBlock, StringComparison.Ordinal);
  243. Assert.DoesNotContain("DELETE", catchBlock, StringComparison.Ordinal);
  244. }
  245. [Fact]
  246. public void 单租户刷新只淘汰该租户()
  247. {
  248. var retire = Between(Service(), "private async Task<int> RetireStaleAsync", "// ── run log");
  249. Assert.Contains("WHERE tenant_id=@TenantId AND IFNULL(sync_batch_id,'') <> @BatchId", retire, StringComparison.Ordinal);
  250. // 全量路径也必须显式排除无主行
  251. Assert.Contains("WHERE tenant_id > 0 AND IFNULL(sync_batch_id,'') <> @BatchId", retire, StringComparison.Ordinal);
  252. // 绝不允许无条件 DELETE
  253. Assert.DoesNotContain("DELETE FROM mdp_std_purchase_order_completion\"", retire, StringComparison.Ordinal);
  254. }
  255. [Fact]
  256. public void 单租户刷新时其它租户的源行不参与本轮()
  257. {
  258. // 否则它们会因为没盖上本批次号而被误删
  259. var run = Between(Service(), "// 单租户刷新", "var matches = poLineIndex.TryGetValue");
  260. Assert.Contains("if (tenantId > 0 && resolved != tenantId)", run, StringComparison.Ordinal);
  261. Assert.Contains("continue;", run, StringComparison.Ordinal);
  262. }
  263. // ── ⑥ 源绑定与 fail-closed ───────────────────────────────────────────────────
  264. [Fact]
  265. public void 源编码必须来自配置_业务代码内不得出现具体源字面量()
  266. {
  267. var code = Service();
  268. Assert.DoesNotContain("DOPDEMORQ", code, StringComparison.OrdinalIgnoreCase);
  269. Assert.Contains("_opt.SourceCode", code, StringComparison.Ordinal);
  270. Assert.Contains("_scopeFactory.GetScopeAsync(binding.SourceCode", code, StringComparison.Ordinal);
  271. }
  272. [Fact]
  273. public void 配置类提供默认值与DEV白名单()
  274. {
  275. var opt = Source("Option", "AidopPoCompletionOptions.cs");
  276. Assert.Contains("[OptionsSettings(\"AiDOP:PoCompletion\")]", opt, StringComparison.Ordinal);
  277. Assert.Contains("IConfigurableOptions", opt, StringComparison.Ordinal);
  278. Assert.Contains("public string SourceCode", opt, StringComparison.Ordinal);
  279. Assert.Contains("public string[] DevOnlySourceCodes", opt, StringComparison.Ordinal);
  280. }
  281. [Fact]
  282. public void 非开发环境命中DEV源必须fail_closed()
  283. {
  284. var bind = Between(Service(), "private async Task<SourceBinding> ResolveSourceBindingAsync", "// ── 源侧读取");
  285. Assert.Contains("_env.IsDevelopment()", bind, StringComparison.Ordinal);
  286. Assert.Contains("DevOnlySourceCodes", bind, StringComparison.Ordinal);
  287. Assert.Contains("throw new InvalidOperationException", bind, StringComparison.Ordinal);
  288. }
  289. [Fact]
  290. public void 源未配置或被禁用必须失败()
  291. {
  292. var bind = Between(Service(), "private async Task<SourceBinding> ResolveSourceBindingAsync", "// ── 源侧读取");
  293. Assert.Contains("code.Length == 0", bind, StringComparison.Ordinal);
  294. Assert.Contains("status=1", bind, StringComparison.Ordinal);
  295. Assert.Contains("mdp_source 未找到启用源", bind, StringComparison.Ordinal);
  296. }
  297. [Fact]
  298. public void 必须挡住回落主库()
  299. {
  300. var code = Service();
  301. // 作用域工厂对本库样板源与未配账号的源会直接返回主库连接,
  302. // 那会让本服务把 Source A 的本地表当成 WMS 执行态读进来
  303. Assert.Contains("ReferenceEquals(remote, _db)", code, StringComparison.Ordinal);
  304. var bind = Between(code, "private async Task<SourceBinding> ResolveSourceBindingAsync", "// ── 源侧读取");
  305. Assert.Contains("IsNullOrWhiteSpace(src.DbUser)", bind, StringComparison.Ordinal);
  306. }
  307. [Fact]
  308. public void 回执与日志不得暴露连接信息()
  309. {
  310. var code = Service();
  311. foreach (var forbidden in new[] { "ConnectionString", "DbPassword", "db_password", "Password=" })
  312. Assert.DoesNotContain(forbidden, code, StringComparison.OrdinalIgnoreCase);
  313. }
  314. // ── ⑦ 边界:唯一 Authority 是源侧 Status ─────────────────────────────────────
  315. [Fact]
  316. public void 禁止用数量重新推导完成()
  317. {
  318. var code = ServiceBody();
  319. foreach (var forbidden in new[]
  320. {
  321. "received_qty", "order_qty", "QtyOrded", "QtyReceived", "RctQty",
  322. "QtyReturned", "ReceiptQty", "mdp_std_purchase_receipt",
  323. })
  324. Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
  325. }
  326. [Fact]
  327. public void 禁止读IQC或算ObjectCompleted()
  328. {
  329. var code = ServiceBody();
  330. foreach (var forbidden in new[]
  331. {
  332. "mdp_std_iqc_inspection", "quality_result", "flow_status",
  333. "ObjectCompleted", "QualityAccepted", "StageComplete",
  334. })
  335. Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
  336. }
  337. [Fact]
  338. public void 只写completion事实表与跑批日志()
  339. {
  340. var code = Service();
  341. var targets = code.Split("INSERT INTO ").Skip(1)
  342. .Select(x => x.Split('\n')[0].Trim().Split(' ')[0].Trim())
  343. .Distinct().OrderBy(x => x, StringComparer.Ordinal).ToArray();
  344. Assert.Equal(new[] { "mdp_std_purchase_order_completion", "mdp_transform_run_log" }, targets);
  345. }
  346. [Fact]
  347. public void 绝不更新SourceA的基础事实()
  348. {
  349. var code = ServiceBody();
  350. foreach (var forbidden in new[]
  351. {
  352. "UPDATE mdp_std_purchase_order\n", "UPDATE mdp_std_purchase_order ",
  353. "UPDATE PurOrdDetail", "UPDATE PurOrdMaster",
  354. "INSERT INTO mdp_std_purchase_order\n", "INSERT INTO PurOrdDetail",
  355. })
  356. Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
  357. }
  358. [Fact]
  359. public void 源侧只读()
  360. {
  361. var read = Between(Service(), "private async Task<List<SourceRow>> ReadSourceRowsAsync", "// ── 租户归属");
  362. Assert.Contains("GetDataTableAsync", read, StringComparison.Ordinal);
  363. foreach (var forbidden in new[] { "UPDATE ", "INSERT ", "DELETE ", "MERGE " })
  364. Assert.DoesNotContain(forbidden, read, StringComparison.Ordinal);
  365. }
  366. [Fact]
  367. public void 入账范围与推送口径一致()
  368. {
  369. var read = Between(Service(), "private async Task<List<SourceRow>> ReadSourceRowsAsync", "// ── 租户归属");
  370. Assert.Contains("Potype", read, StringComparison.Ordinal);
  371. Assert.Contains("_opt.SourcePotype", read, StringComparison.Ordinal);
  372. }
  373. // ── ⑧ 完成时间:当前无权威,不得落库 ────────────────────────────────────────
  374. [Fact]
  375. public void 不得创建completion_time列()
  376. {
  377. var sql = Migration("1.0.544.sql");
  378. foreach (var forbidden in new[] { "`completion_time`", "`completed_at`", "`procurement_completion_time`" })
  379. Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
  380. }
  381. [Fact]
  382. public void 不得把观测时间当完成时间()
  383. {
  384. var code = ServiceBody();
  385. Assert.DoesNotContain("completion_time", code, StringComparison.Ordinal);
  386. Assert.DoesNotContain("CompletionTime", code, StringComparison.Ordinal);
  387. }
  388. // ── ⑨ NOT_OBSERVED 不得物化 ─────────────────────────────────────────────────
  389. [Fact]
  390. public void 不得遍历SourceA反向造行()
  391. {
  392. var code = Service();
  393. // 驱动集合只能来自源侧读取结果
  394. Assert.Contains("foreach (var row in sourceRows)", code, StringComparison.Ordinal);
  395. // 本库采购行只用于校验与租户归属,不得成为写入驱动
  396. var run = Between(code, "var pending = new List<CompletionRow>", "// ④ UPSERT");
  397. Assert.DoesNotContain("foreach (var row in poLineIndex", run, StringComparison.Ordinal);
  398. // NOT_OBSERVED 是「不落行」的口头表达(注释里可以出现),
  399. // 但绝不能变成落库的字符串字面量、枚举值或列默认值
  400. Assert.DoesNotContain("\"NOT_OBSERVED\"", ServiceBody(), StringComparison.Ordinal);
  401. Assert.DoesNotContain("'NOT_OBSERVED'", Migration("1.0.544.sql"), StringComparison.Ordinal);
  402. }
  403. // ── ⑩ 调度与手动入口 ────────────────────────────────────────────────────────
  404. [Fact]
  405. public void 周期性全量对账必须存在且启用()
  406. {
  407. var job = Source("Job", "S3PoCompletionMdpSyncJob.cs");
  408. Assert.Contains("[JobDetail(\"job_s3_po_completion_mdp_sync\"", job, StringComparison.Ordinal);
  409. Assert.Contains("[Period(", job, StringComparison.Ordinal);
  410. Assert.Contains("RunFullAsync(0, \"AUTO\"", job, StringComparison.Ordinal);
  411. }
  412. [Fact]
  413. public void 手动刷新入口就位且不回显连接信息()
  414. {
  415. var ctrl = Source("Controllers", "AidopKanbanController.cs");
  416. Assert.Contains("[HttpPost(\"s3-po-completion-mdp/refresh\")]", ctrl, StringComparison.Ordinal);
  417. var block = Between(ctrl, "[HttpPost(\"s3-po-completion-mdp/refresh\")]", "staleRemoved = r.StaleRemoved");
  418. foreach (var forbidden in new[] { "ConnectionString", "Password", "db_user" })
  419. Assert.DoesNotContain(forbidden, block, StringComparison.OrdinalIgnoreCase);
  420. }
  421. [Fact]
  422. public void 跑批日志复用既有表与固定JobCode()
  423. {
  424. var code = Service();
  425. Assert.Contains("\"S3_PO_COMPLETION_MDP_SYNC\"", code, StringComparison.Ordinal);
  426. Assert.Contains("INSERT INTO mdp_transform_run_log", code, StringComparison.Ordinal);
  427. foreach (var s in new[] { "'RUNNING'", "status='SUCCESS'", "status='FAILED'" })
  428. Assert.Contains(s, code, StringComparison.Ordinal);
  429. }
  430. // ── ⑪ 上游批次资产不得回归 ──────────────────────────────────────────────────
  431. [Fact]
  432. public void IQC标准事实层与采购身份桥不得被本批改动()
  433. {
  434. var iqc = Migration("1.0.541.sql");
  435. Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", iqc, StringComparison.Ordinal);
  436. var sql = Migration("1.0.544.sql");
  437. foreach (var forbidden in new[]
  438. {
  439. "ALTER TABLE `mdp_std_purchase_order`", "ALTER TABLE `mdp_std_iqc_inspection`",
  440. "UPDATE `mdp_std_purchase_order`", "DROP TABLE",
  441. })
  442. Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
  443. }
  444. }