using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.DataPlatform;
///
/// dwd_requirement_examine_detail 作为「工单当前齐套/缺料」中台权威表的契约。
///
/// 为什么是源文本契约测试而不是跑库集成测试:
/// 本表的 schema 与全部投影逻辑都以 SQL 字符串形式内联在 S1MdpSyncTransformService.cs 里
/// (没有 SqlSugar 实体、没有 UpdateScripts 的 CREATE),
/// 本仓库 DataPlatform/ 下既有测试(MdpStandardUpsertContractTests / T8KpiTenantIsolationContractTests)
/// 也一律用这种方式守护数据中台 SQL。这里沿用同一范式,逐条钉死会被悄悄改坏的不变量。
///
public class S1RequirementExamineDwdContractTests
{
private static string Service() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Order", "S1MdpSyncTransformService.cs"));
///
/// 按内容而不是按文件名定位本批迁移。
///
/// 本仓的版本号是「提交那一刻才取号」的(多实例并行,谁先 push 谁定号),
/// 所以迁移文件名在合并前后都可能顺延。写死 1.0.NNN.sql 的测试
/// 会在每一次顺延时集体变红,而红的原因与被测契约毫无关系 —— 实测发生过一次。
///
private static string MigrationPath(string suffix)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var scripts = Path.Combine(dir.FullName, "server", "Admin.NET.Web.Entry", "UpdateScripts");
if (Directory.Exists(scripts))
{
// 注意:通配 "1.0.*.sql" 也会命中 "1.0.NNN.verify.sql"(它同样以 .sql 结尾)。
// 必须按文件名精确收口,否则 Migration() 会拿到 verify 脚本 —— 实测踩过。
var hit = Directory.GetFiles(scripts, "1.0.*.sql")
.Where(f => Path.GetFileName(f).EndsWith(suffix, StringComparison.Ordinal)
&& (suffix != ".sql" || !Path.GetFileName(f).EndsWith(".verify.sql", StringComparison.Ordinal)))
.Where(f => File.ReadAllText(f).Contains("dwd_requirement_examine_detail", StringComparison.Ordinal)
&& File.ReadAllText(f).Contains("material_role", StringComparison.Ordinal))
.OrderBy(f => f, StringComparer.Ordinal)
.LastOrDefault();
if (hit != null) return hit;
}
dir = dir.Parent;
}
throw new FileNotFoundException($"找不到含 material_role 的 dwd_requirement_examine_detail 迁移脚本 (*{suffix})");
}
private static string Migration() => File.ReadAllText(MigrationPath(".sql"));
// ── B1 · 新列必须「内联 DDL」与「迁移脚本」两处同时到位 ──────────────────────────
// 这是本表最容易出事的地方:新库只走内联 CREATE TABLE IF NOT EXISTS,旧库只走 ALTER,
// 任一处漏改都会让两种环境的 schema 分叉,且分叉在运行期才暴露。
[Fact]
public void NewColumns_ExistInBothInlineDdlAndMigration()
{
var service = Service();
var migration = Migration();
Assert.Contains("bom_level INT NULL", service);
Assert.Contains("material_role VARCHAR(32) NULL", service);
Assert.Contains("is_current_flag TINYINT NOT NULL DEFAULT 0", service);
Assert.Contains("ADD COLUMN `bom_level` INT NULL", migration);
Assert.Contains("ADD COLUMN `material_role` VARCHAR(32) NULL", migration);
Assert.Contains("ADD COLUMN `is_current_flag` TINYINT NOT NULL DEFAULT 0", migration);
}
[Fact]
public void CurrentSnapshotIndex_ExistsInBothInlineDdlAndMigration()
{
Assert.Contains("KEY idx_req_exam_current (tenant_id, is_current_flag, morder_no)", Service());
Assert.Contains("ADD KEY `idx_req_exam_current` (`tenant_id`,`is_current_flag`,`morder_no`)", Migration());
}
// 仓库约定:布尔一律 _flag TINYINT,不用 bit、不用 is_* 裸名(is_current_flag 本身是既定命名例外)。
[Fact]
public void BooleanColumn_FollowsRepoFlagConvention_NotBit()
{
var service = Service();
var ddl = Slice(service, "CREATE TABLE IF NOT EXISTS dwd_requirement_examine_detail", ") ENGINE=InnoDB");
Assert.Contains("is_current_flag TINYINT", ddl);
Assert.DoesNotContain("is_current_flag BIT", ddl);
Assert.DoesNotContain("bom_level BIT", ddl);
}
// ── B2 · 角色投影:level=1 → 根,其余(含脏值/NULL)→ 投入料 ────────────────────
[Fact]
public void MaterialRole_ProjectsLevelOneAsFinishedGoodRoot()
{
var service = Service();
Assert.Contains("CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.level')) = '1'", service);
Assert.Contains("THEN 'FINISHED_GOOD_ROOT' ELSE 'INPUT_MATERIAL' END", service);
}
///
/// 失败方向必须保守:脏值 / NULL / 空串一律落 INPUT_MATERIAL,绝不能落成根、更不能整行丢掉。
/// 反向断言(ELSE 不得是 FINISHED_GOOD_ROOT)才是真正防回归的那一条 ——
/// 如果哪天有人把三元反过来写成「非 1 就是根」,正向断言仍会通过,只有这条会挂。
///
[Fact]
public void MaterialRole_MalformedOrNullLevel_FallsBackToInputMaterial()
{
var service = Service();
Assert.DoesNotContain("ELSE 'FINISHED_GOOD_ROOT'", service);
// 角色列不得出现 NULL 分支:CASE 必须有 ELSE,否则脏值会变成 NULL
Assert.DoesNotContain("THEN 'FINISHED_GOOD_ROOT' END", service);
// level 不得被提升为过滤条件——那会把脏值行直接丢掉(漏报缺料)
Assert.DoesNotContain("AND JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.level'))", service);
}
/// bom_level 只在纯数字时物化,不猜层级;下游(含 S8)只需读 material_role。
[Fact]
public void BomLevel_OnlyMaterializedWhenNumeric()
{
Assert.Contains(
"CASE WHEN JSON_UNQUOTE(JSON_EXTRACT(d.raw_data,'$.level')) REGEXP '^[0-9]+$'",
Service());
}
// ── B4 · 当前快照发布必须是「一条」原子语句 ──────────────────────────────────────
[Fact]
public void CurrentSnapshotPublication_IsSingleAtomicStatement()
{
var service = Service();
var publish = Slice(service, "UPDATE dwd_requirement_examine_detail", "\"\"\",");
Assert.Contains("SET is_current_flag = CASE WHEN calc_batch_id=@BatchId THEN 1 ELSE 0 END", publish);
Assert.Contains("AND (calc_batch_id=@BatchId OR is_current_flag=1)", publish);
// 单条语句:整段里不得出现第二个分号分隔的语句
Assert.DoesNotContain(";", publish);
}
///
/// 禁止「先清后置」/「先置后清」两段式:BuildDwdAsync 没有事务,
/// 两段式必然留下「零个当前批次」(漏报)或「两个当前批次」(重复计数)的可见窗口。
///
[Fact]
public void CurrentSnapshotPublication_ForbidsTwoStatementClearThenSet()
{
var service = Service();
var sqlOnly = StripComments(service);
Assert.DoesNotContain("SET is_current_flag = 0", sqlOnly);
Assert.DoesNotContain("SET is_current_flag = 1", sqlOnly);
Assert.DoesNotContain("SET is_current_flag=0", sqlOnly);
Assert.DoesNotContain("SET is_current_flag=1", sqlOnly);
// 只能有一处写 is_current_flag 的 UPDATE
Assert.Equal(1, Count(sqlOnly, "SET is_current_flag"));
}
///
/// factory_id 必须归一后再圈作用域。实测存量 296189/1019060 行 factory_id IS NULL,
/// 且 594/3439 个批次同时含 factory_id=1 与 NULL;
/// 若写成 factory_id=@FactoryId,NULL 行永不被 = 匹配,旧批次会永久残留 is_current_flag=1 → 双 current。
///
[Fact]
public void CurrentSnapshotPublication_NormalizesNullFactoryScope()
{
var publish = Slice(Service(), "UPDATE dwd_requirement_examine_detail", "\"\"\",");
Assert.Contains("AND COALESCE(NULLIF(factory_id, 0), 1)=@FactoryId", publish);
Assert.DoesNotContain("AND factory_id=@FactoryId", publish);
}
/// 发布必须是 DWD 阶段的最后一步:在所有 DWD 写入命令跑完之后。
[Fact]
public void CurrentSnapshotPublication_RunsAfterAllDwdCommands()
{
var service = Service();
var loop = service.IndexOf("foreach (var command in BuildDwdCommands(", StringComparison.Ordinal);
var publish = service.IndexOf("await PublishCurrentSnapshotAsync(db, scope, batchId);", StringComparison.Ordinal);
Assert.True(loop > 0 && publish > loop, "发布语句必须位于 BuildDwdCommands 循环之后");
}
/// ON DUPLICATE KEY UPDATE 不得回写 is_current_flag,否则同批次重跑会把已发布状态打回 0。
[Fact]
public void UpsertClause_DoesNotResetCurrentFlag()
{
Assert.DoesNotContain("is_current_flag=VALUES(is_current_flag)", Service());
Assert.Contains("bom_level=VALUES(bom_level), material_role=VALUES(material_role)", Service());
}
// ── B5 · 表头软删必须被排除 ──────────────────────────────────────────────────────
[Fact]
public void DwdBuild_ExcludesDeletedExamineHeaders()
{
Assert.Contains(
"AND JSON_UNQUOTE(JSON_EXTRACT(r.raw_data,'$.IsDeleted')) IN ('0', 'false', 'False', 'base64:type16:AA==')",
Service());
}
///
/// bit(1) 源列经贴源后两种编码并存('1'/'0' 与 base64:type16:AQ==/AA==),
/// 任何布尔过滤都必须同时认这两种,只认一种会静默漏掉整批数据。
///
[Fact]
public void BooleanFilters_HandleBothBitEncodings()
{
var service = Service();
// 存活表头:明文 '0' 与 base64 AA== 都要认
Assert.Contains("'0', 'false', 'False', 'base64:type16:AA=='", service);
// 启用明细:明文 '1' 与 base64 AQ== 都要认
Assert.Contains("'1', 'true', 'True', 'base64:type16:AQ=='", service);
// base64 编码不得只出现在其中一侧
Assert.True(Count(service, "base64:type16:AA==") >= 1);
Assert.True(Count(service, "base64:type16:AQ==") >= 2);
}
// ── B6 · FULL = 镜像(DWD 侧权宜实现)───────────────────────────────────────────
///
/// 只取每个 (tenant_id, source_table) 的最新 sync_batch_id。
/// 明细与表头两侧都必须过滤,且父行(成品根)查找子查询同样要过滤,
/// 否则会拿一个旧批次的根去挂今天的子件。
///
[Fact]
public void DwdBuild_UsesOnlyLatestSyncBatchPerSourceTable()
{
var service = Service();
Assert.Contains(
"AND d.sync_batch_id = (SELECT lb.sync_batch_id FROM mdp_stg_so lb",
service);
Assert.Contains(
"AND r.sync_batch_id = (SELECT lb.sync_batch_id FROM mdp_stg_so lb",
service);
Assert.Contains("ORDER BY lb.sync_time DESC, lb.id DESC LIMIT 1)", service);
// 明细侧 + 表头侧 + 父行子查询 = 至少 3 处
Assert.True(Count(service, "ORDER BY lb.sync_time DESC, lb.id DESC LIMIT 1)") >= 3);
}
/// 权宜实现必须写明是 STOPGAP 及其失效前提(entity 改成 INCREMENTAL 就会错杀存量行)。
[Fact]
public void LatestBatchStopgap_IsDocumentedAsStopgap()
{
var service = Service();
Assert.Contains("STOPGAP", service);
Assert.Contains("INCREMENTAL", service);
}
// ── B3 · 齐套三量 ────────────────────────────────────────────────────────────────
/// 不得新增别名列,也不得重算出第二套数字。
[Fact]
public void QuantityColumns_NoAliasAndNoRecomputation()
{
// 别名词本身会合法出现在「不要新增 xxx 别名」的列注释与说明文字里,
// 所以不能整文件盲搜,必须针对「列声明 / 列清单 / 回写」三个真正会引入别名列的位置断言。
var service = Service();
var migration = Migration();
var insertColumns = Slice(service, "INSERT INTO dwd_requirement_examine_detail", "SELECT");
foreach (var alias in new[] { "required_qty", "satisfied_qty", "shortage_qty" })
{
Assert.DoesNotContain(alias + " DECIMAL", service); // 内联 DDL 里的列声明
Assert.DoesNotContain(alias + "=VALUES(", service); // ON DUPLICATE KEY UPDATE 回写
Assert.DoesNotContain(alias, insertColumns); // INSERT 列清单
Assert.DoesNotContain("ADD COLUMN `" + alias + "`", migration);
}
// 三个标准列必须是直取源值,不得由其它列相减/相加得出
Assert.DoesNotContain("need_count - use_qty", service);
Assert.DoesNotContain("need_count-use_qty", service);
}
[Fact]
public void QuantityColumns_DocumentedAsStandardNames()
{
Assert.Contains("标准列·需求量", Service());
Assert.Contains("标准列·已满足量", Service());
Assert.Contains("标准列·缺口量", Service());
}
/// 恒等式 lack + use = need 必须被 verify 脚本守住(实测 1019060/1019060 行成立)。
[Fact]
public void QuantityIdentity_IsAssertedByVerifyScript()
{
var verify = File.ReadAllText(MigrationPath(".verify.sql"));
Assert.Contains("ABS(IFNULL(`lack_qty`,0) + IFNULL(`use_qty`,0) - IFNULL(`need_count`,0)) > 0.0001", verify);
}
// ── B7 · 前修数据清理 ────────────────────────────────────────────────────────────
///
/// 前修行以 material_role IS NULL 精确识别(修复后的投影恒不产生 NULL)。
/// 该谓词幂等、可重跑,且不会误删迁移期间新管线产生的正确行。
///
[Fact]
public void LegacyRows_PurgedByNullRolePredicate()
{
var migration = Migration();
Assert.Contains("DELETE FROM `dwd_requirement_examine_detail` WHERE `material_role` IS NULL", migration);
// 不得无条件清表:那样会连迁移期间新产生的正确行一起删掉
Assert.DoesNotContain("TRUNCATE", migration);
}
/// 迁移必须是幂等的:所有 DDL 都要有存在性守卫。
[Fact]
public void Migration_IsIdempotent()
{
var migration = Migration();
Assert.True(Count(migration, "PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;") >= 6);
// 表不存在时(全新库尚未跑过 S1)必须整体跳过,不能 ALTER 一张不存在的表
Assert.Contains("information_schema.TABLES", migration);
Assert.True(Count(migration, "@tbl = 1") >= 6);
}
// ── 通用护栏 ─────────────────────────────────────────────────────────────────────
/// 本表是通用中台权威表,不得为单一消费方(S8)开专属列。
[Fact]
public void Table_HasNoS8SpecificColumns()
{
var ddl = Slice(Service(),
"CREATE TABLE IF NOT EXISTS dwd_requirement_examine_detail", ") ENGINE=InnoDB");
Assert.DoesNotContain("s8_", ddl);
Assert.DoesNotContain("exception_id", ddl);
Assert.DoesNotContain("rule_code", ddl);
}
private static string Slice(string source, string start, string end)
{
var from = source.IndexOf(start, StringComparison.Ordinal);
Assert.True(from >= 0, $"未找到起始锚点:{start}");
var to = source.IndexOf(end, from, StringComparison.Ordinal);
Assert.True(to > from, $"未找到结束锚点:{end}");
return source.Substring(from, to - from);
}
/// 去掉 C# 行注释与 SQL 行注释,避免注释里的示例文本污染「禁止出现」类断言。
private static string StripComments(string source)
{
var lines = source.Split('\n');
var kept = lines.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("//", StringComparison.Ordinal)
&& !t.StartsWith("--", StringComparison.Ordinal)
&& !t.StartsWith("///", StringComparison.Ordinal)
&& !t.StartsWith("*", StringComparison.Ordinal);
});
return string.Join("\n", kept);
}
private static int Count(string source, string token)
{
var count = 0;
for (var index = 0; (index = source.IndexOf(token, index, StringComparison.Ordinal)) >= 0; index += token.Length)
count++;
return count;
}
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));
}
}