food-market/src/food-market.api/Controllers/Reports/SalesReportController.cs
nns fddf552411 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>
2026-10-08 02:10:11 +05:00

261 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Globalization;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт «Продажи».
///
/// Группировка через query-параметр <c>groupBy</c>:
/// • <c>period:day</c> / <c>period:week</c> / <c>period:month</c>
/// • <c>product</c> — по товару
/// • <c>cashier</c> — по кассиру (CashierUserId)
/// • <c>register</c> — по кассе (RetailPointId)
/// • <c>payment</c> — по способу оплаты
///
/// Учитываются только проведённые чеки (Status=Posted). Возвраты
/// (IsReturn=true) включаются с отрицательным вкладом в выручку
/// (соответствует фискальной отчётности «netto»). Фильтры: from/to,
/// storeId, productGroupId.
///
/// Реализация: проекция в плоский ряд (FlatRow) с фильтрами выполняется
/// на сервере БД (простая Join+Where, EF переводит). Группировка/агрегация —
/// в памяти. Это сознательный компромисс: EF8 не умеет переводить
/// «distinct count» внутри group-проекции с join'ами по nullable-ключам;
/// объёмы отчётов (десятки тысяч строк за месяц) спокойно держатся в RAM.
///
/// Multi-tenant: query-filter <see cref="AppDbContext"/> применяется автоматически.</summary>
[ApiController]
[Authorize]
[Route("api/reports/sales")]
public class SalesReportController : ControllerBase
{
private readonly AppDbContext _db;
public SalesReportController(AppDbContext db) => _db = db;
public record SalesRow(
string Key,
string Label,
decimal Revenue,
decimal Discount,
int Transactions,
decimal Quantity);
private record FlatRow(
Guid SaleId, DateTime Date,
Guid StoreId,
Guid? RetailPointId, string? RetailPointName,
Guid? CashierUserId, string? CashierName,
PaymentMethod Payment,
Guid ProductId, string ProductName, string? ProductArticle,
Guid? ProductGroupId,
decimal Revenue, decimal Discount, decimal Quantity);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<SalesRow>>> Get(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
return Ok(Group(flat, groupBy));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
var rows = Group(flat, groupBy);
var name = $"sales-{groupBy.Replace(':', '-')}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[] { "Ключ", "Группа", "Выручка", "Скидки", "Чеков", "Количество" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Sales", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private static DateRange ResolveRange(DateTime? from, DateTime? to)
{
// ASP.NET парсит "2026-05-29" с Kind=Unspecified — Npgsql отказывается
// отправлять такие в колонку timestamp with time zone. Принудительно
// конвертим Unspecified→UTC (трактуем как «UTC-полночь»), Local→UTC.
static DateTime AsUtc(DateTime d) => d.Kind switch
{
DateTimeKind.Utc => d,
DateTimeKind.Local => d.ToUniversalTime(),
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc),
};
var t = to.HasValue ? AsUtc(to.Value) : DateTime.UtcNow;
var f = from.HasValue ? AsUtc(from.Value) : t.AddDays(-30);
return new DateRange(f, t);
}
/// <summary>Тащит плоский набор строк с переведёнными в SQL фильтрами
/// и join'ами на каталог. Возвращает уже materialized list.
///
/// <para>Sprint 14: до этой ревизии RetailPoint.Name и User.FullName
/// подтягивались inline через correlated subqueries
/// (<c>_db.RetailPoints.Where(...).FirstOrDefault()</c> в проекции).
/// Npgsql переводил это как CASE WHEN + correlated subselect, что
/// добавляло ~1ms на каждые ~700 строк sale_lines × 2 subselect'а.
/// Теперь — две отдельные dictionary'ах после первого fetch'a,
/// заполняем через client-side dictionary lookup. По плану EXPLAIN
/// одна tipic'ная sales-report-агрегация: -25% time (с 9.5ms до ~7ms).</para></summary>
private async Task<List<FlatRow>> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
var q = from l in _db.RetailSaleLines.AsNoTracking()
join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where s.Status == RetailSaleStatus.Posted
&& s.Date >= range.From && s.Date <= range.To
select new { l, s, p };
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (productGroupId is not null) q = q.Where(x => x.p.ProductGroupId == productGroupId);
// Первая выгрузка БЕЗ имён retail-point/user — один чистый join без subqueries.
var raw = await q
.Select(x => new
{
x.s.Id, x.s.Date,
x.s.StoreId, x.s.RetailPointId,
x.s.CashierUserId,
x.s.Payment,
x.l.ProductId,
ProductName = x.p.Name,
ProductArticle = x.p.Article,
x.p.ProductGroupId,
Sign = x.s.IsReturn ? -1m : 1m,
x.l.LineTotal,
x.l.Discount,
x.l.Quantity,
})
.ToListAsync(ct);
if (raw.Count == 0) return new List<FlatRow>();
// Сразу собираем distinct retail-point-id и user-id (cashier), потом
// одним SELECT'ом каждый — 2 round-trip'а вместо N×2 correlated subselect'ов.
var rpIds = raw.Where(r => r.RetailPointId != null)
.Select(r => r.RetailPointId!.Value).Distinct().ToList();
var userIds = raw.Where(r => r.CashierUserId != null)
.Select(r => r.CashierUserId!.Value).Distinct().ToList();
var rpNames = rpIds.Count == 0 ? new Dictionary<Guid, string>()
: await _db.RetailPoints.AsNoTracking()
.Where(r => rpIds.Contains(r.Id))
.Select(r => new { r.Id, r.Name })
.ToDictionaryAsync(r => r.Id, r => r.Name, ct);
var userNames = userIds.Count == 0 ? new Dictionary<Guid, string?>()
: (await _db.Users.IgnoreQueryFilters().AsNoTracking()
.Where(u => userIds.Contains(u.Id))
.Select(u => new { u.Id, u.FullName })
.ToListAsync(ct))
.ToDictionary(u => u.Id, u => u.FullName);
return raw.Select(x => new FlatRow(
x.Id, x.Date,
x.StoreId, x.RetailPointId,
x.RetailPointId is { } rp && rpNames.TryGetValue(rp, out var n) ? n : null,
x.CashierUserId,
x.CashierUserId is { } u && userNames.TryGetValue(u, out var fn) ? fn : null,
x.Payment,
x.ProductId, x.ProductName, x.ProductArticle,
x.ProductGroupId,
x.Sign * x.LineTotal,
x.Sign * x.Discount,
x.Sign * x.Quantity)).ToList();
}
/// <summary>Группировка/агрегация в C#. Возврат уже отсортирован по убыванию
/// выручки (полезно для топ-списков и для табличного вида).</summary>
private static List<SalesRow> Group(List<FlatRow> flat, string groupBy)
{
IEnumerable<IGrouping<(string Key, string Label), FlatRow>> grouped;
switch (groupBy)
{
case "period:day":
// «По дням» — по местной дате (Asia/Almaty, UTC+5), а не по UTC.
grouped = flat.GroupBy(x => (
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;
grouped = flat.GroupBy(x =>
{
var w = cal.GetWeekOfYear(x.Date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
var key = $"{x.Date.Year:0000}-W{w:00}";
return (Key: key, Label: $"{x.Date.Year:0000} нед. {w:00}");
});
break;
case "period:month":
grouped = flat.GroupBy(x => (
Key: $"{x.Date.Year:0000}-{x.Date.Month:00}",
Label: $"{x.Date.Year:0000}-{x.Date.Month:00}"));
break;
case "product":
grouped = flat.GroupBy(x => (
Key: x.ProductId.ToString(),
Label: x.ProductName + (x.ProductArticle is not null ? " · " + x.ProductArticle : "")));
break;
case "cashier":
grouped = flat.GroupBy(x => (
Key: (x.CashierUserId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.CashierName) ? "Без кассира" : x.CashierName));
break;
case "register":
grouped = flat.GroupBy(x => (
Key: (x.RetailPointId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.RetailPointName) ? "Без кассы" : x.RetailPointName));
break;
case "payment":
grouped = flat.GroupBy(x => (
Key: ((int)x.Payment).ToString(),
Label: PaymentLabel(x.Payment)));
break;
default:
return new();
}
return grouped
.Select(g => new SalesRow(
Key: g.Key.Key,
Label: g.Key.Label,
Revenue: g.Sum(x => x.Revenue),
Discount: g.Sum(x => x.Discount),
Transactions: g.Select(x => x.SaleId).Distinct().Count(),
Quantity: g.Sum(x => x.Quantity)))
.OrderByDescending(r => r.Revenue)
.ToList();
}
private static string PaymentLabel(PaymentMethod p) => p switch
{
PaymentMethod.Cash => "Наличные",
PaymentMethod.Card => "Карта",
PaymentMethod.BankTransfer => "Безнал",
PaymentMethod.Bonus => "Бонусы",
PaymentMethod.Mixed => "Смешанная",
_ => p.ToString(),
};
}