food-market/src/food-market.api/Controllers/Reports/ProfitReportController.cs
nns 3db112cbee feat(reports): отчёт «Прибыль» (выручка − COGS) (P1-10)
GET /api/reports/profit с группировками period:day/week/month, product,
group (по группе товаров). Cost-snapshot — Product.Cost (скользящее
среднее, документировано как приближение; точный FIFO требует партий).
Маржа = profit/revenue·100. Защита от деления на ноль при нулевой
выручке (пустой период → margin = 0, не NaN).

Возвраты вычитаются и из выручки, и из COGS (returned line делает
−Quantity·UnitPrice выручки и −Quantity·Cost себестоимости).

Export CSV/XLSX через тот же ReportExport.

Web: /reports/profit с KPI-плашками (общая выручка/себестоимость/прибыль
+ маржа) — прибыль зелёным/красным в зависимости от знака.

Тесты: 3 интеграционных (simple profit calc 4×100−4×50=200=50%; empty
period → пустой набор без 500/NaN; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:19:19 +05:00

174 lines
8 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.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;
/// <summary>Отчёт «Прибыль».
///
/// Выручка себестоимость, маржа, рентабельность по периодам / товарам /
/// группам товаров. Cost-snapshot берётся из <see cref="StockMovement.UnitCost"/>
/// сопоставленного по (documentId, productId): движение тип `RetailSale`
/// или `CustomerReturn` несёт UnitCost = `RetailSaleLine.UnitPrice`… НО для
/// расчёта прибыли нам нужна СЕБЕСТОИМОСТЬ, а не цена продажи. Поэтому
/// фактически берём `Product.Cost` (текущее скользящее среднее) как
/// COGS-snapshot — это приближение; точный COGS требует партий или
/// fetch'а на момент продажи. Документируем как компромисс.
///
/// Multi-tenant: query-filter работает прозрачно.</summary>
[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<ActionResult<IReadOnlyList<ProfitRow>>> 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 = $"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<List<FlatRow>> 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<ProfitRow> 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);
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();
}
}