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>
99 lines
5.5 KiB
C#
99 lines
5.5 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 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);
|
||
}
|
||
}
|