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>
207 lines
10 KiB
C#
207 lines
10 KiB
C#
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;
|
||
}
|
||
}
|