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(); } }