diff --git a/Directory.Packages.props b/Directory.Packages.props index bb883d5..fd78287 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -27,6 +27,8 @@ + + diff --git a/src/food-market.api/Controllers/Reports/ReportExport.cs b/src/food-market.api/Controllers/Reports/ReportExport.cs new file mode 100644 index 0000000..6d41420 --- /dev/null +++ b/src/food-market.api/Controllers/Reports/ReportExport.cs @@ -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; + +/// Хелперы экспорта табличных данных отчётов в CSV и XLSX. +/// +/// CSV: разделитель «;» (Excel-RU открывает корректно без import wizard), +/// BOM UTF-8 (тот же повод), `CultureInfo.InvariantCulture` для чисел и дат +/// (так в файл попадает «1234.56», а не «1 234,56» — Excel сам отформатирует +/// по локали при чтении). Заголовки берутся из имён свойств — это compromise, +/// но контроллеры могут передать columnNames для русификации. +/// +/// XLSX: ClosedXML без шрифтов/стилей сверх минимума — отчёты предполагается +/// открывать в Excel и форматировать там. Числовые ячейки реально числа +/// (а не строки), даты — DateTime; auto-fit колонок включён. +public static class ReportExport +{ + public static IActionResult Csv(IEnumerable rows, string fileName, + IReadOnlyList? 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", + }; + } + + /// Простой экспорт «таблица в XLSX». В строке reflection берёт + /// публичные свойства rows[0], ставит заголовки и значения. Для пустого + /// источника возвращает пустой лист с заголовками из дженерик-типа. + public static IActionResult Xlsx(IEnumerable rows, string fileName, + string sheetName = "Report", + IReadOnlyList? 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", + }; + } +} + +/// Универсальный диапазон дат отчёта. Обе границы — UTC. +/// Пустые значения подменяются дефолтами в контроллере (обычно last 30 days). +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); + } +} diff --git a/src/food-market.api/Controllers/Reports/SalesReportController.cs b/src/food-market.api/Controllers/Reports/SalesReportController.cs new file mode 100644 index 0000000..e9c5f1f --- /dev/null +++ b/src/food-market.api/Controllers/Reports/SalesReportController.cs @@ -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; + +/// Отчёт «Продажи». +/// +/// Группировка через query-параметр groupBy: +/// • period:day / period:week / period:month +/// • product — по товару +/// • cashier — по кассиру (CashierUserId) +/// • register — по кассе (RetailPointId) +/// • payment — по способу оплаты +/// +/// Учитываются только проведённые чеки (Status=Posted). Возвраты +/// (IsReturn=true) включаются с отрицательным вкладом в выручку +/// (соответствует фискальной отчётности «netto»). Фильтры: from/to, +/// storeId, productGroupId. +/// +/// Реализация: проекция в плоский ряд (FlatRow) с фильтрами выполняется +/// на сервере БД (простая Join+Where, EF переводит). Группировка/агрегация — +/// в памяти. Это сознательный компромисс: EF8 не умеет переводить +/// «distinct count» внутри group-проекции с join'ами по nullable-ключам; +/// объёмы отчётов (десятки тысяч строк за месяц) спокойно держатся в RAM. +/// +/// Multi-tenant: query-filter применяется автоматически. +[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>> 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 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); + } + + /// Тащит плоский набор строк с переведёнными в SQL фильтрами + /// и join'ами на каталог. Возвращает уже materialized list. + private async Task> 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; + } + + /// Группировка/агрегация в C#. Возврат уже отсортирован по убыванию + /// выручки (полезно для топ-списков и для табличного вида). + private static List Group(List flat, string groupBy) + { + IEnumerable> 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(), + }; +} diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index a37ebc3..c919076 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -279,7 +279,11 @@ public async Task Update(Guid id, [FromBody] RetailSaleInput inpu (nameof(input.StoreId), input.StoreId), (nameof(input.CurrencyId), input.CurrencyId)) is { } 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.Status != RetailSaleStatus.Draft) return Conflict(new { error = "Только черновик может быть изменён." }); @@ -296,8 +300,7 @@ public async Task Update(Guid id, [FromBody] RetailSaleInput inpu sale.PaidCard = R(input.PaidCard); sale.Notes = input.Notes; - _db.RetailSaleLines.RemoveRange(sale.Lines); - sale.Lines.Clear(); + await _db.RetailSaleLines.Where(l => l.RetailSaleId == sale.Id).ExecuteDeleteAsync(ct); ApplyLines(sale, input.Lines, allowFractional); if (await SaveOrFkErrorAsync(ct) is { } err) return err; @@ -672,18 +675,25 @@ private static bool IsSerializationConflict(Exception ex) return false; } - private static void ApplyLines(RetailSale sale, IReadOnlyList input, bool allowFractional) + private void ApplyLines(RetailSale sale, IReadOnlyList input, bool allowFractional) { decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); var order = 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) { var unitPrice = R(l.UnitPrice); var discount = R(l.Discount); var lineTotal = l.Quantity * unitPrice - discount; - sale.Lines.Add(new RetailSaleLine + _db.RetailSaleLines.Add(new RetailSaleLine { + RetailSaleId = sale.Id, ProductId = l.ProductId, Quantity = l.Quantity, UnitPrice = unitPrice, diff --git a/src/food-market.api/food-market.api.csproj b/src/food-market.api/food-market.api.csproj index c92d6e0..ed58bf4 100644 --- a/src/food-market.api/food-market.api.csproj +++ b/src/food-market.api/food-market.api.csproj @@ -22,6 +22,8 @@ + + diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index f0084be..0675cd0 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -38,6 +38,7 @@ import { InventoriesPage } from '@/pages/InventoriesPage' import { InventoryEditPage } from '@/pages/InventoryEditPage' import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' +import { SalesReportPage } from '@/pages/SalesReportPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' @@ -125,6 +126,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index f12aa7f..7d629c4 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -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, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, } from 'lucide-react' import { Logo } from './Logo' 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) { sections.push({ group: 'Импорт', items: [ { to: '/admin/import/moysklad', icon: Download, label: 'МойСклад' }, diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index e758db1..46a224e 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -224,6 +224,48 @@ export interface InventoryDto { 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 type SupplierReturnStatus = (typeof SupplierReturnStatus)[keyof typeof SupplierReturnStatus] diff --git a/src/food-market.web/src/pages/SalesReportPage.tsx b/src/food-market.web/src/pages/SalesReportPage.tsx new file mode 100644 index 0000000..e64d37c --- /dev/null +++ b/src/food-market.web/src/pages/SalesReportPage.tsx @@ -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(monthAgoIso()) + const [to, setTo] = useState(todayIso()) + const [groupBy, setGroupBy] = useState('period:day') + const [storeId, setStoreId] = useState('') + const [productGroupId, setProductGroupId] = useState('') + + 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 = {}) => { + 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(`/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 ( +
+
+

Отчёт «Продажи»

+

+ Проведённые чеки за период. Возвраты включаются с минусом (netto). +

+
+
+
+
+
+ + setFrom(v ?? todayIso())} /> + + + setTo(v ?? todayIso())} /> + + + + + + + + + + +
+
+
+ + Итого: {total.toLocaleString('ru', moneyFmt)} + + + Чеков: {totalTx.toLocaleString('ru')} + +
+
+ + +
+
+
+ +
+ {rep.isLoading &&
Загружаю…
} + {!rep.isLoading && (rep.data?.length ?? 0) === 0 && ( +
За период нет данных.
+ )} + {!rep.isLoading && rep.data && rep.data.length > 0 && ( +
+ + + + + + + + + + + + {rep.data.map((r) => ( + + + + + + + + ))} + +
ГруппаВыручкаСкидкиЧековКол-во
{r.label}{r.revenue.toLocaleString('ru', moneyFmt)}{r.discount === 0 ? '—' : r.discount.toLocaleString('ru', moneyFmt)}{r.transactions}{r.quantity.toLocaleString('ru')}
+
+ )} +
+
+
+
+ ) +} diff --git a/tests/food-market.IntegrationTests/SalesReportTests.cs b/tests/food-market.IntegrationTests/SalesReportTests.cs new file mode 100644 index 0000000..d945871 --- /dev/null +++ b/tests/food-market.IntegrationTests/SalesReportTests.cs @@ -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))); + + /// Создаёт 2 проведённых чека на разные товары и проверяет, что + /// groupBy=product отдаёт обе позиции с правильной выручкой. + [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()).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()).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()).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()).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()).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()).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 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()).GetProperty("id").GetString()!; + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode(); + return saleId; + } +} diff --git a/tests/food-market.IntegrationTests/Support/ApiFactory.cs b/tests/food-market.IntegrationTests/Support/ApiFactory.cs index 02c95b5..ef70675 100644 --- a/tests/food-market.IntegrationTests/Support/ApiFactory.cs +++ b/tests/food-market.IntegrationTests/Support/ApiFactory.cs @@ -23,6 +23,7 @@ static ApiFactory() // Тесты логинятся десятки раз с одного loopback-IP — иначе 429. Environment.SetEnvironmentVariable("RateLimiting__Enabled", "false"); // Тише логи в тестовом прогоне (OpenIddict сыплет Debug-событиями). + // Ошибки (Error) пропускаем — нужно для отладки 500 в тестах. Environment.SetEnvironmentVariable("Serilog__MinimumLevel__Default", "Warning"); // Hangfire-сервер не нужен в интеграционных тестах: он бы создавал свои // таблицы в одноразовом контейнере и удерживал коннекшен. Сам клиент