From 48babf0d1069b7ece687ca0c749f0b3de26a9a45 Mon Sep 17 00:00:00 2001 From: nns <278048682+nurdotnet@users.noreply.github.com> Date: Sun, 26 Apr 2026 01:53:07 +0500 Subject: [PATCH] feat(api): products quick-search + by-barcode endpoints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/catalog/products/quick-search?search=&storeId=&limit=20 — лёгкий поиск для inline-добавления строк в документы. Ранжирует по приоритету: точный barcode → точный article → префикс article → префикс name → name contains. Возвращает QuickSearchItem с stockQty по storeId (если передан) или сумме по всем складам. GET /api/catalog/products/by-barcode/{value}?storeId= — точный поиск для сканера. 404 если 0 совпадений, объект QuickSearchItem если 1, { items: [...] } если несколько (для диалога выбора). Why: новый UX inline-добавления строк в приёмке требует быстрого поиска по штрихкоду/артикулу/названию с показом остатков прямо в дропдауне; полный /products endpoint слишком тяжёлый. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Controllers/Catalog/ProductsController.cs | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/src/food-market.api/Controllers/Catalog/ProductsController.cs b/src/food-market.api/Controllers/Catalog/ProductsController.cs index 95b220d..be95457 100644 --- a/src/food-market.api/Controllers/Catalog/ProductsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductsController.cs @@ -391,6 +391,100 @@ public async Task>> BarcodeDuplicat return result; } + public record QuickSearchItem( + Guid Id, string Name, string? Article, string? DefaultBarcode, + decimal? ReferencePrice, decimal? StockQty); + + /// Лёгкий поиск для inline-добавления строк в документы (приёмка, + /// продажа). Ранжирует точное совпадение штрихкода → точное артикула → + /// префикс артикула → префикс имени → имя contains. Возвращает остаток + /// по storeId если передан, иначе сумму по всем складам организации. + [HttpGet("quick-search")] + public async Task>> QuickSearch( + [FromQuery] string? search, + [FromQuery] Guid? storeId, + [FromQuery] int limit = 20, + CancellationToken ct = default) + { + var s = (search ?? "").Trim(); + if (s.Length == 0) return Array.Empty(); + var sLower = s.ToLower(); + if (limit <= 0 || limit > 50) limit = 20; + + var q = _db.Products.AsNoTracking().Where(p => + p.Name.ToLower().Contains(sLower) || + (p.Article != null && p.Article.ToLower().Contains(sLower)) || + p.Barcodes.Any(b => b.Code.Contains(s))); + + // Ранжирование выводим в память: SQL'ом аккуратно сортировать по + // нескольким булевым приоритетам сложно, а лимит 20–50 строк + // делает накладные расходы пренебрежимыми. + var raw = await q.Select(p => new + { + p.Id, p.Name, p.Article, p.ReferencePrice, + Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(), + StockQty = storeId == null + ? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity) + : _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId) + .Select(st => (decimal?)st.Quantity).FirstOrDefault(), + }).Take(limit * 4).ToListAsync(ct); + + int Rank(string name, string? article, IEnumerable codes) + { + if (codes.Any(c => c.Equals(s, StringComparison.OrdinalIgnoreCase))) return 0; + if (article != null && article.Equals(s, StringComparison.OrdinalIgnoreCase)) return 1; + if (article != null && article.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 2; + if (name.StartsWith(s, StringComparison.OrdinalIgnoreCase)) return 3; + return 4; + } + + var items = raw + .Select(r => new + { + Item = new QuickSearchItem( + r.Id, r.Name, r.Article, + r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault(), + r.ReferencePrice, r.StockQty), + Rank = Rank(r.Name, r.Article, r.Barcodes.Select(b => b.Code)), + }) + .OrderBy(x => x.Rank).ThenBy(x => x.Item.Name) + .Take(limit) + .Select(x => x.Item) + .ToList(); + return items; + } + + public record ByBarcodeResult(IReadOnlyList Items); + + /// Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект, + /// несколько → { items: [...] } чтобы UI показал диалог выбора. + [HttpGet("by-barcode/{value}")] + public async Task> ByBarcode( + string value, [FromQuery] Guid? storeId, CancellationToken ct) + { + var v = (value ?? "").Trim(); + if (v.Length == 0) return NotFound(); + var matches = await _db.Products.AsNoTracking() + .Where(p => p.Barcodes.Any(b => b.Code == v)) + .Select(p => new + { + p.Id, p.Name, p.Article, p.ReferencePrice, + Barcodes = p.Barcodes.Select(b => new { b.Code, b.IsPrimary }).ToList(), + StockQty = storeId == null + ? (decimal?)_db.Stocks.Where(st => st.ProductId == p.Id).Sum(st => (decimal?)st.Quantity) + : _db.Stocks.Where(st => st.ProductId == p.Id && st.StoreId == storeId) + .Select(st => (decimal?)st.Quantity).FirstOrDefault(), + }) + .ToListAsync(ct); + if (matches.Count == 0) return NotFound(); + var items = matches.Select(r => new QuickSearchItem( + r.Id, r.Name, r.Article, + r.Barcodes.OrderByDescending(b => b.IsPrimary).Select(b => b.Code).FirstOrDefault() ?? v, + r.ReferencePrice, r.StockQty)).ToList(); + if (items.Count == 1) return items[0]; + return new ByBarcodeResult(items); + } + private IQueryable QueryIncludes() => _db.Products .Include(p => p.UnitOfMeasure) .Include(p => p.ProductGroup)