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));
}
}