food-market/tests/food-market.IntegrationTests/ProfitReportTests.cs
nns 3db112cbee feat(reports): отчёт «Прибыль» (выручка − COGS) (P1-10)
GET /api/reports/profit с группировками period:day/week/month, product,
group (по группе товаров). Cost-snapshot — Product.Cost (скользящее
среднее, документировано как приближение; точный FIFO требует партий).
Маржа = profit/revenue·100. Защита от деления на ноль при нулевой
выручке (пустой период → margin = 0, не NaN).

Возвраты вычитаются и из выручки, и из COGS (returned line делает
−Quantity·UnitPrice выручки и −Quantity·Cost себестоимости).

Export CSV/XLSX через тот же ReportExport.

Web: /reports/profit с KPI-плашками (общая выручка/себестоимость/прибыль
+ маржа) — прибыль зелёным/красным в зависимости от знака.

Тесты: 3 интеграционных (simple profit calc 4×100−4×50=200=50%; empty
period → пустой набор без 500/NaN; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:19:19 +05:00

99 lines
5.5 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 ProfitReportTests
{
private readonly ApiFactory _factory;
public ProfitReportTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Купили 10 шт по 50 (cost после Supply post = 50), продали 4 шт по
/// 100. Прибыль = 400 200 = 200, маржа = 50%.</summary>
[Fact]
public async Task Profit_simple_supply_sale()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"prft-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var supplierId = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Приёмка: 10 шт по 50.
var sup = await api.Http.PostAsJsonAsync("/api/purchases/supplies", new {
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "init", lines = new[] { new { productId = p1, quantity = 10m, unitPrice = 50m } } });
sup.EnsureSuccessStatusCode();
var supId = (await sup.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/purchases/supplies/{supId}/post", new { })).EnsureSuccessStatusCode();
// Продажа: 4 шт по 100.
var sale = 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 = 400m, paidCard = 0m,
lines = new[] { new { productId = p1, quantity = 4m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
notes = "sale" });
sale.EnsureSuccessStatusCode();
var sid = (await sale.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{sid}/post", new { })).EnsureSuccessStatusCode();
var rep = await api.GetJsonAsync("/api/reports/profit?groupBy=product");
var r = rep.EnumerateArray().First(x => x.GetProperty("key").GetString() == p1);
r.GetProperty("revenue").GetDecimal().Should().Be(400m);
r.GetProperty("cost").GetDecimal().Should().Be(200m);
r.GetProperty("profit").GetDecimal().Should().Be(200m);
r.GetProperty("marginPercent").GetDecimal().Should().Be(50m);
}
/// <summary>Защита от деления на ноль: при нулевой выручке (продажи=0) margin=0,
/// а не NaN/Infinity. Проверяем что хотя бы пустой набор возвращается без 500.</summary>
[Fact]
public async Task Empty_period_returns_no_rows_no_division_by_zero()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"prft-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/profit?groupBy=product&from={Uri.EscapeDataString(pastFrom)}&to={Uri.EscapeDataString(pastTo)}");
rep.EnumerateArray().Should().BeEmpty();
}
[Fact]
public async Task Tenant_isolation_profit()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"prft-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"prft-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var supplierA = await a.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}");
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
var sup = await a.Http.PostAsJsonAsync("/api/purchases/supplies", new {
date = DateTime.UtcNow, supplierId = supplierA, storeId = refsA.StoreId, currencyId = refsA.CurrencyId,
notes = "init", lines = new[] { new { productId = pA, quantity = 5m, unitPrice = 20m } } });
sup.EnsureSuccessStatusCode();
var supId = (await sup.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await a.Http.PostAsJsonAsync($"/api/purchases/supplies/{supId}/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 = "sale" });
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/profit?groupBy=product");
repB.EnumerateArray().Should().NotContain(x => x.GetProperty("key").GetString() == pA);
}
}