fix(reports): «сегодня/за день» считать по Asia/Almaty (UTC+5), а не по UTC

Хелпер BusinessTime (UTC+5). Дашборд (RevenueToday/Week/Month + график) и
группировка period:day в отчётах продаж/прибыли теперь по местной дате.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 02:10:11 +05:00
parent 009a9d8a50
commit fddf552411
4 changed files with 58 additions and 15 deletions

View file

@ -128,9 +128,10 @@ private static List<ProfitRow> Group(List<FlatRow> flat, string groupBy)
switch (groupBy) switch (groupBy)
{ {
case "period:day": case "period:day":
// «По дням» — по местной дате (Asia/Almaty, UTC+5).
grouped = flat.GroupBy(x => ( grouped = flat.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), Key: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))); Label: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
break; break;
case "period:week": case "period:week":
var cal = CultureInfo.InvariantCulture.Calendar; var cal = CultureInfo.InvariantCulture.Calendar;

View file

@ -193,9 +193,10 @@ private static List<SalesRow> Group(List<FlatRow> flat, string groupBy)
switch (groupBy) switch (groupBy)
{ {
case "period:day": case "period:day":
// «По дням» — по местной дате (Asia/Almaty, UTC+5), а не по UTC.
grouped = flat.GroupBy(x => ( grouped = flat.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), Key: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture))); Label: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
break; break;
case "period:week": case "period:week":
var cal = CultureInfo.InvariantCulture.Calendar; var cal = CultureInfo.InvariantCulture.Calendar;

View file

@ -118,14 +118,15 @@ public record SalesStatsResponse(
[FromQuery] int days = 30, [FromQuery] int days = 30,
CancellationToken ct = default) CancellationToken ct = default)
{ {
// «Сегодня/неделя/месяц» — по местному времени (Asia/Almaty, UTC+5), а не UTC.
var nowUtc = DateTime.UtcNow; var nowUtc = DateTime.UtcNow;
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc); var todayStart = foodmarket.Application.Common.BusinessTime.StartOfTodayUtc();
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc); var monthStart = foodmarket.Application.Common.BusinessTime.StartOfMonthUtc(nowUtc);
var prevMonthStart = monthStart.AddMonths(-1); var prevMonthStart = foodmarket.Application.Common.BusinessTime.StartOfMonthUtc(monthStart.AddDays(-1));
var seriesStart = todayStart.AddDays(-(days - 1)); var seriesStart = todayStart.AddDays(-(days - 1));
// Неделя: начиная с понедельника текущей UTC-недели. Без сложной локали — // Неделя: с понедельника текущей МЕСТНОЙ недели.
// на дашборде это «скользящие 7 дней» в первом приближении. var localDow = foodmarket.Application.Common.BusinessTime.NowLocal.DayOfWeek;
var weekStart = todayStart.AddDays(-(((int)todayStart.DayOfWeek + 6) % 7)); var weekStart = todayStart.AddDays(-(((int)localDow + 6) % 7));
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted); var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
@ -149,15 +150,19 @@ public record SalesStatsResponse(
.Select(g => new { Sum = g.Sum(s => s.Total) }) .Select(g => new { Sum = g.Sum(s => s.Total) })
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
var rawSeries = await posted.Where(s => s.Date >= seriesStart) // Группируем «по дням» по МЕСТНОЙ дате (Almaty) — считаем в памяти, чтобы
.GroupBy(s => s.Date.Date) // не упираться в перевод AddHours(5).Date в SQL.
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() }) var rawRows = await posted.Where(s => s.Date >= seriesStart)
.Select(s => new { s.Date, s.Total })
.ToListAsync(ct); .ToListAsync(ct);
var byDay = rawRows
.GroupBy(s => foodmarket.Application.Common.BusinessTime.LocalDate(s.Date))
.ToDictionary(g => g.Key, g => new { Revenue = g.Sum(x => x.Total), Tx = g.Count() });
// Fill missing days with zeros so the chart line is continuous. // Fill missing days with zeros so the chart line is continuous.
var byDay = rawSeries.ToDictionary(x => x.Day, x => x); var startLocalDate = foodmarket.Application.Common.BusinessTime.LocalDate(seriesStart);
var series = Enumerable.Range(0, days) var series = Enumerable.Range(0, days)
.Select(i => seriesStart.AddDays(i).Date) .Select(i => startLocalDate.AddDays(i))
.Select(d => byDay.TryGetValue(d, out var v) .Select(d => byDay.TryGetValue(d, out var v)
? new SalesStatsBucket(d, v.Revenue, v.Tx) ? new SalesStatsBucket(d, v.Revenue, v.Tx)
: new SalesStatsBucket(d, 0m, 0)) : new SalesStatsBucket(d, 0m, 0))

View file

@ -0,0 +1,36 @@
namespace foodmarket.Application.Common;
/// <summary>Бизнес-время организации — Казахстан, Asia/Almaty (UTC+5, без DST).
/// «Сегодня» / «за день» на дашборде, в отчётах и сменах считаем по местному
/// дню, а не по UTC: иначе до 05:00 по Алматы «сегодня» показывало ещё вчерашние
/// цифры (и наоборот после 19:00 UTC данные «уезжали» в завтра).</summary>
public static class BusinessTime
{
/// <summary>Смещение Казахстана относительно UTC (единое +5 с 2024 г.).</summary>
public static readonly TimeSpan Offset = TimeSpan.FromHours(5);
/// <summary>Текущий момент в местном времени (Kind=Unspecified).</summary>
public static DateTime NowLocal => DateTime.UtcNow + Offset;
/// <summary>UTC-инстант местной полуночи сегодня.</summary>
public static DateTime StartOfTodayUtc() => StartOfDayUtc(DateTime.UtcNow);
/// <summary>UTC-инстант местной полуночи того дня, в который попадает instant.</summary>
public static DateTime StartOfDayUtc(DateTime instantUtc)
{
var localMidnight = (DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
return DateTime.SpecifyKind(localMidnight - Offset, DateTimeKind.Utc);
}
/// <summary>UTC-инстант местного начала месяца, в который попадает instant.</summary>
public static DateTime StartOfMonthUtc(DateTime instantUtc)
{
var local = DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset;
var localMonth = new DateTime(local.Year, local.Month, 1, 0, 0, 0, DateTimeKind.Unspecified);
return DateTime.SpecifyKind(localMonth - Offset, DateTimeKind.Utc);
}
/// <summary>Местная дата (без времени) для группировки «по дням» в отчётах.</summary>
public static DateTime LocalDate(DateTime instantUtc) =>
(DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
}