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