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); } }