GET /api/reports/abc — топ-товары по выбранной метрике с распределением A/B/C по Парето (A=80%, B=15%, C=5% накопительной метрики). Параметр metric: • revenue (по умолчанию) — выручка; • profit — прибыль (выручка − Quantity·Product.Cost); • margin — alias для profit (отдельная кнопка в UI). Граничные случаи: пустой период → пустой набор; товары с net-неположительной метрикой исключаются (некоторые отдают только возвраты — для ABC не интересны). Возвраты учтены со знаком (net-метрика). storeId / productGroupId фильтры. Export CSV/XLSX. Web: /reports/abc с цветными плашками класса (A green / B yellow / C red) и визуальной полосой накопительной доли. Тесты: 4 интеграционных (Парето на 3 товара 800/150/50 → A/B/C; пустой период; profit-метрика меняет порядок против revenue; tenant-изоляция). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
159 lines
8.8 KiB
C#
159 lines
8.8 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 AbcReportTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public AbcReportTests(ApiFactory factory) => _factory = factory;
|
||
private static string RandomBarcode()
|
||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||
|
||
/// <summary>3 товара с явно разной долей выручки → классы A/B/C по Парето.</summary>
|
||
[Fact]
|
||
public async Task Pareto_distribution_A_B_C()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"abc-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var pBig = await api.CreateProductAsync(refs, $"BIG-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
var pMed = await api.CreateProductAsync(refs, $"MED-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
var pSmall = await api.CreateProductAsync(refs, $"SML-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
// Подкормим склад.
|
||
foreach (var pid in new[] { pBig, pMed, pSmall })
|
||
{
|
||
var e = 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 = 100m, unitCost = 10m } } });
|
||
e.EnsureSuccessStatusCode();
|
||
var eid = (await e.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
}
|
||
|
||
// Продажи: BIG = 800, MED = 150, SML = 50 → итого 1000.
|
||
// A = накопительно 80% (BIG), B = 95% (+ MED = 95%), C = остаток (SML).
|
||
async Task Sale(string pid, decimal qty, decimal price)
|
||
{
|
||
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 = 0, paidCash = qty * price, paidCard = 0m,
|
||
lines = new[] { new { productId = pid, quantity = qty, unitPrice = price, discount = 0m, vatPercent = 12m } },
|
||
notes = "sale" });
|
||
resp.EnsureSuccessStatusCode();
|
||
var sid = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode();
|
||
}
|
||
await Sale(pBig, 8m, 100m);
|
||
await Sale(pMed, 15m, 10m);
|
||
await Sale(pSmall, 5m, 10m);
|
||
|
||
var rep = await api.GetJsonAsync("/api/reports/abc?metric=revenue");
|
||
var arr = rep.EnumerateArray().ToList();
|
||
arr.Should().HaveCountGreaterOrEqualTo(3);
|
||
|
||
var big = arr.First(x => x.GetProperty("productId").GetString() == pBig);
|
||
var med = arr.First(x => x.GetProperty("productId").GetString() == pMed);
|
||
var small = arr.First(x => x.GetProperty("productId").GetString() == pSmall);
|
||
big.GetProperty("abcClass").GetString().Should().Be("A");
|
||
med.GetProperty("abcClass").GetString().Should().Be("B");
|
||
small.GetProperty("abcClass").GetString().Should().Be("C");
|
||
|
||
// Накопительная доля C должна быть 100%.
|
||
small.GetProperty("cumulativeShare").GetDecimal().Should().BeApproximately(100m, 0.5m);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Empty_period_returns_empty()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"abc-emp-{Guid.NewGuid():N}");
|
||
var pastFrom = DateTime.UtcNow.AddYears(-5).ToString("o");
|
||
var pastTo = DateTime.UtcNow.AddYears(-4).ToString("o");
|
||
var rep = await api.GetJsonAsync($"/api/reports/abc?from={Uri.EscapeDataString(pastFrom)}&to={Uri.EscapeDataString(pastTo)}");
|
||
rep.EnumerateArray().Should().BeEmpty();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Profit_metric_orders_by_margin()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"abc-prft-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
|
||
// pA: 5 шт по 100 — высокая выручка, но низкая маржа (cost 80).
|
||
// pB: 2 шт по 100 — низкая выручка, но высокая маржа (cost 10).
|
||
var pA = await api.CreateProductAsync(refs, $"A-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
var pB = await api.CreateProductAsync(refs, $"B-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
async Task Supply(string pid, decimal qty, decimal cost)
|
||
{
|
||
var s = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new {
|
||
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
notes = "in", lines = new[] { new { productId = pid, quantity = qty, unitPrice = cost } } });
|
||
s.EnsureSuccessStatusCode();
|
||
var sid = (await s.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{sid}/post", new { })).EnsureSuccessStatusCode();
|
||
}
|
||
await Supply(pA, 10m, 80m);
|
||
await Supply(pB, 10m, 10m);
|
||
|
||
async Task Sale(string pid, decimal qty, decimal price)
|
||
{
|
||
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 = 0, paidCash = qty * price, paidCard = 0m,
|
||
lines = new[] { new { productId = pid, quantity = qty, unitPrice = price, discount = 0m, vatPercent = 12m } },
|
||
notes = "sale" });
|
||
resp.EnsureSuccessStatusCode();
|
||
var sid = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode();
|
||
}
|
||
await Sale(pA, 5m, 100m); // profit = 500 − 5*80 = 100
|
||
await Sale(pB, 2m, 100m); // profit = 200 − 2*10 = 180
|
||
|
||
// По revenue: A=500 > B=200, A первый (rank 1).
|
||
var byRev = await api.GetJsonAsync("/api/reports/abc?metric=revenue");
|
||
byRev.EnumerateArray().First().GetProperty("productId").GetString().Should().Be(pA);
|
||
|
||
// По profit: B=180 > A=100, B первый (rank 1).
|
||
var byProf = await api.GetJsonAsync("/api/reports/abc?metric=profit");
|
||
byProf.EnumerateArray().First().GetProperty("productId").GetString().Should().Be(pB);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Tenant_isolation_abc()
|
||
{
|
||
var a = new ApiActor(_factory.CreateClient());
|
||
var b = new ApiActor(_factory.CreateClient());
|
||
await a.SignupAndLoginAsync($"abc-iso-a-{Guid.NewGuid():N}");
|
||
await b.SignupAndLoginAsync($"abc-iso-b-{Guid.NewGuid():N}");
|
||
var refsA = await a.LoadRefsAsync();
|
||
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
var e = 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 = 5m, unitCost = 10m } } });
|
||
e.EnsureSuccessStatusCode();
|
||
var eid = (await e.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await a.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||
var sale = await a.Http.PostAsJsonAsync("/api/sales/retail", new {
|
||
date = DateTime.UtcNow, storeId = refsA.StoreId, retailPointId = (string?)null,
|
||
customerId = (string?)null, currencyId = refsA.CurrencyId, payment = 0, paidCash = 100m, paidCard = 0m,
|
||
lines = new[] { new { productId = pA, quantity = 1m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
||
notes = "s" });
|
||
sale.EnsureSuccessStatusCode();
|
||
var sid = (await sale.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await a.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
var repB = await b.GetJsonAsync("/api/reports/abc");
|
||
repB.EnumerateArray().Should().NotContain(x => x.GetProperty("productId").GetString() == pA);
|
||
}
|
||
}
|