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 { 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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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: 'Прибыль' },
|
||||||
]})
|
]})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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