diff --git a/src/food-market.api/Controllers/Reports/ProfitReportController.cs b/src/food-market.api/Controllers/Reports/ProfitReportController.cs index 4e2e277..81e9aa3 100644 --- a/src/food-market.api/Controllers/Reports/ProfitReportController.cs +++ b/src/food-market.api/Controllers/Reports/ProfitReportController.cs @@ -128,9 +128,10 @@ private static List Group(List 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; diff --git a/src/food-market.api/Controllers/Reports/SalesReportController.cs b/src/food-market.api/Controllers/Reports/SalesReportController.cs index d3c3d32..3cd2297 100644 --- a/src/food-market.api/Controllers/Reports/SalesReportController.cs +++ b/src/food-market.api/Controllers/Reports/SalesReportController.cs @@ -193,9 +193,10 @@ private static List Group(List 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; diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index fa61cbf..e6d6106 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -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)) diff --git a/src/food-market.application/Common/BusinessTime.cs b/src/food-market.application/Common/BusinessTime.cs new file mode 100644 index 0000000..9cbf6a0 --- /dev/null +++ b/src/food-market.application/Common/BusinessTime.cs @@ -0,0 +1,36 @@ +namespace foodmarket.Application.Common; + +/// Бизнес-время организации — Казахстан, Asia/Almaty (UTC+5, без DST). +/// «Сегодня» / «за день» на дашборде, в отчётах и сменах считаем по местному +/// дню, а не по UTC: иначе до 05:00 по Алматы «сегодня» показывало ещё вчерашние +/// цифры (и наоборот после 19:00 UTC данные «уезжали» в завтра). +public static class BusinessTime +{ + /// Смещение Казахстана относительно UTC (единое +5 с 2024 г.). + public static readonly TimeSpan Offset = TimeSpan.FromHours(5); + + /// Текущий момент в местном времени (Kind=Unspecified). + public static DateTime NowLocal => DateTime.UtcNow + Offset; + + /// UTC-инстант местной полуночи сегодня. + public static DateTime StartOfTodayUtc() => StartOfDayUtc(DateTime.UtcNow); + + /// UTC-инстант местной полуночи того дня, в который попадает instant. + public static DateTime StartOfDayUtc(DateTime instantUtc) + { + var localMidnight = (DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date; + return DateTime.SpecifyKind(localMidnight - Offset, DateTimeKind.Utc); + } + + /// UTC-инстант местного начала месяца, в который попадает instant. + 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); + } + + /// Местная дата (без времени) для группировки «по дням» в отчётах. + public static DateTime LocalDate(DateTime instantUtc) => + (DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date; +}