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;
/// Отчёт «Продажи».
///
/// Группировка через query-параметр groupBy:
/// • period:day / period:week / period:month
/// • product — по товару
/// • cashier — по кассиру (CashierUserId)
/// • register — по кассе (RetailPointId)
/// • payment — по способу оплаты
///
/// Учитываются только проведённые чеки (Status=Posted). Возвраты
/// (IsReturn=true) включаются с отрицательным вкладом в выручку
/// (соответствует фискальной отчётности «netto»). Фильтры: from/to,
/// storeId, productGroupId.
///
/// Реализация: проекция в плоский ряд (FlatRow) с фильтрами выполняется
/// на сервере БД (простая Join+Where, EF переводит). Группировка/агрегация —
/// в памяти. Это сознательный компромисс: EF8 не умеет переводить
/// «distinct count» внутри group-проекции с join'ами по nullable-ключам;
/// объёмы отчётов (десятки тысяч строк за месяц) спокойно держатся в RAM.
///
/// Multi-tenant: query-filter применяется автоматически.
[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>> 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 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);
}
/// Тащит плоский набор строк с переведёнными в SQL фильтрами
/// и join'ами на каталог. Возвращает уже materialized list.
///
/// Sprint 14: до этой ревизии RetailPoint.Name и User.FullName
/// подтягивались inline через correlated subqueries
/// (_db.RetailPoints.Where(...).FirstOrDefault() в проекции).
/// 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).
private async Task> 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();
// Сразу собираем 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()
: 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()
: (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();
}
/// Группировка/агрегация в C#. Возврат уже отсортирован по убыванию
/// выручки (полезно для топ-списков и для табличного вида).
private static List Group(List flat, string groupBy)
{
IEnumerable> 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(),
};
}