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 的列长上限(AdoS8ExceptionLength = 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 = @FactoryIddwd_supplier_delivery.factory_id 是写死的常量 1 /// (S3MdpSyncTransformServiceCOALESCE(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 Entitydwd_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; } } }