food-market/tests/food-market.IntegrationTests/AbcReportTests.cs
nns dcf8f60b67 feat(reports): ABC-анализ по Парето (P1-11)
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>
2026-05-28 11:24:26 +05:00

159 lines
8.8 KiB
C#
Raw Permalink 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 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);
}
}