From d49b5869042ad74cdf8af3d7ddac40bf518c92f7 Mon Sep 17 00:00:00 2001 From: nns Date: Sun, 11 Oct 2026 18:29:31 +0500 Subject: [PATCH] =?UTF-8?q?feat(stock):=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82?= =?UTF-8?q?=D1=80=D1=8B=20=D0=BE=D1=81=D1=82=D0=B0=D1=82=D0=BA=D0=BE=D0=B2?= =?UTF-8?q?=20=C2=AB=D0=92=20=D0=BD=D0=B0=D0=BB=D0=B8=D1=87=D0=B8=D0=B8=20?= =?UTF-8?q?/=20=D0=9D=D1=83=D0=BB=D0=B5=D0=B2=D1=8B=D0=B5=20/=20=D0=92=20?= =?UTF-8?q?=D0=BC=D0=B8=D0=BD=D1=83=D1=81=D0=B5=C2=BB=20(states,=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=8A=D0=B5=D0=B4=D0=B8=D0=BD=D0=B5=D0=BD=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=98=D0=9B=D0=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Поручение владельца (задача 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 --- .../Controllers/Inventory/StockController.cs | 60 +++++-- .../src/components/NegativeStockBanner.tsx | 4 +- .../src/components/NotificationCenter.tsx | 2 +- .../components/OverdueNegativeStockBlock.tsx | 2 +- src/food-market.web/src/pages/StockPage.tsx | 49 ++++-- .../StockStateFilterTests.cs | 154 ++++++++++++++++++ 6 files changed, 238 insertions(+), 33 deletions(-) create mode 100644 tests/food-market.IntegrationTests/StockStateFilterTests.cs diff --git a/src/food-market.api/Controllers/Inventory/StockController.cs b/src/food-market.api/Controllers/Inventory/StockController.cs index 87b4972..a31f70a 100644 --- a/src/food-market.api/Controllers/Inventory/StockController.cs +++ b/src/food-market.api/Controllers/Inventory/StockController.cs @@ -27,6 +27,7 @@ public record StockRow( [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId, [FromQuery] string? search, + [FromQuery] List? 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). private async Task> 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; } + /// Разбор набора состояний остатка. states (positive|zero|negative, через запятую + /// или повтором) приоритетнее старых флагов. Пусто → по умолчанию positive+negative + /// (товары с остатком, нулевые скрыты). Совместимость: includeZero=true ≡ все три; + /// negativeOnly=true ≡ только negative. + internal static (bool pos, bool zero, bool neg) ResolveStates( + IEnumerable? states, bool includeZero, bool negativeOnly) + { + var set = new HashSet(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); // по умолчанию: положительные + минусовые, нулевые скрыты + } + /// Сортировка списка остатков. По количеству/резерву/доступному — /// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит). private IQueryable OrderProducts( @@ -181,10 +202,13 @@ private async Task<(Guid Sid, string StoreName)> StoreContextAsync(Guid? storeId public async Task ExportStock( [FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId, [FromQuery] string? search, - [FromQuery] bool includeZero = false, [FromQuery] string? format = null, + [FromQuery] List? 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); diff --git a/src/food-market.web/src/components/NegativeStockBanner.tsx b/src/food-market.web/src/components/NegativeStockBanner.tsx index 2402d0a..b4b53de 100644 --- a/src/food-market.web/src/components/NegativeStockBanner.tsx +++ b/src/food-market.web/src/components/NegativeStockBanner.tsx @@ -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() {