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:
parent
28b264f43b
commit
48babf0d10
|
|
@ -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'ом аккуратно сортировать по
|
||||
// нескольким булевым приоритетам сложно, а лимит 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<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)
|
||||
|
|
|
|||
Loading…
Reference in a new issue