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