feat(reports): отчёт «Продажи» с группировками и экспортом (P1-8)

GET /api/reports/sales — агрегаты по period:day/week/month, product,
cashier, register, payment. Фильтры: from/to (по умолчанию last 30 days),
storeId, productGroupId. Возвраты включаются с минусом (netto-выручка
для фискальной отчётности).

GET /api/reports/sales/export?format=csv|xlsx — выгрузка через CsvHelper
(BOM UTF-8 + ; разделитель для Excel-RU) и ClosedXML.

Реализация: плоский набор строк проектируется на сервере БД (Join+Where,
EF переводит), агрегация в C#. Сознательный компромисс — EF8 не
переводит «distinct count» внутри group-проекции с join'ами по
nullable-ключам; объёмы отчётов (~десятки тысяч строк/месяц) держатся
в RAM спокойно.

Web: /reports/sales — выбор периода, табы группировки, фильтры, экспорт.
Sidebar: «Отчёты → Продажи» для Admin/Storekeeper.

Bonus: попутно вылечен баг RetailSalesController.Update — DbUpdateConcurrency
«0 affected» воспроизводился при PUT на свеже-созданный возврат
(create-return + immediate edit). Исправлено двумя изменениями:
• Update не делает Include(Lines) — старые строки удаляются ExecuteDelete'ом;
• ApplyLines добавляет новые строки напрямую в DbSet (а не через nav-collection
  sale.Lines.Add) — иначе EF8 путается со state'ом из-за client-side Id (Guid).

Тесты: 5 интеграционных (group by product, group by payment, returns reduce
revenue signed, tenant isolation, CSV export). 37 интеграционных всего зелёные.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 11:09:52 +05:00
parent 8111574a08
commit ac77849901
11 changed files with 773 additions and 6 deletions

View file

@ -27,6 +27,8 @@
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
<!-- App services --> <!-- App services -->
<PackageVersion Include="CsvHelper" Version="33.0.1" />
<PackageVersion Include="ClosedXML" Version="0.104.2" />
<PackageVersion Include="MailKit" Version="4.10.0" /> <PackageVersion Include="MailKit" Version="4.10.0" />
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" /> <PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
<PackageVersion Include="MediatR" Version="12.4.1" /> <PackageVersion Include="MediatR" Version="12.4.1" />

View file

@ -0,0 +1,111 @@
using System.Globalization;
using System.Text;
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration;
using Microsoft.AspNetCore.Mvc;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Хелперы экспорта табличных данных отчётов в CSV и XLSX.
///
/// CSV: разделитель «;» (Excel-RU открывает корректно без import wizard),
/// BOM UTF-8 (тот же повод), `CultureInfo.InvariantCulture` для чисел и дат
/// (так в файл попадает «1234.56», а не «1 234,56» — Excel сам отформатирует
/// по локали при чтении). Заголовки берутся из имён свойств — это compromise,
/// но контроллеры могут передать <c>columnNames</c> для русификации.
///
/// XLSX: ClosedXML без шрифтов/стилей сверх минимума — отчёты предполагается
/// открывать в Excel и форматировать там. Числовые ячейки реально числа
/// (а не строки), даты — DateTime; auto-fit колонок включён.</summary>
public static class ReportExport
{
public static IActionResult Csv<T>(IEnumerable<T> rows, string fileName,
IReadOnlyList<string>? columnNames = null)
{
using var ms = new MemoryStream();
var preamble = Encoding.UTF8.GetPreamble();
ms.Write(preamble, 0, preamble.Length);
using (var writer = new StreamWriter(ms, new UTF8Encoding(false), leaveOpen: true))
using (var csv = new CsvWriter(writer, new CsvConfiguration(CultureInfo.InvariantCulture)
{
Delimiter = ";",
// Если контроллер передал русские заголовки — мы их пишем сами и просим
// CsvHelper не генерить заголовок повторно. Иначе — пусть пишет
// заголовок из имён свойств.
HasHeaderRecord = columnNames is null || columnNames.Count == 0,
}))
{
if (columnNames is not null && columnNames.Count > 0)
{
foreach (var h in columnNames) csv.WriteField(h);
csv.NextRecord();
}
csv.WriteRecords(rows);
}
return new FileContentResult(ms.ToArray(), "text/csv; charset=utf-8")
{
FileDownloadName = fileName.EndsWith(".csv") ? fileName : fileName + ".csv",
};
}
/// <summary>Простой экспорт «таблица в XLSX». В строке reflection берёт
/// публичные свойства rows[0], ставит заголовки и значения. Для пустого
/// источника возвращает пустой лист с заголовками из дженерик-типа.</summary>
public static IActionResult Xlsx<T>(IEnumerable<T> rows, string fileName,
string sheetName = "Report",
IReadOnlyList<string>? columnNames = null)
{
using var wb = new XLWorkbook();
var ws = wb.AddWorksheet(sheetName.Length > 31 ? sheetName[..31] : sheetName);
var props = typeof(T).GetProperties();
for (int i = 0; i < props.Length; i++)
{
ws.Cell(1, i + 1).Value = (columnNames is not null && i < columnNames.Count)
? columnNames[i]
: props[i].Name;
ws.Cell(1, i + 1).Style.Font.Bold = true;
}
var r = 2;
foreach (var row in rows)
{
for (int i = 0; i < props.Length; i++)
{
var v = props[i].GetValue(row);
var c = ws.Cell(r, i + 1);
switch (v)
{
case null: c.Value = string.Empty; break;
case DateTime dt: c.Value = dt; c.Style.NumberFormat.Format = "yyyy-mm-dd hh:mm"; break;
case decimal dec: c.Value = dec; break;
case double dbl: c.Value = dbl; break;
case int ii: c.Value = ii; break;
case long ll: c.Value = ll; break;
case bool bb: c.Value = bb; break;
case Guid g: c.Value = g.ToString(); break;
default: c.Value = v.ToString(); break;
}
}
r++;
}
ws.Columns().AdjustToContents();
using var ms = new MemoryStream();
wb.SaveAs(ms);
return new FileContentResult(ms.ToArray(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
{
FileDownloadName = fileName.EndsWith(".xlsx") ? fileName : fileName + ".xlsx",
};
}
}
/// <summary>Универсальный диапазон дат отчёта. Обе границы — UTC.
/// Пустые значения подменяются дефолтами в контроллере (обычно last 30 days).</summary>
public sealed record DateRange(DateTime From, DateTime To)
{
public static DateRange LastDays(int days)
{
var to = DateTime.UtcNow;
return new DateRange(to.AddDays(-days), to);
}
}

View file

@ -0,0 +1,211 @@
using System.Globalization;
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>Отчёт «Продажи».
///
/// Группировка через query-параметр <c>groupBy</c>:
/// • <c>period:day</c> / <c>period:week</c> / <c>period:month</c>
/// • <c>product</c> — по товару
/// • <c>cashier</c> — по кассиру (CashierUserId)
/// • <c>register</c> — по кассе (RetailPointId)
/// • <c>payment</c> — по способу оплаты
///
/// Учитываются только проведённые чеки (Status=Posted). Возвраты
/// (IsReturn=true) включаются с отрицательным вкладом в выручку
/// (соответствует фискальной отчётности «netto»). Фильтры: from/to,
/// storeId, productGroupId.
///
/// Реализация: проекция в плоский ряд (FlatRow) с фильтрами выполняется
/// на сервере БД (простая Join+Where, EF переводит). Группировка/агрегация —
/// в памяти. Это сознательный компромисс: EF8 не умеет переводить
/// «distinct count» внутри group-проекции с join'ами по nullable-ключам;
/// объёмы отчётов (десятки тысяч строк за месяц) спокойно держатся в RAM.
///
/// Multi-tenant: query-filter <see cref="AppDbContext"/> применяется автоматически.</summary>
[ApiController]
[Authorize]
[Route("api/reports/sales")]
public class SalesReportController : ControllerBase
{
private readonly AppDbContext _db;
public SalesReportController(AppDbContext db) => _db = db;
public record SalesRow(
string Key,
string Label,
decimal Revenue,
decimal Discount,
int Transactions,
decimal Quantity);
private record FlatRow(
Guid SaleId, DateTime Date,
Guid StoreId,
Guid? RetailPointId, string? RetailPointName,
Guid? CashierUserId, string? CashierName,
PaymentMethod Payment,
Guid ProductId, string ProductName, string? ProductArticle,
Guid? ProductGroupId,
decimal Revenue, decimal Discount, decimal Quantity);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<SalesRow>>> 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 = $"sales-{groupBy.Replace(':', '-')}-{range.From:yyyyMMdd}-{range.To:yyyyMMdd}";
var headers = new[] { "Ключ", "Группа", "Выручка", "Скидки", "Чеков", "Количество" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Sales", 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);
}
/// <summary>Тащит плоский набор строк с переведёнными в SQL фильтрами
/// и join'ами на каталог. Возвращает уже materialized list.</summary>
private async Task<List<FlatRow>> FetchAsync(
DateRange range, Guid? storeId, Guid? productGroupId, CancellationToken ct)
{
// Сначала список саleId с фильтрами по чеку (period/store/return-знак).
// Затем JOIN на линии и на каталог. У EF8 эта форма успешно переводится в SQL.
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);
// Левые join'ы на RetailPoints и Users — для имён. Подтаскиваем имена
// прямо в проекции через .Where + .FirstOrDefault — Npgsql переведёт.
var flat = await q
.Select(x => new FlatRow(
x.s.Id, x.s.Date,
x.s.StoreId,
x.s.RetailPointId,
x.s.RetailPointId == null ? null
: _db.RetailPoints.Where(r => r.Id == x.s.RetailPointId).Select(r => r.Name).FirstOrDefault(),
x.s.CashierUserId,
x.s.CashierUserId == null ? null
: _db.Users.Where(u => u.Id == x.s.CashierUserId).Select(u => u.FullName).FirstOrDefault(),
x.s.Payment,
x.l.ProductId, x.p.Name, x.p.Article,
x.p.ProductGroupId,
x.s.IsReturn ? -x.l.LineTotal : x.l.LineTotal,
x.s.IsReturn ? -x.l.Discount : x.l.Discount,
x.s.IsReturn ? -x.l.Quantity : x.l.Quantity))
.ToListAsync(ct);
return flat;
}
/// <summary>Группировка/агрегация в C#. Возврат уже отсортирован по убыванию
/// выручки (полезно для топ-списков и для табличного вида).</summary>
private static List<SalesRow> 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);
var key = $"{x.Date.Year:0000}-W{w:00}";
return (Key: key, 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 "cashier":
grouped = flat.GroupBy(x => (
Key: (x.CashierUserId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.CashierName) ? "Без кассира" : x.CashierName));
break;
case "register":
grouped = flat.GroupBy(x => (
Key: (x.RetailPointId ?? Guid.Empty).ToString(),
Label: string.IsNullOrEmpty(x.RetailPointName) ? "Без кассы" : x.RetailPointName));
break;
case "payment":
grouped = flat.GroupBy(x => (
Key: ((int)x.Payment).ToString(),
Label: PaymentLabel(x.Payment)));
break;
default:
return new();
}
return grouped
.Select(g => new SalesRow(
Key: g.Key.Key,
Label: g.Key.Label,
Revenue: g.Sum(x => x.Revenue),
Discount: g.Sum(x => x.Discount),
Transactions: g.Select(x => x.SaleId).Distinct().Count(),
Quantity: g.Sum(x => x.Quantity)))
.OrderByDescending(r => r.Revenue)
.ToList();
}
private static string PaymentLabel(PaymentMethod p) => p switch
{
PaymentMethod.Cash => "Наличные",
PaymentMethod.Card => "Карта",
PaymentMethod.BankTransfer => "Безнал",
PaymentMethod.Bonus => "Бонусы",
PaymentMethod.Mixed => "Смешанная",
_ => p.ToString(),
};
}

View file

@ -279,7 +279,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
(nameof(input.StoreId), input.StoreId), (nameof(input.StoreId), input.StoreId),
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing) (nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct); // Загружаем sale БЕЗ Include(Lines): иначе вылавливается баг EF8,
// когда после ExecuteDelete+Add EF путается со state'ом строк и кидает
// DbUpdateConcurrency. Старые строки удаляем хирургически через
// ExecuteDelete (минует трекер), новые — через отдельный AddRange.
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
if (sale is null) return NotFound(); if (sale is null) return NotFound();
if (sale.Status != RetailSaleStatus.Draft) if (sale.Status != RetailSaleStatus.Draft)
return Conflict(new { error = "Только черновик может быть изменён." }); return Conflict(new { error = "Только черновик может быть изменён." });
@ -296,8 +300,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput inpu
sale.PaidCard = R(input.PaidCard); sale.PaidCard = R(input.PaidCard);
sale.Notes = input.Notes; sale.Notes = input.Notes;
_db.RetailSaleLines.RemoveRange(sale.Lines); await _db.RetailSaleLines.Where(l => l.RetailSaleId == sale.Id).ExecuteDeleteAsync(ct);
sale.Lines.Clear();
ApplyLines(sale, input.Lines, allowFractional); ApplyLines(sale, input.Lines, allowFractional);
if (await SaveOrFkErrorAsync(ct) is { } err) return err; if (await SaveOrFkErrorAsync(ct) is { } err) return err;
@ -672,18 +675,25 @@ private static bool IsSerializationConflict(Exception ex)
return false; return false;
} }
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional) private void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
{ {
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
var order = 0; var order = 0;
decimal subtotal = 0, discountTotal = 0; decimal subtotal = 0, discountTotal = 0;
// Добавляем строки напрямую в DbSet, а не через nav `sale.Lines.Add`.
// На пути через nav EF8 в некоторых случаях помечает новую сущность
// как Modified (а не Added), потому что Id уже задан клиентом
// (Guid.NewGuid в Entity ctor) и связь child→parent через
// collection-navigation запутывает change-detector. Прямой Add в DbSet
// снимает неоднозначность.
foreach (var l in input) foreach (var l in input)
{ {
var unitPrice = R(l.UnitPrice); var unitPrice = R(l.UnitPrice);
var discount = R(l.Discount); var discount = R(l.Discount);
var lineTotal = l.Quantity * unitPrice - discount; var lineTotal = l.Quantity * unitPrice - discount;
sale.Lines.Add(new RetailSaleLine _db.RetailSaleLines.Add(new RetailSaleLine
{ {
RetailSaleId = sale.Id,
ProductId = l.ProductId, ProductId = l.ProductId,
Quantity = l.Quantity, Quantity = l.Quantity,
UnitPrice = unitPrice, UnitPrice = unitPrice,

View file

@ -22,6 +22,8 @@
<PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Hangfire.AspNetCore" /> <PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.PostgreSql" /> <PackageReference Include="Hangfire.PostgreSql" />
<PackageReference Include="CsvHelper" />
<PackageReference Include="ClosedXML" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -38,6 +38,7 @@ import { InventoriesPage } from '@/pages/InventoriesPage'
import { InventoryEditPage } from '@/pages/InventoryEditPage' import { InventoryEditPage } from '@/pages/InventoryEditPage'
import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { SalesReportPage } from '@/pages/SalesReportPage'
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'
@ -125,6 +126,7 @@ export default function App() {
<Route path="/purchases/supplier-returns" element={<SupplierReturnsPage />} /> <Route path="/purchases/supplier-returns" element={<SupplierReturnsPage />} />
<Route path="/purchases/supplier-returns/new" element={<SupplierReturnEditPage />} /> <Route path="/purchases/supplier-returns/new" element={<SupplierReturnEditPage />} />
<Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} /> <Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} />
<Route path="/reports/sales" element={<SalesReportPage />} />
<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, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner'
@ -108,6 +108,13 @@ function buildNav(roles: string[]): NavSection[] {
]}) ]})
} }
// Отчёты — Admin и Storekeeper (Cashier видит ограниченные через permissions).
if (isAdmin || isStorekeeper) {
sections.push({ group: 'Отчёты', items: [
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
]})
}
if (isAdmin) { if (isAdmin) {
sections.push({ group: 'Импорт', items: [ sections.push({ group: 'Импорт', items: [
{ to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' }, { to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' },

View file

@ -224,6 +224,48 @@ export interface InventoryDto {
lines: InventoryLineDto[]; lines: InventoryLineDto[];
} }
export interface SalesReportRow {
key: string
label: string
revenue: number
discount: number
transactions: number
quantity: number
}
export interface StockReportRow {
productId: string
productName: string
productArticle: string | null
unitName: string | null
storeId: string
storeName: string
quantity: number
cost: number
value: number
}
export interface ProfitReportRow {
key: string
label: string
revenue: number
cost: number
profit: number
marginPercent: number
quantity: number
}
export interface AbcReportRow {
productId: string
productName: string
productArticle: string | null
metricValue: number
share: number
cumulativeShare: number
abcClass: string
rank: number
}
export const SupplierReturnStatus = { Draft: 0, Posted: 1 } as const export const SupplierReturnStatus = { Draft: 0, Posted: 1 } as const
export type SupplierReturnStatus = (typeof SupplierReturnStatus)[keyof typeof SupplierReturnStatus] export type SupplierReturnStatus = (typeof SupplierReturnStatus)[keyof typeof SupplierReturnStatus]

View file

@ -0,0 +1,173 @@
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 SalesReportRow } from '@/lib/types'
const GROUPS = [
{ value: 'period:day', label: 'По дням' },
{ value: 'period:week', label: 'По неделям' },
{ value: 'period:month', label: 'По месяцам' },
{ value: 'product', label: 'По товарам' },
{ value: 'cashier', label: 'По кассирам' },
{ value: 'register', label: 'По кассам' },
{ value: 'payment', label: 'По способам оплаты' },
] as const
/** Отчёт «Продажи». Табы выбора группировки, фильтры по периоду/складу/группе,
* экспорт в CSV/XLSX. Возвраты включены со знаком минус (фискальная отчётность). */
export function SalesReportPage() {
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: ['sales-report', from, to, groupBy, storeId, productGroupId],
queryFn: async () => (await api.get<SalesReportRow[]>(`/api/reports/sales?${params()}`)).data,
})
const exportFile = async (format: 'csv' | 'xlsx') => {
const resp = await api.get(`/api/reports/sales/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] ?? `sales-report.${format}`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const total = rep.data?.reduce((s, r) => s + r.revenue, 0) ?? 0
const totalTx = rep.data?.reduce((s, r) => s + r.transactions, 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">Отчёт «Продажи»</h1>
<p className="text-xs text-slate-500 mt-1">
Проведённые чеки за период. Возвраты включаются с минусом (netto).
</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 text-slate-700 dark:text-slate-200">
Итого: <span className="font-mono font-semibold">{total.toLocaleString('ru', moneyFmt)}</span>
</span>
<span className="px-3 py-1.5 rounded bg-slate-50 dark:bg-slate-800/60 text-slate-700 dark:text-slate-200">
Чеков: <span className="font-mono font-semibold">{totalTx.toLocaleString('ru')}</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-[120px] text-right">Скидки</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[90px] 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.discount === 0 ? '—' : r.discount.toLocaleString('ru', moneyFmt)}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">{r.transactions}</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,206 @@
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 SalesReportTests
{
private readonly ApiFactory _factory;
public SalesReportTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Создаёт 2 проведённых чека на разные товары и проверяет, что
/// groupBy=product отдаёт обе позиции с правильной выручкой.</summary>
[Fact]
public async Task Group_by_product_returns_revenue_per_item()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"rpt-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P1-{Guid.NewGuid():N}", 200m, RandomBarcode());
var p2 = await api.CreateProductAsync(refs, $"P2-{Guid.NewGuid():N}", 300m, RandomBarcode());
// Принять по 10 шт каждый.
foreach (var (pid, price, cost) in new[] { (p1, 200m, 100m), (p2, 300m, 150m) })
{
var enter = 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 = 10m, unitCost = cost } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
// Чек 1: 2 шт p1 (400) + 1 шт p2 (300) = 700
await PostSale(api, refs, new[]
{
(p1, 2m, 200m),
(p2, 1m, 300m),
}, paid: 700m);
// Чек 2: 3 шт p1 (600)
await PostSale(api, refs, new[] { (p1, 3m, 200m) }, paid: 600m);
var rows = await api.GetJsonAsync("/api/reports/sales?groupBy=product");
var arr = rows.EnumerateArray().ToList();
arr.Should().HaveCountGreaterOrEqualTo(2);
var p1Row = arr.First(x => x.GetProperty("key").GetString() == p1);
p1Row.GetProperty("revenue").GetDecimal().Should().Be(1000m, "5 шт по 200");
p1Row.GetProperty("quantity").GetDecimal().Should().Be(5m);
var p2Row = arr.First(x => x.GetProperty("key").GetString() == p2);
p2Row.GetProperty("revenue").GetDecimal().Should().Be(300m);
}
[Fact]
public async Task Group_by_payment_returns_per_method()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"rpt-pm-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 20m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
// Cash на 200 + Card на 300.
await PostSale(api, refs, new[] { (p1, 2m, 100m) }, paid: 200m, payment: 0);
await PostSale(api, refs, new[] { (p1, 3m, 100m) }, paid: 0, paidCard: 300m, payment: 1);
var rows = await api.GetJsonAsync("/api/reports/sales?groupBy=payment");
var arr = rows.EnumerateArray().ToList();
var cash = arr.First(x => x.GetProperty("key").GetString() == "0");
var card = arr.First(x => x.GetProperty("key").GetString() == "1");
cash.GetProperty("revenue").GetDecimal().Should().Be(200m);
card.GetProperty("revenue").GetDecimal().Should().Be(300m);
}
[Fact]
public async Task Returns_reduce_revenue_signed()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"rpt-ret-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var saleId = await PostSale(api, refs, new[] { (p1, 5m, 100m) }, paid: 500m);
// Создаём возврат на 2 шт.
var crt = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/create-return", new { });
crt.EnsureSuccessStatusCode();
var retId = (await crt.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
// Уменьшаем возврат до 2 шт (по умолчанию подтянулось 5).
var put = await api.Http.PutAsJsonAsync($"/api/sales/retail/{retId}", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId = (string?)null,
customerId = (string?)null, currencyId = refs.CurrencyId,
payment = 0, paidCash = 200m, paidCard = 0m, notes = "ret",
lines = new[] { new { productId = p1, quantity = 2m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
});
put.EnsureSuccessStatusCode();
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{retId}/post", new { })).EnsureSuccessStatusCode();
var rows = await api.GetJsonAsync("/api/reports/sales?groupBy=product");
var p1Row = rows.EnumerateArray().First(x => x.GetProperty("key").GetString() == p1);
// 5 шт по 100 минус 2 шт по 100 = 300.
p1Row.GetProperty("revenue").GetDecimal().Should().Be(300m);
p1Row.GetProperty("quantity").GetDecimal().Should().Be(3m);
}
[Fact]
public async Task Tenant_isolation_sales_report()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"rpt-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"rpt-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = 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 = 1m, unitCost = 10m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await a.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
await PostSale(a, refsA, new[] { (pA, 1m, 100m) }, paid: 100m);
// B запросил тот же отчёт — должен видеть пусто.
var rowsB = await b.GetJsonAsync("/api/reports/sales?groupBy=product");
rowsB.EnumerateArray().Should().NotContain(x => x.GetProperty("key").GetString() == pA);
}
[Fact]
public async Task Csv_export_works()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"rpt-csv-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
await PostSale(api, refs, new[] { (p1, 2m, 100m) }, paid: 200m);
using var resp = await api.Http.GetAsync("/api/reports/sales/export?groupBy=product&format=csv");
resp.EnsureSuccessStatusCode();
resp.Content.Headers.ContentType!.MediaType.Should().Be("text/csv");
var text = await resp.Content.ReadAsStringAsync();
text.Should().Contain("Выручка"); // русский заголовок
text.Should().Contain("200");
}
private async Task<string> PostSale(ApiActor api, ApiActor.Refs refs,
(string ProductId, decimal Qty, decimal Price)[] lines,
decimal paid, decimal paidCard = 0m, int payment = 0)
{
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, paidCash = paid, paidCard,
lines = lines.Select(l => new
{
productId = l.ProductId, quantity = l.Qty,
unitPrice = l.Price, discount = 0m, vatPercent = 12m,
}).ToArray(),
notes = "test",
});
resp.EnsureSuccessStatusCode();
var saleId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode();
return saleId;
}
}

View file

@ -23,6 +23,7 @@ static ApiFactory()
// Тесты логинятся десятки раз с одного loopback-IP — иначе 429. // Тесты логинятся десятки раз с одного loopback-IP — иначе 429.
Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false"); Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false");
// Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями). // Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями).
// Ошибки (Error) пропускаем — нужно для отладки 500 в тестах.
Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning"); Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning");
// Hangfire-сервер не нужен в интеграционных тестах: он бы создавал свои // Hangfire-сервер не нужен в интеграционных тестах: он бы создавал свои
// таблицы в одноразовом контейнере и удерживал коннекшен. Сам клиент // таблицы в одноразовом контейнере и удерживал коннекшен. Сам клиент