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>
139 lines
7.5 KiB
C#
139 lines
7.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 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);
|
||
}
|
||
}
|