S8OrderDeliveryDataProvider.cs 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187
  1. using Microsoft.Extensions.Logging;
  2. using SqlSugar;
  3. namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
  4. /// <summary>
  5. /// S8-RULE02-ORDER-DELIVERY-WARNING-1:S1 订单交付风险取数。
  6. ///
  7. /// <para><b>SQL 只负责「哪些订单行值得看」,不负责「哪些算异常」</b>
  8. /// (与 <see cref="S8PurchaseDeliveryDataProvider"/> 同一条纪律):
  9. /// 过滤只到「有 ETA + 单据还活着 + 身份完整」为止,是否延期一律交给 evaluator 判。</para>
  10. ///
  11. /// <para><b>⚠️ 绝不要把时间条件或延期条件加回 WHERE。</b>
  12. /// S8 的恢复判定唯一依据是「dedup_key 还在不在本轮 hits 里」
  13. /// (<c>S8WatchSchedulerService.ReconcileRecoveriesForRuleAsync</c>),
  14. /// 它<b>无法区分</b>「这一行不在结果集里」与「这一行还在但不再越界」。
  15. /// 因此任何被 SQL 滤掉的行都等价于「风险已解除」:
  16. /// <list type="bullet">
  17. /// <item>加回 <c>plan_date &gt;= NOW()</c> → 订单跨过客户交期时行消失 →
  18. /// 风险<b>兑现</b>的那一刻异常被标成「已恢复」。这正是 2026-09-07 实测复现并修掉的缺陷
  19. /// (真库两条命中,跨期后数据集返回 0 行,而 ETA 一天未改、仍晚于交期)。</item>
  20. /// <item>加回 <c>sys_capacity_date &gt; plan_date</c> → ETA 修回交期以内时行同样消失,
  21. /// 恢复结论虽然碰巧正确,理由却是错的;下次判据变化就不成立了。</item>
  22. /// </list>
  23. /// 「只在到期前立案」这条业务约束由规则定义的
  24. /// <c>CreateOnlyBeforeDueAt</c> 表达,<b>不在这里</b>。</para>
  25. ///
  26. /// <para><b>不做任何日期加减补偿</b>:不加 +24h、不补 23:59:59、不提前 N 天。
  27. /// 那些都是在没有业务依据的情况下改判据。</para>
  28. /// </summary>
  29. public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
  30. {
  31. internal const string ExpectedSourceObjectType = "SALES_ORDER_LINE";
  32. /// <summary>与 <c>S8PurchaseDeliveryDataProvider</c> 同一口径:dedup_key 列宽上限。</summary>
  33. internal const int DedupKeyMaxLength = 128;
  34. /// <summary>
  35. /// 取数 SQL。
  36. ///
  37. /// <para><c>DelayDays</c> 就是 <c>measured_value</c>:系统建议交期比客户要求交期晚几天。
  38. /// 用 <c>DATEDIFF</c> 而不是时间戳差 —— 两个字段在业务上都是「哪一天交」,
  39. /// 精确到秒的差值没有业务含义,反而会让 12.0 天和 11.6 天表现不同。</para>
  40. ///
  41. /// <para>结果集 = <b>全部有 ETA 且单据还活着的订单行</b>,含已过交期的。
  42. /// 其中 <c>DelayDays &lt;= 0</c> 的行由 evaluator 判为不命中 —— 它们必须留在结果集里,
  43. /// 「行还在但不越界」才是这条规则唯一合法的恢复形态。</para>
  44. /// </summary>
  45. internal const string Sql =
  46. """
  47. SELECT
  48. CONCAT(e.bill_no, '#', e.entry_seq) AS SourceObjectId,
  49. e.bill_no AS RelatedObjectCode,
  50. COALESCE(NULLIF(o.custom_name, ''), o.custom_no) AS RelatedObjectName,
  51. e.plan_date AS DueAt,
  52. DATEDIFF(e.sys_capacity_date, e.plan_date) AS DelayDays,
  53. e.bill_no AS BillNo,
  54. e.entry_seq AS EntrySeq,
  55. e.plan_date AS PlanDate,
  56. e.sys_capacity_date AS SysCapacityDate,
  57. e.qty AS Qty,
  58. e.item_number AS ItemNumber,
  59. e.item_name AS ItemName,
  60. o.custom_no AS CustomNo,
  61. o.custom_name AS CustomName,
  62. o.bill_no AS OrderBillNo
  63. FROM crm_seorderentry e
  64. JOIN crm_seorder o ON o.Id = e.seorder_id
  65. WHERE e.tenant_id = @TenantId
  66. AND e.sys_capacity_date IS NOT NULL
  67. AND e.plan_date IS NOT NULL
  68. AND o.closed = 0
  69. AND o.IsDeleted = 0
  70. AND e.IsDeleted = 0
  71. AND e.bill_no <> ''
  72. AND e.entry_seq IS NOT NULL
  73. """;
  74. private readonly ISqlSugarClient _db;
  75. private readonly ILogger<S8OrderDeliveryDataProvider> _logger;
  76. public S8OrderDeliveryDataProvider(
  77. ISqlSugarClient db,
  78. ILogger<S8OrderDeliveryDataProvider> logger)
  79. {
  80. _db = db;
  81. _logger = logger;
  82. }
  83. public string DatasetCode => S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode;
  84. public S8DatasetCapabilities Capabilities =>
  85. S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCapabilities;
  86. public async Task<S8MonitoringRowSet> LoadAsync(
  87. S8MonitoringDataRequest request,
  88. CancellationToken cancellationToken = default)
  89. {
  90. request.EnsureValid();
  91. var rows = await _db.Ado.SqlQueryAsync<OrderDeliveryRow>(
  92. Sql,
  93. new SugarParameter[] { new("@TenantId", request.TenantId) });
  94. var budget = ResolveSourceObjectIdBudget(request);
  95. var mapped = new List<S8MonitoringRow>(rows.Count);
  96. var skipped = 0;
  97. foreach (var row in rows)
  98. {
  99. var sourceObjectId = row.SourceObjectId ?? string.Empty;
  100. if (sourceObjectId.Length > budget)
  101. {
  102. // 超长的 source_object_id 会让 dedup_key 被截断 —— 截断后两个不同订单行
  103. // 可能算出同一个键,于是第二行永远建不出异常且没有任何报错。
  104. // 宁可跳过并留证,也不要制造一个静默丢单的去重键。
  105. skipped++;
  106. _logger.LogWarning(
  107. "s8_order_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
  108. "soid={Soid} length={Length} budget={Budget}",
  109. request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
  110. continue;
  111. }
  112. mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
  113. }
  114. _logger.LogDebug(
  115. "s8_order_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
  116. request.RuleCode, request.TenantId, mapped.Count, skipped);
  117. return S8MonitoringRowSet.FromRows(mapped);
  118. }
  119. /// <summary>dedup_key 前缀之外还能留给 source_object_id 的长度。与采购交付 Provider 同一算法。</summary>
  120. internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
  121. {
  122. var prefixLength =
  123. $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
  124. return Math.Max(0, DedupKeyMaxLength - prefixLength);
  125. }
  126. /// <summary>
  127. /// 行 → canonical 列 + 展示字段。
  128. /// <para>canonical 的 <c>measured_value</c> 是 <c>DelayDays</c>;
  129. /// <c>due_at</c> 放客户要求交期,只用于展示与排序,<b>不参与判定</b>。</para>
  130. /// </summary>
  131. internal static Dictionary<string, object> ToCanonicalValues(OrderDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
  132. {
  133. [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
  134. [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
  135. [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
  136. [S8CanonicalColumns.DueAt] = row.DueAt,
  137. [S8CanonicalColumns.MeasuredValue] = row.DelayDays,
  138. ["bill_no"] = row.BillNo,
  139. ["entry_seq"] = row.EntrySeq,
  140. ["plan_date"] = row.PlanDate,
  141. ["sys_capacity_date"] = row.SysCapacityDate,
  142. ["delay_days"] = row.DelayDays,
  143. ["qty"] = row.Qty,
  144. ["item_number"] = row.ItemNumber,
  145. ["item_name"] = row.ItemName,
  146. ["custom_no"] = row.CustomNo,
  147. ["custom_name"] = row.CustomName,
  148. ["order_bill_no"] = row.OrderBillNo
  149. };
  150. /// <summary>SQL 投影行。字段名与 SELECT 别名一一对应。</summary>
  151. internal sealed class OrderDeliveryRow
  152. {
  153. public string SourceObjectId { get; set; } = string.Empty;
  154. public string RelatedObjectCode { get; set; } = string.Empty;
  155. public string? RelatedObjectName { get; set; }
  156. public DateTime? DueAt { get; set; }
  157. public decimal? DelayDays { get; set; }
  158. public string? BillNo { get; set; }
  159. public int? EntrySeq { get; set; }
  160. public DateTime? PlanDate { get; set; }
  161. public DateTime? SysCapacityDate { get; set; }
  162. public decimal? Qty { get; set; }
  163. public string? ItemNumber { get; set; }
  164. public string? ItemName { get; set; }
  165. public string? CustomNo { get; set; }
  166. public string? CustomName { get; set; }
  167. public string? OrderBillNo { get; set; }
  168. }
  169. }