using Microsoft.Extensions.Logging;
using SqlSugar;
namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
///
/// S8-RULE01-PURCHASE-DELIVERY-A:PURCHASE_DELIVERY 数据集的取数 Provider。
/// S8 第一个真实 STANDARD_DATASET Provider。
///
/// 职责边界:只做「租户作用域 → 最新快照收敛 → DWD 行 → canonical 行」。
/// 不判断是否异常、不判 severity、不建 Exception、不写 Detection、不决定 Recovery、
/// 不读 rule.Expression、不读 HttpContext / UserManager、不回落 Legacy SQL、
/// 不硬编码租户 / 工厂 / PO 号,也不修任何数据平台数据。
/// 判定完全交给 。
///
/// 为什么不在 SQL 里过滤 status / remaining_qty:
/// 若 Provider 先滤掉「已完成」行,那么「逾期但已完成不命中」这条契约就退化成
/// 「Provider 把它藏起来了」,而不是「evaluator 判它不命中」——真正的判定逻辑测不到。
/// 更要命的是:被滤掉的行不在 hits 里,ReconcileRecoveriesForRuleAsync 会把它当成
/// 「不再命中」而正常触发 recovery,结果看似正确但恢复原因不可解释。
/// 因此 Provider 返回当前快照的全部 PO Line 事实,一行不滤。
///
/// remaining_qty 仅作诊断:字段级审计已证其不可信(781/784 行恒等于 order_qty),
/// 绝不参与 Trigger,也绝不出现在 WHERE 中。
///
///
/// 刻意不实现 ITransient:Furion 的自动扫描会按接口再注册一次,而
/// 是以 IEnumerable<IS8MonitoringDataProvider>
/// 注入的——同一个类型会出现两份,注册表按"同 DatasetCode 重复注册"抛
/// InvalidOperationException。且因两者均为 Transient,该异常在首次解析时才发生:
/// 应用启动完全正常,直到第一个碰它的请求返回 500。
/// (S8DatasetCatalog / S8LegacySqlDataProvider 同时带 ITransient 与显式注册却不出问题,
/// 是因为它们按单一接口解析,后注册覆盖先注册,不走集合。)
/// 本类只保留 Startup.cs 中的显式注册。
///
public sealed class S8PurchaseDeliveryDataProvider : IS8MonitoringDataProvider
{
///
/// 本 Provider 产出行对应的业务对象类型。
/// 仅用于 DedupKey 长度预算估算;运行期真正写入 hit 的是
/// rule.SourceObjectType ?? rule.WatchObjectType(见 ),
/// 由规则配置决定,本类不参与。两者应保持一致(规则配置为 PURCHASE_ORDER_LINE)。
///
internal const string ExpectedSourceObjectType = "PURCHASE_ORDER_LINE";
///
/// ado_s8_exception.dedup_key 的列长上限(AdoS8Exception 中 Length = 128)。
/// DedupKey 由 S8TimeoutRuleEvaluator.BuildDedupKey 拼成
/// T{tenant}:F{factory}:R{ruleCode}:{objectType}:{objectId}。
///
internal const int DedupKeyMaxLength = 128;
///
/// 取数 SQL。
///
/// 作用域只按 tenant_id(Phase 1 已拍板:Security Boundary = Tenant,Factory = Business Context)。
/// 刻意不加 factory_id = @FactoryId:dwd_supplier_delivery.factory_id 是写死的常量 1
/// (S3MdpSyncTransformService 的 COALESCE(NULLIF(po.factory_id,0),1) 与
/// DeliveryScheduleService 的字面量 1),与 S8 规则侧的真实 SysOrg.Id 语义不同,
/// 强行相等匹配会让每条规则恒返 0 行。客户运营模式为 1 Tenant = 1 Factory,
/// 因此租户过滤已构成完整的数据隔离边界。
///
/// 快照收敛用 ORDER BY … DESC LIMIT 1 而非 MAX():idx_stat_date 为
/// (tenant_id, stat_date),聚合写法在大租户上实测 143ms(扫 34,112 条索引项),
/// 改为反向索引扫描后 2.7ms(约 53×)。语义等价:无数据时同样返回 NULL → 外层 0 行。
///
/// po_no / po_line 均为 NOT NULL,故只判空串;两者共同构成对象身份,
/// 任一为空会产生退化的 "#" 身份,必须排除。
///
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
FROM dwd_supplier_delivery d
WHERE d.tenant_id = @TenantId
AND d.stat_date = (SELECT x.stat_date
FROM dwd_supplier_delivery x
WHERE x.tenant_id = @TenantId
ORDER BY x.stat_date DESC
LIMIT 1)
AND d.po_no <> ''
AND d.po_line <> ''
""";
private readonly ISqlSugarClient _db;
private readonly ILogger _logger;
public S8PurchaseDeliveryDataProvider(
ISqlSugarClient db,
ILogger logger)
{
_db = db;
_logger = logger;
}
public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
/// 与 Catalog 引用同一实例,杜绝目录与实现的能力声明漂移。
public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
public async Task LoadAsync(
S8MonitoringDataRequest request,
CancellationToken cancellationToken = default)
{
// 作用域必须由调用方显式给出;缺失时显式失败,绝不用默认值兜底
// (兜底会让跨租户读取以"看起来正常"的方式发生)。
request.EnsureValid();
var rows = await _db.Ado.SqlQueryAsync(
Sql,
new SugarParameter[] { new("@TenantId", request.TenantId) });
var budget = ResolveSourceObjectIdBudget(request);
var mapped = new List(rows.Count);
var skipped = 0;
foreach (var row in rows)
{
var sourceObjectId = row.SourceObjectId ?? string.Empty;
// DedupKey 越界必须"响"而不是静默消失:数据库层的截断会让两个不同 PO Line
// 折叠成同一个异常,且没有任何痕迹。这里显式跳过并计数,收尾统一 warn。
if (sourceObjectId.Length > budget)
{
skipped++;
_logger.LogWarning(
"s8_purchase_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
"soid={Soid} length={Length} budget={Budget}",
request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
continue;
}
mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
}
_logger.LogDebug(
"s8_purchase_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
request.RuleCode, request.TenantId, mapped.Count, skipped);
return S8MonitoringRowSet.FromRows(mapped);
}
///
/// 计算 source_object_id 在 DedupKey 中可用的字符预算。
///
/// 预算由 request 现算而非写死常量:DedupKey 前缀长度随租户 / 工厂 / 规则编码变化,
/// 写死一个数字会在换规则或换租户时悄悄失效。
///
/// 注意这个预算通常比 source_object_id 的列宽(64)更紧:
/// 15 位租户 + 15 位工厂 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀已占 92,只剩 36。
/// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
///
internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
{
var prefixLength =
$"T{request.TenantId}:F{request.FactoryId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
return Math.Max(0, DedupKeyMaxLength - prefixLength);
}
///
/// DWD 行 → canonical 值。canonical 四列驱动 TIMEOUT 判定,其余列随 payload 落盘供追溯。
///
/// due_at_source 必须保留:due_at 是 need_date / due_date 的混合映射
/// (实测该租户 18/36 行两者不同),若不逐行记录实际用了哪个基准,将来复盘无从下手。
///
internal static Dictionary ToCanonicalValues(PurchaseDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
{
// canonical:evaluator 判定与身份
[S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
[S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
[S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
[S8CanonicalColumns.DueAt] = row.DueAt,
[S8CanonicalColumns.Status] = row.Status,
// 诊断 payload:不参与任何判定
["due_at_source"] = row.DueAtSource,
["po_no"] = row.PoNo,
["po_line"] = row.PoLine,
["po_type"] = row.PoType,
["supplier_code"] = row.SupplierCode,
["supplier_name"] = row.SupplierName,
["item_code"] = row.ItemCode,
["item_name"] = row.ItemName,
["order_qty"] = row.OrderQty,
["remaining_qty"] = row.RemainingQty,
["need_date"] = row.NeedDate,
["due_date"] = row.DueDate,
["risk_level"] = row.RiskLevel,
["stat_date"] = row.StatDate
};
///
/// DWD 只读投影。刻意不建 CodeFirst Entity:dwd_supplier_delivery 由数据平台维护,
/// S8 侧建 Entity 会把它拉进结构同步范围,造成归属混乱。
///
internal sealed class PurchaseDeliveryRow
{
public string SourceObjectId { get; set; }
public string RelatedObjectCode { get; set; }
public string RelatedObjectName { get; set; }
public DateTime? DueAt { get; set; }
public string Status { get; set; }
public string DueAtSource { get; set; }
public string PoNo { get; set; }
public string PoLine { get; set; }
public string PoType { get; set; }
public string SupplierCode { get; set; }
public string SupplierName { get; set; }
public string ItemCode { get; set; }
public string ItemName { get; set; }
public decimal? OrderQty { get; set; }
public decimal? RemainingQty { get; set; }
public DateTime? NeedDate { get; set; }
public DateTime? DueDate { get; set; }
public string RiskLevel { get; set; }
public DateTime? StatDate { get; set; }
}
}