Some checks are pending
1. **DateTime Kind=Unspecified → UTC** в ResolveRange / AsUtc. ASP.NET парсит 'from=2026-05-29' с Kind=Unspecified, Npgsql 8 отказывается слать такие в timestamp with time zone (500). Принудительно конвертим Unspecified→UTC (трактуем как полночь UTC), Local→ToUniversalTime. Применено к Sales/Profit/ABC/Stock. 2. **Enter.Post теперь пересчитывает Product.Cost** по той же формуле скользящего среднего что Supply.Post. Без этого товары, попавшие в систему через Оприходование (а не через Supply), имели Cost=0 — Profit/ABC-отчёты показывали cost=0 и неверную маржу. Воспроизведение: Enter 100@30 + RetailSale 10@500 → Profit-отчёт показывал revenue=5000, cost=0 (должно cost=300). 3. **ABC report: Парето-граница по cumBefore (а не cumAfter).** Единственный товар с cumShare=100% валился в класс C, хотя полностью покрывает Парето — должен быть A. Чиним: товар принадлежит классу A если он нужен чтобы пересечь порог 80% (cumBefore < 80%). Стандартный Парето-алгоритм. stage-reports (8 шагов): Sales/Stock/Profit/ABC + CSV/XLSX export + edge — все зелёные. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
221 lines
10 KiB
C#
221 lines
10 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.</summary>
|
||
private async Task<List<FlatRow>> 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;
|
||
}
|
||
|
||
/// <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":
|
||
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(),
|
||
};
|
||
}
|