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;
|
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
|
private IQueryable<Product> QueryIncludes() => _db.Products
|
||||||
.Include(p => p.UnitOfMeasure)
|
.Include(p => p.UnitOfMeasure)
|
||||||
.Include(p => p.ProductGroup)
|
.Include(p => p.ProductGroup)
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue