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() {