using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S5.MaterialWarehouse;
///
/// S5 IQC 报检分录「采购单号」文档级身份的生产契约(1.0.540)。
///
/// 背景:qms_qcp_insappnentry 是 165 侧报检分录的本地镜像。热关注登记的是
/// BizType='PUR_ORDER' + BizKey=<采购单号>,取数 SQL 也写着
/// WHERE d.OrdNbr=@k —— 也就是说采购单号在镜像那一刻就是确定的,
/// 但历史实现只 SELECT e.*,采购单号在 QUERY 层被丢弃。
///
/// 本文件守住修好之后的形状,尤其守住三条容易被"顺手优化"破坏的边界:
/// ① 采购单号取标量 purOrd,不从 JOIN 行取(否则受扇出与行序影响);
/// ② FSRCORDERNUM 仍是收货单号,不得被改写成采购单号;
/// ③ 不得借机补 PO 行号 —— 上游 FSRCBILLID/FSRCBILLENTRYSEQ 实测全空,
/// 属 SOURCE CONTRACT GAP,任何本地推导都是弱关联。
///
public class IqcDocumentIdentityContractTests
{
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 HotWatch() => Source("DataPlatform", "HotWatch", "MdpHotWatchService.cs");
private static int At(string code, string anchor)
{
var i = code.IndexOf(anchor, StringComparison.Ordinal);
Assert.True(i >= 0, $"未找到锚点:{anchor}");
return i;
}
/// 取 qms_qcp_insappnentry 那一条 UPSERT 的完整区间(含参数绑定)。
private static string EntryUpsertBlock()
{
var code = HotWatch();
var start = At(code, "INSERT INTO qms_qcp_insappnentry");
var end = At(code, "INSERT INTO qms_qcp_inspbill");
Assert.True(end > start, "分录 UPSERT 应出现在检验单 UPSERT 之前");
return code[start..end];
}
// ── ① 采购单号确实被写入,且来自 watch.BizKey ──────────────────────────────────
[Fact]
public void 分录UPSERT_必须写入采购单号列()
{
var block = EntryUpsertBlock();
Assert.Contains("purchase_order_no", block, StringComparison.Ordinal);
Assert.Contains("@PurchaseOrderNo", block, StringComparison.Ordinal);
}
[Fact]
public void 采购单号必须取标量purOrd_不得从JOIN行取()
{
var block = EntryUpsertBlock();
// 必须绑定局部标量 poNo(由 purOrd 归一而来)
Assert.Contains("new SugarParameter(\"@PurchaseOrderNo\", poNo", block, StringComparison.Ordinal);
// 绝不允许从 row 字典里取 —— 那会把 JOIN 扇出与行序引入身份值
Assert.DoesNotContain("\"@PurchaseOrderNo\", GetString(row", block, StringComparison.Ordinal);
Assert.DoesNotContain("GetString(row, \"OrdNbr\")", block, StringComparison.Ordinal);
}
[Fact]
public void poNo必须由purOrd归一而来_空值不伪造()
{
var code = HotWatch();
Assert.Contains(
"var poNo = string.IsNullOrWhiteSpace(purOrd) ? null : purOrd.Trim();",
code,
StringComparison.Ordinal);
}
// ── ② 既有语义不被破坏 ────────────────────────────────────────────────────────
[Fact]
public void FSRCORDERNUM必须仍绑定源行的收货单号()
{
var block = EntryUpsertBlock();
Assert.Contains(
"new SugarParameter(\"@FSRCORDERNUM\", GetString(row, \"FSRCORDERNUM\")",
block,
StringComparison.Ordinal);
// 不得把采购单号灌进收货号列
Assert.DoesNotContain("\"@FSRCORDERNUM\", poNo", block, StringComparison.Ordinal);
}
[Fact]
public void 取数SQL不得为了带PO号而改成取JOIN列()
{
var code = HotWatch();
var start = At(code, "case \"QMS_QCP_INSAPPNENTRY\":");
var end = At(code, "case \"QMS_QCP_INSPECAPPLYN\":");
var sql = code[start..end];
// 仍只取 e.*:这是"免疫扇出"的设计前提,改成 e.*, d.* 会让一行分录因多个 PO 行而重复
Assert.Contains("SELECT TOP 200 e.*", sql, StringComparison.Ordinal);
Assert.DoesNotContain("d.OrdNbr AS", sql, StringComparison.Ordinal);
// 作用域键必须仍是 d.OrdNbr=@k —— 这是 poNo 与每行 d.OrdNbr 恒等的依据
Assert.Contains("d.OrdNbr = @k", sql, StringComparison.Ordinal);
}
// ── ③ 不得借机补行级身份 ──────────────────────────────────────────────────────
[Fact]
public void 禁止写入PO行号_行级身份属源契约缺口()
{
var block = EntryUpsertBlock();
// FSRCBILLENTRYSEQ / FSRCBILLID 上游实测全空,任何本地推导都是弱关联
Assert.DoesNotContain("FSRCBILLENTRYSEQ", block, StringComparison.Ordinal);
Assert.DoesNotContain("FSRCBILLID", block, StringComparison.Ordinal);
Assert.DoesNotContain("purchase_order_line", block, StringComparison.Ordinal);
Assert.DoesNotContain("OrdLine", block, StringComparison.Ordinal);
}
// ── ④ 幂等与租户 ─────────────────────────────────────────────────────────────
[Fact]
public void 分录UPSERT必须按主键幂等_不因扇出重复建行()
{
var block = EntryUpsertBlock();
Assert.Contains("ON DUPLICATE KEY UPDATE", block, StringComparison.Ordinal);
// 主键沿用 165 的 id:同一分录被多次镜像只会更新,不会新增
Assert.Contains("new SugarParameter(\"@Id\", id)", block, StringComparison.Ordinal);
}
[Fact]
public void 采购单号为空时不得把已有权威值打回NULL()
{
var block = EntryUpsertBlock();
Assert.Contains(
"purchase_order_no = IF(IFNULL(VALUES(purchase_order_no),'')='',",
block,
StringComparison.Ordinal);
}
[Fact]
public void 租户必须来自参数而非远端Domain()
{
var block = EntryUpsertBlock();
Assert.Contains("new SugarParameter(\"@TenantId\", tenantId)", block, StringComparison.Ordinal);
// 既有的"租户只在为空时写入"守卫不得丢
Assert.Contains(
"tenant_id = IF(IFNULL(tenant_id,0)=0, VALUES(tenant_id), tenant_id)",
block,
StringComparison.Ordinal);
}
[Fact]
public void 无法解析租户时必须整体跳过_不得落无主数据()
{
var code = HotWatch();
var start = At(code, "private async Task ApplyIqcAsync");
var body = code[start..(start + 1200)];
Assert.Contains("if (tenantId <= 0)", body, StringComparison.Ordinal);
Assert.Contains("return;", body, StringComparison.Ordinal);
}
// ── ⑤ 身份与质量正交 ─────────────────────────────────────────────────────────
[Fact]
public void 镜像不得按检验判定过滤_身份与质量是正交维度()
{
var code = HotWatch();
var start = At(code, "private async Task ApplyIqcAsync");
var end = At(code, "private async Task ApplyPurchaseReceiptAsync");
var body = code[start..end];
// pd=0/pd=1 都必须照常镜像,否则不合格单将拿不到采购单身份
Assert.DoesNotContain("pd = 0", body, StringComparison.Ordinal);
Assert.DoesNotContain("pd=0", body, StringComparison.Ordinal);
}
// ── ⑥ 检验单经 FK 回到分录的链路不得断 ────────────────────────────────────────
[Fact]
public void 检验单必须保留回到分录的外键()
{
var code = HotWatch();
var start = At(code, "INSERT INTO qms_qcp_inspbill");
var block = code[start..(start + 2600)];
// hid → insappnentry.id,lydjbh → inspecapplyn.FBILLNO:
// 有这两把 FK,检验单就不需要重复存采购单号
Assert.Contains("hid", block, StringComparison.Ordinal);
Assert.Contains("lydjbh", block, StringComparison.Ordinal);
}
// ── ⑦ 1.0.536 采购身份桥不得回归 ──────────────────────────────────────────────
[Fact]
public void S3身份桥的采购申请投影不得被移除()
{
var s3 = Source("Supply", "S3MdpSyncTransformService.cs");
Assert.Contains("purchase_request_no", s3, StringComparison.Ordinal);
Assert.Contains("sales_order_entry_id", s3, StringComparison.Ordinal);
}
}