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>
174 lines
8 KiB
C#
174 lines
8 KiB
C#
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();
|
||
}
|
||
}
|