food-market/src/food-market.api/Controllers/Reports/SalesReportController.cs
nns 97d5ae5eb0
Some checks are pending
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
fix(reports): 3 фикса по итогам stage-тестирования
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>
2026-05-29 17:35:31 +05:00

221 lines
10 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.</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(),
};
}