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))); /// 3 товара с явно разной долей выручки → классы A/B/C по Парето. [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()).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()).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()).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()).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()).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()).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); } }