diff --git a/src/food-market.api/Controllers/Reports/AbcReportController.cs b/src/food-market.api/Controllers/Reports/AbcReportController.cs new file mode 100644 index 0000000..2e75ec2 --- /dev/null +++ b/src/food-market.api/Controllers/Reports/AbcReportController.cs @@ -0,0 +1,164 @@ +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Reports; + +/// Отчёт ABC-анализ. +/// +/// Распределяет товары по классам A/B/C по правилу Парето: +/// A — топ-товары, дающие 80% накопительной метрики; +/// B — следующие 15% (порог 95% накопительно); +/// C — оставшиеся 5%. +/// +/// Параметр metric: +/// • revenue — выручка (по умолчанию); +/// • profit — прибыль (выручка − себестоимость); +/// • margin — суммарная маржа в деньгах (то же что profit, но +/// отсортированы по убыванию margin per unit; для совместимости с +/// требованием отдельной кнопки в UI оставляем как алиас). +/// +/// Возвраты учтены со знаком: net-метрика для периода. +[ApiController] +[Authorize] +[Route("api/reports/abc")] +public class AbcReportController : ControllerBase +{ + private readonly AppDbContext _db; + public AbcReportController(AppDbContext db) => _db = db; + + public record AbcRow( + Guid ProductId, string ProductName, string? ProductArticle, + decimal MetricValue, + decimal Share, // % от общей метрики (0..100) + decimal CumulativeShare, // накопительный % (0..100) + string AbcClass, // "A" | "B" | "C" + int Rank); + + [HttpGet] + public async Task>> Get( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string metric = "revenue", + [FromQuery] Guid? storeId = null, + [FromQuery] Guid? productGroupId = null, + CancellationToken ct = default) + { + var range = ResolveRange(from, to); + return Ok(await BuildAsync(range, metric, storeId, productGroupId, ct)); + } + + [HttpGet("export")] + public async Task Export( + [FromQuery] DateTime? from, + [FromQuery] DateTime? to, + [FromQuery] string metric = "revenue", + [FromQuery] Guid? storeId = null, + [FromQuery] Guid? productGroupId = null, + [FromQuery] string format = "csv", + CancellationToken ct = default) + { + var range = ResolveRange(from, to); + var rows = await BuildAsync(range, metric, storeId, productGroupId, ct); + var name = $"abc-{metric}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}"; + var headers = new[] + { + "ProductId", "Товар", "Артикул", "Метрика", "Доля,%", "Накоп.доля,%", "Класс", "Ранг", + }; + return format.ToLower() switch + { + "xlsx" => ReportExport.Xlsx(rows, name, "ABC", 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> BuildAsync( + DateRange range, string metric, Guid? storeId, Guid? productGroupId, CancellationToken ct) + { + 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 raw = await q + .Select(x => new + { + x.l.ProductId, + x.p.Name, + x.p.Article, + x.p.Cost, + Revenue = x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal, + Quantity = x.s.IsReturn ? -x.l.Quantity : x.l.Quantity, + }) + .ToListAsync(ct); + + // Группируем по product, считаем метрику. + var grouped = raw + .GroupBy(r => new { r.ProductId, r.Name, r.Article, r.Cost }) + .Select(g => + { + var rev = g.Sum(x => x.Revenue); + var qty = g.Sum(x => x.Quantity); + var cost = g.Key.Cost * qty; + var prof = rev - cost; + var value = metric.ToLower() switch + { + "profit" or "margin" => prof, + _ => rev, + }; + return new + { + g.Key.ProductId, + g.Key.Name, + g.Key.Article, + MetricValue = value, + }; + }) + // Только положительные значения: товары с net-убытком или нулевой + // выручкой не имеют смысла в ABC (никакого вклада в Парето). При + // желании можно отдельным флагом включить и отрицательные. + .Where(x => x.MetricValue > 0m) + .OrderByDescending(x => x.MetricValue) + .ToList(); + + var total = grouped.Sum(x => x.MetricValue); + if (total <= 0m) return new(); + + var rows = new List(grouped.Count); + decimal cum = 0m; + var rank = 1; + foreach (var g in grouped) + { + cum += g.MetricValue; + var share = g.MetricValue / total * 100m; + var cumShare = cum / total * 100m; + // Граница A: накопительная ≤ 80%. Если первый товар уже > 80%, + // он всё равно A (единичная позиция, исчерпывающая Парето). + var cls = cumShare <= 80m + 0.000001m ? "A" + : cumShare <= 95m + 0.000001m ? "B" + : "C"; + rows.Add(new AbcRow( + g.ProductId, g.Name, g.Article, + Math.Round(g.MetricValue, 4), + Math.Round(share, 2), + Math.Round(cumShare, 2), + cls, + rank++)); + } + return rows; + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 2f2672e..b77d5a4 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -41,6 +41,7 @@ import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SalesReportPage } from '@/pages/SalesReportPage' import { StockReportPage } from '@/pages/StockReportPage' import { ProfitReportPage } from '@/pages/ProfitReportPage' +import { AbcReportPage } from '@/pages/AbcReportPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' @@ -131,6 +132,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 913410d..b66936a 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, TrendingUp, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, } from 'lucide-react' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' @@ -114,6 +114,7 @@ function buildNav(roles: string[]): NavSection[] { { to: '/reports/sales', icon: BarChart3, label: 'Продажи' }, { to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' }, { to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' }, + { to: '/reports/abc', icon: Target, label: 'ABC-анализ' }, ]}) } diff --git a/src/food-market.web/src/pages/AbcReportPage.tsx b/src/food-market.web/src/pages/AbcReportPage.tsx new file mode 100644 index 0000000..4d9b0b1 --- /dev/null +++ b/src/food-market.web/src/pages/AbcReportPage.tsx @@ -0,0 +1,193 @@ +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 AbcReportRow } from '@/lib/types' + +const METRICS = [ + { value: 'revenue', label: 'Выручка' }, + { value: 'profit', label: 'Прибыль' }, + { value: 'margin', label: 'Маржа' }, +] as const + +const CLASS_COLOR: Record = { + A: 'bg-green-100 text-green-800', + B: 'bg-yellow-100 text-yellow-800', + C: 'bg-red-100 text-red-800', +} + +/** ABC-анализ. Топ-товары по выручке/прибыли/марже с распределением по + * классам Парето (80/15/5). Визуализация — цветные плашки класса и + * полоса накопительной доли. */ +export function AbcReportPage() { + 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 [metric, setMetric] = useState('revenue') + 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(), + metric, + ...extra, + }) + if (storeId) p.set('storeId', storeId) + if (productGroupId) p.set('productGroupId', productGroupId) + return p + } + + const rep = useQuery({ + queryKey: ['abc-report', from, to, metric, storeId, productGroupId], + queryFn: async () => (await api.get(`/api/reports/abc?${params()}`)).data, + }) + + const exportFile = async (format: 'csv' | 'xlsx') => { + const resp = await api.get(`/api/reports/abc/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] ?? `abc-report.${format}` + document.body.appendChild(a) + a.click() + a.remove() + URL.revokeObjectURL(url) + } + + const counts = { A: 0, B: 0, C: 0 } as Record + rep.data?.forEach((r) => { counts[r.abcClass] = (counts[r.abcClass] ?? 0) + 1 }) + const total = rep.data?.length ?? 0 + const totalValue = rep.data?.reduce((s, r) => s + r.metricValue, 0) ?? 0 + + return ( +
+
+

Отчёт «ABC-анализ»

+

+ Топ-товары по выбранной метрике с разбиением A/B/C по правилу Парето (80/15/5). +

+
+
+
+
+
+ + setFrom(v ?? todayIso())} /> + + + setTo(v ?? todayIso())} /> + + + + + + + + + + +
+
+
+ A: {counts.A ?? 0} + B: {counts.B ?? 0} + C: {counts.C ?? 0} + + Всего: {total} · итог метрики: {totalValue.toLocaleString('ru', moneyFmt)} + +
+
+ + +
+
+
+ +
+ {rep.isLoading &&
Загружаю…
} + {!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( +
За период нет продаж.
+ )} + {!rep.isLoading && rep.data && rep.data.length > 0 && ( +
+ + + + + + + + + + + + + + {rep.data.map((r) => ( + + + + + + + + + + ))} + +
РангКлассТоварАртикулМетрикаДоля,%Накопит.
{r.rank} + {r.abcClass} + {r.productName}{r.productArticle ?? '—'}{r.metricValue.toLocaleString('ru', moneyFmt)}{r.share.toFixed(2)} +
+
+
+
+ {r.cumulativeShare.toFixed(1)}% +
+
+
+ )} +
+
+
+
+ ) +} diff --git a/tests/food-market.IntegrationTests/AbcReportTests.cs b/tests/food-market.IntegrationTests/AbcReportTests.cs new file mode 100644 index 0000000..9e3d6ec --- /dev/null +++ b/tests/food-market.IntegrationTests/AbcReportTests.cs @@ -0,0 +1,158 @@ +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 AbcReportTests +{ + private readonly ApiFactory _factory; + public AbcReportTests(ApiFactory factory) => _factory = factory; + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + /// 3 товара с явно разной долей выручки → классы A/B/C по Парето. + [Fact] + public async Task Pareto_distribution_A_B_C() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"abc-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var pBig = await api.CreateProductAsync(refs, $"BIG-{Guid.NewGuid():N}", 100m, RandomBarcode()); + var pMed = await api.CreateProductAsync(refs, $"MED-{Guid.NewGuid():N}", 100m, RandomBarcode()); + var pSmall = await api.CreateProductAsync(refs, $"SML-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Подкормим склад. + foreach (var pid in new[] { pBig, pMed, pSmall }) + { + var e = await api.Http.PostAsJsonAsync("/api/inventory/enters", new { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "seed", lines = new[] { new { productId = pid, quantity = 100m, unitCost = 10m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode(); + } + + // Продажи: BIG = 800, MED = 150, SML = 50 → итого 1000. + // A = накопительно 80% (BIG), B = 95% (+ MED = 95%), C = остаток (SML). + async Task Sale(string pid, decimal qty, decimal price) + { + var resp = 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 = qty * price, paidCard = 0m, + lines = new[] { new { productId = pid, quantity = qty, unitPrice = price, discount = 0m, vatPercent = 12m } }, + notes = "sale" }); + resp.EnsureSuccessStatusCode(); + var sid = (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode(); + } + await Sale(pBig, 8m, 100m); + await Sale(pMed, 15m, 10m); + await Sale(pSmall, 5m, 10m); + + var rep = await api.GetJsonAsync("/api/reports/abc?metric=revenue"); + var arr = rep.EnumerateArray().ToList(); + arr.Should().HaveCountGreaterOrEqualTo(3); + + var big = arr.First(x => x.GetProperty("productId").GetString() == pBig); + var med = arr.First(x => x.GetProperty("productId").GetString() == pMed); + var small = arr.First(x => x.GetProperty("productId").GetString() == pSmall); + big.GetProperty("abcClass").GetString().Should().Be("A"); + med.GetProperty("abcClass").GetString().Should().Be("B"); + small.GetProperty("abcClass").GetString().Should().Be("C"); + + // Накопительная доля C должна быть 100%. + small.GetProperty("cumulativeShare").GetDecimal().Should().BeApproximately(100m, 0.5m); + } + + [Fact] + public async Task Empty_period_returns_empty() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"abc-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/abc?from={Uri.EscapeDataString(pastFrom)}&to={Uri.EscapeDataString(pastTo)}"); + rep.EnumerateArray().Should().BeEmpty(); + } + + [Fact] + public async Task Profit_metric_orders_by_margin() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"abc-prft-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + // pA: 5 шт по 100 — высокая выручка, но низкая маржа (cost 80). + // pB: 2 шт по 100 — низкая выручка, но высокая маржа (cost 10). + var pA = await api.CreateProductAsync(refs, $"A-{Guid.NewGuid():N}", 100m, RandomBarcode()); + var pB = await api.CreateProductAsync(refs, $"B-{Guid.NewGuid():N}", 100m, RandomBarcode()); + async Task Supply(string pid, decimal qty, decimal cost) + { + var s = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new { + date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = "in", lines = new[] { new { productId = pid, quantity = qty, unitPrice = cost } } }); + s.EnsureSuccessStatusCode(); + var sid = (await s.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{sid}/post", new { })).EnsureSuccessStatusCode(); + } + await Supply(pA, 10m, 80m); + await Supply(pB, 10m, 10m); + + async Task Sale(string pid, decimal qty, decimal price) + { + var resp = 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 = qty * price, paidCard = 0m, + lines = new[] { new { productId = pid, quantity = qty, unitPrice = price, discount = 0m, vatPercent = 12m } }, + notes = "sale" }); + resp.EnsureSuccessStatusCode(); + var sid = (await resp.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode(); + } + await Sale(pA, 5m, 100m); // profit = 500 − 5*80 = 100 + await Sale(pB, 2m, 100m); // profit = 200 − 2*10 = 180 + + // По revenue: A=500 > B=200, A первый (rank 1). + var byRev = await api.GetJsonAsync("/api/reports/abc?metric=revenue"); + byRev.EnumerateArray().First().GetProperty("productId").GetString().Should().Be(pA); + + // По profit: B=180 > A=100, B первый (rank 1). + var byProf = await api.GetJsonAsync("/api/reports/abc?metric=profit"); + byProf.EnumerateArray().First().GetProperty("productId").GetString().Should().Be(pB); + } + + [Fact] + public async Task Tenant_isolation_abc() + { + var a = new ApiActor(_factory.CreateClient()); + var b = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"abc-iso-a-{Guid.NewGuid():N}"); + await b.SignupAndLoginAsync($"abc-iso-b-{Guid.NewGuid():N}"); + var refsA = await a.LoadRefsAsync(); + var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + var e = await a.Http.PostAsJsonAsync("/api/inventory/enters", new { + date = DateTime.UtcNow, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, + notes = "seed", lines = new[] { new { productId = pA, quantity = 5m, unitCost = 10m } } }); + e.EnsureSuccessStatusCode(); + var eid = (await e.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await a.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/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 = "s" }); + 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/abc"); + repB.EnumerateArray().Should().NotContain(x => x.GetProperty("productId").GetString() == pA); + } +}