namespace Admin.NET.Plugin.AiDOP.Infrastructure.S8;
///
/// S8-DASHBOARD-P2-SEMANTICS-1:日粒度趋势的「桶枚举」。
///
/// 要解决的问题:趋势按 CreatedAt.Date(零点)分组,而
/// 的 last_24h 返回的是 [Now-24h, Now)
/// 这种非零点窗口。原实现直接拿 from.AddDays(i) 当桶键去查分组字典,
/// 键上带着 17:25:46,与零点键永远对不上 ⇒ 窗口内明明有行,峰值 / 均值 / 今日
/// 却全是 0。
///
/// 本类确立的口径:桶 = 查询窗口所跨越的自然日。
/// 窗口非零点对齐时,首尾桶承载的是「该自然日落在窗口内的那一段」,
/// 因此 Σ桶 == 窗口内行数 恒成立——这是趋势图与任何独立 SQL 核对的唯一不变量。
///
/// 窗口只收紧、不放宽:桶对齐绝不把查询范围撑大,否则会把窗口外的行算进来。
/// 桶数超过上限时保留最近的 N 天并同步收紧查询窗口(而不是砍掉尾巴、把已查出的行
/// 静默丢弃),并通过 自报。
///
public static class S8TrendBucketHelper
{
/// 日桶上限。与既有 period 分支一致(季度最长 92 天,超出部分保留最近 90 天)。
public const int MaxBuckets = 90;
///
/// 桶枚举结果。
///
/// 实际应下发到 SQL 的窗口起点(含)。
/// 实际应下发到 SQL 的窗口终点(不含)。
/// 零点对齐的桶键,升序,与分组字典的 DateTime.Date 键同构。
/// 首桶或末桶只承载了该自然日的一部分(如 last_24h)。
/// 为 true 时「今日 vs 昨日」环比不可比,调用方应置 null。
/// 原窗口跨越的自然日超过上限,已保留最近 N 天。
public sealed record DayBucketPlan(
DateTime QueryFrom,
DateTime QueryToExclusive,
IReadOnlyList Buckets,
bool HasPartialEdge,
bool IsTruncated);
///
/// 把任意半开窗口 [from, toExclusive) 映射为日桶计划。
///
public static DayBucketPlan Resolve(DateTime from, DateTime toExclusive, int maxBuckets = MaxBuckets)
{
if (maxBuckets < 1) maxBuckets = 1;
// 退化窗口(倒挂或零长)兜底为「from 所在自然日」的 1 个桶,不抛异常。
if (toExclusive <= from) toExclusive = from.AddTicks(1);
var firstDay = from.Date;
var lastDay = toExclusive.AddTicks(-1).Date; // 半开区间右端所属的最后一个自然日
var span = (int)(lastDay - firstDay).TotalDays + 1;
var truncated = span > maxBuckets;
if (truncated)
{
firstDay = lastDay.AddDays(-(maxBuckets - 1));
span = maxBuckets;
}
var bucketEnd = lastDay.AddDays(1);
var queryFrom = from > firstDay ? from : firstDay;
var queryTo = toExclusive < bucketEnd ? toExclusive : bucketEnd;
var buckets = new List(span);
for (var i = 0; i < span; i++) buckets.Add(firstDay.AddDays(i));
var hasPartialEdge = queryFrom != firstDay || queryTo != bucketEnd;
return new DayBucketPlan(queryFrom, queryTo, buckets, hasPartialEdge, truncated);
}
}