using Microsoft.Extensions.Logging;
using SqlSugar;
namespace Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers;
///
/// S8-RULE02-ORDER-DELIVERY-WARNING-1:S1 订单交付风险取数。
///
/// 命中判据全部下推到 SQL(而不是取全量再在内存里筛):
/// 订单行表当前 390 行,其中 385 行早已过期、只有 40 行有 ETA。
/// 把「尚未到期 + 有 ETA + 未关闭」这三个条件留在 SQL 里,
/// evaluator 拿到的就只有真正的候选行,既省一次全表搬运,
/// 也让「为什么这一行没被判」在 SQL 层就能解释清楚。
///
/// 时间比较用 NOW()(数据库时区)而不是应用侧 DateTime.Now:
/// plan_date 与 sys_capacity_date 都是库里的日期,比较双方应当同源。
/// 混用应用时钟会在跨时区部署时把「今天到期」判成「已过期」,
/// 而这条规则的整个价值恰恰建立在「还没到期」这个前提上。
///
/// 不做任何日期加减补偿:不加 +24h、不补 23:59:59、不提前 N 天。
/// 那些都是在没有业务依据的情况下改判据。
///
public sealed class S8OrderDeliveryDataProvider : IS8MonitoringDataProvider
{
internal const string ExpectedSourceObjectType = "SALES_ORDER_LINE";
/// 与 S8PurchaseDeliveryDataProvider 同一口径:dedup_key 列宽上限。
internal const int DedupKeyMaxLength = 128;
///
/// 取数 SQL。
///
/// DelayDays 就是 measured_value:系统建议交期比客户要求交期晚几天。
/// 用 DATEDIFF 而不是时间戳差 —— 两个字段在业务上都是「哪一天交」,
/// 精确到秒的差值没有业务含义,反而会让 12.0 天和 11.6 天表现不同。
///
/// e.plan_date >= NOW() 是本规则与「已逾期」规则的分界线,
/// 也是它敢叫「预警」的唯一依据:客户交期还没到,风险已经算出来了。
///
internal const string Sql =
"""
SELECT
CONCAT(e.bill_no, '#', e.entry_seq) AS SourceObjectId,
e.bill_no AS RelatedObjectCode,
COALESCE(NULLIF(o.custom_name, ''), o.custom_no) AS RelatedObjectName,
e.plan_date AS DueAt,
DATEDIFF(e.sys_capacity_date, e.plan_date) AS DelayDays,
e.bill_no AS BillNo,
e.entry_seq AS EntrySeq,
e.plan_date AS PlanDate,
e.sys_capacity_date AS SysCapacityDate,
e.qty AS Qty,
e.item_number AS ItemNumber,
e.item_name AS ItemName,
o.custom_no AS CustomNo,
o.custom_name AS CustomName,
o.bill_no AS OrderBillNo
FROM crm_seorderentry e
JOIN crm_seorder o ON o.Id = e.seorder_id
WHERE e.tenant_id = @TenantId
AND e.sys_capacity_date IS NOT NULL
AND e.plan_date IS NOT NULL
AND e.sys_capacity_date > e.plan_date
AND e.plan_date >= NOW()
AND o.closed = 0
AND o.IsDeleted = 0
AND e.IsDeleted = 0
AND e.bill_no <> ''
AND e.entry_seq IS NOT NULL
""";
private readonly ISqlSugarClient _db;
private readonly ILogger _logger;
public S8OrderDeliveryDataProvider(
ISqlSugarClient db,
ILogger logger)
{
_db = db;
_logger = logger;
}
public string DatasetCode => S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCode;
public S8DatasetCapabilities Capabilities =>
S8OrderDeliveryDatasetDefinitions.OrderDeliveryRiskCapabilities;
public async Task LoadAsync(
S8MonitoringDataRequest request,
CancellationToken cancellationToken = default)
{
request.EnsureValid();
var rows = await _db.Ado.SqlQueryAsync(
Sql,
new SugarParameter[] { new("@TenantId", request.TenantId) });
var budget = ResolveSourceObjectIdBudget(request);
var mapped = new List(rows.Count);
var skipped = 0;
foreach (var row in rows)
{
var sourceObjectId = row.SourceObjectId ?? string.Empty;
if (sourceObjectId.Length > budget)
{
// 超长的 source_object_id 会让 dedup_key 被截断 —— 截断后两个不同订单行
// 可能算出同一个键,于是第二行永远建不出异常且没有任何报错。
// 宁可跳过并留证,也不要制造一个静默丢单的去重键。
skipped++;
_logger.LogWarning(
"s8_order_delivery_soid_over_dedup_budget rule={RuleCode} tenant={Tenant} " +
"soid={Soid} length={Length} budget={Budget}",
request.RuleCode, request.TenantId, sourceObjectId, sourceObjectId.Length, budget);
continue;
}
mapped.Add(S8MonitoringRow.FromValues(ToCanonicalValues(row)));
}
_logger.LogDebug(
"s8_order_delivery_loaded rule={RuleCode} tenant={Tenant} rows={Rows} skipped={Skipped}",
request.RuleCode, request.TenantId, mapped.Count, skipped);
return S8MonitoringRowSet.FromRows(mapped);
}
/// dedup_key 前缀之外还能留给 source_object_id 的长度。与采购交付 Provider 同一算法。
internal static int ResolveSourceObjectIdBudget(S8MonitoringDataRequest request)
{
var prefixLength =
$"T{request.TenantId}:R{request.RuleCode}:{ExpectedSourceObjectType}:".Length;
return Math.Max(0, DedupKeyMaxLength - prefixLength);
}
///
/// 行 → canonical 列 + 展示字段。
/// canonical 的 measured_value 是 DelayDays;
/// due_at 放客户要求交期,只用于展示与排序,不参与判定。
///
internal static Dictionary ToCanonicalValues(OrderDeliveryRow row) => new(StringComparer.OrdinalIgnoreCase)
{
[S8CanonicalColumns.SourceObjectId] = row.SourceObjectId,
[S8CanonicalColumns.RelatedObjectCode] = row.RelatedObjectCode,
[S8CanonicalColumns.RelatedObjectName] = row.RelatedObjectName,
[S8CanonicalColumns.DueAt] = row.DueAt,
[S8CanonicalColumns.MeasuredValue] = row.DelayDays,
["bill_no"] = row.BillNo,
["entry_seq"] = row.EntrySeq,
["plan_date"] = row.PlanDate,
["sys_capacity_date"] = row.SysCapacityDate,
["delay_days"] = row.DelayDays,
["qty"] = row.Qty,
["item_number"] = row.ItemNumber,
["item_name"] = row.ItemName,
["custom_no"] = row.CustomNo,
["custom_name"] = row.CustomName,
["order_bill_no"] = row.OrderBillNo
};
/// SQL 投影行。字段名与 SELECT 别名一一对应。
internal sealed class OrderDeliveryRow
{
public string SourceObjectId { get; set; } = string.Empty;
public string RelatedObjectCode { get; set; } = string.Empty;
public string? RelatedObjectName { get; set; }
public DateTime? DueAt { get; set; }
public decimal? DelayDays { get; set; }
public string? BillNo { get; set; }
public int? EntrySeq { get; set; }
public DateTime? PlanDate { get; set; }
public DateTime? SysCapacityDate { get; set; }
public decimal? Qty { get; set; }
public string? ItemNumber { get; set; }
public string? ItemName { get; set; }
public string? CustomNo { get; set; }
public string? CustomName { get; set; }
public string? OrderBillNo { get; set; }
}
}