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>
This commit is contained in:
nns 2026-05-28 11:14:24 +05:00
parent 76380d86bf
commit 63c58ef6c1
5 changed files with 441 additions and 0 deletions

View file

@ -0,0 +1,146 @@
using foodmarket.Infrastructure.Persistence;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace foodmarket.Api.Controllers.Reports;
/// <summary>Отчёт «Остатки на дату».
///
/// Восстанавливает остаток (Product, Store) на произвольный момент,
/// агрегируя журнал <see cref="Domain.Inventory.StockMovement"/> до этой даты.
/// Текущий <see cref="Domain.Inventory.Stock"/> является результатом
/// Σ движений (инвариант учёта), поэтому подсчёт на «сейчас» совпадает с
/// материализованным остатком; подсчёт на прошлую дату — реконструкция.
///
/// Edge-cases:
/// • Дата в будущем — возвращаем текущий остаток (Σ до сейчас): движений
/// из будущего нет, фильтр на это пропускает.
/// • Дата раньше первой операции — возвращаем нули (или вообще не отдаём
/// строку, потому что и таких пар (Product, Store) не было).
///
/// Стоимость остатка считаем по последнему известному `UnitCost` движения
/// до этой даты ИЛИ снимаем с `Product.Cost` если в движениях не было ни
/// одной партии (например, оприходование без UnitCost). Это компромисс
/// для приближённой оценки — точная FIFO/LIFO потребует партий, которые
/// в текущем учёте не ведутся.</summary>
[ApiController]
[Authorize]
[Route("api/reports/stock")]
public class StockReportController : ControllerBase
{
private readonly AppDbContext _db;
public StockReportController(AppDbContext db) => _db = db;
public record StockRow(
Guid ProductId, string ProductName, string? ProductArticle, string? UnitName,
Guid StoreId, string StoreName,
decimal Quantity, decimal Cost, decimal Value);
[HttpGet]
public async Task<ActionResult<IReadOnlyList<StockRow>>> Get(
[FromQuery] DateTime? date,
[FromQuery] Guid? storeId,
[FromQuery] Guid? productGroupId,
[FromQuery] bool includeZero = false,
CancellationToken ct = default)
{
var on = date ?? DateTime.UtcNow;
return Ok(await BuildAsync(on, storeId, productGroupId, includeZero, ct));
}
[HttpGet("export")]
public async Task<IActionResult> Export(
[FromQuery] DateTime? date,
[FromQuery] Guid? storeId,
[FromQuery] Guid? productGroupId,
[FromQuery] bool includeZero = false,
[FromQuery] string format = "csv",
CancellationToken ct = default)
{
var on = date ?? DateTime.UtcNow;
var rows = await BuildAsync(on, storeId, productGroupId, includeZero, ct);
var name = $"stock-{on:yyyyMMdd}";
var headers = new[] { "ProductId", "Товар", "Артикул", "Ед.", "StoreId", "Склад", "Кол-во", "Цена", "Стоимость" };
return format.ToLower() switch
{
"xlsx" => ReportExport.Xlsx(rows, name, "Stock", headers),
_ => ReportExport.Csv(rows, name, headers),
};
}
private async Task<List<StockRow>> BuildAsync(
DateTime on, Guid? storeId, Guid? productGroupId, bool includeZero, CancellationToken ct)
{
// Σ Quantity и последний UnitCost (через max OccurredAt) на (Product, Store)
// — два отдельных запроса; объединяем в памяти. Альтернатива через window
// functions требует sql-raw; на типовых объёмах (десятки тыс. движений)
// двойной round-trip с агрегатом дешевле и читабельнее.
var movQ = _db.StockMovements.AsNoTracking().Where(m => m.OccurredAt <= on);
if (storeId is not null) movQ = movQ.Where(m => m.StoreId == storeId);
var qtyByPair = await movQ
.GroupBy(m => new { m.ProductId, m.StoreId })
.Select(g => new { g.Key.ProductId, g.Key.StoreId, Qty = g.Sum(m => m.Quantity) })
.ToListAsync(ct);
// Последний UnitCost (max OccurredAt с не-null UnitCost) на пару.
var lastCostsRaw = await movQ
.Where(m => m.UnitCost != null)
.GroupBy(m => new { m.ProductId, m.StoreId })
.Select(g => new
{
g.Key.ProductId,
g.Key.StoreId,
LastCost = g.OrderByDescending(m => m.OccurredAt).Select(m => m.UnitCost).First(),
})
.ToListAsync(ct);
var lastCosts = lastCostsRaw
.ToDictionary(x => (x.ProductId, x.StoreId), x => x.LastCost ?? 0m);
if (productGroupId is not null)
{
// Фильтр по группе — отдельным запросом, чтобы получить ProductIds.
var allowed = await _db.Products.AsNoTracking()
.Where(p => p.ProductGroupId == productGroupId)
.Select(p => p.Id)
.ToListAsync(ct);
var set = allowed.ToHashSet();
qtyByPair = qtyByPair.Where(x => set.Contains(x.ProductId)).ToList();
}
if (!includeZero) qtyByPair = qtyByPair.Where(x => x.Qty != 0m).ToList();
// Подтаскиваем имена товара/склада.
var productIds = qtyByPair.Select(x => x.ProductId).Distinct().ToList();
var storeIds = qtyByPair.Select(x => x.StoreId).Distinct().ToList();
var products = await _db.Products.AsNoTracking()
.Where(p => productIds.Contains(p.Id))
.Join(_db.UnitsOfMeasure.AsNoTracking(), p => p.UnitOfMeasureId, u => u.Id,
(p, u) => new { p.Id, p.Name, p.Article, p.Cost, UnitName = u.Name })
.ToListAsync(ct);
var stores = await _db.Stores.AsNoTracking()
.Where(s => storeIds.Contains(s.Id))
.Select(s => new { s.Id, s.Name })
.ToListAsync(ct);
var pMap = products.ToDictionary(p => p.Id);
var sMap = stores.ToDictionary(s => s.Id);
return qtyByPair
.Select(x =>
{
pMap.TryGetValue(x.ProductId, out var p);
sMap.TryGetValue(x.StoreId, out var s);
lastCosts.TryGetValue((x.ProductId, x.StoreId), out var cost);
if (cost == 0m && p is not null) cost = p.Cost;
return new StockRow(
x.ProductId, p?.Name ?? "(unknown)", p?.Article, p?.UnitName,
x.StoreId, s?.Name ?? "(unknown)",
x.Qty, cost, x.Qty * cost);
})
.OrderBy(r => r.ProductName)
.ThenBy(r => r.StoreName)
.ToList();
}
}

View file

@ -39,6 +39,7 @@ import { InventoryEditPage } from '@/pages/InventoryEditPage'
import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage'
import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage'
import { SalesReportPage } from '@/pages/SalesReportPage' import { SalesReportPage } from '@/pages/SalesReportPage'
import { StockReportPage } from '@/pages/StockReportPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
@ -127,6 +128,7 @@ export default function App() {
<Route path="/purchases/supplier-returns/new" element={<SupplierReturnEditPage />} /> <Route path="/purchases/supplier-returns/new" element={<SupplierReturnEditPage />} />
<Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} /> <Route path="/purchases/supplier-returns/:id" element={<SupplierReturnEditPage />} />
<Route path="/reports/sales" element={<SalesReportPage />} /> <Route path="/reports/sales" element={<SalesReportPage />} />
<Route path="/reports/stock" element={<StockReportPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

@ -112,6 +112,7 @@ function buildNav(roles: string[]): NavSection[] {
if (isAdmin || isStorekeeper) { if (isAdmin || isStorekeeper) {
sections.push({ group: 'Отчёты', items: [ sections.push({ group: 'Отчёты', items: [
{ to: '/reports/sales', icon: BarChart3, label: 'Продажи' }, { to: '/reports/sales', icon: BarChart3, label: 'Продажи' },
{ to: '/reports/stock', icon: Boxes, label: 'Остатки на дату' },
]}) ]})
} }

View file

@ -0,0 +1,154 @@
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Download } from 'lucide-react'
import { api } from '@/lib/api'
import { Button } from '@/components/Button'
import { Field, Select, Checkbox } from '@/components/Field'
import { DateField } from '@/components/DateField'
import { useStores, useProductGroups } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { type StockReportRow } from '@/lib/types'
/** Отчёт «Остатки на дату». Реконструкция через журнал StockMovement. */
export function StockReportPage() {
const today = new Date().toISOString().slice(0, 10)
const [date, setDate] = useState<string>(today)
const [storeId, setStoreId] = useState<string>('')
const [productGroupId, setProductGroupId] = useState<string>('')
const [includeZero, setIncludeZero] = useState<boolean>(false)
const stores = useStores()
const groups = useProductGroups()
const org = useOrgSettings()
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
const params = (extra: Record<string, string> = {}) => {
const p = new URLSearchParams({
date: new Date(`${date}T23:59:59`).toISOString(),
...extra,
})
if (storeId) p.set('storeId', storeId)
if (productGroupId) p.set('productGroupId', productGroupId)
if (includeZero) p.set('includeZero', 'true')
return p
}
const rep = useQuery({
queryKey: ['stock-report', date, storeId, productGroupId, includeZero],
queryFn: async () => (await api.get<StockReportRow[]>(`/api/reports/stock?${params()}`)).data,
})
const exportFile = async (format: 'csv' | 'xlsx') => {
const resp = await api.get(`/api/reports/stock/export?${params({ format })}`, { responseType: 'blob' })
const blob = new Blob([resp.data])
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
const cd = resp.headers['content-disposition'] as string | undefined
const match = cd?.match(/filename="?([^";]+)"?/i)
a.download = match?.[1] ?? `stock-report.${format}`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
const total = rep.data?.reduce((s, r) => s + r.value, 0) ?? 0
const items = rep.data?.length ?? 0
return (
<div className="flex flex-col h-full">
<div className="px-6 py-4 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<h1 className="text-lg font-semibold text-slate-900 dark:text-slate-100">Отчёт «Остатки на дату»</h1>
<p className="text-xs text-slate-500 mt-1">
Реконструкция через журнал движений. Стоимость последний UnitCost
движения; если в журнале нет Product.Cost (приближённая оценка).
</p>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4">
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-x-4 gap-y-3 items-end">
<Field label="На дату">
<DateField value={date} onChange={(v) => setDate(v ?? today)} />
</Field>
<Field label="Склад">
<Select value={storeId} onChange={(e) => setStoreId(e.target.value)}>
<option value="">Все</option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="Группа товаров">
<Select value={productGroupId} onChange={(e) => setProductGroupId(e.target.value)}>
<option value="">Все</option>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
</Select>
</Field>
<Field label=" ">
<Checkbox label="Показывать нулевые остатки" checked={includeZero} onChange={setIncludeZero} />
</Field>
</div>
<div className="mt-4 flex flex-wrap gap-3 items-center justify-between">
<div className="flex gap-2 text-sm">
<span className="px-3 py-1.5 rounded bg-slate-50 dark:bg-slate-800/60">
Позиций: <span className="font-mono font-semibold">{items.toLocaleString('ru')}</span>
</span>
<span className="px-3 py-1.5 rounded bg-slate-50 dark:bg-slate-800/60">
Стоимость: <span className="font-mono font-semibold">{total.toLocaleString('ru', moneyFmt)}</span>
</span>
</div>
<div className="flex gap-2">
<Button type="button" variant="secondary" size="sm" onClick={() => exportFile('csv')}>
<Download className="w-4 h-4" /> CSV
</Button>
<Button type="button" variant="secondary" size="sm" onClick={() => exportFile('xlsx')}>
<Download className="w-4 h-4" /> XLSX
</Button>
</div>
</div>
</section>
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
{rep.isLoading && <div className="text-sm text-slate-500 py-6 text-center">Загружаю</div>}
{!rep.isLoading && (rep.data?.length ?? 0) === 0 && (
<div className="text-sm text-slate-500 py-6 text-center">На эту дату нет остатков.</div>
)}
{!rep.isLoading && rep.data && rep.data.length > 0 && (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[100px]">Артикул</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[80px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[180px]">Склад</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[120px] text-right">Кол-во</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[120px] text-right">Цена</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[140px] text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{rep.data.map((r) => (
<tr key={`${r.productId}-${r.storeId}`} className="border-b border-slate-100 dark:border-slate-800 hover:bg-slate-50 dark:hover:bg-slate-800/30">
<td className="py-2 pr-3">{r.productName}</td>
<td className="py-2 px-3 text-slate-500">{r.productArticle ?? '—'}</td>
<td className="py-2 px-3 text-slate-500">{r.unitName ?? '—'}</td>
<td className="py-2 px-3 text-slate-500">{r.storeName}</td>
<td className="py-2 px-3 text-right font-mono">{r.quantity.toLocaleString('ru')}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">{r.cost.toLocaleString('ru', moneyFmt)}</td>
<td className="py-2 px-3 text-right font-mono">{r.value.toLocaleString('ru', moneyFmt)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,138 @@
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);
}
}