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:
parent
3ded4db73a
commit
3db112cbee
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -40,6 +40,7 @@ import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
|
|||
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
|
||||
import { SalesReportPage } from '@/pages/SalesReportPage'
|
||||
import { StockReportPage } from '@/pages/StockReportPage'
|
||||
import { ProfitReportPage } from '@/pages/ProfitReportPage'
|
||||
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
|
|
@ -129,6 +130,7 @@ export default function App() {
|
|||
<Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} />
|
||||
<Route path="/reports/sales" element={<SalesReportPage />} />
|
||||
<Route path="/reports/stock" element={<StockReportPage />} />
|
||||
<Route path="/reports/profit" element={<ProfitReportPage />} />
|
||||
<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,
|
||||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
|
||||
|
|
@ -113,6 +113,7 @@ function buildNav(roles: string[]): NavSection[] {
|
|||
sections.push({ group: 'Отчёты', items: [
|
||||
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
|
||||
{ to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' },
|
||||
{ to: '/reports/profit', icon: TrendingUp, label: 'Прибыль' },
|
||||
]})
|
||||
}
|
||||
|
||||
|
|
|
|||
177
src/food-market.web/src/pages/ProfitReportPage.tsx
Normal file
177
src/food-market.web/src/pages/ProfitReportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
tests/food-market.IntegrationTests/ProfitReportTests.cs
Normal file
98
tests/food-market.IntegrationTests/ProfitReportTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue