Fix powder catalog lookup: exact match auto-fills, partials show picker modal

- CatalogLookup now returns all partial color name matches ranked by
  specificity (exact vendor+color first, same-vendor partial, cross-vendor)
  with isExact flag so JS can decide to auto-fill vs show modal
- Removed cross-vendor fallback that was silently overwriting manufacturer
  field with wrong brand when vendor-scoped search found nothing
- Picker modal now includes "Not listed — search online" option that
  triggers AI lookup as an escape hatch from the catalog results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-14 14:18:52 -04:00
parent 2ad6df1195
commit 7e79a13cb1
2 changed files with 75 additions and 61 deletions
@@ -1112,61 +1112,50 @@ public class InventoryController : Controller
.Select(i => i.ManufacturerPartNumber!.Trim().ToLower())
.ToHashSet();
// When a vendor is specified, search vendor-scoped first. Only widen to all vendors
// if the scoped search returns nothing — prevents a cross-vendor color match from
// being returned as the only result when the user clearly intended a specific manufacturer.
IEnumerable<PowderCatalogItem> matches;
if (!string.IsNullOrEmpty(vendorTerm))
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.VendorName.ToLower().Contains(vendorTerm) && (
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term)));
// Fall back to all vendors only when the scoped search finds nothing
if (!matches.Any())
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
}
}
else
{
matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.Sku.ToLower() == term ||
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower().Contains(term));
}
// Single query — all partial color/SKU matches across all vendors.
// Results are ranked: exact vendor + exact color (isExact=true) sorts first and
// triggers auto-fill in the JS. Everything else goes to the picker modal.
// This means a user who typed "Columbia Coatings" + "Lime Green" gets auto-fill
// only when that exact product is in the catalog; otherwise they see a ranked modal
// with same-vendor results at the top and a "Not Listed — Search Online" escape hatch.
var matches = await _unitOfWork.PowderCatalog.FindAsync(p =>
p.ColorName.ToLower().Contains(term) ||
p.Sku.ToLower() == term ||
p.Sku.ToLower().Contains(term));
var results = matches
.Where(p => !existingSkus.Contains(p.Sku.ToLower()))
.OrderBy(p => p.Sku.ToLower() == term ? 0 : 1)
.ThenBy(p => p.ColorName)
.Select(p => new
.Select(p =>
{
id = p.Id,
vendorName = p.VendorName,
sku = p.Sku,
colorName = p.ColorName,
description = p.Description,
unitPrice = p.UnitPrice,
imageUrl = p.ImageUrl,
sdsUrl = p.SdsUrl,
tdsUrl = p.TdsUrl,
applicationGuideUrl = p.ApplicationGuideUrl,
productUrl = p.ProductUrl,
isDiscontinued = p.IsDiscontinued,
cureTemperatureF = p.CureTemperatureF,
cureTimeMinutes = p.CureTimeMinutes,
finish = p.Finish,
colorFamilies = p.ColorFamilies,
requiresClearCoat = p.RequiresClearCoat,
coverageSqFtPerLb = p.CoverageSqFtPerLb,
specificGravity = p.SpecificGravity,
transferEfficiency = GetEffectiveTransferEfficiency(p.TransferEfficiency)
var vendorMatch = string.IsNullOrEmpty(vendorTerm) || p.VendorName.ToLower().Contains(vendorTerm);
var colorExact = p.ColorName.ToLower() == term;
return (p, isExact: vendorMatch && colorExact, vendorMatch, colorExact);
})
.OrderBy(x => x.isExact ? 0 : x.vendorMatch ? 1 : x.colorExact ? 2 : 3)
.ThenBy(x => x.p.ColorName)
.Select(x => new
{
id = x.p.Id,
vendorName = x.p.VendorName,
sku = x.p.Sku,
colorName = x.p.ColorName,
description = x.p.Description,
unitPrice = x.p.UnitPrice,
imageUrl = x.p.ImageUrl,
sdsUrl = x.p.SdsUrl,
tdsUrl = x.p.TdsUrl,
applicationGuideUrl = x.p.ApplicationGuideUrl,
productUrl = x.p.ProductUrl,
isDiscontinued = x.p.IsDiscontinued,
isExact = x.isExact,
cureTemperatureF = x.p.CureTemperatureF,
cureTimeMinutes = x.p.CureTimeMinutes,
finish = x.p.Finish,
colorFamilies = x.p.ColorFamilies,
requiresClearCoat = x.p.RequiresClearCoat,
coverageSqFtPerLb = x.p.CoverageSqFtPerLb,
specificGravity = x.p.SpecificGravity,
transferEfficiency = GetEffectiveTransferEfficiency(x.p.TransferEfficiency)
})
.ToList();
@@ -62,23 +62,25 @@
const items = await resp.json();
if (items.length === 0) {
// No catalog match — fall back to AI if available
hideStatus();
if (typeof window._runInventoryAiLookup === 'function') {
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Not in catalog — searching with AI…');
await window._runInventoryAiLookup();
} else {
showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.');
}
// Nothing in catalog — go straight to AI
await runAiOrWarn();
return;
}
if (items.length === 1) {
// Single exact match (vendor + color name both match precisely) — auto-fill
if (items.length === 1 && items[0].isExact) {
await fillFields(items[0]);
return;
}
// Multiple matches — let the user pick via modal
// Exact match exists but so do other results — auto-fill the exact one
const exactMatches = items.filter(i => i.isExact);
if (exactMatches.length === 1) {
await fillFields(exactMatches[0]);
return;
}
// No exact match (or ambiguous) — show picker modal with "Not Listed" escape hatch
hideStatus();
showPickerModal(items);
@@ -89,6 +91,18 @@
}
});
// ── AI fallback helper ───────────────────────────────────────────────────
async function runAiOrWarn() {
hideStatus();
if (typeof window._runInventoryAiLookup === 'function') {
showStatus('info', '<span class="spinner-border spinner-border-sm me-1"></span>Not in catalog — searching online with AI…');
await window._runInventoryAiLookup();
} else {
showStatus('warning', 'No match found in the catalog. Enter details manually or enable AI Lookup.');
}
}
// ── Fill fields from a catalog result ────────────────────────────────────
async function fillFields(item) {
@@ -368,6 +382,12 @@
<div class="modal-body p-0">
<div class="list-group list-group-flush">${rows}</div>
</div>
<div class="modal-footer py-2 justify-content-start">
<button type="button" class="btn btn-sm btn-outline-secondary" id="catalogPickerNotListed">
<i class="bi bi-search me-1"></i>Not listed — search online
</button>
<span class="text-muted small ms-2">Uses AI to look up the exact product</span>
</div>
</div>
</div>
</div>`;
@@ -383,6 +403,11 @@
});
});
document.getElementById('catalogPickerNotListed').addEventListener('click', function () {
bsModal.hide();
runAiOrWarn();
});
bsModal.show();
}