using Admin.NET.Plugin.AiDOP.DataPlatform.MdpRebuild;
using Admin.NET.Plugin.AiDOP.Supply;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
///
/// 委外交付链(mdp_std_process_outsource_order → dwd_process_outsource_delivery)的**粒度契约**。
///
/// 这张 DWD 曾同时混装两个互不相干的业务对象:
///
///
/// - 种群 A(错误) —— RoutingOpDetail(表注释「S0 标准工艺路线明细」,是**主数据**)
/// 经 S3_ROUTING_OUTSOURCE 入站落 mdp_stg_work_order_material,再被一条
/// WHERE source_table='RoutingOpDetail' 的 STD INSERT 当成委外交付事实物化。
/// 它根本没有 work_order / po_no / po_line / 订单量 / 完成量 / 交期,
/// SupplierCode 100% 为空,只有 0.48% 的行真是委外工序;
/// 且该源表早已由 S0 维度层正确建模为 bridge_item_routing_op,属重复建模。
/// - 种群 B(正确) —— PurOrdMaster(Potype='PW') JOIN PurOrdDetail,
/// 由 Supply/ProcessOutsourceOrderService.cs 写入 STD,字段齐全。
/// 这是唯一真实的委外交付事实。
///
///
/// 为什么是 P0:种群 A 的 po_no / po_line 只能硬编码成 NULL,
/// 而 DWD 的唯一键 uk_work_op_stat 含这两列 —— MySQL 唯一索引是 NULL-distinct 的,
/// 于是 ON DUPLICATE KEY UPDATE 永不命中,每轮跑批全量追加,实测膨胀至 3244 万行。
/// 症状不是报错,是一张越跑越大、且语义上根本不是委外交付的表。
///
/// 本文件守住止血之后的形状:种群 A 的两个环节(入站注册 + STD 物化)都不得回来,
/// 种群 B 的链路必须完好,且 DWD 投影的 NULL 保护必须对称。
///
public class S3ProcessOutsourceGrainContractTests
{
private static string Source(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
dir = dir.Parent;
Assert.NotNull(dir);
var full = Path.Combine(new[] { dir!.FullName, "Admin.NET.Plugin.AiDOP" }.Concat(parts).ToArray());
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
private static string S3Service() => Source("Supply", "S3MdpSyncTransformService.cs");
///
/// 取可执行 SQL/代码行 —— 剔除 C# 行注释与 SQL 行注释。
/// 本文件的断言大量是「某个标识符不得再出现」,而移除它的那一处恰恰留下了
/// 解释性注释、注释里必然提到这些标识符。不过滤会把注释误判成活代码。
///
private static string ExecutableLines(string code) =>
string.Join('\n', code.Split('\n')
.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("//", StringComparison.Ordinal)
&& !t.StartsWith("--", StringComparison.Ordinal)
&& !t.StartsWith("///", StringComparison.Ordinal);
}));
/// 取一条语句的 SQL 区间(从锚点到原始字符串字面量的收尾 """)。
private static string StatementBlock(string code, string startAnchor)
{
var start = code.IndexOf(startAnchor, StringComparison.Ordinal);
Assert.True(start >= 0, $"未找到锚点:{startAnchor}");
var end = code.IndexOf("\"\"\"", start, StringComparison.Ordinal);
Assert.True(end > start, $"语句未正常收尾:{startAnchor}");
return code[start..end];
}
// ── Test A1 ─────────────────────────────────────────────────────────────────────
///
/// 入站层不得再把 RoutingOpDetail 拉进数据中台。
///
/// 这是最强的一档断言:直接读运行期真正使用的配置对象
/// ,不是比对源码文本。
///
/// 同时按 EntityCode 与 SourceTable 两个维度断言,
/// 是为了堵住「换个 entity_code 重新注册同一张源表」这条回归路径。
///
[Fact]
public void Inbound_NoLongerRegisters_RoutingOpDetail()
{
Assert.DoesNotContain(S3MdpEntityConfig.All, c =>
string.Equals(c.EntityCode, "S3_ROUTING_OUTSOURCE", StringComparison.Ordinal));
Assert.DoesNotContain(S3MdpEntityConfig.All, c =>
string.Equals(c.SourceTable, "RoutingOpDetail", StringComparison.Ordinal));
}
// ── Test A2 ─────────────────────────────────────────────────────────────────────
///
/// STD 层不得再由本服务把 RoutingOpDetail 物化成委外交付订单。
///
/// 断言强度说明:BuildStandardCommands 是私有实例方法,
/// 其宿主 S3MdpSyncTransformService 的构造依赖 SqlSugar / DI 容器,
/// 纯单测里无法在不连库的前提下构造实例并枚举它产出的 SQL。
/// 因此这里退化为对源码文本的断言 —— 由于该方法体是纯 yield return 原始字符串字面量、
/// 不做任何运行期 SQL 拼接,文本断言与枚举产出在本例中是等价的。
/// 配置层那一档()才是强断言,
/// 两者合起来锁死「贴源没有它 + STD 不读它」。
///
[Fact]
public void StdBuild_NoLongerMaterializes_RoutingOpDetail()
{
var sqlOnly = ExecutableLines(S3Service());
// 驱动集本身已不存在。
Assert.DoesNotContain("source_table='RoutingOpDetail'", sqlOnly);
Assert.DoesNotContain("RoutingOpDetail", sqlOnly);
// 本服务对该 STD 表的写入处数必须归零 —— 写入方只剩 ProcessOutsourceOrderService。
Assert.DoesNotContain("INSERT INTO mdp_std_process_outsource_order", sqlOnly);
Assert.DoesNotContain("UPDATE mdp_std_process_outsource_order", sqlOnly);
}
///
/// mdp_std_process_outsource_order 在运行期只能有一个写入方,且必须是种群 B。
/// 把「本服务写入归零」与「另一方仍在」写成同一条断言,
/// 避免后续有人以为移除是把整条链都砍了。
///
[Fact]
public void ProcessOutsourceStd_HasExactlyOneRuntimeWriter()
{
var expectations = new[]
{
("S3MdpSyncTransformService.cs", 0),
("ProcessOutsourceOrderService.cs", 1)
};
foreach (var (file, expected) in expectations)
{
var writes = ExecutableLines(Source("Supply", file)).Split('\n')
.Count(l => l.Contains("INSERT INTO mdp_std_process_outsource_order", StringComparison.Ordinal)
|| l.Contains("UPDATE mdp_std_process_outsource_order", StringComparison.Ordinal));
Assert.True(writes == expected,
$"{file} 对 mdp_std_process_outsource_order 的运行期写入应为 {expected} 处,实测 {writes} 处");
}
}
///
/// 种群 B 必须真的带着 po_no / po_line 落库。
/// 止血的前提是「剩下的那一个写入方字段齐全」。若它也写 NULL,
/// 那么删掉种群 A 只是把膨胀速率降下来,并没有消除根因。
///
[Fact]
public void PopulationB_SuppliesRealPoNoAndPoLine()
{
var block = StatementBlock(
Source("Supply", "ProcessOutsourceOrderService.cs"),
"INSERT INTO mdp_std_process_outsource_order");
Assert.Contains("FROM PurOrdMaster m", block);
Assert.Contains("JOIN PurOrdDetail d ON m.RecID = d.PurOrdRecID", block);
Assert.Contains("m.Potype = 'PW'", block);
// 唯一键的两列来自源侧真实值,而不是字面量 NULL。
Assert.Contains("IFNULL(m.PurOrd, '')", block);
Assert.Contains("CAST(IFNULL(d.Line, 0) AS CHAR)", block);
}
// ── Test A3 ─────────────────────────────────────────────────────────────────────
///
/// 同表 mdp_stg_work_order_material 的其余三个分区必须原样保留。
///
/// 移除的是**一个分区**,不是整张贴源表。工单主表 / 明细 / 工艺路线三个分区
/// 各自有下游(mdp_std_material_readiness → dwd_material_readiness),
/// 误删任何一个都会让物料齐套直接断供。这条是防止止血扩大化。
///
[Fact]
public void WorkOrderMaterialPartitions_RemainRegistered()
{
var expected = new[]
{
("S3_WORK_ORDER_MASTER", "WorkOrdMaster"),
("S3_WORK_ORDER_DETAIL", "WorkOrdDetail"),
("S3_WORK_ORDER_ROUTING", "WorkOrdRouting")
};
foreach (var (entityCode, sourceTable) in expected)
{
Assert.Contains(S3MdpEntityConfig.All, c =>
string.Equals(c.EntityCode, entityCode, StringComparison.Ordinal)
&& string.Equals(c.SourceTable, sourceTable, StringComparison.Ordinal)
&& string.Equals(c.TargetTable, "mdp_stg_work_order_material", StringComparison.Ordinal));
}
// 该贴源表现在恰好由三个分区供给,不多不少。
var partitions = S3MdpEntityConfig.All
.Count(c => string.Equals(c.TargetTable, "mdp_stg_work_order_material", StringComparison.Ordinal));
Assert.Equal(3, partitions);
}
// ── Test A4 ─────────────────────────────────────────────────────────────────────
///
/// DWD 投影对唯一键两列的 NULL 保护必须**对称**。
///
/// uk_work_op_stat 含 po_no 与 po_line。MySQL 唯一索引是
/// NULL-distinct 的:只要其中**任意一列**为 NULL,该行就永远不与已有行冲突,
/// ON DUPLICATE KEY UPDATE 永不命中 → 每轮跑批纯追加。
/// 历史上 po_line 有 IFNULL 而 po_no 没有,保护形同虚设。
///
/// 注意顺序依赖:这条保护只有在种群 A 被切断之后才是安全的。
/// 若种群 A 仍在(其 po_no/po_line 恒为 NULL),加上 IFNULL 会让它们全部塌进
/// 同一个键,实测 80,022 行会压成 381 行、静默丢掉 99.5% 的物料维 ——
/// 那是把「膨胀」换成了「静默丢数」,更坏。
///
[Fact]
public void DwdProjection_GuardsBothUniqueKeyColumns()
{
var block = StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery");
Assert.Contains("IFNULL(o.po_no,'')", block);
Assert.Contains("IFNULL(o.po_line,'')", block);
// 不得退回到裸 o.po_no(逗号紧跟,排除 IFNULL(o.po_no 这种被包住的写法)。
var sqlOnly = ExecutableLines(block);
Assert.DoesNotContain(" o.po_no,", sqlOnly);
}
// ── Test A5 ─────────────────────────────────────────────────────────────────────
///
/// 回归防护:DWD 的 SELECT 不得再以任何形式引到种群 A。
///
/// 它只能从 mdp_std_process_outsource_order 取数、并 LEFT JOIN 供应商维;
/// 一旦有人为了「补回那 38,806 行」而在这里 UNION 回贴源层,本条立刻失败。
///
[Fact]
public void DwdProjection_DoesNotReferenceRoutingOpDetailSource()
{
var sqlOnly = ExecutableLines(
StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery"));
Assert.DoesNotContain("RoutingOpDetail", sqlOnly);
Assert.DoesNotContain("mdp_stg_work_order_material", sqlOnly);
Assert.DoesNotContain("UNION", sqlOnly);
// 唯一允许的取数来源。
Assert.Contains("FROM mdp_std_process_outsource_order o", sqlOnly);
}
///
/// 作用域注入不得被 DWD 投影里新增的 SQL 行注释带偏。
///
/// MdpSqlScope.InjectTenantFactory 是正则实现:它改写 WHERE,
/// 并反向扫描最近的 FROM 表 别名 来决定列前缀。注释对它同样「可见」——
/// 若注释里出现 WHERE 或形似 FROM x y 的文字,前缀会被解析成错的东西,
/// 生成的列要么是裸列(歧义列)、要么指向没有该列的表,两种都表现为整轮跑批 FAILED,
/// 而不是少几行数据。
///
/// 本条断言的是注释相对注入是惰性的:先注入再剥注释,与先剥注释再注入,
/// 结果必须逐字相同。它不去判定注入结果本身「对不对」(那由别的事实决定,
/// 且本语句里确实存在一处先于本批就有的空前缀 —— 位于未取别名的单表子查询
/// FROM mdp_std_supplier 内,那里裸列无歧义、无害),
/// 只锁死「加注释不改变生成的 SQL」这一条,正是新增注释唯一可能引入的风险。
///
/// 同时确认 NULL 保护在注入后仍原样存活。
///
[Fact]
public void DwdProjection_SqlComments_AreInertUnderScopeInjection()
{
var block = StatementBlock(S3Service(), "INSERT INTO dwd_process_outsource_delivery");
static string StripSqlComments(string s) =>
string.Join('\n', s.Split('\n')
.Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
// 该语句确实带 SQL 行注释,否则这条断言是空转。
Assert.NotEqual(block, StripSqlComments(block));
Assert.Equal(
MdpSqlScope.InjectTenantFactory(StripSqlComments(block)),
StripSqlComments(MdpSqlScope.InjectTenantFactory(block)));
// NULL 保护必须原样存活。
var injected = MdpSqlScope.InjectTenantFactory(block);
Assert.Contains("IFNULL(o.po_no,'')", injected);
Assert.Contains("IFNULL(o.po_line,'')", injected);
}
}