MdpStandardUpsertContractTests.cs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161
  1. using Xunit;
  2. namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
  3. public class MdpStandardUpsertContractTests
  4. {
  5. [Fact]
  6. public void S1ShipStandardUpsert_RefreshesFactoryScope()
  7. {
  8. var source = File.ReadAllText(FindFile(
  9. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Order", "S1MdpSyncTransformService.cs"));
  10. Assert.True(Count(source, "factory_id=VALUES(factory_id)") >= 3);
  11. Assert.Contains("shipper_no=VALUES(shipper_no)", source);
  12. Assert.DoesNotContain("SELECT tenant_id AS TenantId, 1 AS FactoryId,\n 'S1_L1_004'", source);
  13. Assert.DoesNotContain("SELECT tenant_id AS TenantId, 1 AS FactoryId,\n 'S1_L2_013'", source);
  14. Assert.DoesNotContain("SELECT tenant_id AS TenantId, 1 AS FactoryId,\n 'S1_L2_014'", source);
  15. Assert.DoesNotContain("SELECT tenant_id AS TenantId, 1 AS FactoryId,\n 'S1_L2_015'", source);
  16. }
  17. /// <summary>
  18. /// mdp_std_so 的 closed 必须与 order_status 一起刷新。
  19. ///
  20. /// <para>两列派生自同一个源字段 <c>h.raw_data.$.closed</c>(分别在 :1057 / :1063)。
  21. /// 若只把 order_status 放进 ODKU,订单在源端关闭后同一行会分叉成
  22. /// <c>order_status='CLOSED'</c> 而 <c>closed=0</c> —— 而 <c>mdp_std_so.closed</c> 的
  23. /// 全仓唯一读取方 <c>S8OrderDeliveryDataProvider</c>(<c>IFNULL(s.closed,0)=0</c>)正是靠它
  24. /// 把已关闭订单排除出 Rule02 数据集。分叉的后果是已关单订单继续命中(假建单),
  25. /// 且已建异常无法因关单退出数据集(漏恢复)。</para>
  26. ///
  27. /// <para>断言限定在 <c>mdp_std_so</c> 这一条 INSERT 的 ODKU 区间内 —— 对整份源文件做
  28. /// Contains 会被别的表的 ODKU 蒙混过关。</para>
  29. /// </summary>
  30. [Fact]
  31. public void S1SoStandardUpsert_RefreshesClosedAlongsideOrderStatus()
  32. {
  33. var source = File.ReadAllText(FindFile(
  34. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Order", "S1MdpSyncTransformService.cs"));
  35. var odku = ExtractOnDuplicateKeyUpdate(source, "INSERT INTO mdp_std_so");
  36. Assert.Contains("closed=VALUES(closed)", odku);
  37. Assert.Contains("order_status=VALUES(order_status)", odku);
  38. // 补 closed 不得挤掉既有刷新列(deleted_flag 与 closed 成对被 Provider 消费)。
  39. Assert.Contains("deleted_flag=VALUES(deleted_flag)", odku);
  40. }
  41. [Fact]
  42. public void S3DeliveryScheduleUpsert_RefreshesLifecycleDates()
  43. {
  44. var source = File.ReadAllText(FindFile(
  45. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Supply", "S3MdpSyncTransformService.cs"));
  46. Assert.Contains("request_date=VALUES(request_date)", source);
  47. Assert.Contains("submit_date=VALUES(submit_date)", source);
  48. Assert.Contains("last_sent_date=VALUES(last_sent_date)", source);
  49. Assert.Contains("send_date=VALUES(send_date)", source);
  50. Assert.Contains("arrive_date=VALUES(arrive_date)", source);
  51. Assert.Contains("issued_qty=VALUES(issued_qty)", source);
  52. Assert.Contains("shortage_qty=VALUES(shortage_qty)", source);
  53. Assert.Contains("ready_status=VALUES(ready_status)", source);
  54. Assert.Contains("factory_id=VALUES(factory_id)", source);
  55. }
  56. [Fact]
  57. public void S3S4DashboardDerivedQueries_StayFactoryScoped()
  58. {
  59. var source = File.ReadAllText(FindFile(
  60. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "Controllers", "AidopKanbanController.cs"));
  61. Assert.Contains("GetS3ReadinessSummaryAsync(long tenantId, long factoryId)", source);
  62. Assert.Contains("GetS3DeliverySummaryAsync(long tenantId, long factoryId)", source);
  63. Assert.True(Count(source, "COALESCE(NULLIF(factory_id,0),1)=@factoryId") >= 10);
  64. }
  65. [Fact]
  66. public void S4ProcurementCycle_UsesActualReceiptDate()
  67. {
  68. var source = File.ReadAllText(FindFile(
  69. "server", "Plugins", "Admin.NET.Plugin.AiDOP", "ProcurementExecution", "S4MdpSyncTransformService.cs"));
  70. Assert.Contains("FROM mdp_std_delivery_result", source);
  71. Assert.Contains("MAX(receipt_date) AS receipt_date", source);
  72. Assert.Contains("TIMESTAMPDIFF(DAY, ds.request_date, r.receipt_date)", source);
  73. Assert.DoesNotContain(
  74. "TIMESTAMPDIFF(DAY, ds.request_date, COALESCE(ds.submit_date, ds.last_sent_date))",
  75. source);
  76. }
  77. /// <summary>
  78. /// 注册表反查的比较对象必须限定到外层驱动表,不能写裸列名。
  79. ///
  80. /// <para><c>mdp_source_table_registry</c> 自己就有 <c>source_table</c> / <c>source_system</c> 两列,
  81. /// 而 MySQL 解析不带前缀的列名时内层作用域优先。于是
  82. /// <c>WHERE r.source_table = source_table</c> 绑成了 <c>r.source_table = r.source_table</c> —— 恒真,
  83. /// <c>LIMIT 1</c> 返回注册表的第一行。实测(aidopdev,只读)裸写法对每一行都返回
  84. /// <c>DOPDEMORQ_SQLSERVER</c>(id=1 的 InvTransHist),而正确关联的 <c>d.source_table</c>
  85. /// 对未登记的 srm_pr_main 返回 NULL,继而回落到该行自带的 AIDOP_NATIVE。</para>
  86. ///
  87. /// <para>后果不是字段填错就完事:<c>MdpNeutralSourceGate</c> 会按 SOURCE_MISMATCH 拒写,
  88. /// 数据被静默丢掉。多表语句还会因为外层 <c>NULLIF(source_system,'')</c> 两边都能匹配而直接报
  89. /// 「Column 'source_system' in field list is ambiguous」,整轮跑批 FAILED。</para>
  90. /// </summary>
  91. [Theory]
  92. [InlineData("Order", "S1MdpSyncTransformService.cs")]
  93. [InlineData("Production", "S2MdpSyncTransformService.cs")]
  94. [InlineData("Supply", "S3MdpSyncTransformService.cs")]
  95. [InlineData("ProcurementExecution", "S4MdpSyncTransformService.cs")]
  96. public void SourceRegistryLookup_CorrelatesToDrivingTable(string folder, string file)
  97. {
  98. var source = File.ReadAllText(FindFile(
  99. "server", "Plugins", "Admin.NET.Plugin.AiDOP", folder, file));
  100. var lookups = System.Text.RegularExpressions.Regex.Matches(
  101. source, @"mdp_source_table_registry r WHERE r\.source_table = (?<operand>\S+)");
  102. Assert.NotEmpty(lookups);
  103. foreach (var operand in lookups.Select(m => m.Groups["operand"].Value))
  104. Assert.True(operand.Contains('.') || operand.StartsWith('@'),
  105. $"{file}:注册表反查写成了裸列 `{operand}`,会恒真并取到注册表第一行");
  106. }
  107. /// <summary>
  108. /// 取出 <paramref name="insertMarker"/> 之后那一条 INSERT 的 ODKU 区间(到 raw string 结束符为止)。
  109. /// 用它把断言限定在目标表上——同一份源文件里有多条 INSERT,全文 Contains 判不出列属于哪张表。
  110. /// </summary>
  111. private static string ExtractOnDuplicateKeyUpdate(string source, string insertMarker)
  112. {
  113. var insertAt = source.IndexOf(insertMarker, StringComparison.Ordinal);
  114. Assert.True(insertAt >= 0, $"未找到 {insertMarker}");
  115. var odkuAt = source.IndexOf("ON DUPLICATE KEY UPDATE", insertAt, StringComparison.Ordinal);
  116. Assert.True(odkuAt > insertAt, $"{insertMarker} 之后未找到 ON DUPLICATE KEY UPDATE");
  117. var endAt = source.IndexOf("\"\"\"", odkuAt, StringComparison.Ordinal);
  118. Assert.True(endAt > odkuAt, $"{insertMarker} 的 ODKU 未正常收尾");
  119. return source[odkuAt..endAt];
  120. }
  121. private static int Count(string source, string token)
  122. {
  123. var count = 0;
  124. for (var index = 0; (index = source.IndexOf(token, index, StringComparison.Ordinal)) >= 0; index += token.Length)
  125. count++;
  126. return count;
  127. }
  128. private static string FindFile(params string[] parts)
  129. {
  130. var dir = new DirectoryInfo(AppContext.BaseDirectory);
  131. while (dir != null)
  132. {
  133. var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
  134. if (File.Exists(candidate)) return candidate;
  135. dir = dir.Parent;
  136. }
  137. throw new FileNotFoundException(string.Join("/", parts));
  138. }
  139. }