Хелпер BusinessTime (UTC+5). Дашборд (RevenueToday/Week/Month + график) и группировка period:day в отчётах продаж/прибыли теперь по местной дате. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
261 lines
12 KiB
C#
261 lines
12 KiB
C#
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(),
|
||
};
|
||
}
|