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:
parent
009a9d8a50
commit
fddf552411
|
|
@ -128,9 +128,10 @@ private static List<ProfitRow> Group(List<FlatRow> flat, string groupBy)
|
|||
switch (groupBy)
|
||||
{
|
||||
case "period:day":
|
||||
// «По дням» — по местной дате (Asia/Almaty, UTC+5).
|
||||
grouped = flat.GroupBy(x => (
|
||||
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
Key: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
case "period:week":
|
||||
var cal = CultureInfo.InvariantCulture.Calendar;
|
||||
|
|
|
|||
|
|
@ -193,9 +193,10 @@ private static List<SalesRow> Group(List<FlatRow> flat, string groupBy)
|
|||
switch (groupBy)
|
||||
{
|
||||
case "period:day":
|
||||
// «По дням» — по местной дате (Asia/Almaty, UTC+5), а не по UTC.
|
||||
grouped = flat.GroupBy(x => (
|
||||
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
Key: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
Label: foodmarket.Application.Common.BusinessTime.LocalDate(x.Date).ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
|
||||
break;
|
||||
case "period:week":
|
||||
var cal = CultureInfo.InvariantCulture.Calendar;
|
||||
|
|
|
|||
|
|
@ -118,14 +118,15 @@ public record SalesStatsResponse(
|
|||
[FromQuery] int days = 30,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// «Сегодня/неделя/месяц» — по местному времени (Asia/Almaty, UTC+5), а не UTC.
|
||||
var nowUtc = DateTime.UtcNow;
|
||||
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||
var prevMonthStart = monthStart.AddMonths(-1);
|
||||
var todayStart = foodmarket.Application.Common.BusinessTime.StartOfTodayUtc();
|
||||
var monthStart = foodmarket.Application.Common.BusinessTime.StartOfMonthUtc(nowUtc);
|
||||
var prevMonthStart = foodmarket.Application.Common.BusinessTime.StartOfMonthUtc(monthStart.AddDays(-1));
|
||||
var seriesStart = todayStart.AddDays(-(days - 1));
|
||||
// Неделя: начиная с понедельника текущей UTC-недели. Без сложной локали —
|
||||
// на дашборде это «скользящие 7 дней» в первом приближении.
|
||||
var weekStart = todayStart.AddDays(-(((int)todayStart.DayOfWeek + 6) % 7));
|
||||
// Неделя: с понедельника текущей МЕСТНОЙ недели.
|
||||
var localDow = foodmarket.Application.Common.BusinessTime.NowLocal.DayOfWeek;
|
||||
var weekStart = todayStart.AddDays(-(((int)localDow + 6) % 7));
|
||||
|
||||
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) })
|
||||
.FirstOrDefaultAsync(ct);
|
||||
|
||||
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
|
||||
.GroupBy(s => s.Date.Date)
|
||||
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
|
||||
// Группируем «по дням» по МЕСТНОЙ дате (Almaty) — считаем в памяти, чтобы
|
||||
// не упираться в перевод AddHours(5).Date в SQL.
|
||||
var rawRows = await posted.Where(s => s.Date >= seriesStart)
|
||||
.Select(s => new { s.Date, s.Total })
|
||||
.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.
|
||||
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
|
||||
var startLocalDate = foodmarket.Application.Common.BusinessTime.LocalDate(seriesStart);
|
||||
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)
|
||||
? new SalesStatsBucket(d, v.Revenue, v.Tx)
|
||||
: new SalesStatsBucket(d, 0m, 0))
|
||||
|
|
|
|||
36
src/food-market.application/Common/BusinessTime.cs
Normal file
36
src/food-market.application/Common/BusinessTime.cs
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue