S8PurchaseDeliveryDataProvider.cs 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230
  1. using Microsoft.Extensions.Logging;
  2. using SqlSugar;
  3. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  4. /// <summary>
  5. /// S8-RULE01-PURCHASE-DELIVERY-A:<c>PURCHASE_DELIVERY</c> 数据集的取数 Provider。
  6. /// S8 第一个真实 STANDARD_DATASET Provider。
  7. ///
  8. /// <b>职责边界</b>:只做「租户作用域 → 最新快照收敛 → DWD 行 → canonical 行」。
  9. /// 不判断是否异常、不判 severity、不建 Exception、不写 Detection、不决定 Recovery、
  10. /// 不读 <c>rule.Expression</c>、不读 HttpContext / UserManager、不回落 Legacy SQL、
  11. /// 不硬编码租户 / 工厂 / PO 号,也不修任何数据平台数据。
  12. /// 判定完全交给 <see cref="S8TimeoutRuleEvaluator"/>。
  13. ///
  14. /// <b>为什么不在 SQL 里过滤 status / remaining_qty</b>:
  15. /// 若 Provider 先滤掉「已完成」行,那么「逾期但已完成不命中」这条契约就退化成
  16. /// 「Provider 把它藏起来了」,而不是「evaluator 判它不命中」——真正的判定逻辑测不到。
  17. /// 更要命的是:被滤掉的行不在 hits 里,<c>ReconcileRecoveriesForRuleAsync</c> 会把它当成
  18. /// 「不再命中」而正常触发 recovery,结果看似正确但恢复原因不可解释。
  19. /// 因此 Provider 返回当前快照的<b>全部</b> PO Line 事实,一行不滤。
  20. ///
  21. /// <b>remaining_qty 仅作诊断</b>:字段级审计已证其不可信(781/784 行恒等于 order_qty),
  22. /// 绝不参与 Trigger,也绝不出现在 WHERE 中。
  23. /// </summary>
  24. /// <remarks>
  25. /// <b>刻意不实现 <c>ITransient</c></b>:Furion 的自动扫描会按接口再注册一次,而
  26. /// <see cref="S8MonitoringDataProviderRegistry"/> 是以 <c>IEnumerable&lt;IS8MonitoringDataProvider&gt;</c>
  27. /// 注入的——同一个类型会出现两份,注册表按"同 DatasetCode 重复注册"抛
  28. /// <c>InvalidOperationException</c>。且因两者均为 Transient,该异常在<b>首次解析时</b>才发生:
  29. /// 应用启动完全正常,直到第一个碰它的请求返回 500。
  30. /// (<c>S8DatasetCatalog</c> / <c>S8LegacySqlDataProvider</c> 同时带 ITransient 与显式注册却不出问题,
  31. /// 是因为它们按单一接口解析,后注册覆盖先注册,不走集合。)
  32. /// 本类只保留 <c>Startup.cs</c> 中的显式注册。
  33. /// </remarks>
  34. public sealed class S8PurchaseDeliveryDataProvider : IS8MonitoringDataProvider
  35. {
  36. /// <summary>
  37. /// 本 Provider 产出行对应的业务对象类型。
  38. /// 仅用于 DedupKey 长度预算估算;运行期真正写入 hit 的是
  39. /// <c>rule.SourceObjectType ?? rule.WatchObjectType</c>(见 <see cref="S8TimeoutRuleEvaluator"/>),
  40. /// 由规则配置决定,本类不参与。两者应保持一致(规则配置为 PURCHASE_ORDER_LINE)。
  41. /// </summary>
  42. internal const string ExpectedSourceObjectType = "PURCHASE_ORDER_LINE";
  43. /// <summary>
  44. /// <c>ado_s8_exception.dedup_key</c> 的列长上限(<c>AdoS8Exception</c> 中 <c>Length = 128</c>)。
  45. /// DedupKey 由 <c>S8TimeoutRuleEvaluator.BuildDedupKey</c> 拼成
  46. /// <c>T{tenant}:F{factory}:R{ruleCode}:{objectType}:{objectId}</c>。
  47. /// </summary>
  48. internal const int DedupKeyMaxLength = 128;
  49. /// <summary>
  50. /// 取数 SQL。
  51. ///
  52. /// <b>作用域只按 tenant_id</b>(Phase 1 已拍板:Security Boundary = Tenant,Factory = Business Context)。
  53. /// 刻意<b>不</b>加 <c>factory_id = @FactoryId</c>:<c>dwd_supplier_delivery.factory_id</c> 是写死的常量 1
  54. /// (<c>S3MdpSyncTransformService</c> 的 <c>COALESCE(NULLIF(po.factory_id,0),1)</c> 与
  55. /// <c>DeliveryScheduleService</c> 的字面量 1),与 S8 规则侧的真实 <c>SysOrg.Id</c> 语义不同,
  56. /// 强行相等匹配会让每条规则恒返 0 行。客户运营模式为 1 Tenant = 1 Factory,
  57. /// 因此租户过滤已构成完整的数据隔离边界。
  58. ///
  59. /// <b>快照收敛用 ORDER BY … DESC LIMIT 1 而非 MAX()</b>:<c>idx_stat_date</c> 为
  60. /// <c>(tenant_id, stat_date)</c>,聚合写法在大租户上实测 143ms(扫 34,112 条索引项),
  61. /// 改为反向索引扫描后 2.7ms(约 53×)。语义等价:无数据时同样返回 NULL → 外层 0 行。
  62. ///
  63. /// <c>po_no</c> / <c>po_line</c> 均为 NOT NULL,故只判空串;两者共同构成对象身份,
  64. /// 任一为空会产生退化的 "#" 身份,必须排除。
  65. /// </summary>
  66. internal const string Sql =
  67. """
  68. SELECT
  69. CONCAT(d.po_no, '#', d.po_line) AS source_object_id,
  70. d.po_no AS related_object_code,
  71. COALESCE(NULLIF(d.supplier_name, ''), d.supplier_code) AS related_object_name,
  72. COALESCE(d.need_date, d.due_date) AS due_at,
  73. d.delivery_status AS status,
  74. CASE WHEN d.need_date IS NULL THEN 'due_date' ELSE 'need_date' END AS due_at_source,
  75. d.po_no, d.po_line, d.po_type,
  76. d.supplier_code, d.supplier_name,
  77. d.item_code, d.item_name,
  78. d.order_qty, d.remaining_qty,
  79. d.need_date, d.due_date,
  80. d.risk_level, d.stat_date
  81. FROM dwd_supplier_delivery d
  82. WHERE d.tenant_id = @TenantId
  83. AND d.stat_date = (SELECT x.stat_date
  84. FROM dwd_supplier_delivery x
  85. WHERE x.tenant_id = @TenantId
  86. ORDER BY x.stat_date DESC
  87. LIMIT 1)
  88. AND d.po_no <> ''
  89. AND d.po_line <> ''
  90. """;
  91. private readonly ISqlSugarClient _db;
  92. private readonly ILogger<S8PurchaseDeliveryDataProvider> _logger;
  93. public S8PurchaseDeliveryDataProvider(
  94. ISqlSugarClient db,
  95. ILogger<S8PurchaseDeliveryDataProvider> logger)
  96. {
  97. _db = db;
  98. _logger = logger;
  99. }
  100. public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  101. /// <summary>与 Catalog 引用同一实例,杜绝目录与实现的能力声明漂移。</summary>
  102. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  103. public async Task<S8MonitoringRowSet> LoadAsync(
  104. S8MonitoringDataRequest request,
  105. CancellationToken cancellationToken = default)
  106. {
  107. // 作用域必须由调用方显式给出;缺失时显式失败,绝不用默认值兜底
  108. // (兜底会让跨租户读取以"看起来正常"的方式发生)。
  109. request.EnsureValid();
  110. var rows = await _db.Ado.SqlQueryAsync<PurchaseDeliveryRow>(
  111. Sql,
  112. new SugarParameter[] { new("@TenantId", request.TenantId) });
  113. var budget = ResolveSourceObjectIdBudget(request);
  114. var mapped = new List<S8MonitoringRow>(rows.Count);
  115. var skipped = 0;
  116. foreach (var row in rows)
  117. {
  118. var sourceObjectId = row.SourceObjectId ?? string.Empty;
  119. // DedupKey 越界必须"响"而不是静默消失:数据库层的截断会让两个不同 PO Line
  120. // 折叠成同一个异常,且没有任何痕迹。这里显式跳过并计数,收尾统一 warn。
  121. if (sourceObjectId.Length > budget)
  122. {
  123. skipped++;
  124. _logger.LogWarning(
  125. "s8_purchase_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
  126. "soid={Soid} length={Length} budget={Budget}",
  127. request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
  128. continue;
  129. }
  130. mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
  131. }
  132. _logger.LogDebug(
  133. "s8_purchase_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
  134. request.RuleCode, request.TenantId, mapped.Count, skipped);
  135. return S8MonitoringRowSet.FromRows(mapped);
  136. }
  137. /// <summary>
  138. /// 计算 <c>source_object_id</c> 在 DedupKey 中可用的字符预算。
  139. ///
  140. /// 预算<b>由 request 现算</b>而非写死常量:DedupKey 前缀长度随租户 / 工厂 / 规则编码变化,
  141. /// 写死一个数字会在换规则或换租户时悄悄失效。
  142. ///
  143. /// 注意这个预算通常比 <c>source_object_id</c> 的列宽(64)更紧:
  144. /// 15 位租户 + 15 位工厂 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀已占 92,只剩 36。
  145. /// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
  146. /// </summary>
  147. internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
  148. {
  149. var prefixLength =
  150. $"T{request.TenantId}:F{request.FactoryId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
  151. return Math.Max(0, DedupKeyMaxLength - prefixLength);
  152. }
  153. /// <summary>
  154. /// DWD 行 → canonical 值。canonical 四列驱动 TIMEOUT 判定,其余列随 payload 落盘供追溯。
  155. ///
  156. /// <c>due_at_source</c> 必须保留:<c>due_at</c> 是 need_date / due_date 的混合映射
  157. /// (实测该租户 18/36 行两者不同),若不逐行记录实际用了哪个基准,将来复盘无从下手。
  158. /// </summary>
  159. internal static Dictionary<string, object> ToCanonicalValues(PurchaseDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
  160. {
  161. // canonical:evaluator 判定与身份
  162. [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
  163. [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
  164. [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
  165. [S8CanonicalColumns.DueAt] = row.DueAt,
  166. [S8CanonicalColumns.Status] = row.Status,
  167. // 诊断 payload:不参与任何判定
  168. ["due_at_source"] = row.DueAtSource,
  169. ["po_no"] = row.PoNo,
  170. ["po_line"] = row.PoLine,
  171. ["po_type"] = row.PoType,
  172. ["supplier_code"] = row.SupplierCode,
  173. ["supplier_name"] = row.SupplierName,
  174. ["item_code"] = row.ItemCode,
  175. ["item_name"] = row.ItemName,
  176. ["order_qty"] = row.OrderQty,
  177. ["remaining_qty"] = row.RemainingQty,
  178. ["need_date"] = row.NeedDate,
  179. ["due_date"] = row.DueDate,
  180. ["risk_level"] = row.RiskLevel,
  181. ["stat_date"] = row.StatDate
  182. };
  183. /// <summary>
  184. /// DWD 只读投影。刻意<b>不建 CodeFirst Entity</b>:<c>dwd_supplier_delivery</c> 由数据平台维护,
  185. /// S8 侧建 Entity 会把它拉进结构同步范围,造成归属混乱。
  186. /// </summary>
  187. internal sealed class PurchaseDeliveryRow
  188. {
  189. public string SourceObjectId { get; set; }
  190. public string RelatedObjectCode { get; set; }
  191. public string RelatedObjectName { get; set; }
  192. public DateTime? DueAt { get; set; }
  193. public string Status { get; set; }
  194. public string DueAtSource { get; set; }
  195. public string PoNo { get; set; }
  196. public string PoLine { get; set; }
  197. public string PoType { get; set; }
  198. public string SupplierCode { get; set; }
  199. public string SupplierName { get; set; }
  200. public string ItemCode { get; set; }
  201. public string ItemName { get; set; }
  202. public decimal? OrderQty { get; set; }
  203. public decimal? RemainingQty { get; set; }
  204. public DateTime? NeedDate { get; set; }
  205. public DateTime? DueDate { get; set; }
  206. public string RiskLevel { get; set; }
  207. public DateTime? StatDate { get; set; }
  208. }
  209. }