feat(reports): отчёт «Прибыль» (выручка − COGS) (P1-10)

GET /api/reports/profit с группировками period:day/week/month, product,
group (по группе товаров). Cost-snapshot — Product.Cost (скользящее
среднее, документировано как приближение; точный FIFO требует партий).
Маржа = profit/revenue·100. Защита от деления на ноль при нулевой
выручке (пустой период → margin = 0, не NaN).

Возвраты вычитаются и из выручки, и из COGS (returned line делает
−Quantity·UnitPrice выручки и −Quantity·Cost себестоимости).

Export CSV/XLSX через тот же ReportExport.

Web: /reports/profit с KPI-плашками (общая выручка/себестоимость/прибыль
+ маржа) — прибыль зелёным/красным в зависимости от знака.

Тесты: 3 интеграционных (simple profit calc 4×100−4×50=200=50%; empty
period → пустой набор без 500/NaN; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 11:19:19 +05:00
parent 3ded4db73a
commit 3db112cbee
5 changed files with 452 additions and 1 deletions

View file

@ -0,0 +1,173 @@
using System.Globalization;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт «Прибыль».
///
/// Выручка себестоимость, маржа, рентабельность по периодам / товарам /
/// группам товаров. Cost-snapshot берётся из <see cref="StockMovement.UnitCost"/>
/// сопоставленного по (documentId, productId): движение тип `RetailSale`
/// или `CustomerReturn` несёт UnitCost = `RetailSaleLine.UnitPrice`… НО для
/// расчёта прибыли нам нужна СЕБЕСТОИМОСТЬ, а не цена продажи. Поэтому
/// фактически берём `Product.Cost` (текущее скользящее среднее) как
/// COGS-snapshot — это приближение; точный COGS требует партий или
/// fetch'а на момент продажи. Документируем как компромисс.
///
/// Multi-tenant: query-filter работает прозрачно.</summary>
[ApiController]
[Authorize]
[Route("api/reports/profit")]
public class ProfitReportController : ControllerBase
{
private readonly AppDbContext _db;
public ProfitReportController(AppDbContext db) => _db = db;
public record ProfitRow(
string Key, string Label,
decimal Revenue, decimal Cost, decimal Profit,
decimal MarginPercent, decimal Quantity);
private record FlatRow(
Guid SaleId, DateTime Date,
Guid ProductId, string ProductName, string? ProductArticle,
Guid? ProductGroupId, string? ProductGroupName,
decimal Revenue, decimal Cost, decimal Quantity);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<ProfitRow>>> Get(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
return Ok(Group(flat, groupBy));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? from,
[FromQuery] DateTime? to,
[FromQuery] string groupBy = "period:day",
[FromQuery] Guid? storeId = null,
[FromQuery] Guid? productGroupId = null,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var range = ResolveRange(from, to);
var flat = await FetchAsync(range, storeId, productGroupId, ct);
var rows = Group(flat, groupBy);
var name = $"profit-{groupBy.Replace(':', '-')}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[] { "Ключ", "Группа", "Выручка", "Себестоимость", "Прибыль", "Маржа,%", "Количество" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Profit", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private static DateRange ResolveRange(DateTime? from, DateTime? to)
{
var t = to ?? DateTime.UtcNow;
var f = from ?? t.AddDays(-30);
return new DateRange(f, t);
}
private async Task<List<FlatRow>> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
// Берём строки проведённых чеков с присоединённым Product (для Cost-snapshot
// и группы) и ProductGroup (для имени). Возврат вычитается из выручки и
// из COGS (продали по цене X с себестоимостью C → returned восстанавливает).
var q = from l in _db.RetailSaleLines.AsNoTracking()
join s in _db.RetailSales.AsNoTracking() on l.RetailSaleId equals s.Id
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
where s.Status == RetailSaleStatus.Posted
&& s.Date >= range.From && s.Date <= range.To
select new { l, s, p };
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
if (productGroupId is not null) q = q.Where(x => x.p.ProductGroupId == productGroupId);
var flat = await q
.Select(x => new FlatRow(
x.s.Id, x.s.Date,
x.l.ProductId, x.p.Name, x.p.Article,
x.p.ProductGroupId,
x.p.ProductGroupId == null ? null
: _db.ProductGroups.Where(g => g.Id == x.p.ProductGroupId).Select(g => g.Name).FirstOrDefault(),
x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
// COGS-snapshot: Quantity × Product.Cost (текущее скользящее среднее).
// Документировано как приближение; точный COGS требует партий.
(x.s.IsReturn ? -x.l.Quantity : x.l.Quantity) * x.p.Cost,
x.s.IsReturn ? -x.l.Quantity : x.l.Quantity))
.ToListAsync(ct);
return flat;
}
private static List<ProfitRow> Group(List<FlatRow> flat, string groupBy)
{
IEnumerable<IGrouping<(string Key, string Label), FlatRow>> grouped;
switch (groupBy)
{
case "period:day":
grouped = flat.GroupBy(x => (
Key: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
Label: x.Date.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)));
break;
case "period:week":
var cal = CultureInfo.InvariantCulture.Calendar;
grouped = flat.GroupBy(x =>
{
var w = cal.GetWeekOfYear(x.Date, CalendarWeekRule.FirstFourDayWeek, DayOfWeek.Monday);
return (Key: $"{x.Date.Year:0000}-W{w:00}", Label: $"{x.Date.Year:0000} нед. {w:00}");
});
break;
case "period:month":
grouped = flat.GroupBy(x => (
Key: $"{x.Date.Year:0000}-{x.Date.Month:00}",
Label: $"{x.Date.Year:0000}-{x.Date.Month:00}"));
break;
case "product":
grouped = flat.GroupBy(x => (
Key: x.ProductId.ToString(),
Label: x.ProductName + (x.ProductArticle is not null ? " · " + x.ProductArticle : "")));
break;
case "group":
grouped = flat.GroupBy(x => (
Key: (x.ProductGroupId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.ProductGroupName) ? "Без группы" : x.ProductGroupName));
break;
default:
return new();
}
return grouped
.Select(g =>
{
var revenue = g.Sum(x => x.Revenue);
var cost = g.Sum(x => x.Cost);
var profit = revenue - cost;
// Защита от деления на ноль: при нулевой выручке маржа = 0
// (а не NaN/Infinity). Бывает при чисто возвратных периодах,
// когда продажи отсутствуют.
var margin = revenue == 0m ? 0m : Math.Round(profit / revenue * 100m, 2);
return new ProfitRow(
g.Key.Key, g.Key.Label,
revenue, cost, profit, margin,
g.Sum(x => x.Quantity));
})
.OrderByDescending(r => r.Profit)
.ToList();
}
}

View file

@ -40,6 +40,7 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' 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 { 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'
@ -129,6 +130,7 @@ export default function App() {
<Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} /> <Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} />
<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="/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 />} />

View file

@ -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, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -113,6 +113,7 @@ function buildNav(roles: string[]): NavSection[] {
sections.push({ group: 'Отчёты', items: [ sections.push({ group: 'Отчёты', items: [
{ 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: 'Прибыль' },
]}) ]})
} }

View file

@ -0,0 +1,177 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, Select } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { useStores, useProductGroups } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type ProfitReportRow } from '@/lib/types'
const GROUPS = [
{ value: 'period:day', label: 'По дням' },
{ value: 'period:week', label: 'По неделям' },
{ value: 'period:month', label: 'По месяцам' },
{ value: 'product', label: 'По товарам' },
{ value: 'group', label: 'По группам товаров' },
] as const
export function ProfitReportPage() {
const todayIso = () => new Date().toISOString().slice(0, 10)
const monthAgoIso = () => {
const d = new Date(); d.setDate(d.getDate() - 30)
return d.toISOString().slice(0, 10)
}
const [from, setFrom] = useState<string>(monthAgoIso())
const [to, setTo] = useState<string>(todayIso())
const [groupBy, setGroupBy] = useState<string>('period:day')
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(),
groupBy,
...extra,
})
if (storeId) p.set('storeId', storeId)
if (productGroupId) p.set('productGroupId', productGroupId)
return p
}
const rep = useQuery({
queryKey: ['profit-report', from, to, groupBy, storeId, productGroupId],
queryFn: async () => (await api.get<ProfitReportRow[]>(`/api/reports/profit?${params()}`)).data,
})
const exportFile = async (format: 'csv' | 'xlsx') => {
const resp = await api.get(`/api/reports/profit/export?${params({ format })}`, { responseType: 'blob' })
const blob = new Blob([resp.data])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const cd = resp.headers['content-disposition'] as string | undefined
const match = cd?.match(/filename="?([^";]+)"?/i)
a.download = match?.[1] ?? `profit-report.${format}`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const totalRev = rep.data?.reduce((s, r) => s + r.revenue, 0) ?? 0
const totalCost = rep.data?.reduce((s, r) => s + r.cost, 0) ?? 0
const totalProf = totalRev - totalCost
const totalMargin = totalRev === 0 ? 0 : Math.round((totalProf / totalRev) * 10000) / 100
return (
<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">Отчёт «Прибыль»</h1>
<p className="text-xs text-slate-500 mt-1">
Выручка себестоимость = прибыль. COGS-snapshot Product.Cost
(скользящее среднее на момент запроса; приближённая оценка).
</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={groupBy} onChange={(e) => setGroupBy(e.target.value)}>
{GROUPS.map((g) => <option key={g.value} value={g.value}>{g.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 bg-slate-50 dark:bg-slate-800/60">
Выручка: <span className="font-mono font-semibold">{totalRev.toLocaleString('ru', moneyFmt)}</span>
</span>
<span className="px-3 py-1.5 rounded bg-slate-50 dark:bg-slate-800/60">
Себестоимость: <span className="font-mono font-semibold">{totalCost.toLocaleString('ru', moneyFmt)}</span>
</span>
<span className={`px-3 py-1.5 rounded font-semibold ${totalProf >= 0 ? 'bg-green-50 text-green-700' : 'bg-red-50 text-red-700'}`}>
Прибыль: <span className="font-mono">{totalProf.toLocaleString('ru', moneyFmt)}</span>
<span className="ml-2 text-xs">({totalMargin.toFixed(2)}%)</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">Группа</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[130px] text-right">Выручка</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[130px] text-right">Себест.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[130px] text-right">Прибыль</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px] text-right">Маржа,%</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[120px] text-right">Кол-во</th>
</tr>
</thead>
<tbody>
{rep.data.map((r) => (
<tr key={r.key} 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">{r.label}</td>
<td className="py-2 px-3 text-right font-mono">{r.revenue.toLocaleString('ru', moneyFmt)}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">{r.cost.toLocaleString('ru', moneyFmt)}</td>
<td className={`py-2 px-3 text-right font-mono ${r.profit >= 0 ? 'text-green-700' : 'text-red-700'}`}>{r.profit.toLocaleString('ru', moneyFmt)}</td>
<td className="py-2 px-3 text-right font-mono">{r.marginPercent.toFixed(2)}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">{r.quantity.toLocaleString('ru')}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
</div>
)
}

View file

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