S8OrderDeliveryDataProvider.cs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180
  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. /// 订单行表当前 390 行,其中 385 行早已过期、只有 40 行有 ETA。
  9. /// 把「尚未到期 + 有 ETA + 未关闭」这三个条件留在 SQL 里,
  10. /// evaluator 拿到的就只有真正的候选行,既省一次全表搬运,
  11. /// 也让「为什么这一行没被判」在 SQL 层就能解释清楚。</para>
  12. ///
  13. /// <para><b>时间比较用 <c>NOW()</c>(数据库时区)而不是应用侧 <c>DateTime.Now</c></b>:
  14. /// <c>plan_date</c> 与 <c>sys_capacity_date</c> 都是库里的日期,比较双方应当同源。
  15. /// 混用应用时钟会在跨时区部署时把「今天到期」判成「已过期」,
  16. /// 而这条规则的整个价值恰恰建立在「还没到期」这个前提上。</para>
  17. ///
  18. /// <para><b>不做任何日期加减补偿</b>:不加 +24h、不补 23:59:59、不提前 N 天。
  19. /// 那些都是在没有业务依据的情况下改判据。</para>
  20. /// </summary>
  21. public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
  22. {
  23. internal const string ExpectedSourceObjectType = "SALES_ORDER_LINE";
  24. /// <summary>与 <c>S8PurchaseDeliveryDataProvider</c> 同一口径:dedup_key 列宽上限。</summary>
  25. internal const int DedupKeyMaxLength = 128;
  26. /// <summary>
  27. /// 取数 SQL。
  28. ///
  29. /// <para><c>DelayDays</c> 就是 <c>measured_value</c>:系统建议交期比客户要求交期晚几天。
  30. /// 用 <c>DATEDIFF</c> 而不是时间戳差 —— 两个字段在业务上都是「哪一天交」,
  31. /// 精确到秒的差值没有业务含义,反而会让 12.0 天和 11.6 天表现不同。</para>
  32. ///
  33. /// <para><c>e.plan_date &gt;= NOW()</c> 是本规则与「已逾期」规则的分界线,
  34. /// 也是它敢叫「预警」的唯一依据:客户交期还没到,风险已经算出来了。</para>
  35. /// </summary>
  36. internal const string Sql =
  37. """
  38. SELECT
  39. CONCAT(e.bill_no, '#', e.entry_seq) AS SourceObjectId,
  40. e.bill_no AS RelatedObjectCode,
  41. COALESCE(NULLIF(o.custom_name, ''), o.custom_no) AS RelatedObjectName,
  42. e.plan_date AS DueAt,
  43. DATEDIFF(e.sys_capacity_date, e.plan_date) AS DelayDays,
  44. e.bill_no AS BillNo,
  45. e.entry_seq AS EntrySeq,
  46. e.plan_date AS PlanDate,
  47. e.sys_capacity_date AS SysCapacityDate,
  48. e.qty AS Qty,
  49. e.item_number AS ItemNumber,
  50. e.item_name AS ItemName,
  51. o.custom_no AS CustomNo,
  52. o.custom_name AS CustomName,
  53. o.bill_no AS OrderBillNo
  54. FROM crm_seorderentry e
  55. JOIN crm_seorder o ON o.Id = e.seorder_id
  56. WHERE e.tenant_id = @TenantId
  57. AND e.sys_capacity_date IS NOT NULL
  58. AND e.plan_date IS NOT NULL
  59. AND e.sys_capacity_date > e.plan_date
  60. AND e.plan_date >= NOW()
  61. AND o.closed = 0
  62. AND o.IsDeleted = 0
  63. AND e.IsDeleted = 0
  64. AND e.bill_no <> ''
  65. AND e.entry_seq IS NOT NULL
  66. """;
  67. private readonly ISqlSugarClient _db;
  68. private readonly ILogger<S8OrderDeliveryDataProvider> _logger;
  69. public S8OrderDeliveryDataProvider(
  70. ISqlSugarClient db,
  71. ILogger<S8OrderDeliveryDataProvider> logger)
  72. {
  73. _db = db;
  74. _logger = logger;
  75. }
  76. public string DatasetCode => S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode;
  77. public S8DatasetCapabilities Capabilities =>
  78. S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCapabilities;
  79. public async Task<S8MonitoringRowSet> LoadAsync(
  80. S8MonitoringDataRequest request,
  81. CancellationToken cancellationToken = default)
  82. {
  83. request.EnsureValid();
  84. var rows = await _db.Ado.SqlQueryAsync<OrderDeliveryRow>(
  85. Sql,
  86. new SugarParameter[] { new("@TenantId", request.TenantId) });
  87. var budget = ResolveSourceObjectIdBudget(request);
  88. var mapped = new List<S8MonitoringRow>(rows.Count);
  89. var skipped = 0;
  90. foreach (var row in rows)
  91. {
  92. var sourceObjectId = row.SourceObjectId ?? string.Empty;
  93. if (sourceObjectId.Length > budget)
  94. {
  95. // 超长的 source_object_id 会让 dedup_key 被截断 —— 截断后两个不同订单行
  96. // 可能算出同一个键,于是第二行永远建不出异常且没有任何报错。
  97. // 宁可跳过并留证,也不要制造一个静默丢单的去重键。
  98. skipped++;
  99. _logger.LogWarning(
  100. "s8_order_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
  101. "soid={Soid} length={Length} budget={Budget}",
  102. request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
  103. continue;
  104. }
  105. mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
  106. }
  107. _logger.LogDebug(
  108. "s8_order_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
  109. request.RuleCode, request.TenantId, mapped.Count, skipped);
  110. return S8MonitoringRowSet.FromRows(mapped);
  111. }
  112. /// <summary>dedup_key 前缀之外还能留给 source_object_id 的长度。与采购交付 Provider 同一算法。</summary>
  113. internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
  114. {
  115. var prefixLength =
  116. $"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
  117. return Math.Max(0, DedupKeyMaxLength - prefixLength);
  118. }
  119. /// <summary>
  120. /// 行 → canonical 列 + 展示字段。
  121. /// <para>canonical 的 <c>measured_value</c> 是 <c>DelayDays</c>;
  122. /// <c>due_at</c> 放客户要求交期,只用于展示与排序,<b>不参与判定</b>。</para>
  123. /// </summary>
  124. internal static Dictionary<string, object> ToCanonicalValues(OrderDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
  125. {
  126. [S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
  127. [S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
  128. [S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
  129. [S8CanonicalColumns.DueAt] = row.DueAt,
  130. [S8CanonicalColumns.MeasuredValue] = row.DelayDays,
  131. ["bill_no"] = row.BillNo,
  132. ["entry_seq"] = row.EntrySeq,
  133. ["plan_date"] = row.PlanDate,
  134. ["sys_capacity_date"] = row.SysCapacityDate,
  135. ["delay_days"] = row.DelayDays,
  136. ["qty"] = row.Qty,
  137. ["item_number"] = row.ItemNumber,
  138. ["item_name"] = row.ItemName,
  139. ["custom_no"] = row.CustomNo,
  140. ["custom_name"] = row.CustomName,
  141. ["order_bill_no"] = row.OrderBillNo
  142. };
  143. /// <summary>SQL 投影行。字段名与 SELECT 别名一一对应。</summary>
  144. internal sealed class OrderDeliveryRow
  145. {
  146. public string SourceObjectId { get; set; } = string.Empty;
  147. public string RelatedObjectCode { get; set; } = string.Empty;
  148. public string? RelatedObjectName { get; set; }
  149. public DateTime? DueAt { get; set; }
  150. public decimal? DelayDays { get; set; }
  151. public string? BillNo { get; set; }
  152. public int? EntrySeq { get; set; }
  153. public DateTime? PlanDate { get; set; }
  154. public DateTime? SysCapacityDate { get; set; }
  155. public decimal? Qty { get; set; }
  156. public string? ItemNumber { get; set; }
  157. public string? ItemName { get; set; }
  158. public string? CustomNo { get; set; }
  159. public string? CustomName { get; set; }
  160. public string? OrderBillNo { get; set; }
  161. }
  162. }