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:
nns 2026-05-28 11:24:26 +05:00
parent 9795eeeafc
commit dcf8f60b67
5 changed files with 519 additions and 1 deletions

View 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;
}
}

View file

@ -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() {
<Route path="/reports/sales" element={<SalesReportPage />} />
<Route path="/reports/stock" element={<StockReportPage />} />
<Route path="/reports/profit" element={<ProfitReportPage />} />
<Route path="/reports/abc" element={<AbcReportPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

@ -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-анализ' },
]})
}

View 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>
)
}

View 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);
}
}