using System.Globalization;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// Отчёт «Прибыль».
///
/// Выручка − себестоимость, маржа, рентабельность по периодам / товарам /
/// группам товаров. Cost-snapshot берётся из
/// сопоставленного по (documentId, productId): движение тип `RetailSale`
/// или `CustomerReturn` несёт UnitCost = `RetailSaleLine.UnitPrice`… НО для
/// расчёта прибыли нам нужна СЕБЕСТОИМОСТЬ, а не цена продажи. Поэтому
/// фактически берём `Product.Cost` (текущее скользящее среднее) как
/// COGS-snapshot — это приближение; точный COGS требует партий или
/// fetch'а на момент продажи. Документируем как компромисс.
///
/// Multi-tenant: query-filter работает прозрачно.
[ApiController]
[Authorize]
[Route("api/reports/profit")]
public class ProfitReportController : ControllerBase
{
private readonly AppDbContext _db;
public ProfitReportController(AppDbContext db) => _db = db;
public record ProfitRow(
string Key, string Label,
decimal Revenue, decimal Cost, decimal Profit,
decimal MarginPercent, decimal Quantity);
private record FlatRow(
Guid SaleId, DateTime Date,
Guid ProductId, string ProductName, string? ProductArticle,
Guid? ProductGroupId, string? ProductGroupName,
decimal Revenue, decimal Cost, 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 = $"profit-{groupBy.Replace(':', '-')}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[] { "Ключ", "Группа", "Выручка", "Себестоимость", "Прибыль", "Маржа,%", "Количество" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Profit", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private static DateRange ResolveRange(DateTime? from, DateTime? to)
{
var t = to ?? DateTime.UtcNow;
var f = from ?? t.AddDays(-30);
return new DateRange(f, t);
}
private async Task> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
// Берём строки проведённых чеков с присоединённым Product (для Cost-snapshot
// и группы) и ProductGroup (для имени). Возврат вычитается из выручки и
// из COGS (продали по цене X с себестоимостью C → returned восстанавливает).
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);
var flat = await q
.Select(x => new FlatRow(
x.s.Id, x.s.Date,
x.l.ProductId, x.p.Name, x.p.Article,
x.p.ProductGroupId,
x.p.ProductGroupId == null ? null
: _db.ProductGroups.Where(g => g.Id == x.p.ProductGroupId).Select(g => g.Name).FirstOrDefault(),
x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
// COGS-snapshot: Quantity × Product.Cost (текущее скользящее среднее).
// Документировано как приближение; точный COGS требует партий.
(x.s.IsReturn ? -x.l.Quantity : x.l.Quantity) * x.p.Cost,
x.s.IsReturn ? -x.l.Quantity : x.l.Quantity))
.ToListAsync(ct);
return flat;
}
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);
return (Key: $"{x.Date.Year:0000}-W{w:00}", 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 "group":
grouped = flat.GroupBy(x => (
Key: (x.ProductGroupId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.ProductGroupName) ? "Без группы" : x.ProductGroupName));
break;
default:
return new();
}
return grouped
.Select(g =>
{
var revenue = g.Sum(x => x.Revenue);
var cost = g.Sum(x => x.Cost);
var profit = revenue - cost;
// Защита от деления на ноль: при нулевой выручке маржа = 0
// (а не NaN/Infinity). Бывает при чисто возвратных периодах,
// когда продажи отсутствуют.
var margin = revenue == 0m ? 0m : Math.Round(profit / revenue * 100m, 2);
return new ProfitRow(
g.Key.Key, g.Key.Label,
revenue, cost, profit, margin,
g.Sum(x => x.Quantity));
})
.OrderByDescending(r => r.Profit)
.ToList();
}
}