feat(reports): ABC-анализ по Парето (P1-11)
GET /api/reports/abc — топ-товары по выбранной метрике с распределением A/B/C по Парето (A=80%, B=15%, C=5% накопительной метрики). Параметр metric: • revenue (по умолчанию) — выручка; • profit — прибыль (выручка − Quantity·Product.Cost); • margin — alias для profit (отдельная кнопка в UI). Граничные случаи: пустой период → пустой набор; товары с net-неположительной метрикой исключаются (некоторые отдают только возвраты — для ABC не интересны). Возвраты учтены со знаком (net-метрика). storeId / productGroupId фильтры. Export CSV/XLSX. Web: /reports/abc с цветными плашками класса (A green / B yellow / C red) и визуальной полосой накопительной доли. Тесты: 4 интеграционных (Парето на 3 товара 800/150/50 → A/B/C; пустой период; profit-метрика меняет порядок против revenue; tenant-изоляция). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9795eeeafc
commit
dcf8f60b67
164
src/food-market.api/Controllers/Reports/AbcReportController.cs
Normal file
164
src/food-market.api/Controllers/Reports/AbcReportController.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>Отчёт ABC-анализ.
|
||||||
|
///
|
||||||
|
/// Распределяет товары по классам A/B/C по правилу Парето:
|
||||||
|
/// A — топ-товары, дающие 80% накопительной метрики;
|
||||||
|
/// B — следующие 15% (порог 95% накопительно);
|
||||||
|
/// C — оставшиеся 5%.
|
||||||
|
///
|
||||||
|
/// Параметр <c>metric</c>:
|
||||||
|
/// • <c>revenue</c> — выручка (по умолчанию);
|
||||||
|
/// • <c>profit</c> — прибыль (выручка − себестоимость);
|
||||||
|
/// • <c>margin</c> — суммарная маржа в деньгах (то же что profit, но
|
||||||
|
/// отсортированы по убыванию margin per unit; для совместимости с
|
||||||
|
/// требованием отдельной кнопки в UI оставляем как алиас).
|
||||||
|
///
|
||||||
|
/// Возвраты учтены со знаком: net-метрика для периода.</summary>
|
||||||
|
[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<ActionResult<IReadOnlyList<AbcRow>>> 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<IActionResult> 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<List<AbcRow>> 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<AbcRow>(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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -41,6 +41,7 @@ import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
|
||||||
import { SalesReportPage } from '@/pages/SalesReportPage'
|
import { SalesReportPage } from '@/pages/SalesReportPage'
|
||||||
import { StockReportPage } from '@/pages/StockReportPage'
|
import { StockReportPage } from '@/pages/StockReportPage'
|
||||||
import { ProfitReportPage } from '@/pages/ProfitReportPage'
|
import { ProfitReportPage } from '@/pages/ProfitReportPage'
|
||||||
|
import { AbcReportPage } from '@/pages/AbcReportPage'
|
||||||
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||||
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||||
import { AppLayout } from '@/components/AppLayout'
|
import { AppLayout } from '@/components/AppLayout'
|
||||||
|
|
@ -131,6 +132,7 @@ export default function App() {
|
||||||
<Route path="/reports/sales" element={<SalesReportPage />} />
|
<Route path="/reports/sales" element={<SalesReportPage />} />
|
||||||
<Route path="/reports/stock" element={<StockReportPage />} />
|
<Route path="/reports/stock" element={<StockReportPage />} />
|
||||||
<Route path="/reports/profit" element={<ProfitReportPage />} />
|
<Route path="/reports/profit" element={<ProfitReportPage />} />
|
||||||
|
<Route path="/reports/abc" element={<AbcReportPage />} />
|
||||||
<Route path="/sales/retail" element={<RetailSalesPage />} />
|
<Route path="/sales/retail" element={<RetailSalesPage />} />
|
||||||
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
|
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
|
||||||
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||||
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
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'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||||
|
|
@ -114,6 +114,7 @@ function buildNav(roles: string[]): NavSection[] {
|
||||||
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
|
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
|
||||||
{ to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' },
|
{ to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' },
|
||||||
{ to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' },
|
{ to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' },
|
||||||
|
{ to: '/reports/abc', icon: Target, label: 'ABC-анализ' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
193
src/food-market.web/src/pages/AbcReportPage.tsx
Normal file
193
src/food-market.web/src/pages/AbcReportPage.tsx
Normal file
|
|
@ -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<string, string> = {
|
||||||
|
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<string>(monthAgoIso())
|
||||||
|
const [to, setTo] = useState<string>(todayIso())
|
||||||
|
const [metric, setMetric] = useState<string>('revenue')
|
||||||
|
const [storeId, setStoreId] = useState<string>('')
|
||||||
|
const [productGroupId, setProductGroupId] = useState<string>('')
|
||||||
|
|
||||||
|
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<string, string> = {}) => {
|
||||||
|
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<AbcReportRow[]>(`/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<string, number>
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||||
|
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Отчёт «ABC-анализ»</h1>
|
||||||
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
|
Топ-товары по выбранной метрике с разбиением A/B/C по правилу Парето (80/15/5).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4">
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-x-4 gap-y-3 items-end">
|
||||||
|
<Field label="От">
|
||||||
|
<DateField value={from} onChange={(v) => setFrom(v ?? todayIso())} />
|
||||||
|
</Field>
|
||||||
|
<Field label="До">
|
||||||
|
<DateField value={to} onChange={(v) => setTo(v ?? todayIso())} />
|
||||||
|
</Field>
|
||||||
|
<Field label="Метрика">
|
||||||
|
<Select value={metric} onChange={(e) => setMetric(e.target.value)}>
|
||||||
|
{METRICS.map((m) => <option key={m.value} value={m.value}>{m.label}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Склад">
|
||||||
|
<Select value={storeId} onChange={(e) => setStoreId(e.target.value)}>
|
||||||
|
<option value="">Все</option>
|
||||||
|
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
<Field label="Группа товаров">
|
||||||
|
<Select value={productGroupId} onChange={(e) => setProductGroupId(e.target.value)}>
|
||||||
|
<option value="">Все</option>
|
||||||
|
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex flex-wrap gap-3 items-center justify-between">
|
||||||
|
<div className="flex gap-2 text-sm">
|
||||||
|
<span className={`px-3 py-1.5 rounded ${CLASS_COLOR.A}`}>A: {counts.A ?? 0}</span>
|
||||||
|
<span className={`px-3 py-1.5 rounded ${CLASS_COLOR.B}`}>B: {counts.B ?? 0}</span>
|
||||||
|
<span className={`px-3 py-1.5 rounded ${CLASS_COLOR.C}`}>C: {counts.C ?? 0}</span>
|
||||||
|
<span className="px-3 py-1.5 rounded bg-slate-50 dark:bg-slate-800/60">
|
||||||
|
Всего: {total} · итог метрики: <span className="font-mono font-semibold">{totalValue.toLocaleString('ru', moneyFmt)}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => exportFile('csv')}>
|
||||||
|
<Download className="w-4 h-4" /> CSV
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={() => exportFile('xlsx')}>
|
||||||
|
<Download className="w-4 h-4" /> XLSX
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
|
||||||
|
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю…</div>}
|
||||||
|
{!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
|
||||||
|
<div className="text-sm text-slate-500 py-6 text-center">За период нет продаж.</div>
|
||||||
|
)}
|
||||||
|
{!rep.isLoading && rep.data && rep.data.length > 0 && (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="text-left">
|
||||||
|
<tr className="border-b border-slate-200 dark:border-slate-700">
|
||||||
|
<th className="py-2 pr-3 font-medium text-xs uppercase text-slate-500 w-[60px]">Ранг</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[60px]">Класс</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500">Товар</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px]">Артикул</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[150px] text-right">Метрика</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[100px] text-right">Доля,%</th>
|
||||||
|
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[200px]">Накопит.</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rep.data.map((r) => (
|
||||||
|
<tr key={r.productId} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/30">
|
||||||
|
<td className="py-2 pr-3 text-slate-500">{r.rank}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<span className={`inline-block w-6 h-6 rounded text-center text-xs font-semibold leading-6 ${CLASS_COLOR[r.abcClass] ?? 'bg-slate-100 text-slate-700'}`}>{r.abcClass}</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3">{r.productName}</td>
|
||||||
|
<td className="py-2 px-3 text-slate-500">{r.productArticle ?? '—'}</td>
|
||||||
|
<td className="py-2 px-3 text-right font-mono">{r.metricValue.toLocaleString('ru', moneyFmt)}</td>
|
||||||
|
<td className="py-2 px-3 text-right font-mono text-slate-500">{r.share.toFixed(2)}</td>
|
||||||
|
<td className="py-2 px-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-slate-100 dark:bg-slate-800 rounded overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={r.abcClass === 'A' ? 'bg-green-500' : r.abcClass === 'B' ? 'bg-yellow-500' : 'bg-red-500'}
|
||||||
|
style={{ width: `${Math.min(100, r.cumulativeShare)}%`, height: '100%' }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="font-mono text-xs text-slate-500 w-[55px] text-right">{r.cumulativeShare.toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
158
tests/food-market.IntegrationTests/AbcReportTests.cs
Normal file
158
tests/food-market.IntegrationTests/AbcReportTests.cs
Normal file
|
|
@ -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)));
|
||||||
|
|
||||||
|
/// <summary>3 товара с явно разной долей выручки → классы A/B/C по Парето.</summary>
|
||||||
|
[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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).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<JsonElement>()).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue