S8PurchaseDeliveryDataProvider.cs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256
  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. /// <remarks>
  67. /// <b>结果列别名必须是 PascalCase,与 <see cref="PurchaseDeliveryRow"/> 属性逐字同名。</b>
  68. ///
  69. /// 本仓 <c>DbSettings.EnableUnderLine = false</c>(Database.json),SqlSugar 不做
  70. /// snake_case → PascalCase 的自动换算;<c>Ado.SqlQueryAsync&lt;T&gt;</c> 按列名与属性名匹配。
  71. /// 曾经这里写的是 <c>AS source_object_id</c> / <c>AS due_at</c> 这类 snake_case 别名,
  72. /// 结果是**每个属性都映射不上、全部为 null**:行数照常返回(36 行),但 <c>DueAt</c> 为 null,
  73. /// evaluator 在 <c>due == null</c> 处逐行跳过 → 命中恒为 0,且不报任何错。
  74. ///
  75. /// 这类缺陷单元测试抓不到:契约测试是直接 new <c>PurchaseDeliveryRow</c> 再喂 evaluator,
  76. /// 整条 SqlSugar 映射被绕开。守护改由 <c>S8PurchaseDeliverySqlMappingTests</c> 承担
  77. /// (用反射比对 SQL 别名与属性名)。
  78. ///
  79. /// 仓内既有同款写法可参照 <c>S8WatchSchedulerService.ListEnabledScopesAsync</c>:
  80. /// <c>SELECT r.tenant_id AS TenantId</c>。
  81. ///
  82. /// WHERE / FROM 中引用的仍是数据库真实列名(snake_case),只有输出别名改为 PascalCase。
  83. /// </remarks>
  84. internal const string Sql =
  85. """
  86. SELECT
  87. CONCAT(d.po_no, '#', d.po_line) AS SourceObjectId,
  88. d.po_no AS RelatedObjectCode,
  89. COALESCE(NULLIF(d.supplier_name, ''), d.supplier_code) AS RelatedObjectName,
  90. COALESCE(d.need_date, d.due_date) AS DueAt,
  91. d.delivery_status AS Status,
  92. CASE WHEN d.need_date IS NULL THEN 'due_date' ELSE 'need_date' END AS DueAtSource,
  93. d.po_no AS PoNo,
  94. d.po_line AS PoLine,
  95. d.po_type AS PoType,
  96. d.supplier_code AS SupplierCode,
  97. d.supplier_name AS SupplierName,
  98. d.item_code AS ItemCode,
  99. d.item_name AS ItemName,
  100. d.order_qty AS OrderQty,
  101. d.remaining_qty AS RemainingQty,
  102. d.need_date AS NeedDate,
  103. d.due_date AS DueDate,
  104. d.risk_level AS RiskLevel,
  105. d.stat_date AS StatDate
  106. FROM dwd_supplier_delivery d
  107. WHERE d.tenant_id = @TenantId
  108. AND d.stat_date = (SELECT x.stat_date
  109. FROM dwd_supplier_delivery x
  110. WHERE x.tenant_id = @TenantId
  111. ORDER BY x.stat_date DESC
  112. LIMIT 1)
  113. AND d.po_no <> ''
  114. AND d.po_line <> ''
  115. """;
  116. private readonly ISqlSugarClient _db;
  117. private readonly ILogger<S8PurchaseDeliveryDataProvider> _logger;
  118. public S8PurchaseDeliveryDataProvider(
  119. ISqlSugarClient db,
  120. ILogger<S8PurchaseDeliveryDataProvider> logger)
  121. {
  122. _db = db;
  123. _logger = logger;
  124. }
  125. public string DatasetCode => S8BusinessDatasetDefinitions.PurchaseDeliveryCode;
  126. /// <summary>与 Catalog 引用同一实例,杜绝目录与实现的能力声明漂移。</summary>
  127. public S8DatasetCapabilities Capabilities => S8BusinessDatasetDefinitions.PurchaseDeliveryCapabilities;
  128. public async Task<S8MonitoringRowSet> LoadAsync(
  129. S8MonitoringDataRequest request,
  130. CancellationToken cancellationToken = default)
  131. {
  132. // 作用域必须由调用方显式给出;缺失时显式失败,绝不用默认值兜底
  133. // (兜底会让跨租户读取以"看起来正常"的方式发生)。
  134. request.EnsureValid();
  135. var rows = await _db.Ado.SqlQueryAsync<PurchaseDeliveryRow>(
  136. Sql,
  137. new SugarParameter[] { new("@TenantId", request.TenantId) });
  138. var budget = ResolveSourceObjectIdBudget(request);
  139. var mapped = new List<S8MonitoringRow>(rows.Count);
  140. var skipped = 0;
  141. foreach (var row in rows)
  142. {
  143. var sourceObjectId = row.SourceObjectId ?? string.Empty;
  144. // DedupKey 越界必须"响"而不是静默消失:数据库层的截断会让两个不同 PO Line
  145. // 折叠成同一个异常,且没有任何痕迹。这里显式跳过并计数,收尾统一 warn。
  146. if (sourceObjectId.Length > budget)
  147. {
  148. skipped++;
  149. _logger.LogWarning(
  150. "s8_purchase_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
  151. "soid={Soid} length={Length} budget={Budget}",
  152. request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
  153. continue;
  154. }
  155. mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
  156. }
  157. _logger.LogDebug(
  158. "s8_purchase_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
  159. request.RuleCode, request.TenantId, mapped.Count, skipped);
  160. return S8MonitoringRowSet.FromRows(mapped);
  161. }
  162. /// <summary>
  163. /// 计算 <c>source_object_id</c> 在 DedupKey 中可用的字符预算。
  164. ///
  165. /// 预算<b>由 request 现算</b>而非写死常量:DedupKey 前缀长度随租户 / 工厂 / 规则编码变化,
  166. /// 写死一个数字会在换规则或换租户时悄悄失效。
  167. ///
  168. /// 注意这个预算通常比 <c>source_object_id</c> 的列宽(64)更紧:
  169. /// S8-TENANT-ONLY-BATCH5 去掉 <c>:F{factory}</c> 段后,
  170. /// 15 位租户 + 36 字符规则编码 + PURCHASE_ORDER_LINE 时前缀约占 76,余量比之前宽松了 16 字符。
  171. /// 落在 37–64 之间的键会过得了列校验、卡在 DedupKey 上,因此必须按本预算判定。
  172. /// </summary>
  173. internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
  174. {
  175. var prefixLength =
  176. $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
  177. return Math.Max(0, DedupKeyMaxLength - prefixLength);
  178. }
  179. /// <summary>
  180. /// DWD 行 → canonical 值。canonical 四列驱动 TIMEOUT 判定,其余列随 payload 落盘供追溯。
  181. ///
  182. /// <c>due_at_source</c> 必须保留:<c>due_at</c> 是 need_date / due_date 的混合映射
  183. /// (实测该租户 18/36 行两者不同),若不逐行记录实际用了哪个基准,将来复盘无从下手。
  184. /// </summary>
  185. internal static Dictionary<string, object> ToCanonicalValues(PurchaseDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
  186. {
  187. // canonical:evaluator 判定与身份
  188. [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
  189. [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
  190. [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
  191. [S8CanonicalColumns.DueAt] = row.DueAt,
  192. [S8CanonicalColumns.Status] = row.Status,
  193. // 诊断 payload:不参与任何判定
  194. ["due_at_source"] = row.DueAtSource,
  195. ["po_no"] = row.PoNo,
  196. ["po_line"] = row.PoLine,
  197. ["po_type"] = row.PoType,
  198. ["supplier_code"] = row.SupplierCode,
  199. ["supplier_name"] = row.SupplierName,
  200. ["item_code"] = row.ItemCode,
  201. ["item_name"] = row.ItemName,
  202. ["order_qty"] = row.OrderQty,
  203. ["remaining_qty"] = row.RemainingQty,
  204. ["need_date"] = row.NeedDate,
  205. ["due_date"] = row.DueDate,
  206. ["risk_level"] = row.RiskLevel,
  207. ["stat_date"] = row.StatDate
  208. };
  209. /// <summary>
  210. /// DWD 只读投影。刻意<b>不建 CodeFirst Entity</b>:<c>dwd_supplier_delivery</c> 由数据平台维护,
  211. /// S8 侧建 Entity 会把它拉进结构同步范围,造成归属混乱。
  212. /// </summary>
  213. internal sealed class PurchaseDeliveryRow
  214. {
  215. public string SourceObjectId { get; set; }
  216. public string RelatedObjectCode { get; set; }
  217. public string RelatedObjectName { get; set; }
  218. public DateTime? DueAt { get; set; }
  219. public string Status { get; set; }
  220. public string DueAtSource { get; set; }
  221. public string PoNo { get; set; }
  222. public string PoLine { get; set; }
  223. public string PoType { get; set; }
  224. public string SupplierCode { get; set; }
  225. public string SupplierName { get; set; }
  226. public string ItemCode { get; set; }
  227. public string ItemName { get; set; }
  228. public decimal? OrderQty { get; set; }
  229. public decimal? RemainingQty { get; set; }
  230. public DateTime? NeedDate { get; set; }
  231. public DateTime? DueDate { get; set; }
  232. public string RiskLevel { get; set; }
  233. public DateTime? StatDate { get; set; }
  234. }
  235. }