feat(api): products quick-search + by-barcode endpoints

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) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 01:53:07 +05:00
parent 28b264f43b
commit 48babf0d10

View file

@ -391,6 +391,100 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
return result;
}
public record QuickSearchItem(
Guid Id, string Name, string? Article, string? DefaultBarcode,
decimal? ReferencePrice, decimal? StockQty);
/// <summary>Лёгкий поиск для inline-добавления строк в документы (приёмка,
/// продажа). Ранжирует точное совпадение штрихкода → точное артикула →
/// префикс артикула → префикс имени → имя contains. Возвращает остаток
/// по storeId если передан, иначе сумму по всем складам организации.</summary>
[HttpGet("quick-search")]
public async Task<ActionResult<IReadOnlyList<QuickSearchItem>>> 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<QuickSearchItem>();
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'ом аккуратно сортировать по
// нескольким булевым приоритетам сложно, а лимит 2050 строк
// делает накладные расходы пренебрежимыми.
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<string> 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<QuickSearchItem> Items);
/// <summary>Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект,
/// несколько → { items: [...] } чтобы UI показал диалог выбора.</summary>
[HttpGet("by-barcode/{value}")]
public async Task<ActionResult<object>> 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<Product> QueryIncludes() => _db.Products
.Include(p => p.UnitOfMeasure)
.Include(p => p.ProductGroup)