|
@@ -0,0 +1,407 @@
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Dto.S8.OrderFlow;
|
|
|
|
|
+using Admin.NET.Plugin.AiDOP.Service.S8.OrderFlow;
|
|
|
|
|
+using Xunit;
|
|
|
|
|
+
|
|
|
|
|
+namespace Admin.NET.Plugin.AiDOP.Tests.S8.Supply;
|
|
|
|
|
+
|
|
|
|
|
+using S = S8Stage3ProcurementReadService;
|
|
|
|
|
+using C = AdoS8Stage3Const;
|
|
|
|
|
+
|
|
|
|
|
+/// <summary>
|
|
|
|
|
+/// S8 Stage-3 最小只读模型的生产契约。
|
|
|
|
|
+///
|
|
|
|
|
+/// <para>这个模型第一次把三类事实拼在一起:采购身份、WMS 采购完成态、来料检验质量。
|
|
|
|
|
+/// 它最危险的失败模式不是报错,而是<b>安静地把「没有数据」说成「没做完」</b>,
|
|
|
|
|
+/// 或者把归属不到订单的采购对象偷偷塞进某张订单。本文件守住这些边界:</para>
|
|
|
|
|
+///
|
|
|
|
|
+/// <list type="number">
|
|
|
|
|
+/// <item>缺数据 ≠ 未完成 —— 完成态事实缺失是 NOT_OBSERVED,对象态是 UNKNOWN;</item>
|
|
|
|
|
+/// <item>两轴独立 —— 质量合格不能让采购未完成的行变成已完成;</item>
|
|
|
|
|
+/// <item>多义不消解 —— 多张检验单一律 MULTIPLE_UNRESOLVED,不取最新/不取任一通过;</item>
|
|
|
|
|
+/// <item>归属唯一才用 —— 多订单归属的采购行不进任何订单;</item>
|
|
|
|
|
+/// <item>只消费中台 —— 不碰贴源层、源业务表、WMS 源库与 SEED 表。</item>
|
|
|
|
|
+/// </list>
|
|
|
|
|
+/// </summary>
|
|
|
|
|
+public class S8Stage3ProcurementReadModelTests
|
|
|
|
|
+{
|
|
|
|
|
+ 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("Service", "S8", "OrderFlow", "S8Stage3ProcurementReadService.cs");
|
|
|
|
|
+
|
|
|
|
|
+ /// <summary>去掉 XML 文档注释后的代码体 —— 文档需要点名被禁的东西来说明边界,那是说明不是违规。</summary>
|
|
|
|
|
+ private static string ServiceBody()
|
|
|
|
|
+ => string.Join('\n', Service().Split('\n')
|
|
|
|
|
+ .Where(l => !l.TrimStart().StartsWith("///", StringComparison.Ordinal)));
|
|
|
|
|
+
|
|
|
|
|
+ private static string Sql()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = Service();
|
|
|
|
|
+ var i = code.IndexOf("private const string LineSql", StringComparison.Ordinal);
|
|
|
|
|
+ Assert.True(i >= 0, "未找到 LineSql");
|
|
|
|
|
+ return code[i..];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ① 采购完成态映射(§三十二 5-8)────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 完成态_COMPLETED()
|
|
|
|
|
+ => Assert.Equal(C.ProcurementCompleted, S.MapProcurementStatus("COMPLETED"));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 完成态_NOT_COMPLETED()
|
|
|
|
|
+ => Assert.Equal(C.ProcurementNotCompleted, S.MapProcurementStatus("NOT_COMPLETED"));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 完成态_UNKNOWN()
|
|
|
|
|
+ => Assert.Equal(C.ProcurementUnknown, S.MapProcurementStatus("UNKNOWN"));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 完成态事实缺失必须是NOT_OBSERVED而不是未完成()
|
|
|
|
|
+ {
|
|
|
|
|
+ // WMS 侧没有这条采购行,通常只是「还没推过去」,把它说成未完成是造假
|
|
|
|
|
+ Assert.Equal(C.ProcurementNotObserved, S.MapProcurementStatus(null));
|
|
|
|
|
+ Assert.NotEqual(C.ProcurementNotCompleted, S.MapProcurementStatus(null));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 完成态取值域封闭()
|
|
|
|
|
+ {
|
|
|
|
|
+ var allowed = new[] { C.ProcurementCompleted, C.ProcurementNotCompleted, C.ProcurementUnknown, C.ProcurementNotObserved };
|
|
|
|
|
+ foreach (var raw in new string?[] { null, "", "COMPLETED", "NOT_COMPLETED", "UNKNOWN", "怪值" })
|
|
|
|
|
+ Assert.Contains(S.MapProcurementStatus(raw), allowed);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ② IQC 归属(§三十二 2-4)──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 候选唯一且有检验单才算UNIQUE()
|
|
|
|
|
+ => Assert.Equal(C.IqcUnique, S.MapIqcAttribution(1, 1));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 没有检验单是UNATTRIBUTED()
|
|
|
|
|
+ => Assert.Equal(C.IqcUnattributed, S.MapIqcAttribution(1, 0));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 候选多条是AMBIGUOUS_不得任选一行()
|
|
|
|
|
+ => Assert.Equal(C.IqcAmbiguous, S.MapIqcAttribution(3, 2));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 候选为零也不得归属()
|
|
|
|
|
+ => Assert.Equal(C.IqcAmbiguous, S.MapIqcAttribution(0, 1));
|
|
|
|
|
+
|
|
|
|
|
+ // ── ③ 质量映射(§三十二 9-13)────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 唯一归属且终态PASS_是ACCEPTED()
|
|
|
|
|
+ => Assert.Equal(C.QualityAccepted, S.MapQualityStatus(C.IqcUnique, 1, 1, 0));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 唯一归属且终态FAIL_是REJECTED()
|
|
|
|
|
+ => Assert.Equal(C.QualityRejected, S.MapQualityStatus(C.IqcUnique, 1, 0, 1));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 唯一归属但流程未终态_是PENDING()
|
|
|
|
|
+ => Assert.Equal(C.QualityPending, S.MapQualityStatus(C.IqcUnique, 1, 0, 0));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 零检验单_是NOT_OBSERVED()
|
|
|
|
|
+ => Assert.Equal(C.QualityNotObserved, S.MapQualityStatus(C.IqcUnattributed, 0, 0, 0));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 多检验单_必须MULTIPLE_UNRESOLVED而不是任一通过()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 归并规则(取最新 / 任一通过 / 复检覆盖)未由业务冻结,读模型不得替业务拍板
|
|
|
|
|
+ Assert.Equal(C.QualityMultipleUnresolved, S.MapQualityStatus(C.IqcUnique, 2, 2, 0));
|
|
|
|
|
+ Assert.Equal(C.QualityMultipleUnresolved, S.MapQualityStatus(C.IqcUnique, 3, 1, 2));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 归属多义时质量不得被采纳()
|
|
|
|
|
+ => Assert.Equal(C.QualityNotObserved, S.MapQualityStatus(C.IqcAmbiguous, 2, 2, 0));
|
|
|
|
|
+
|
|
|
|
|
+ // ── ④ 对象态(§三十二 14-16)────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 采购完成且质量合格_才是COMPLETED()
|
|
|
|
|
+ => Assert.Equal(C.ObjectCompleted, S.MapObjectStatus(C.ProcurementCompleted, C.QualityAccepted));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 采购完成但质量未观测_必须UNKNOWN而不是完成()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 这是本模型最重要的一条:不能因为采购做完了就宣称对象完成
|
|
|
|
|
+ Assert.Equal(C.ObjectUnknown, S.MapObjectStatus(C.ProcurementCompleted, C.QualityNotObserved));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 质量合格但采购未完成_是NOT_COMPLETED而不是完成()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 两轴独立的关键回归:IQC202608110001/2 正是这种形状
|
|
|
|
|
+ Assert.Equal(C.ObjectNotCompleted, S.MapObjectStatus(C.ProcurementNotCompleted, C.QualityAccepted));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 缺数据一律UNKNOWN_绝不当成未完成()
|
|
|
|
|
+ {
|
|
|
|
|
+ foreach (var (p, q) in new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ (C.ProcurementNotObserved, C.QualityAccepted),
|
|
|
|
|
+ (C.ProcurementUnknown, C.QualityAccepted),
|
|
|
|
|
+ (C.ProcurementCompleted, C.QualityMultipleUnresolved),
|
|
|
|
|
+ (C.ProcurementNotCompleted, C.QualityNotObserved),
|
|
|
|
|
+ })
|
|
|
|
|
+ Assert.Equal(C.ObjectUnknown, S.MapObjectStatus(p, q));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 采购完成且质量不合格_是NOT_COMPLETED()
|
|
|
|
|
+ => Assert.Equal(C.ObjectNotCompleted, S.MapObjectStatus(C.ProcurementCompleted, C.QualityRejected));
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 对象态取值域封闭()
|
|
|
|
|
+ {
|
|
|
|
|
+ var allowed = new[] { C.ObjectCompleted, C.ObjectNotCompleted, C.ObjectUnknown };
|
|
|
|
|
+ foreach (var p in new[] { C.ProcurementCompleted, C.ProcurementNotCompleted, C.ProcurementUnknown, C.ProcurementNotObserved })
|
|
|
|
|
+ foreach (var q in new[] { C.QualityAccepted, C.QualityRejected, C.QualityPending, C.QualityNotObserved, C.QualityMultipleUnresolved })
|
|
|
|
|
+ Assert.Contains(S.MapObjectStatus(p, q), allowed);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑤ 租户隔离(§三十二 1)──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 租户必须来自服务端上下文()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = ServiceBody();
|
|
|
|
|
+ Assert.Contains("AidopTenantScope.ResolveOrThrow(_userManager)", code, StringComparison.Ordinal);
|
|
|
|
|
+ // 不得由调用方传租户
|
|
|
|
|
+ Assert.DoesNotContain("long tenantId,", code, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("[FromQuery]", code, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 所有业务JOIN必须显式对齐租户()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ foreach (var join in new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "p.tenant_id = o.tenant_id", "s.tenant_id = p.tenant_id",
|
|
|
|
|
+ "o.tenant_id = a.TenantId", "pa.TenantId = o.tenant_id",
|
|
|
|
|
+ "sup.tenant_id = o.tenant_id", "c.tenant_id = o.tenant_id",
|
|
|
|
|
+ "cd.TenantId = o.tenant_id", "ia.TenantId = o.tenant_id",
|
|
|
|
|
+ })
|
|
|
|
|
+ Assert.Contains(join, sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 每个CTE都必须自带租户过滤()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ Assert.Equal(4, CountOccurrences(sql, "tenant_id = @TenantId"));
|
|
|
|
|
+ Assert.Contains("a.TenantId = @TenantId", sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private static int CountOccurrences(string haystack, string needle)
|
|
|
|
|
+ {
|
|
|
|
|
+ var n = 0;
|
|
|
|
|
+ for (var i = haystack.IndexOf(needle, StringComparison.Ordinal); i >= 0;
|
|
|
|
|
+ i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
|
|
|
|
|
+ n++;
|
|
|
|
|
+ return n;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑥ 订单归属(§三十二 2-4、§十二)─────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 多订单归属的采购行不得进入任何订单()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ Assert.Contains("COUNT(DISTINCT s.order_id)", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("a.OrderCount = 1", sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 归属链走采购申请号_禁止用work_order关联()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ Assert.Contains("p.pr_no = o.purchase_request_no", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("s.order_entry_id = p.sales_order_entry_id", sql, StringComparison.Ordinal);
|
|
|
|
|
+ // 采购标准层的 work_order 含大量字面量 'null',绝不可作关联键
|
|
|
|
|
+ Assert.DoesNotContain("= o.work_order", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("o.work_order =", sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 禁止用排序取一条解决多义()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ Assert.DoesNotContain("LIMIT 1", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("ORDER BY q.", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("MAX(q.", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("MIN(q.", sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 采购申请必须先聚合再JOIN_防止行翻倍()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ // pr_no 不是唯一键(还有 pr_line),直接 JOIN 会按申请明细条数放大采购行
|
|
|
|
|
+ Assert.Contains("PrAgg AS (", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("GROUP BY tenant_id, pr_no", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("LEFT JOIN mdp_std_purchase_request pr", sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑦ IQC 归属键(§十四)────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void IQC只按采购单号加物料归属()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ Assert.Contains("TRIM(q.purchase_order_no)", sql, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("TRIM(q.material_code)", sql, StringComparison.Ordinal);
|
|
|
|
|
+ // 禁止用这些字段增强匹配
|
|
|
|
|
+ foreach (var forbidden in new[] { "q.receipt_no", "q.supplier_code", "q.lot_serial", "q.completion_time" })
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑧ 数据来源白名单(§三十三、§三十二 17-20)──────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 只读数据中台标准层()
|
|
|
|
|
+ {
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ var allowed = new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "mdp_std_purchase_order", "mdp_std_purchase_order_completion",
|
|
|
|
|
+ "mdp_std_iqc_inspection", "mdp_std_purchase_request", "mdp_std_so", "mdp_std_supplier",
|
|
|
|
|
+ };
|
|
|
|
|
+ foreach (var t in allowed)
|
|
|
|
|
+ Assert.Contains(t, sql, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 禁止贴源层与源业务表与SEED()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = ServiceBody();
|
|
|
|
|
+ foreach (var forbidden in new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "mdp_stg_", "PurOrdDetail", "PurOrdMaster", "qms_qcp_",
|
|
|
|
|
+ "DOPDEMORQ_SQLSERVER", "ado_s8_order_flow_procurement_pivot",
|
|
|
|
|
+ "mes_moentry", "MdpSourceScopeFactory",
|
|
|
|
|
+ })
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 禁止用数量重新推导完成()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = ServiceBody();
|
|
|
|
|
+ foreach (var forbidden in new[] { "received_qty", "receipt_qty", ">= order_qty", "RctQty" })
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 纯读_不得有任何写操作()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = ServiceBody();
|
|
|
|
|
+ foreach (var forbidden in new[] { "INSERT ", "UPDATE ", "DELETE ", "ExecuteCommandAsync" })
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑨ 不得输出完成时间与周期(§十八、§十九)────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 不得输出任何完成时间()
|
|
|
|
|
+ {
|
|
|
|
|
+ var code = ServiceBody();
|
|
|
|
|
+ var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
|
|
|
|
|
+ foreach (var forbidden in new[]
|
|
|
|
|
+ {
|
|
|
|
|
+ "CompletionTime", "completion_time", "ProcurementCompletionTime",
|
|
|
|
|
+ "Stage3CompletionTime", "observed_at", "source_update_time",
|
|
|
|
|
+ })
|
|
|
|
|
+ {
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, code, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, dto, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 不得输出周期类字段()
|
|
|
|
|
+ {
|
|
|
|
|
+ var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
|
|
|
|
|
+ foreach (var forbidden in new[] { "ActualDays", "PlannedDays", "ElapsedDays", "OnTime", "Delayed", "Warning" })
|
|
|
|
|
+ Assert.DoesNotContain(forbidden, dto, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑩ 范围诚实性(§二十、§二十一、§二十二)────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 必须声明ATTRIBUTED_ONLY范围()
|
|
|
|
|
+ {
|
|
|
|
|
+ var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
|
|
|
|
|
+ Assert.Contains("ScopeAttributedOnly = \"ATTRIBUTED_ONLY\"", dto, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("public string DataScope", dto, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 不得宣称Stage3完成或给出裸完成率()
|
|
|
|
|
+ {
|
|
|
|
|
+ var dto = Source("Dto", "S8", "OrderFlow", "AdoS8Stage3ProcurementDtos.cs");
|
|
|
|
|
+ Assert.DoesNotContain("Stage3Complete", dto, StringComparison.Ordinal);
|
|
|
|
|
+ // 百分比字段必须带 Known 前缀,避免被当成业务完成率
|
|
|
|
|
+ Assert.Contains("KnownObjectCompletionRate", dto, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("public decimal? CompletionRate", dto, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.DoesNotContain("public decimal CompletionRate", dto, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑪ API 形状(§二十七)────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 端点复用既有S8订单路由与权限()
|
|
|
|
|
+ {
|
|
|
|
|
+ var ctrl = Source("Controllers", "S8", "AdoS8OrderFlowController.cs");
|
|
|
|
|
+ Assert.Contains("[HttpGet(\"orders/by-id/{salesOrderId}/stage3\")]", ctrl, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("[S8Permission(S8PermissionCatalog.DashboardRead)]", ctrl, StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("_stage3Svc.GetStage3Async(salesOrderId", ctrl, StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void SQL别名必须PascalCase()
|
|
|
|
|
+ {
|
|
|
|
|
+ // 本库 EnableUnderLine=false:snake_case 别名会静默映射成 null 而行数正常
|
|
|
|
|
+ var sql = Sql();
|
|
|
|
|
+ var aliases = System.Text.RegularExpressions.Regex
|
|
|
|
|
+ .Matches(sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)")
|
|
|
|
|
+ .Select(m => m.Groups[1].Value)
|
|
|
|
|
+ .Distinct()
|
|
|
|
|
+ .ToList();
|
|
|
|
|
+ Assert.NotEmpty(aliases);
|
|
|
|
|
+ var bad = aliases.Where(a => a.Contains('_') || !char.IsUpper(a[0])).ToList();
|
|
|
|
|
+ Assert.True(bad.Count == 0, "以下别名不是 PascalCase:" + string.Join(", ", bad));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── ⑫ 上游批次资产不得回归 ──────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+ [Fact]
|
|
|
|
|
+ public void 完成态事实表与IQC事实表的唯一键不得被本批改动()
|
|
|
|
|
+ {
|
|
|
|
|
+ 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 scripts = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts");
|
|
|
|
|
+
|
|
|
|
|
+ Assert.Contains("UNIQUE KEY `uk_mdp_std_po_completion` (`tenant_id`, `po_no`, `po_line`)",
|
|
|
|
|
+ File.ReadAllText(Path.Combine(scripts, "1.0.544.sql")), StringComparison.Ordinal);
|
|
|
|
|
+ Assert.Contains("UNIQUE KEY `uk_mdp_std_iqc_insp` (`tenant_id`, `source_row_id`)",
|
|
|
|
|
+ File.ReadAllText(Path.Combine(scripts, "1.0.541.sql")), StringComparison.Ordinal);
|
|
|
|
|
+ }
|
|
|
|
|
+}
|