food-market/tests/food-market.IntegrationTests/StockReportTests.cs
nns 63c58ef6c1 feat(reports): отчёт «Остатки на дату» с реконструкцией (P1-9)
GET /api/reports/stock?date=… — восстанавливает остатки (Product, Store)
агрегацией журнала StockMovement до указанной даты. На «сейчас» совпадает
с материализованным Stock (инвариант учёта); на прошлую дату — реальная
реконструкция через Σ движений.

Edge-cases:
• дата в будущем → текущий остаток (движений из будущего нет);
• дата раньше первой операции → пусто (пара не существовала);
• операция с future-датой исключена снимком на «сегодня».

Стоимость: последний UnitCost движения до даты на пару (Product, Store);
fallback на Product.Cost если в журнале не было ни одной партии. Это
приближённая оценка — точный FIFO требует партий.

Параметры: storeId, productGroupId, includeZero (вернуть и нулевые
позиции). Export CSV/XLSX через тот же ReportExport.

Web: /reports/stock — дата, фильтры, экспорт, итоговая стоимость.

Тесты: 5 интеграционных (today=current, before-first-mov→empty, future=current,
date-before-op-excludes-it, tenant-изоляция).

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

139 lines
7.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 StockReportTests
{
private readonly ApiFactory _factory;
public StockReportTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Реконструкция остатков на «сейчас» совпадает с текущим Stock.</summary>
[Fact]
public async Task Today_matches_current_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"strpt-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
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 = p1, quantity = 7m, unitCost = 30m } } });
e.EnsureSuccessStatusCode();
var eid = (await e.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var rep = await api.GetJsonAsync("/api/reports/stock");
var rows = rep.EnumerateArray().ToList();
var r = rows.First(x => x.GetProperty("productId").GetString() == p1);
r.GetProperty("quantity").GetDecimal().Should().Be(7m);
r.GetProperty("cost").GetDecimal().Should().Be(30m);
r.GetProperty("value").GetDecimal().Should().Be(210m);
}
/// <summary>Запрос на дату до первой операции — пусто.</summary>
[Fact]
public async Task Date_before_first_movement_returns_empty()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"strpt-pre-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Принять товар прямо сейчас.
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 = p1, quantity = 3m, 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();
// Запрос на год назад — ничего.
var pastDate = DateTime.UtcNow.AddYears(-1).ToString("o");
var rep = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(pastDate)}");
var rows = rep.EnumerateArray().ToList();
rows.Should().NotContain(x => x.GetProperty("productId").GetString() == p1);
}
/// <summary>Дата в будущем — отчёт = текущий остаток.</summary>
[Fact]
public async Task Future_date_equals_current()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"strpt-fut-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
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 = p1, quantity = 5m, unitCost = 20m } } });
e.EnsureSuccessStatusCode();
var eid = (await e.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var future = DateTime.UtcNow.AddYears(1).ToString("o");
var rep = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(future)}");
var r = rep.EnumerateArray().First(x => x.GetProperty("productId").GetString() == p1);
r.GetProperty("quantity").GetDecimal().Should().Be(5m);
}
/// <summary>Снимок «вчера» — без учёта сегодняшней операции.</summary>
[Fact]
public async Task Date_before_operation_excludes_it()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"strpt-bef-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// Принять с future-датой (так контроллер использует входящую дату).
var date = DateTime.UtcNow.AddDays(2);
var e = await api.Http.PostAsJsonAsync("/api/inventory/enters", new {
date, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "future-supply", lines = new[] { new { productId = p1, quantity = 4m, 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();
// На сейчас (раньше OccurredAt = date) — товара ещё нет.
var now = DateTime.UtcNow.ToString("o");
var rep = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(now)}");
var rows = rep.EnumerateArray().ToList();
rows.Should().NotContain(x => x.GetProperty("productId").GetString() == p1);
// На дату ПОСЛЕ операции — товар появился.
var after = date.AddDays(1).ToString("o");
var rep2 = await api.GetJsonAsync($"/api/reports/stock?date={Uri.EscapeDataString(after)}");
var r = rep2.EnumerateArray().First(x => x.GetProperty("productId").GetString() == p1);
r.GetProperty("quantity").GetDecimal().Should().Be(4m);
}
[Fact]
public async Task Tenant_isolation_stock_report()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"strpt-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"strpt-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 = 1m, unitCost = 5m } } });
e.EnsureSuccessStatusCode();
var eid = (await e.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await a.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var repB = await b.GetJsonAsync("/api/reports/stock");
repB.EnumerateArray().Should().NotContain(x => x.GetProperty("productId").GetString() == pA);
}
}