diff --git a/src/food-market.api/Controllers/Reports/ProfitReportController.cs b/src/food-market.api/Controllers/Reports/ProfitReportController.cs new file mode 100644 index 0000000..ac1f049 --- /dev/null +++ b/src/food-market.api/Controllers/Reports/ProfitReportController.cs @@ -0,0 +1,173 @@ +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(); + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 3ba2680..2f2672e 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -40,6 +40,7 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SalesReportPage } from '@/pages/SalesReportPage' import { StockReportPage } from '@/pages/StockReportPage' +import { ProfitReportPage } from '@/pages/ProfitReportPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' @@ -129,6 +130,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index e724d5e..913410d 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Tag, Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, - Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, } from 'lucide-react' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' @@ -113,6 +113,7 @@ function buildNav(roles: string[]): NavSection[] { sections.push({ group: 'Отчёты', items: [ { to: '/reports/sales', icon: BarChart3, label: 'Продажи' }, { to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' }, + { to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' }, ]}) } diff --git a/src/food-market.web/src/pages/ProfitReportPage.tsx b/src/food-market.web/src/pages/ProfitReportPage.tsx new file mode 100644 index 0000000..e1c1d08 --- /dev/null +++ b/src/food-market.web/src/pages/ProfitReportPage.tsx @@ -0,0 +1,177 @@ +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { Download } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, Select } from '@/components/Field' +import { DateField } from '@/components/DateField' +import { useStores, useProductGroups } from '@/lib/useLookups' +import { useOrgSettings } from '@/lib/useOrgSettings' +import { type ProfitReportRow } from '@/lib/types' + +const GROUPS = [ + { value: 'period:day', label: 'По дням' }, + { value: 'period:week', label: 'По неделям' }, + { value: 'period:month', label: 'По месяцам' }, + { value: 'product', label: 'По товарам' }, + { value: 'group', label: 'По группам товаров' }, +] as const + +export function ProfitReportPage() { + const todayIso = () => new Date().toISOString().slice(0, 10) + const monthAgoIso = () => { + const d = new Date(); d.setDate(d.getDate() - 30) + return d.toISOString().slice(0, 10) + } + + const [from, setFrom] = useState(monthAgoIso()) + const [to, setTo] = useState(todayIso()) + const [groupBy, setGroupBy] = useState('period:day') + const [storeId, setStoreId] = useState('') + const [productGroupId, setProductGroupId] = useState('') + + const stores = useStores() + const groups = useProductGroups() + const org = useOrgSettings() + const fractional = org.data?.allowFractionalPrices ?? false + const moneyFmt = fractional + ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } + : { maximumFractionDigits: 0 } + + const params = (extra: Record = {}) => { + const p = new URLSearchParams({ + from: new Date(from).toISOString(), + to: new Date(`${to}T23:59:59`).toISOString(), + groupBy, + ...extra, + }) + if (storeId) p.set('storeId', storeId) + if (productGroupId) p.set('productGroupId', productGroupId) + return p + } + + const rep = useQuery({ + queryKey: ['profit-report', from, to, groupBy, storeId, productGroupId], + queryFn: async () => (await api.get(`/api/reports/profit?${params()}`)).data, + }) + + const exportFile = async (format: 'csv' | 'xlsx') => { + const resp = await api.get(`/api/reports/profit/export?${params({ format })}`, { responseType: 'blob' }) + const blob = new Blob([resp.data]) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + const cd = resp.headers['content-disposition'] as string | undefined + const match = cd?.match(/filename="?([^";]+)"?/i) + a.download = match?.[1] ?? `profit-report.${format}` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } + + const totalRev = rep.data?.reduce((s, r) => s + r.revenue, 0) ?? 0 + const totalCost = rep.data?.reduce((s, r) => s + r.cost, 0) ?? 0 + const totalProf = totalRev - totalCost + const totalMargin = totalRev === 0 ? 0 : Math.round((totalProf / totalRev) * 10000) / 100 + + return ( +
+
+

Отчёт «Прибыль»

+

+ Выручка − себестоимость = прибыль. COGS-snapshot — Product.Cost + (скользящее среднее на момент запроса; приближённая оценка). +

+
+
+
+
+
+ + setFrom(v ?? todayIso())} /> + + + setTo(v ?? todayIso())} /> + + + + + + + + + + +
+
+
+ + Выручка: {totalRev.toLocaleString('ru', moneyFmt)} + + + Себестоимость: {totalCost.toLocaleString('ru', moneyFmt)} + + = 0 ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}> + Прибыль: {totalProf.toLocaleString('ru', moneyFmt)} + ({totalMargin.toFixed(2)}%) + +
+
+ + +
+
+
+ +
+ {rep.isLoading &&
Загружаю…
} + {!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( +
За период нет данных.
+ )} + {!rep.isLoading && rep.data && rep.data.length > 0 && ( +
+ + + + + + + + + + + + + {rep.data.map((r) => ( + + + + + + + + + ))} + +
ГруппаВыручкаСебест.ПрибыльМаржа,%Кол-во
{r.label}{r.revenue.toLocaleString('ru', moneyFmt)}{r.cost.toLocaleString('ru', moneyFmt)}= 0 ? 'text-green-700' : 'text-red-700'}`}>{r.profit.toLocaleString('ru', moneyFmt)}{r.marginPercent.toFixed(2)}{r.quantity.toLocaleString('ru')}
+
+ )} +
+
+
+
+ ) +} diff --git a/tests/food-market.IntegrationTests/ProfitReportTests.cs b/tests/food-market.IntegrationTests/ProfitReportTests.cs new file mode 100644 index 0000000..8742596 --- /dev/null +++ b/tests/food-market.IntegrationTests/ProfitReportTests.cs @@ -0,0 +1,98 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +[Collection(ApiCollection.Name)] +public class ProfitReportTests +{ + private readonly ApiFactory _factory; + public ProfitReportTests(ApiFactory factory) => _factory = factory; + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + /// Купили 10 шт по 50 (cost после Supply post = 50), продали 4 шт по + /// 100. Прибыль = 400 − 200 = 200, маржа = 50%. + [Fact] + public async Task Profit_simple_supply_sale() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"prft-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Приёмка: 10 шт по 50. + var sup = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new { + date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "init", lines = new[] { new { productId = p1, quantity = 10m, unitPrice = 50m } } }); + sup.EnsureSuccessStatusCode(); + var supId = (await sup.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supId}/post", new { })).EnsureSuccessStatusCode(); + + // Продажа: 4 шт по 100. + var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new { + date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null, + customerId = (string?)null, currencyId = refs.CurrencyId, payment = 0, paidCash = 400m, paidCard = 0m, + lines = new[] { new { productId = p1, quantity = 4m, unitPrice = 100m, discount = 0m, vatPercent = 12m } }, + notes = "sale" }); + sale.EnsureSuccessStatusCode(); + var sid = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode(); + + var rep = await api.GetJsonAsync("/api/reports/profit?groupBy=product"); + var r = rep.EnumerateArray().First(x => x.GetProperty("key").GetString() == p1); + r.GetProperty("revenue").GetDecimal().Should().Be(400m); + r.GetProperty("cost").GetDecimal().Should().Be(200m); + r.GetProperty("profit").GetDecimal().Should().Be(200m); + r.GetProperty("marginPercent").GetDecimal().Should().Be(50m); + } + + /// Защита от деления на ноль: при нулевой выручке (продажи=0) margin=0, + /// а не NaN/Infinity. Проверяем что хотя бы пустой набор возвращается без 500. + [Fact] + public async Task Empty_period_returns_no_rows_no_division_by_zero() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"prft-emp-{Guid.NewGuid():N}"); + + var pastFrom = DateTime.UtcNow.AddYears(-5).ToString("o"); + var pastTo = DateTime.UtcNow.AddYears(-4).ToString("o"); + var rep = await api.GetJsonAsync($"/api/reports/profit?groupBy=product&from={Uri.EscapeDataString(pastFrom)}&to={Uri.EscapeDataString(pastTo)}"); + rep.EnumerateArray().Should().BeEmpty(); + } + + [Fact] + public async Task Tenant_isolation_profit() + { + var a = new ApiActor(_factory.CreateClient()); + var b = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"prft-iso-a-{Guid.NewGuid():N}"); + await b.SignupAndLoginAsync($"prft-iso-b-{Guid.NewGuid():N}"); + var refsA = await a.LoadRefsAsync(); + var supplierA = await a.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + var sup = await a.Http.PostAsJsonAsync("/api/purchases/supplies", new { + date = DateTime.UtcNow, supplierId = supplierA, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, + notes = "init", lines = new[] { new { productId = pA, quantity = 5m, unitPrice = 20m } } }); + sup.EnsureSuccessStatusCode(); + var supId = (await sup.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await a.Http.PostAsJsonAsync($"/api/purchases/supplies/{supId}/post", new { })).EnsureSuccessStatusCode(); + + var sale = await a.Http.PostAsJsonAsync("/api/sales/retail", new { + date = DateTime.UtcNow, storeId = refsA.StoreId, retailPointId = (string?)null, + customerId = (string?)null, currencyId = refsA.CurrencyId, payment = 0, paidCash = 100m, paidCard = 0m, + lines = new[] { new { productId = pA, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } }, + notes = "sale" }); + sale.EnsureSuccessStatusCode(); + var sid = (await sale.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await a.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode(); + + var repB = await b.GetJsonAsync("/api/reports/profit?groupBy=product"); + repB.EnumerateArray().Should().NotContain(x => x.GetProperty("key").GetString() == pA); + } +}