using Admin.NET.Plugin.AiDOP;
using Admin.NET.Plugin.AiDOP.Supply;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8.Supply;
///
/// S8 Stage-3 采购完成态事实(mdp_std_purchase_order_completion,1.0.544)的生产契约。
///
/// 这条链让 S8 在不覆盖 mdp_std_purchase_order.status 的前提下拿到 WMS 侧
/// 「这条采购行是否收货关闭」。一旦被"顺手优化"破坏,S8 会在毫无征兆的情况下
/// 把未知当成未完成、把已回退的行当成仍然完成,或者把事实写到别的租户名下。
///
/// 本文件守住六条最易被破坏的边界:
/// ① 三态映射 —— NULL / 未知值绝不能滑成 NOT_COMPLETED;
/// ② 可逆 —— UPSERT 必须无条件覆盖结论,不许有"一旦完成永远完成"的守卫;
/// ③ 租户 —— 唯一才用,歧义与未解析一律跳过,绝不"取第一个",绝不用 Domain 推租户;
/// ④ 淘汰 —— 只在源侧读取成功后执行,且必须 tenant-safe;
/// ⑤ 源绑定 —— 配置化 + 非开发环境 fail-closed,且必须挡住"回落主库";
/// ⑥ 边界 —— 不读 IQC、不用数量推完成、不写任何源表。
///
public class PurchaseOrderCompletionContractTests
{
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("Supply", "PurchaseOrderCompletionMdpSyncService.cs");
///
/// 去掉 XML 文档注释后的代码体。禁止类断言必须扫这个 —— 文档注释需要点名
/// 「不读 IQC」「回到 NOT_OBSERVED」这些概念来说明边界,那是说明而不是违规。
///
private static string ServiceBody()
=> string.Join('\n', Service().Split('\n').Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
private static string Migration(string name)
{
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", name);
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 Between(string code, string startAnchor, string endAnchor)
{
var s = At(code, startAnchor);
var e = At(code, endAnchor);
Assert.True(e > s, $"锚点顺序不符:{startAnchor} 应在 {endAnchor} 之前");
return code[s..e];
}
// ── ① 三态映射 ───────────────────────────────────────────────────────────────
[Theory]
[InlineData("C")]
[InlineData("c")]
[InlineData(" C ")]
public void 状态C一律映射为COMPLETED(string raw)
=> Assert.Equal("COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
[Theory]
[InlineData("")]
[InlineData(" ")]
public void 空串映射为NOT_COMPLETED(string raw)
=> Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
[Fact]
public void NULL绝不能滑成NOT_COMPLETED()
{
// 「源侧没给值」不等于「已确认尚未完成」,下游会把它当确定结论用
Assert.Equal("UNKNOWN", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(null));
}
[Theory]
[InlineData("R")]
[InlineData("O")]
[InlineData("S")]
[InlineData("X")]
public void 未知非空值一律UNKNOWN(string raw)
=> Assert.Equal("UNKNOWN", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw));
[Fact]
public void 映射取值域封闭()
{
var allowed = new[] { "COMPLETED", "NOT_COMPLETED", "UNKNOWN" };
foreach (var raw in new string?[] { null, "", " ", "C", "c", "R", "O", "Z", "完成" })
Assert.Contains(PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(raw), allowed);
}
[Fact]
public void raw_status归一保留NULL()
{
Assert.Null(PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(null));
Assert.Equal("", PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(" "));
Assert.Equal("C", PurchaseOrderCompletionMdpSyncService.NormalizeRawStatus(" C "));
}
[Fact]
public void 禁止用IFNULL把NULL和空串合并()
{
var code = Service();
var mapper = Between(code, "public static string MapCompletionStatus", "public static string? NormalizeRawStatus");
// 出现 ?? "" 或 IFNULL 语义即意味着 NULL 被当成空串处理
Assert.DoesNotContain("?? \"\"", mapper, StringComparison.Ordinal);
Assert.DoesNotContain("?? string.Empty", mapper, StringComparison.Ordinal);
Assert.Contains("if (rawStatus is null) return StatusUnknown;", mapper, StringComparison.Ordinal);
}
// ── ② 可逆:COMPLETED → NOT_COMPLETED 必须能回退 ──────────────────────────────
[Fact]
public void 回退序列_空到C再回空()
{
// 同一 Grain 连续三次观测,结论必须跟着源侧走
Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(""));
Assert.Equal("COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus("C"));
Assert.Equal("NOT_COMPLETED", PurchaseOrderCompletionMdpSyncService.MapCompletionStatus(""));
}
[Fact]
public void UPSERT必须无条件覆盖结论_不得有一旦完成永远完成的守卫()
{
var block = Between(Service(), "ON DUPLICATE KEY UPDATE", "await _db.Ado.ExecuteCommandAsync(sb.ToString()");
Assert.Contains("raw_status=VALUES(raw_status)", block, StringComparison.Ordinal);
Assert.Contains("completion_status=VALUES(completion_status)", block, StringComparison.Ordinal);
// 任何 IF(...) / CASE / COALESCE 形式的保留旧值写法都会让回退静默失效
foreach (var forbidden in new[]
{
"completion_status = IF(", "completion_status=IF(",
"raw_status = IF(", "raw_status=IF(",
"CASE WHEN completion_status", "COALESCE(completion_status",
})
Assert.DoesNotContain(forbidden, block, StringComparison.Ordinal);
}
[Fact]
public void 同一Grain只能一行_唯一键就位()
{
var sql = Migration("1.0.544.sql");
Assert.Contains(
"UNIQUE KEY `uk_mdp_std_po_completion` (`tenant_id`, `po_no`, `po_line`)",
sql, StringComparison.Ordinal);
}
[Fact]
public void provenance列不得混进业务唯一键()
{
var sql = Migration("1.0.544.sql");
foreach (var forbidden in new[]
{
"uk_mdp_std_po_completion` (`tenant_id`, `domain`",
"uk_mdp_std_po_completion` (`tenant_id`, `source_row_id`",
})
Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
}
// ── ③ 租户归属:唯一才用 ─────────────────────────────────────────────────────
private static List Candidates(params (string po, string dom, long tid)[] rows)
=> rows.Select(r => new PoTenantCandidate { PoNo = r.po, Domain = r.dom, TenantId = r.tid }).ToList();
[Fact]
public void 租户唯一时使用()
{
var c = Candidates(("PO1", "", 797L), ("PO2", "", 838L));
Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
}
[Fact]
public void 租户无候选时返回0_不得臆造()
{
var c = Candidates(("PO1", "", 797L));
Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO-NOT-EXIST", "8010"));
}
[Fact]
public void 租户歧义时返回0_绝不取第一个()
{
// 同一采购单号在两个租户下都存在 —— 必须整体跳过,不能任选
var c = Candidates(("PO1", "", 797L), ("PO1", "", 838L));
Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
}
[Fact]
public void 空Domain视为通配_自建单可命中()
{
var c = Candidates(("PO1", "", 797L));
Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
}
[Fact]
public void Domain不一致的候选被排除()
{
var c = Candidates(("PO1", "9999", 797L));
Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
}
[Fact]
public void 相同Domain不会把不同租户混成一个()
{
// Domain 8010 横跨多个租户,绝不能因为 Domain 相同就认为是同一租户
var c = Candidates(("PO1", "8010", 797L), ("PO1", "8010", 838L));
Assert.Equal(0L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
}
[Fact]
public void 同一租户多行不算歧义()
{
var c = Candidates(("PO1", "", 797L), ("PO1", "8010", 797L));
Assert.Equal(797L, PurchaseOrderCompletionMdpSyncService.ResolveTenantId(c, "PO1", "8010"));
}
[Fact]
public void 禁止用Domain映射表反推租户()
{
var code = ServiceBody();
// ado_source_domain_tenant_map 全表只有一行,用它会把 838 的数据错记到 797 名下
Assert.DoesNotContain("ado_source_domain_tenant_map", code, StringComparison.Ordinal);
Assert.DoesNotContain("ResolveDomainAsync", code, StringComparison.Ordinal);
}
[Fact]
public void 租户解析不到必须跳过_不得落无主Fact()
{
var run = Between(Service(), "var resolved = ResolveTenant(", "// 单租户刷新");
Assert.Contains("if (resolved <= 0)", run, StringComparison.Ordinal);
Assert.Contains("continue;", run, StringComparison.Ordinal);
}
// ── ④ 本地采购行校验 ─────────────────────────────────────────────────────────
[Fact]
public void 多命中必须硬失败_不得取第一条()
{
var run = Between(Service(), "var matches = poLineIndex.TryGetValue", "result.MatchedRows++");
Assert.Contains("if (matches > 1)", run, StringComparison.Ordinal);
Assert.Contains("throw new InvalidOperationException", run, StringComparison.Ordinal);
Assert.Contains("if (matches == 0)", run, StringComparison.Ordinal);
foreach (var forbidden in new[] { "LIMIT 1", ".First()", ".Min()", ".Max()" })
Assert.DoesNotContain(forbidden, run, StringComparison.Ordinal);
}
// ── ⑤ 淘汰:只在成功后、且 tenant-safe ───────────────────────────────────────
[Fact]
public void 淘汰必须在源读取与写入成功之后()
{
var code = Service();
var upsert = At(code, "result.WrittenRows = await UpsertAsync");
var retire = At(code, "result.StaleRemoved = await RetireStaleAsync");
var read = At(code, "var sourceRows = await ReadSourceRowsAsync");
Assert.True(read < upsert, "必须先读源再写入");
Assert.True(upsert < retire, "必须先写入再淘汰,读取失败时异常会跳过淘汰");
}
[Fact]
public void 异常分支绝不淘汰()
{
var catchBlock = Between(Service(), "catch (Exception ex)", "// ── 源绑定与 fail-closed");
Assert.DoesNotContain("RetireStaleAsync", catchBlock, StringComparison.Ordinal);
Assert.DoesNotContain("DELETE", catchBlock, StringComparison.Ordinal);
}
[Fact]
public void 单租户刷新只淘汰该租户()
{
var retire = Between(Service(), "private async Task RetireStaleAsync", "// ── run log");
Assert.Contains("WHERE tenant_id=@TenantId AND IFNULL(sync_batch_id,'') <> @BatchId", retire, StringComparison.Ordinal);
// 全量路径也必须显式排除无主行
Assert.Contains("WHERE tenant_id > 0 AND IFNULL(sync_batch_id,'') <> @BatchId", retire, StringComparison.Ordinal);
// 绝不允许无条件 DELETE
Assert.DoesNotContain("DELETE FROM mdp_std_purchase_order_completion\"", retire, StringComparison.Ordinal);
}
[Fact]
public void 单租户刷新时其它租户的源行不参与本轮()
{
// 否则它们会因为没盖上本批次号而被误删
var run = Between(Service(), "// 单租户刷新", "var matches = poLineIndex.TryGetValue");
Assert.Contains("if (tenantId > 0 && resolved != tenantId)", run, StringComparison.Ordinal);
Assert.Contains("continue;", run, StringComparison.Ordinal);
}
// ── ⑥ 源绑定与 fail-closed ───────────────────────────────────────────────────
[Fact]
public void 源编码必须来自配置_业务代码内不得出现具体源字面量()
{
var code = Service();
Assert.DoesNotContain("DOPDEMORQ", code, StringComparison.OrdinalIgnoreCase);
Assert.Contains("_opt.SourceCode", code, StringComparison.Ordinal);
Assert.Contains("_scopeFactory.GetScopeAsync(binding.SourceCode", code, StringComparison.Ordinal);
}
[Fact]
public void 配置类提供默认值与DEV白名单()
{
var opt = Source("Option", "AidopPoCompletionOptions.cs");
Assert.Contains("[OptionsSettings(\"AiDOP:PoCompletion\")]", opt, StringComparison.Ordinal);
Assert.Contains("IConfigurableOptions", opt, StringComparison.Ordinal);
Assert.Contains("public string SourceCode", opt, StringComparison.Ordinal);
Assert.Contains("public string[] DevOnlySourceCodes", opt, StringComparison.Ordinal);
}
[Fact]
public void 非开发环境命中DEV源必须fail_closed()
{
var bind = Between(Service(), "private async Task ResolveSourceBindingAsync", "// ── 源侧读取");
Assert.Contains("_env.IsDevelopment()", bind, StringComparison.Ordinal);
Assert.Contains("DevOnlySourceCodes", bind, StringComparison.Ordinal);
Assert.Contains("throw new InvalidOperationException", bind, StringComparison.Ordinal);
}
[Fact]
public void 源未配置或被禁用必须失败()
{
var bind = Between(Service(), "private async Task ResolveSourceBindingAsync", "// ── 源侧读取");
Assert.Contains("code.Length == 0", bind, StringComparison.Ordinal);
Assert.Contains("status=1", bind, StringComparison.Ordinal);
Assert.Contains("mdp_source 未找到启用源", bind, StringComparison.Ordinal);
}
[Fact]
public void 必须挡住回落主库()
{
var code = Service();
// 作用域工厂对本库样板源与未配账号的源会直接返回主库连接,
// 那会让本服务把 Source A 的本地表当成 WMS 执行态读进来
Assert.Contains("ReferenceEquals(remote, _db)", code, StringComparison.Ordinal);
var bind = Between(code, "private async Task ResolveSourceBindingAsync", "// ── 源侧读取");
Assert.Contains("IsNullOrWhiteSpace(src.DbUser)", bind, StringComparison.Ordinal);
}
[Fact]
public void 回执与日志不得暴露连接信息()
{
var code = Service();
foreach (var forbidden in new[] { "ConnectionString", "DbPassword", "db_password", "Password=" })
Assert.DoesNotContain(forbidden, code, StringComparison.OrdinalIgnoreCase);
}
// ── ⑦ 边界:唯一 Authority 是源侧 Status ─────────────────────────────────────
[Fact]
public void 禁止用数量重新推导完成()
{
var code = ServiceBody();
foreach (var forbidden in new[]
{
"received_qty", "order_qty", "QtyOrded", "QtyReceived", "RctQty",
"QtyReturned", "ReceiptQty", "mdp_std_purchase_receipt",
})
Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
}
[Fact]
public void 禁止读IQC或算ObjectCompleted()
{
var code = ServiceBody();
foreach (var forbidden in new[]
{
"mdp_std_iqc_inspection", "quality_result", "flow_status",
"ObjectCompleted", "QualityAccepted", "StageComplete",
})
Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
}
[Fact]
public void 只写completion事实表与跑批日志()
{
var code = Service();
var targets = code.Split("INSERT INTO ").Skip(1)
.Select(x => x.Split('\n')[0].Trim().Split(' ')[0].Trim())
.Distinct().OrderBy(x => x, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { "mdp_std_purchase_order_completion", "mdp_transform_run_log" }, targets);
}
[Fact]
public void 绝不更新SourceA的基础事实()
{
var code = ServiceBody();
foreach (var forbidden in new[]
{
"UPDATE mdp_std_purchase_order\n", "UPDATE mdp_std_purchase_order ",
"UPDATE PurOrdDetail", "UPDATE PurOrdMaster",
"INSERT INTO mdp_std_purchase_order\n", "INSERT INTO PurOrdDetail",
})
Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
}
[Fact]
public void 源侧只读()
{
var read = Between(Service(), "private async Task> ReadSourceRowsAsync", "// ── 租户归属");
Assert.Contains("GetDataTableAsync", read, StringComparison.Ordinal);
foreach (var forbidden in new[] { "UPDATE ", "INSERT ", "DELETE ", "MERGE " })
Assert.DoesNotContain(forbidden, read, StringComparison.Ordinal);
}
[Fact]
public void 入账范围与推送口径一致()
{
var read = Between(Service(), "private async Task> ReadSourceRowsAsync", "// ── 租户归属");
Assert.Contains("Potype", read, StringComparison.Ordinal);
Assert.Contains("_opt.SourcePotype", read, StringComparison.Ordinal);
}
// ── ⑧ 完成时间:当前无权威,不得落库 ────────────────────────────────────────
[Fact]
public void 不得创建completion_time列()
{
var sql = Migration("1.0.544.sql");
foreach (var forbidden in new[] { "`completion_time`", "`completed_at`", "`procurement_completion_time`" })
Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
}
[Fact]
public void 不得把观测时间当完成时间()
{
var code = ServiceBody();
Assert.DoesNotContain("completion_time", code, StringComparison.Ordinal);
Assert.DoesNotContain("CompletionTime", code, StringComparison.Ordinal);
}
// ── ⑨ NOT_OBSERVED 不得物化 ─────────────────────────────────────────────────
[Fact]
public void 不得遍历SourceA反向造行()
{
var code = Service();
// 驱动集合只能来自源侧读取结果
Assert.Contains("foreach (var row in sourceRows)", code, StringComparison.Ordinal);
// 本库采购行只用于校验与租户归属,不得成为写入驱动
var run = Between(code, "var pending = new List", "// ④ UPSERT");
Assert.DoesNotContain("foreach (var row in poLineIndex", run, StringComparison.Ordinal);
// NOT_OBSERVED 是「不落行」的口头表达(注释里可以出现),
// 但绝不能变成落库的字符串字面量、枚举值或列默认值
Assert.DoesNotContain("\"NOT_OBSERVED\"", ServiceBody(), StringComparison.Ordinal);
Assert.DoesNotContain("'NOT_OBSERVED'", Migration("1.0.544.sql"), StringComparison.Ordinal);
}
// ── ⑩ 调度与手动入口 ────────────────────────────────────────────────────────
[Fact]
public void 周期性全量对账必须存在且启用()
{
var job = Source("Job", "S3PoCompletionMdpSyncJob.cs");
Assert.Contains("[JobDetail(\"job_s3_po_completion_mdp_sync\"", job, StringComparison.Ordinal);
Assert.Contains("[Period(", job, StringComparison.Ordinal);
Assert.Contains("RunFullAsync(0, \"AUTO\"", job, StringComparison.Ordinal);
}
[Fact]
public void 手动刷新入口就位且不回显连接信息()
{
var ctrl = Source("Controllers", "AidopKanbanController.cs");
Assert.Contains("[HttpPost(\"s3-po-completion-mdp/refresh\")]", ctrl, StringComparison.Ordinal);
var block = Between(ctrl, "[HttpPost(\"s3-po-completion-mdp/refresh\")]", "staleRemoved = r.StaleRemoved");
foreach (var forbidden in new[] { "ConnectionString", "Password", "db_user" })
Assert.DoesNotContain(forbidden, block, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void 跑批日志复用既有表与固定JobCode()
{
var code = Service();
Assert.Contains("\"S3_PO_COMPLETION_MDP_SYNC\"", code, StringComparison.Ordinal);
Assert.Contains("INSERT INTO mdp_transform_run_log", code, StringComparison.Ordinal);
foreach (var s in new[] { "'RUNNING'", "status='SUCCESS'", "status='FAILED'" })
Assert.Contains(s, code, StringComparison.Ordinal);
}
// ── ⑪ 上游批次资产不得回归 ──────────────────────────────────────────────────
[Fact]
public void IQC标准事实层与采购身份桥不得被本批改动()
{
var iqc = Migration("1.0.541.sql");
Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)", iqc, StringComparison.Ordinal);
var sql = Migration("1.0.544.sql");
foreach (var forbidden in new[]
{
"ALTER TABLE `mdp_std_purchase_order`", "ALTER TABLE `mdp_std_iqc_inspection`",
"UPDATE `mdp_std_purchase_order`", "DROP TABLE",
})
Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
}
}