Преглед на файлове

fix(s8): 修正 PURCHASE_DELIVERY 取数 SQL 的结果列别名,恢复 Rule 01 命中

本地隔离库全链实验暴露的真实缺陷:Rule 01 Preview 返回 candidateRows=36 但 hitCount=0。

根因
本仓 DbSettings.EnableUnderLine=false(Database.json),SqlSugar 不做 snake_case →
PascalCase 换算,Ado.SqlQueryAsync<T> 按列名与属性名直接匹配。而 Provider 的 SELECT
用的是 snake_case 别名(AS source_object_id / AS due_at / AS po_no …),
与 PurchaseDeliveryRow 的 PascalCase 属性对不上。

失败形态是**静默的**,这是它能活到现在的原因:
  · 不抛异常、不打警告
  · 行数照常返回 36(对象都 new 出来了)
  · 但每个属性都是 null,DueAt 为 null
  · evaluator 在 `due == null` 处逐行 continue → 命中恒为 0
表面看像「当前没有超期单」,实际是取数根本没落到对象上。

为什么既有测试全绿却没抓到
Provider 映射契约测试直接 new PurchaseDeliveryRow{...} 再喂 evaluator;
E2E 测试用 stub Provider 产出 canonical 行。两者都从「映射之后」开始验证,
整条 SqlSugar 映射被绕开——这正是纯单元测试的盲区,也正是本次接真库才发现的原因。

修复
输出别名全部改为与属性逐字同名的 PascalCase(SourceObjectId / DueAt / PoNo …),
与仓内既有写法一致(S8WatchSchedulerService.ListEnabledScopesAsync:
`SELECT r.tenant_id AS TenantId`)。WHERE / FROM 仍用数据库真实列名,只改输出侧。

守护 S8PurchaseDeliverySqlMappingTests(8 例)
从 SQL 文本本身入手,补上单元测试够不到的那一段:
  · 每个 AS 别名必须逐字等于某个属性名(Ordinal,不靠"大概能对上")
  · 每个属性必须被某个别名覆盖(漏一个就是静默 null)
  · 别名一律不得含下划线(EnableUnderLine=false 下等于注定映射不上)
  · 四个驱动判定的 canonical 字段单独钉死
  · WHERE/FROM 的物理列名不得被误改
已做红绿验证:临时把 AS DueAt 改回 AS due_at,4 条用例精准变红;恢复后全绿。

修复后本地实测(真实运行的 Controller → Service → Gateway → Provider → Evaluator)
  candidateRows=36  hitCount=28  noHitRows=8  wouldCreateCount=28
与只读 dry-run 的 36/28/8 逐数一致,且 28 恰等于中台自标 DELAYED 数。
Preview 零副作用复核:detection/exception/timeline/state 前后均为 79/83/188/0,
规则 enabled 与运行态列未被改动。

Rule 01 仍 enabled=false,未启用、未建单。
YY968XX преди 1 месец
родител
ревизия
cc7ce93477

+ 114 - 0
server/Admin.NET.Test/S8/S8PurchaseDeliverySqlMappingTests.cs

@@ -0,0 +1,114 @@
+using System.Reflection;
+using System.Text.RegularExpressions;
+using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
+using Xunit;
+
+namespace Admin.NET.Test.S8;
+
+/// <summary>
+/// S8-RULE01-SQL-MAPPING-GUARD-1:SQL 结果别名与 POCO 属性的映射契约。
+///
+/// <b>为什么需要单独一个文件</b>:本仓 <c>DbSettings.EnableUnderLine = false</c>,
+/// SqlSugar 不做 snake_case → PascalCase 换算,<c>Ado.SqlQueryAsync&lt;T&gt;</c> 按名字直接匹配。
+/// 别名一旦写成 snake_case,映射会**静默失败**:
+///   · 不抛异常
+///   · 行数照常返回
+///   · 但每个属性都是 null / default
+/// 于是 evaluator 在 <c>due == null</c> 处逐行跳过,命中恒为 0 —— 表面像"当前没有超期单",
+/// 实际是取数根本没落到对象上。本地全链实验中真实发生过一次(36 候选 / 0 命中)。
+///
+/// <b>为什么既有测试抓不到</b>:Provider 映射契约测试是直接 <c>new PurchaseDeliveryRow{...}</c>
+/// 再喂 evaluator,整条 SqlSugar 映射被绕开;E2E 测试用的 stub Provider 同理。
+/// 两者都在"映射之后"开始验证,因此本文件必须从 <b>SQL 文本本身</b>入手。
+///
+/// 不接 DB、不接 DI:解析 SQL 常量 + 反射比对属性名。
+/// </summary>
+public class S8PurchaseDeliverySqlMappingTests
+{
+    /// <summary>取 SELECT 列表里所有 <c>AS &lt;alias&gt;</c> 的别名。</summary>
+    private static string[] SelectAliases() =>
+        Regex.Matches(S8PurchaseDeliveryDataProvider.Sql, @"\bAS\s+([A-Za-z_][A-Za-z0-9_]*)", RegexOptions.IgnoreCase)
+            .Select(m => m.Groups[1].Value)
+            .ToArray();
+
+    private static string[] RowProperties() =>
+        typeof(S8PurchaseDeliveryDataProvider.PurchaseDeliveryRow)
+            .GetProperties(BindingFlags.Public | BindingFlags.Instance)
+            .Select(p => p.Name)
+            .ToArray();
+
+    /// <summary>
+    /// 每个 SQL 别名都必须**逐字**等于某个属性名。
+    /// 用 Ordinal 而非忽略大小写:SqlSugar 的匹配行为不该靠"大概能对上"来赌。
+    /// </summary>
+    [Fact]
+    public void EverySelectAlias_MapsToRowProperty()
+    {
+        var props = RowProperties();
+        var aliases = SelectAliases();
+
+        Assert.NotEmpty(aliases);
+
+        var unmapped = aliases.Where(a => !props.Contains(a, StringComparer.Ordinal)).ToArray();
+        Assert.True(unmapped.Length == 0,
+            "以下 SQL 别名在 PurchaseDeliveryRow 上找不到同名属性,SqlSugar 会静默映射为 null:\n  "
+            + string.Join("\n  ", unmapped));
+    }
+
+    /// <summary>
+    /// 反向:每个属性都必须被某个别名覆盖。
+    /// 漏掉一个属性同样是静默 null —— 例如漏掉 DueAt 就直接导致命中恒为 0。
+    /// </summary>
+    [Fact]
+    public void EveryRowProperty_IsProducedBySql()
+    {
+        var aliases = SelectAliases();
+        var missing = RowProperties().Where(p => !aliases.Contains(p, StringComparer.Ordinal)).ToArray();
+
+        Assert.True(missing.Length == 0,
+            "以下属性没有对应的 SQL 别名,取数后将恒为 null:\n  " + string.Join("\n  ", missing));
+    }
+
+    /// <summary>
+    /// 别名不得含下划线。EnableUnderLine=false 时 snake_case 别名等于"注定映射不上",
+    /// 而失败形态是静默的,所以这条要单独钉死,而不是依赖上面两条的间接覆盖。
+    /// </summary>
+    [Fact]
+    public void NoAlias_UsesSnakeCase()
+    {
+        var snake = SelectAliases().Where(a => a.Contains('_', StringComparison.Ordinal)).ToArray();
+
+        Assert.True(snake.Length == 0,
+            "本仓 EnableUnderLine=false,snake_case 别名会静默映射失败:\n  " + string.Join("\n  ", snake));
+    }
+
+    /// <summary>
+    /// 驱动判定的四个 canonical 字段必须齐备。
+    /// 其余诊断列缺失只是 payload 少信息,这四个缺一个就直接改变判定结果。
+    /// </summary>
+    [Theory]
+    [InlineData("SourceObjectId")]
+    [InlineData("RelatedObjectCode")]
+    [InlineData("DueAt")]
+    [InlineData("Status")]
+    public void CanonicalDrivingField_IsSelected(string alias)
+    {
+        Assert.Contains(alias, SelectAliases(), StringComparer.Ordinal);
+    }
+
+    /// <summary>
+    /// WHERE / FROM 仍必须用数据库真实列名(snake_case)——
+    /// 别名只改输出侧,改错这一侧会直接让 SQL 报「Unknown column」。
+    /// </summary>
+    [Fact]
+    public void PhysicalColumnNames_StayUnchangedInPredicates()
+    {
+        var sql = S8PurchaseDeliveryDataProvider.Sql;
+
+        Assert.Contains("d.tenant_id = @TenantId", sql, StringComparison.Ordinal);
+        Assert.Contains("d.stat_date =", sql, StringComparison.Ordinal);
+        Assert.Contains("d.po_no <> ''", sql, StringComparison.Ordinal);
+        Assert.Contains("d.po_line <> ''", sql, StringComparison.Ordinal);
+        Assert.Contains("FROM dwd_supplier_delivery d", sql, StringComparison.Ordinal);
+    }
+}

+ 37 - 12
server/Plugins/Admin.NET.Plugin.AiDOP/Service/S8/Rules/DataAccess/Providers/S8PurchaseDeliveryDataProvider.cs

@@ -67,21 +67,46 @@ public sealed class S8PurchaseDeliveryDataProvider : IS8MonitoringDataProvider
     /// <c>po_no</c> / <c>po_line</c> 均为 NOT NULL,故只判空串;两者共同构成对象身份,
     /// 任一为空会产生退化的 "#" 身份,必须排除。
     /// </summary>
+    /// <remarks>
+    /// <b>结果列别名必须是 PascalCase,与 <see cref="PurchaseDeliveryRow"/> 属性逐字同名。</b>
+    ///
+    /// 本仓 <c>DbSettings.EnableUnderLine = false</c>(Database.json),SqlSugar 不做
+    /// snake_case → PascalCase 的自动换算;<c>Ado.SqlQueryAsync&lt;T&gt;</c> 按列名与属性名匹配。
+    /// 曾经这里写的是 <c>AS source_object_id</c> / <c>AS due_at</c> 这类 snake_case 别名,
+    /// 结果是**每个属性都映射不上、全部为 null**:行数照常返回(36 行),但 <c>DueAt</c> 为 null,
+    /// evaluator 在 <c>due == null</c> 处逐行跳过 → 命中恒为 0,且不报任何错。
+    ///
+    /// 这类缺陷单元测试抓不到:契约测试是直接 new <c>PurchaseDeliveryRow</c> 再喂 evaluator,
+    /// 整条 SqlSugar 映射被绕开。守护改由 <c>S8PurchaseDeliverySqlMappingTests</c> 承担
+    /// (用反射比对 SQL 别名与属性名)。
+    ///
+    /// 仓内既有同款写法可参照 <c>S8WatchSchedulerService.ListEnabledScopesAsync</c>:
+    /// <c>SELECT r.tenant_id AS TenantId</c>。
+    ///
+    /// WHERE / FROM 中引用的仍是数据库真实列名(snake_case),只有输出别名改为 PascalCase。
+    /// </remarks>
     internal const string Sql =
         """
         SELECT
-            CONCAT(d.po_no, '#', d.po_line)                        AS source_object_id,
-            d.po_no                                                AS related_object_code,
-            COALESCE(NULLIF(d.supplier_name, ''), d.supplier_code) AS related_object_name,
-            COALESCE(d.need_date, d.due_date)                      AS due_at,
-            d.delivery_status                                      AS status,
-            CASE WHEN d.need_date IS NULL THEN 'due_date' ELSE 'need_date' END AS due_at_source,
-            d.po_no, d.po_line, d.po_type,
-            d.supplier_code, d.supplier_name,
-            d.item_code, d.item_name,
-            d.order_qty, d.remaining_qty,
-            d.need_date, d.due_date,
-            d.risk_level, d.stat_date
+            CONCAT(d.po_no, '#', d.po_line)                        AS SourceObjectId,
+            d.po_no                                                AS RelatedObjectCode,
+            COALESCE(NULLIF(d.supplier_name, ''), d.supplier_code) AS RelatedObjectName,
+            COALESCE(d.need_date, d.due_date)                      AS DueAt,
+            d.delivery_status                                      AS Status,
+            CASE WHEN d.need_date IS NULL THEN 'due_date' ELSE 'need_date' END AS DueAtSource,
+            d.po_no          AS PoNo,
+            d.po_line        AS PoLine,
+            d.po_type        AS PoType,
+            d.supplier_code  AS SupplierCode,
+            d.supplier_name  AS SupplierName,
+            d.item_code      AS ItemCode,
+            d.item_name      AS ItemName,
+            d.order_qty      AS OrderQty,
+            d.remaining_qty  AS RemainingQty,
+            d.need_date      AS NeedDate,
+            d.due_date       AS DueDate,
+            d.risk_level     AS RiskLevel,
+            d.stat_date      AS StatDate
         FROM dwd_supplier_delivery d
         WHERE d.tenant_id = @TenantId
           AND d.stat_date = (SELECT x.stat_date