feat(stock): фильтры остатков «В наличии / Нулевые / В минусе» (states, объединение ИЛИ)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Поручение владельца (задача 11): галочки состояния остатка объединяются (ИЛИ),
группа/склад/поиск сужают (И). Пусто → positive+negative (нулевые скрыты).

- API StockController: параметр states (positive|zero|negative; через запятую или
  повтором) на /api/inventory/stock и /stock/export. Один WHERE:
  (positive И qty>0) ИЛИ (zero И qty=0) ИЛИ (negative И qty<0) — по остатку
  (COALESCE подзапрос; товар без строки Stock = 0). total/пагинация/сортировка
  корректны. Старые includeZero/negativeOnly оставлены для совместимости
  (includeZero ≡ все три; negativeOnly ≡ negative); states приоритетнее.
- Веб-админка StockPage: три галочки «В наличии» (>0), «Нулевые» (=0), «В минусе»
  (<0) вместо прежних двух; состояние в URL (states=…), экспорт учитывает галочки.
  Ссылки NegativeStockBanner/OverdueNegativeStockBlock/NotificationCenter на
  минусовые → states=negative.
- Интеграционный тест: сочетания (zero; negative; positive; zero+negative;
  +группа; все три = весь каталог; пусто = positive+negative; со складом) и
  числовые тождества (positive+negative=пусто; positive+zero+negative=весь
  каталог) + совместимость старых флагов. 1/1 зелёный (EF8 переводит предикат).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-11 18:29:31 +05:00
parent 8371ef6683
commit d49b586904
6 changed files with 238 additions and 33 deletions

View file

@ -27,6 +27,7 @@ public record StockRow(
[FromQuery] Guid? groupId,
[FromQuery] Guid? productGroupId,
[FromQuery] string? search,
[FromQuery] List<string>? states = null,
[FromQuery] bool includeZero = false,
[FromQuery] bool negativeOnly = false,
[FromQuery] int page = 1,
@ -35,7 +36,8 @@ public record StockRow(
[FromQuery] string? order = null,
CancellationToken ct = default)
{
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct);
var (pos, zero, neg) = ResolveStates(states, includeZero, negativeOnly);
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, pos, zero, neg, ct);
var total = await products.CountAsync(ct);
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
@ -68,7 +70,7 @@ public record StockRow(
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
private async Task<IQueryable<Domain.Catalog.Product>> FilteredProductsAsync(
Guid? storeId, Guid? productId, Guid? groupId, string? search,
bool includeZero, bool negativeOnly, CancellationToken ct)
bool pos, bool zero, bool neg, CancellationToken ct)
{
var products = _db.Products.Where(p => !p.IsService && !p.IsArchived);
if (productId.HasValue) products = products.Where(p => p.Id == productId.Value);
@ -85,26 +87,45 @@ public record StockRow(
|| p.Barcodes.Any(b => EF.Functions.ILike(b.Code, term)));
}
// Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE
// EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory).
// Фильтр по состоянию остатка — ОДНИМ WHERE: объединение (ИЛИ) отмеченных состояний.
// Остаток = скалярный подзапрос с COALESCE(...,0) (товар без строки Stock = 0).
// pos/zero/neg — булевы, EF8 переводит их в параметры (@pos AND qty>0) OR ... .
// Группа/склад/поиск сужают (И), галочки состояния объединяются (ИЛИ).
var sid = storeId ?? Guid.Empty;
if (storeId.HasValue)
{
if (negativeOnly)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m);
else if (!includeZero)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) != 0m);
}
products = products.Where(p =>
(pos && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) > 0m)
|| (zero && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) == 0m)
|| (neg && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m));
else
{
if (negativeOnly)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m);
else if (!includeZero)
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) != 0m);
}
products = products.Where(p =>
(pos && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) > 0m)
|| (zero && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) == 0m)
|| (neg && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m));
return products;
}
/// <summary>Разбор набора состояний остатка. states (positive|zero|negative, через запятую
/// или повтором) приоритетнее старых флагов. Пусто → по умолчанию positive+negative
/// (товары с остатком, нулевые скрыты). Совместимость: includeZero=true ≡ все три;
/// negativeOnly=true ≡ только negative.</summary>
internal static (bool pos, bool zero, bool neg) ResolveStates(
IEnumerable<string>? states, bool includeZero, bool negativeOnly)
{
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
if (states is not null)
foreach (var raw in states)
foreach (var tok in (raw ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
if (tok is "positive" or "zero" or "negative")
set.Add(tok.ToLowerInvariant());
if (set.Count > 0)
return (set.Contains("positive"), set.Contains("zero"), set.Contains("negative"));
if (negativeOnly) return (false, false, true);
if (includeZero) return (true, true, true);
return (true, false, true); // по умолчанию: положительные + минусовые, нулевые скрыты
}
/// <summary>Сортировка списка остатков. По количеству/резерву/доступному —
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
private IQueryable<Domain.Catalog.Product> OrderProducts(
@ -181,10 +202,13 @@ private async Task<(Guid Sid, string StoreName)> StoreContextAsync(Guid? storeId
public async Task<IActionResult> ExportStock(
[FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId,
[FromQuery] string? search,
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
[FromQuery] List<string>? states = null,
[FromQuery] bool includeZero = false, [FromQuery] bool negativeOnly = false,
[FromQuery] string? format = null,
CancellationToken ct = default)
{
var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, includeZero, false, ct);
var (pos, zero, neg) = ResolveStates(states, includeZero, negativeOnly);
var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, pos, zero, neg, ct);
var paged = await products.OrderBy(p => p.Name)
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
var (_, storeName) = await StoreContextAsync(storeId, ct);

View file

@ -32,7 +32,7 @@ export function NegativeStockBanner() {
navigate(`/inventory/inventories/${res.data.inventoryId}`)
} catch {
// покажем список, чтобы пользователь разобрался вручную
navigate('/inventory/stock?negativeOnly=true')
navigate('/inventory/stock?states=negative')
} finally {
setBusy(false)
}
@ -63,7 +63,7 @@ export function NegativeStockBanner() {
</button>
<button
type="button"
onClick={() => navigate('/inventory/stock?negativeOnly=true')}
onClick={() => navigate('/inventory/stock?states=negative')}
className="text-xs text-amber-800 dark:text-amber-300 underline hover:text-amber-900"
>
Список

View file

@ -92,7 +92,7 @@ export function NotificationCenter() {
navigate(`/inventory/inventories/${res.data.inventoryId}`)
} catch {
setOpen(false)
navigate(n.link ?? '/inventory/stock?negativeOnly=true')
navigate(n.link ?? '/inventory/stock?states=negative')
} finally {
setRecounting(null)
}

View file

@ -31,7 +31,7 @@ export function OverdueNegativeStockBlock() {
const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId })
navigate(`/inventory/inventories/${res.data.inventoryId}`)
} catch {
navigate(`/inventory/stock?negativeOnly=true&storeId=${storeId}`)
navigate(`/inventory/stock?states=negative&storeId=${storeId}`)
} finally {
setBusy(null)
}

View file

@ -16,22 +16,48 @@ export function StockPage() {
const stores = useStores()
const [storeId, setStoreId] = useState('')
const [search, setSearch] = useState('')
const [includeZero, setIncludeZero] = useState(false)
const [searchParams] = useSearchParams()
const [negativeOnly, setNegativeOnly] = useState(searchParams.get('negativeOnly') === 'true')
const [searchParams, setSearchParams] = useSearchParams()
// Три галочки состояния остатка (объединяются ИЛИ). Инициализация из URL
// (states=... или старый negativeOnly/includeZero). Ни одна не отмечена →
// по умолчанию: товары с остатком (плюс и минус), нулевые скрыты.
const initStates = (() => {
const s = searchParams.get('states')
if (s) { const set = s.split(','); return { pos: set.includes('positive'), zero: set.includes('zero'), neg: set.includes('negative') } }
if (searchParams.get('negativeOnly') === 'true') return { pos: false, zero: false, neg: true }
if (searchParams.get('includeZero') === 'true') return { pos: true, zero: true, neg: true }
return { pos: false, zero: false, neg: false }
})()
const [pos, setPos] = useState(initStates.pos)
const [zero, setZero] = useState(initStates.zero)
const [neg, setNeg] = useState(initStates.neg)
const [page, setPage] = useState(1)
const [sortKey, setSortKey] = useState<string | null>(null)
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) }
const checked = [pos && 'positive', zero && 'zero', neg && 'negative'].filter(Boolean) as string[]
const statesCsv = checked.join(',')
// Переключить галочку + отразить в URL (ссылку можно скопировать/поделиться).
const toggle = (next: { pos?: boolean; zero?: boolean; neg?: boolean }) => {
const np = next.pos ?? pos, nz = next.zero ?? zero, nn = next.neg ?? neg
if (next.pos !== undefined) setPos(next.pos)
if (next.zero !== undefined) setZero(next.zero)
if (next.neg !== undefined) setNeg(next.neg)
setPage(1)
const csv = [np && 'positive', nz && 'zero', nn && 'negative'].filter(Boolean).join(',')
const sp = new URLSearchParams(searchParams)
sp.delete('negativeOnly'); sp.delete('includeZero')
if (csv) sp.set('states', csv); else sp.delete('states')
setSearchParams(sp, { replace: true })
}
const { data, isLoading } = useQuery({
queryKey: ['/api/inventory/stock', { storeId, search, includeZero, negativeOnly, page, sortKey, sortOrder }],
queryKey: ['/api/inventory/stock', { storeId, search, statesCsv, page, sortKey, sortOrder }],
queryFn: async () => {
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
if (storeId) params.set('storeId', storeId)
if (search) params.set('search', search)
if (negativeOnly) params.set('negativeOnly', 'true')
else if (includeZero) params.set('includeZero', 'true')
if (statesCsv) params.set('states', statesCsv)
if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) }
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
},
@ -50,10 +76,11 @@ export function StockPage() {
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</div>
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
<Checkbox label="Только минусы" checked={negativeOnly} onChange={(v) => { setNegativeOnly(v); setPage(1) }} />
<Checkbox label="В наличии" checked={pos} onChange={(v) => toggle({ pos: v })} />
<Checkbox label="Нулевые" checked={zero} onChange={(v) => toggle({ zero: v })} />
<Checkbox label="В минусе" checked={neg} onChange={(v) => toggle({ neg: v })} />
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, includeZero }} defaultName="stock" />
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, states: statesCsv || undefined }} defaultName="stock" />
</div>
}
footer={data && data.total > 0 && (
@ -63,8 +90,8 @@ export function StockPage() {
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
empty={!includeZero && !negativeOnly
? 'Нет товаров с остатком — включите «Показать нулевые», чтобы увидеть все товары'
empty={checked.length === 0
? 'Нет товаров с остатком — отметьте «Нулевые», чтобы увидеть товары с нулевым остатком'
: 'Нет данных'}
rowKey={(r) => `${r.productId}:${r.storeId}`}
sortKey={sortKey}

View file

@ -0,0 +1,154 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Задача 11: фильтры остатков «В наличии / Нулевые / В минусе».
/// states (positive|zero|negative) объединяются ИЛИ; группа/склад сужают И.
/// Пусто → positive+negative (нулевые скрыты). Проверяем сочетания и числовые
/// тождества (positive+negative=пусто; positive+zero+negative=весь каталог).</summary>
[Collection(ApiCollection.Name)]
public class StockStateFilterTests
{
private readonly ApiFactory _factory;
public StockStateFilterTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
private static async Task EnterAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
{
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId, quantity = qty, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var id = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{id}/post", new { })).EnsureSuccessStatusCode();
}
private static async Task LossAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
{
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
reason = 0, notes = "seed-minus", lines = new[] { new { productId, quantity = qty, unitCost = 50m } },
});
loss.EnsureSuccessStatusCode();
var id = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/losses/{id}/post", new { })).EnsureSuccessStatusCode();
}
private static async Task<string> CreateGroupAsync(ApiActor api, string name)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/product-groups", new { name, parentId = (string?)null });
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
private static async Task<string> CreateProductInGroupAsync(ApiActor api, ApiActor.Refs refs, string name, string groupId, string barcode)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new
{
name, article = (string?)null, description = (string?)null,
unitOfMeasureId = refs.UnitId, vat = 12, vatEnabled = true,
productGroupId = groupId, isService = false, packaging = 0, isMarked = false,
referencePrice = 70m, purchaseCurrencyId = refs.CurrencyId,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 100m, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
private async Task<(int total, HashSet<string> ids)> QueryAsync(ApiActor api, string qs)
{
var j = await api.GetJsonAsync($"/api/inventory/stock?{qs}&pageSize=500");
var ids = j.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()!).ToHashSet();
return (j.GetProperty("total").GetInt32(), ids);
}
[Fact]
public async Task State_filters_union_and_group_narrows_and_totals_partition()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"stk-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// В группе по умолчанию: положительный, минусовой, нулевой (без строки Stock).
var pPos = await api.CreateProductAsync(refs, $"POS-{Guid.NewGuid():N}", 100m, RandomBarcode());
await EnterAsync(api, refs, pPos, 5m);
var pNeg = await api.CreateProductAsync(refs, $"NEG-{Guid.NewGuid():N}", 100m, RandomBarcode());
await EnterAsync(api, refs, pNeg, 2m); await LossAsync(api, refs, pNeg, 5m); // −3
var pZero = await api.CreateProductAsync(refs, $"ZERO-{Guid.NewGuid():N}", 100m, RandomBarcode()); // нет остатка → 0
// В другой группе: ещё один минусовой (для проверки сужения по группе).
var gOther = await CreateGroupAsync(api, $"G-{Guid.NewGuid():N}");
var pOtherNeg = await CreateProductInGroupAsync(api, refs, $"ONEG-{Guid.NewGuid():N}", gOther, RandomBarcode());
await EnterAsync(api, refs, pOtherNeg, 1m); await LossAsync(api, refs, pOtherNeg, 4m); // −3
// Контрольные остатки.
(await api.StockOfAsync(refs.StoreId, pPos)).Should().Be(5m);
(await api.StockOfAsync(refs.StoreId, pNeg)).Should().Be(-3m);
(await api.StockOfAsync(refs.StoreId, pZero)).Should().Be(0m);
// Пусто → positive+negative (нулевые скрыты).
var def = await QueryAsync(api, "");
def.ids.Should().Contain(new[] { pPos, pNeg, pOtherNeg });
def.ids.Should().NotContain(pZero);
// Только нулевые.
var zero = await QueryAsync(api, "states=zero");
zero.ids.Should().Contain(pZero);
zero.ids.Should().NotContain(new[] { pPos, pNeg, pOtherNeg });
// Только минусовые.
var neg = await QueryAsync(api, "states=negative");
neg.ids.Should().Contain(new[] { pNeg, pOtherNeg });
neg.ids.Should().NotContain(new[] { pPos, pZero });
// Только в наличии.
var pos = await QueryAsync(api, "states=positive");
pos.ids.Should().Contain(pPos);
pos.ids.Should().NotContain(new[] { pNeg, pZero, pOtherNeg });
// Нулевые + минусовые (объединение).
var zn = await QueryAsync(api, "states=zero,negative");
zn.ids.Should().Contain(new[] { pZero, pNeg, pOtherNeg });
zn.ids.Should().NotContain(pPos);
// Нулевые + минусовые ТОЛЬКО внутри группы по умолчанию (сужение по группе).
var znGroup = await QueryAsync(api, $"states=zero,negative&groupId={refs.GroupId}");
znGroup.ids.Should().Contain(new[] { pZero, pNeg });
znGroup.ids.Should().NotContain(new[] { pPos, pOtherNeg });
// Минусовые внутри группы по умолчанию → только pNeg (pOtherNeg в другой группе).
var negGroup = await QueryAsync(api, $"states=negative&groupId={refs.GroupId}");
negGroup.ids.Should().Contain(pNeg);
negGroup.ids.Should().NotContain(pOtherNeg);
// Все три = весь каталог.
var all = await QueryAsync(api, "states=positive,zero,negative");
all.ids.Should().Contain(new[] { pPos, pNeg, pZero, pOtherNeg });
// Числовые тождества.
(pos.total + neg.total).Should().Be(def.total, "positive + negative = пусто (по умолчанию)");
(pos.total + zero.total + neg.total).Should().Be(all.total, "positive + zero + negative = весь каталог");
// Совместимость старых флагов: negativeOnly ≡ negative; includeZero ≡ все три.
var legacyNeg = await QueryAsync(api, "negativeOnly=true");
legacyNeg.ids.Should().BeEquivalentTo(neg.ids);
var legacyAll = await QueryAsync(api, "includeZero=true");
legacyAll.total.Should().Be(all.total);
// Тот же фильтр со складом (другая ветка кода — по конкретному складу).
var negStore = await QueryAsync(api, $"states=negative&storeId={refs.StoreId}");
negStore.ids.Should().Contain(new[] { pNeg, pOtherNeg });
negStore.ids.Should().NotContain(new[] { pPos, pZero });
}
}