S5PurchaseReceiptDisplayContractTests.cs 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
  3. /// <summary>
  4. /// S5 PURCHASE RECEIPT DISPLAY FIX 源码契约测试。
  5. ///
  6. /// 缺陷:<c>JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.x'))</c> 对 JSON null 产出的是**长度 4 的字面量字符串 'null'**
  7. /// (<c>IS NULL = 0</c>),一路落进 <c>mdp_std_purchase_receipt</c> 并在采购收货单页面显示成 `null`,
  8. /// 前端 <c>|| '-'</c> 之类空值兜底对它完全无效。
  9. ///
  10. /// 修法:展示/属性列一律经 <c>MdpJsonSql.Str / Int / Dec</c>;
  11. /// <b>identity / JOIN 键刻意保留裸取值</b>——<c>Domain</c> / <c>Receiver</c> / <c>Line</c> 是
  12. /// <c>uk_mdp_std_pur_rct</c> 的 NOT NULL 组成部分,且头明细 INNER JOIN 依赖 Domain 相等;
  13. /// 若一并归一为 SQL NULL,则 <c>NULL = NULL</c> 不成立、整批收货行会从标准层消失。
  14. /// 本测试同时钉住"该归一的归一"与"不该动的没动"两侧。
  15. /// </summary>
  16. public class S5PurchaseReceiptDisplayContractTests
  17. {
  18. private static string SyncService() => File.ReadAllText(FindFile(
  19. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "PurchaseReceiptMdpSyncService.cs"));
  20. private static string JsonSql() => File.ReadAllText(FindFile(
  21. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpJsonSql.cs"));
  22. /// <summary>标准层的字符串展示列必须走 MdpJsonSql.Str。</summary>
  23. [Theory]
  24. [InlineData("p", "Supp")] // supp
  25. [InlineData("d", "Supp")] // sort_name 的编码部分
  26. [InlineData("p", "ItemNum")] // item_num
  27. [InlineData("p", "UM")] // um
  28. [InlineData("p", "LotSerial")] // lot_serial
  29. [InlineData("p", "Location")] // location
  30. [InlineData("p", "OrdNbr")] // ord_nbr
  31. public void StandardTransform_StringDisplayColumn_UsesNullSafeHelper(string alias, string field)
  32. {
  33. Assert.Contains($"MdpJsonSql.Str(\"{alias}\", \"{field}\")", SyncService());
  34. }
  35. /// <summary>数值展示列用类型 helper,杜绝 STRICT 模式下的 CAST('null' AS ...)。</summary>
  36. [Fact]
  37. public void StandardTransform_NumericDisplayColumns_UseTypedHelpers()
  38. {
  39. var s = SyncService();
  40. Assert.Contains("MdpJsonSql.Dec(\"p\", \"QtyOrded\", 18, 6)", s);
  41. Assert.Contains("MdpJsonSql.Dec(\"p\", \"QtyReceived\", 18, 6)", s);
  42. Assert.Contains("MdpJsonSql.Int(\"p\", \"OrdLine\")", s);
  43. Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"QtyOrded\")", s);
  44. Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"QtyReceived\")", s);
  45. Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"OrdLine\")", s);
  46. }
  47. /// <summary>修复前的裸写法不得回归(这些正是把 'null' 写进标准层的那几行)。</summary>
  48. [Theory]
  49. [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.UM'))")]
  50. [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.LotSerial'))")]
  51. [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Location'))")]
  52. [InlineData("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyOrded')) AS DECIMAL(18,6))")]
  53. [InlineData("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyReceived')) AS DECIMAL(18,6))")]
  54. public void StandardTransform_BareDisplayExtraction_IsGone(string bareSql)
  55. {
  56. Assert.DoesNotContain(bareSql, SyncService());
  57. }
  58. /// <summary>
  59. /// OrdLine 的裸 CAST 只剩 PurOrdDetail 关联条件里的两处(pd / sd),SELECT 列已改用 helper。
  60. /// 这两处是 JOIN 键,按本批约定不动;数量钉死可挡住"顺手也改了 join"或"漏改 SELECT"两种回归。
  61. /// </summary>
  62. [Fact]
  63. public void OrdLineBareCast_OnlyRemainsInJoinConditions()
  64. {
  65. var s = SyncService();
  66. var bare = s.Split("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED)").Length - 1;
  67. Assert.Equal(2, bare);
  68. Assert.Contains("ELSE CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED) END) = pd.Line", s);
  69. Assert.Contains("AND CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED) = sd.Line", s);
  70. }
  71. /// <summary>
  72. /// 头明细 INNER JOIN 仍按裸取值比较 Domain / Receiver。
  73. /// 这是刻意的:改成 MdpJsonSql.Str 会让源侧 Domain 为 NULL 的收货单 <c>NULL = NULL</c> 不成立、整批消失。
  74. /// </summary>
  75. [Fact]
  76. public void MasterDetailJoinKeys_StayBare_SoRowsDoNotVanish()
  77. {
  78. var s = SyncService();
  79. Assert.Contains(
  80. "JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Domain')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Domain'))", s);
  81. Assert.Contains(
  82. "JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Receiver')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Receiver'))", s);
  83. Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"Domain\")", s);
  84. Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"Receiver\")", s);
  85. }
  86. /// <summary>identity 列 domain / receiver / line 与 source_biz_key 兜底保持裸取值(NOT NULL 唯一键成分)。</summary>
  87. [Fact]
  88. public void IdentityColumns_StayBare()
  89. {
  90. var s = SyncService();
  91. Assert.Contains("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Line')) AS SIGNED)", s);
  92. Assert.Contains("IFNULL(NULLIF(p.source_biz_key,''), CONCAT(", s);
  93. Assert.DoesNotContain("MdpJsonSql.Int(\"p\", \"Line\")", s);
  94. }
  95. /// <summary>rct_date 既有的 'null'/空 守卫不得被去掉(本批不重写它,只保证不回归)。</summary>
  96. [Fact]
  97. public void RctDate_KeepsExistingNullGuard()
  98. {
  99. Assert.Contains(
  100. "NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RctDate')),'null'),'')", SyncService());
  101. }
  102. /// <summary>RctType='rc' 业务过滤未被本批波及。</summary>
  103. [Fact]
  104. public void RctTypeFilter_Unchanged()
  105. {
  106. Assert.Contains("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.RctType'))='rc'", SyncService());
  107. }
  108. /// <summary>helper 语义钉死:JSON null / 字符串 'null' → SQL NULL。</summary>
  109. [Fact]
  110. public void JsonSqlStrHelper_NormalizesLiteralNullToSqlNull()
  111. {
  112. Assert.Contains("NULLIF({Ext(alias, field)},'null')", JsonSql());
  113. }
  114. private static string FindFile(params string[] parts)
  115. {
  116. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  117. while (dir != null)
  118. {
  119. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  120. if (File.Exists(candidate)) return candidate;
  121. dir = dir.Parent;
  122. }
  123. throw new FileNotFoundException(string.Join("/", parts));
  124. }
  125. }