food-market/tests/food-market.IntegrationTests/SalesReportTests.cs
nns ac77849901 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>
2026-05-28 11:09:52 +05:00

207 lines
10 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}