using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
///
/// S5 IQC 标准事实层投影(mdp_std_iqc_inspection,1.0.541)的生产契约。
///
/// 这条链的价值在于让 S8 Stage-3 不再直读 qms_qcp_inspbill /
/// qms_qcp_inspecapplyn / qms_qcp_insappnentry / ApprovalFlowInstance。
/// 一旦投影逻辑被"顺手优化"破坏,S8 会在毫无征兆的情况下拿到错误的质量结论。
///
/// 本文件守住四条最易被破坏的边界:
/// ① 质量结论只能由 pd 派生,pd IS NULL 绝不能滑成 PASS;
/// ② 流程完成 ≠ 质量合格,两轴必须分开存;
/// ③ 租户只能来自检验单,审批流表没有 TenantId、绝不可反向驱动;
/// ④ 报检分录只认 hid 单值外键,不得"从申请单下挑一条",更不得推 PO 行号。
///
public class IqcInspectionMdpProjectionContractTests
{
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 Service()
=> Source("MaterialWarehouse", "IqcInspectionMdpSyncService.cs");
private static string Migration()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
dir = dir.Parent;
Assert.NotNull(dir);
var full = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.541.sql");
Assert.True(File.Exists(full), $"迁移脚本不存在:{full}");
return File.ReadAllText(full);
}
private static int At(string code, string anchor)
{
var i = code.IndexOf(anchor, StringComparison.Ordinal);
Assert.True(i >= 0, $"未找到锚点:{anchor}");
return i;
}
private static string InsertBlock()
{
var code = Service();
var start = At(code, "INSERT INTO mdp_std_iqc_inspection");
var end = At(code, "private async Task InsertRunLogAsync");
Assert.True(end > start);
return code[start..end];
}
// ── ① 质量结论只能由 pd 派生 ──────────────────────────────────────────────────
[Fact]
public void 质量结论必须严格由raw_pd派生()
{
var block = InsertBlock();
Assert.Contains("CASE WHEN b.pd = 0 THEN 'PASS'", block, StringComparison.Ordinal);
Assert.Contains("WHEN b.pd = 1 THEN 'FAIL'", block, StringComparison.Ordinal);
Assert.Contains("ELSE 'PENDING' END", block, StringComparison.Ordinal);
}
[Fact]
public void 禁止用FBILLSTATUS当质量Authority()
{
// 只扫 SQL 区间:服务头部的说明注释里正当地解释了"为什么不用它",不算违规
var block = InsertBlock();
// 实测 15 条 Approved 中 9 条 FBILLSTATUS 未回写,是有损镜像,绝不可作判据
Assert.DoesNotContain("FBILLSTATUS", block, StringComparison.Ordinal);
}
[Fact]
public void raw_pd原值必须一并保留_不得只落结论()
{
var block = InsertBlock();
Assert.Contains("raw_pd", block, StringComparison.Ordinal);
Assert.Contains("b.pd,", block, StringComparison.Ordinal);
}
// ── ② 流程与质量分轴 ─────────────────────────────────────────────────────────
[Fact]
public void 流程轴与质量轴必须各自成列()
{
var block = InsertBlock();
foreach (var col in new[] { "raw_pd", "quality_result", "flow_status", "completion_time" })
Assert.Contains(col, block, StringComparison.Ordinal);
// 不得塌缩成单一布尔,否则 pd/flow 的四种组合无法表达
Assert.DoesNotContain("is_completed", block, StringComparison.Ordinal);
}
[Fact]
public void 完成时间只在流程终态才投影()
{
var block = InsertBlock();
Assert.Contains("CASE WHEN i.Status = 2 THEN i.EndTime ELSE NULL END", block, StringComparison.Ordinal);
}
// ── ③ 租户方向单向 ───────────────────────────────────────────────────────────
[Fact]
public void 租户必须来自检验单而非审批流()
{
var block = InsertBlock();
// 投出来的 tenant 只能是 b.tenant_id
Assert.Contains("SELECT\n b.tenant_id,", block, StringComparison.Ordinal);
// 审批流只按 BizType+BizId 反查,不参与租户判定
Assert.Contains("i.BizType = @FlowBizType", block, StringComparison.Ordinal);
Assert.Contains("i.BizId = b.id", block, StringComparison.Ordinal);
Assert.DoesNotContain("i.TenantId", block, StringComparison.Ordinal);
}
[Fact]
public void 本地业务JOIN必须显式带租户()
{
var block = InsertBlock();
Assert.Contains("a.tenant_id = b.tenant_id", block, StringComparison.Ordinal);
Assert.Contains("e.tenant_id = b.tenant_id", block, StringComparison.Ordinal);
// Domain 不是租户边界,不得作为关联依据
Assert.DoesNotContain("Domain =", block, StringComparison.Ordinal);
}
[Fact]
public void 只投租户合法的检验单()
{
var block = InsertBlock();
Assert.Contains("WHERE b.tenant_id > 0", block, StringComparison.Ordinal);
}
// ── ④ 分录解析与行级身份 ─────────────────────────────────────────────────────
[Fact]
public void 分录只认hid单值外键_不得从申请单挑一条()
{
var block = InsertBlock();
Assert.Contains("e.id = b.hid", block, StringComparison.Ordinal);
Assert.Contains("b.hid IS NOT NULL", block, StringComparison.Ordinal);
// 不得出现 e.glid = a.id 这类"申请单下任取一条"的回退
Assert.DoesNotContain("e.glid = a.id", block, StringComparison.Ordinal);
}
[Fact]
public void 禁止投影任何行级身份()
{
// 只扫 SQL 区间:文档注释需要点名这些字段来说明"为什么刻意不投"
var block = InsertBlock();
foreach (var forbidden in new[] { "po_line", "OrdLine", "FSRCBILLENTRYSEQ", "FSRCBILLID", "receipt_line" })
Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal);
}
[Fact]
public void 表结构不得含行级身份列()
{
var sql = Migration();
foreach (var forbidden in new[] { "`po_line`", "`purchase_order_line`", "`receipt_line`", "`source_entry_seq`" })
Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
}
// ── ⑤ 身份字段不得互相覆盖 ───────────────────────────────────────────────────
[Fact]
public void 采购单号与收货单号取自各自权威列()
{
var block = InsertBlock();
Assert.Contains("NULLIF(e.purchase_order_no, '')", block, StringComparison.Ordinal);
Assert.Contains("NULLIF(e.FSRCORDERNUM, '')", block, StringComparison.Ordinal);
}
// ── ⑥ Grain 与幂等 ───────────────────────────────────────────────────────────
[Fact]
public void Canonical键必须是租户加检验单()
{
var sql = Migration();
Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", sql, StringComparison.Ordinal);
// source_row_id 必须就是检验单 id,否则 Grain 名不副实
var block = InsertBlock();
Assert.Contains("CAST(b.id AS CHAR)", block, StringComparison.Ordinal);
}
[Fact]
public void 采购单号不得作为唯一键()
{
var sql = Migration();
Assert.DoesNotContain("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `purchase_order_no`)", sql, StringComparison.Ordinal);
}
[Fact]
public void 必须是幂等UPSERT()
{
var block = InsertBlock();
Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal);
// 后补的身份与流程结果都必须能刷新
foreach (var col in new[] { "purchase_order_no=VALUES(purchase_order_no)",
"raw_pd=VALUES(raw_pd)",
"quality_result=VALUES(quality_result)",
"flow_status=VALUES(flow_status)",
"completion_time=VALUES(completion_time)" })
Assert.Contains(col, block, StringComparison.Ordinal);
}
// ── ⑦ 边界:只读源、不做聚合 ─────────────────────────────────────────────────
[Fact]
public void 只写标准层_不得写任何业务源表()
{
var code = Service();
// 允许且仅允许两个写入目标:标准层事实表 + 中台跑批日志
var targets = code.Split("INSERT INTO ").Skip(1)
.Select(x => x.Split('\n')[0].Trim())
.Distinct()
.OrderBy(x => x, StringComparer.Ordinal)
.ToArray();
Assert.Equal(new[] { "mdp_std_iqc_inspection", "mdp_transform_run_log" }, targets);
// 业务源表一律只读
foreach (var src in new[] { "UPDATE qms_qcp", "INSERT INTO qms_qcp", "DELETE FROM qms_qcp",
"UPDATE ApprovalFlowInstance", "INSERT INTO ApprovalFlowInstance" })
Assert.DoesNotContain(src, code, StringComparison.Ordinal);
}
[Fact]
public void 本批不得做订单级完成聚合()
{
var code = Service();
foreach (var forbidden in new[] { "MAX(i.EndTime)", "MAX(completion_time)", "ALL_IQC", "S3_PROCUREMENT_COMPLETE" })
Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
}
// ── ⑧ 上游批次资产不得回归 ───────────────────────────────────────────────────
[Fact]
public void 依赖的1_0_540采购单号列必须仍被读取()
{
var block = InsertBlock();
Assert.Contains("e.purchase_order_no", block, StringComparison.Ordinal);
}
}