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.
private async Task> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
// Сначала список саleId с фильтрами по чеку (period/store/return-знак).
// Затем JOIN на линии и на каталог. У EF8 эта форма успешно переводится в SQL.
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);
// Левые join'ы на RetailPoints и Users — для имён. Подтаскиваем имена
// прямо в проекции через .Where + .FirstOrDefault — Npgsql переведёт.
var flat = await q
.Select(x => new FlatRow(
x.s.Id, x.s.Date,
x.s.StoreId,
x.s.RetailPointId,
x.s.RetailPointId == null ? null
: _db.RetailPoints.Where(r => r.Id == x.s.RetailPointId).Select(r => r.Name).FirstOrDefault(),
x.s.CashierUserId,
x.s.CashierUserId == null ? null
: _db.Users.Where(u => u.Id == x.s.CashierUserId).Select(u => u.FullName).FirstOrDefault(),
x.s.Payment,
x.l.ProductId, x.p.Name, x.p.Article,
x.p.ProductGroupId,
x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
x.s.IsReturn ? -x.l.Discount : x.l.Discount,
x.s.IsReturn ? -x.l.Quantity : x.l.Quantity))
.ToListAsync(ct);
return flat;
}
/// Группировка/агрегация в C#. Возврат уже отсортирован по убыванию
/// выручки (полезно для топ-списков и для табличного вида).
private static List Group(List flat, string groupBy)
{
IEnumerable> grouped;
switch (groupBy)
{
case "period:day":
grouped = flat.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: 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(),
};
}