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:
parent
8111574a08
commit
ac77849901
|
|
@ -27,6 +27,8 @@
|
|||
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.11" />
|
||||
|
||||
<!-- 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="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||
<PackageVersion Include="MediatR" Version="12.4.1" />
|
||||
|
|
|
|||
111
src/food-market.api/Controllers/Reports/ReportExport.cs
Normal file
111
src/food-market.api/Controllers/Reports/ReportExport.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
211
src/food-market.api/Controllers/Reports/SalesReportController.cs
Normal file
211
src/food-market.api/Controllers/Reports/SalesReportController.cs
Normal 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(),
|
||||
};
|
||||
}
|
||||
|
|
@ -279,7 +279,11 @@ public async Task<IActionResult> 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<IActionResult> 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<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);
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@
|
|||
<PackageReference Include="Serilog.Sinks.File" />
|
||||
<PackageReference Include="Hangfire.AspNetCore" />
|
||||
<PackageReference Include="Hangfire.PostgreSql" />
|
||||
<PackageReference Include="CsvHelper" />
|
||||
<PackageReference Include="ClosedXML" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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() {
|
|||
<Route path="/purchases/supplier-returns" element={<SupplierReturnsPage />} />
|
||||
<Route path="/purchases/supplier-returns/new" 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/new" element={<RetailSaleEditPage />} />
|
||||
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
|
|||
import {
|
||||
LayoutDashboard, Package, FolderTree, Ruler, Tag,
|
||||
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck,
|
||||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2,
|
||||
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: 'МойСклад' },
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
||||
|
|
|
|||
173
src/food-market.web/src/pages/SalesReportPage.tsx
Normal file
173
src/food-market.web/src/pages/SalesReportPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
206
tests/food-market.IntegrationTests/SalesReportTests.cs
Normal file
206
tests/food-market.IntegrationTests/SalesReportTests.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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-сервер не нужен в интеграционных тестах: он бы создавал свои
|
||||
// таблицы в одноразовом контейнере и удерживал коннекшен. Сам клиент
|
||||
|
|
|
|||
Loading…
Reference in a new issue