S8PurchaseDeliveryDataProvider.cs 15 KB

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