using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse; /// /// S5 PURCHASE RECEIPT DISPLAY FIX 源码契约测试。 /// /// 缺陷:JSON_UNQUOTE(JSON_EXTRACT(raw_data,'$.x')) 对 JSON null 产出的是**长度 4 的字面量字符串 'null'** /// (IS NULL = 0),一路落进 mdp_std_purchase_receipt 并在采购收货单页面显示成 `null`, /// 前端 || '-' 之类空值兜底对它完全无效。 /// /// 修法:展示/属性列一律经 MdpJsonSql.Str / Int / Dec; /// identity / JOIN 键刻意保留裸取值——Domain / Receiver / Line 是 /// uk_mdp_std_pur_rct 的 NOT NULL 组成部分,且头明细 INNER JOIN 依赖 Domain 相等; /// 若一并归一为 SQL NULL,则 NULL = NULL 不成立、整批收货行会从标准层消失。 /// 本测试同时钉住"该归一的归一"与"不该动的没动"两侧。 /// public class S5PurchaseReceiptDisplayContractTests { private static string SyncService() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "MaterialWarehouse", "PurchaseReceiptMdpSyncService.cs")); private static string JsonSql() => File.ReadAllText(FindFile( "server", "Plugins", "Admin.NET.Plugin.AiDOP", "DataPlatform", "MdpJsonSql.cs")); /// 标准层的字符串展示列必须走 MdpJsonSql.Str。 [Theory] [InlineData("p", "Supp")] // supp [InlineData("d", "Supp")] // sort_name 的编码部分 [InlineData("p", "ItemNum")] // item_num [InlineData("p", "UM")] // um [InlineData("p", "LotSerial")] // lot_serial [InlineData("p", "Location")] // location [InlineData("p", "OrdNbr")] // ord_nbr public void StandardTransform_StringDisplayColumn_UsesNullSafeHelper(string alias, string field) { Assert.Contains($"MdpJsonSql.Str(\"{alias}\", \"{field}\")", SyncService()); } /// 数值展示列用类型 helper,杜绝 STRICT 模式下的 CAST('null' AS ...)。 [Fact] public void StandardTransform_NumericDisplayColumns_UseTypedHelpers() { var s = SyncService(); Assert.Contains("MdpJsonSql.Dec(\"p\", \"QtyOrded\", 18, 6)", s); Assert.Contains("MdpJsonSql.Dec(\"p\", \"QtyReceived\", 18, 6)", s); Assert.Contains("MdpJsonSql.Int(\"p\", \"OrdLine\")", s); Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"QtyOrded\")", s); Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"QtyReceived\")", s); Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"OrdLine\")", s); } /// 修复前的裸写法不得回归(这些正是把 'null' 写进标准层的那几行)。 [Theory] [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.UM'))")] [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.LotSerial'))")] [InlineData("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Location'))")] [InlineData("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyOrded')) AS DECIMAL(18,6))")] [InlineData("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.QtyReceived')) AS DECIMAL(18,6))")] public void StandardTransform_BareDisplayExtraction_IsGone(string bareSql) { Assert.DoesNotContain(bareSql, SyncService()); } /// /// OrdLine 的裸 CAST 只剩 PurOrdDetail 关联条件里的两处(pd / sd),SELECT 列已改用 helper。 /// 这两处是 JOIN 键,按本批约定不动;数量钉死可挡住"顺手也改了 join"或"漏改 SELECT"两种回归。 /// [Fact] public void OrdLineBareCast_OnlyRemainsInJoinConditions() { var s = SyncService(); var bare = s.Split("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED)").Length - 1; Assert.Equal(2, bare); Assert.Contains("ELSE CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED) END) = pd.Line", s); Assert.Contains("AND CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.OrdLine')) AS SIGNED) = sd.Line", s); } /// /// 头明细 INNER JOIN 仍按裸取值比较 Domain / Receiver。 /// 这是刻意的:改成 MdpJsonSql.Str 会让源侧 Domain 为 NULL 的收货单 NULL = NULL 不成立、整批消失。 /// [Fact] public void MasterDetailJoinKeys_StayBare_SoRowsDoNotVanish() { var s = SyncService(); Assert.Contains( "JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Domain')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Domain'))", s); Assert.Contains( "JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Receiver')) = JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.Receiver'))", s); Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"Domain\")", s); Assert.DoesNotContain("MdpJsonSql.Str(\"p\", \"Receiver\")", s); } /// identity 列 domain / receiver / line 与 source_biz_key 兜底保持裸取值(NOT NULL 唯一键成分)。 [Fact] public void IdentityColumns_StayBare() { var s = SyncService(); Assert.Contains("CAST(JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.Line')) AS SIGNED)", s); Assert.Contains("IFNULL(NULLIF(p.source_biz_key,''), CONCAT(", s); Assert.DoesNotContain("MdpJsonSql.Int(\"p\", \"Line\")", s); } /// rct_date 既有的 'null'/空 守卫不得被去掉(本批不重写它,只保证不回归)。 [Fact] public void RctDate_KeepsExistingNullGuard() { Assert.Contains( "NULLIF(NULLIF(JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.RctDate')),'null'),'')", SyncService()); } /// RctType='rc' 业务过滤未被本批波及。 [Fact] public void RctTypeFilter_Unchanged() { Assert.Contains("JSON_UNQUOTE(JSON_EXTRACT(p.raw_data,'$.RctType'))='rc'", SyncService()); } /// helper 语义钉死:JSON null / 字符串 'null' → SQL NULL。 [Fact] public void JsonSqlStrHelper_NormalizesLiteralNullToSqlNull() { Assert.Contains("NULLIF({Ext(alias, field)},'null')", JsonSql()); } private static string FindFile(params string[] parts) { var dir = new DirectoryInfo(AppContext.BaseDirectory); while (dir != null) { var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray()); if (File.Exists(candidate)) return candidate; dir = dir.Parent; } throw new FileNotFoundException(string.Join("/", parts)); } }