feat(catalog): supplier catalog cleanup, fuzzy matching, and navigation fixes
- Cleaned 137+ fake engine-displacement models from supplier imports (v3/v4 scripts: Chevrolet, Ford, Chrysler, Dodge, Jeep, Nissan, etc.) - Removed 1,251+ corrupted models (INT. prefixes, year-suffix, torque specs, empty names, trailing-year variants) - Migrated supplier tables to master DB (supplier_catalog, supplier_catalog_compat, supplier_catalog_interchange) - Fixed _get_mye_ids_with_parts() to query supplier_catalog_compat from master DB so supplier-only vehicles appear for all tenants - Added fuzzy model matcher with parenthesis stripping, noise suffix removal, compact matching, prefix/substring fallback, model aliases, and ±3 year proximity - Matched compat rows: KEEP GREEN +14,152, KNADIAN +3,021, VAZLO +127,500, LUK +477, RAYBESTOS +1,743 - Added KNADIAN catalog importer with year-range expansion and future-year filtering - Added VAZLO catalog importer with position parsing and SKU-in-model cleanup - Added Keep Green, LUK, Yokomitsu, Raybestos catalog importers - Cache clearing after cleanups (_classify_cache_*, nexus:mye_ids:*, nexus:brand_mye_counts:*) Final match rates: - KEEP GREEN: 90.3% - VAZLO: 93.6% - YOKOMITSU: 100.0% - KNADIAN: 57.4% - LUK: 51.0% - RAYBESTOS: 55.9%
This commit is contained in:
278
pos/blueprints/supplier_catalog_bp.py
Normal file
278
pos/blueprints/supplier_catalog_bp.py
Normal file
@@ -0,0 +1,278 @@
|
||||
"""Supplier Catalog Blueprint — parts from suppliers with vehicle compatibility.
|
||||
|
||||
Independent from inventory. Supports:
|
||||
- Browse by supplier/category
|
||||
- Search by text or vehicle (MYE or make/model/year)
|
||||
- Part detail with compatibilities and interchanges
|
||||
- Bulk import via Excel
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, g, render_template
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
from tenant_db import get_master_conn
|
||||
from middleware import require_auth
|
||||
|
||||
supplier_catalog_bp = Blueprint('supplier_catalog', __name__, url_prefix='/pos/api/supplier-catalog')
|
||||
|
||||
|
||||
# ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_master_conn():
|
||||
return get_master_conn()
|
||||
|
||||
|
||||
def _json_response(data, status=200):
|
||||
return jsonify(data), status
|
||||
|
||||
|
||||
# ─── Brands ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/brands', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_brands():
|
||||
"""Return distinct makes (vehicle brands) present in the supplier catalog."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT DISTINCT make, COUNT(*) as cnt
|
||||
FROM supplier_catalog_compat
|
||||
WHERE make IS NOT NULL AND make != ''
|
||||
GROUP BY make
|
||||
ORDER BY make ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'brands': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Search ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/search', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def search_items():
|
||||
"""Search supplier catalog by text and/or vehicle."""
|
||||
q = (request.args.get('q') or '').strip()
|
||||
mye_id = request.args.get('mye_id', type=int)
|
||||
make = (request.args.get('make') or '').strip()
|
||||
model = (request.args.get('model') or '').strip()
|
||||
year = request.args.get('year', type=int)
|
||||
supplier = (request.args.get('supplier') or '').strip()
|
||||
category = (request.args.get('category') or '').strip()
|
||||
page = max(1, request.args.get('page', 1, type=int))
|
||||
per_page = min(100, request.args.get('per_page', 30, type=int))
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Build query dynamically
|
||||
where_parts = ["sc.is_active = true"]
|
||||
params = []
|
||||
|
||||
if supplier:
|
||||
where_parts.append("sc.supplier_name = %s")
|
||||
params.append(supplier)
|
||||
if category:
|
||||
where_parts.append("sc.category = %s")
|
||||
params.append(category)
|
||||
|
||||
# Text search on SKU, name, or interchange part_number
|
||||
if q:
|
||||
where_parts.append("""
|
||||
(sc.sku ILIKE %s OR sc.name ILIKE %s
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM supplier_catalog_interchange sci2
|
||||
WHERE sci2.catalog_id = sc.id AND sci2.part_number ILIKE %s
|
||||
))
|
||||
""")
|
||||
like_q = f'%{q}%'
|
||||
params.extend([like_q, like_q, like_q])
|
||||
|
||||
# Vehicle filter
|
||||
vehicle_join = ""
|
||||
if mye_id:
|
||||
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||
where_parts.append("scc.model_year_engine_id = %s")
|
||||
params.append(mye_id)
|
||||
elif make or model or year:
|
||||
vehicle_join = "JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id"
|
||||
if make:
|
||||
where_parts.append("scc.make ILIKE %s")
|
||||
params.append(f'%{make}%')
|
||||
if model:
|
||||
where_parts.append("scc.model ILIKE %s")
|
||||
params.append(f'%{model}%')
|
||||
if year:
|
||||
where_parts.append("scc.year = %s")
|
||||
params.append(year)
|
||||
|
||||
where_sql = " AND ".join(where_parts)
|
||||
|
||||
# Count total
|
||||
count_sql = f"""
|
||||
SELECT COUNT(DISTINCT sc.id)
|
||||
FROM supplier_catalog sc
|
||||
{vehicle_join}
|
||||
WHERE {where_sql}
|
||||
"""
|
||||
cur.execute(count_sql, params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
# Fetch page
|
||||
fetch_sql = f"""
|
||||
SELECT DISTINCT
|
||||
sc.id, sc.supplier_name, sc.sku, sc.name,
|
||||
sc.category, sc.description, sc.image_url
|
||||
FROM supplier_catalog sc
|
||||
{vehicle_join}
|
||||
WHERE {where_sql}
|
||||
ORDER BY sc.name ASC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cur.execute(fetch_sql, params + [per_page, offset])
|
||||
rows = cur.fetchall()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
'id': r[0],
|
||||
'supplier_name': r[1],
|
||||
'sku': r[2],
|
||||
'name': r[3],
|
||||
'category': r[4],
|
||||
'description': r[5],
|
||||
'image_url': r[6],
|
||||
})
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify({
|
||||
'data': items,
|
||||
'pagination': {
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
'total': total,
|
||||
'total_pages': (total + per_page - 1) // per_page,
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
# ─── Item Detail ───────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def get_item_detail(item_id):
|
||||
"""Return full detail for a supplier catalog item including compat + interchanges."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
SELECT id, supplier_name, sku, name, category, description, image_url, created_at
|
||||
FROM supplier_catalog WHERE id = %s AND is_active = true
|
||||
""", (item_id,))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'error': 'Item not found'}), 404
|
||||
|
||||
item = {
|
||||
'id': row[0],
|
||||
'supplier_name': row[1],
|
||||
'sku': row[2],
|
||||
'name': row[3],
|
||||
'category': row[4],
|
||||
'description': row[5],
|
||||
'image_url': row[6],
|
||||
'created_at': str(row[7]) if row[7] else None,
|
||||
}
|
||||
|
||||
# Compatibilities — deduplicate by (make, model, year, engine) because
|
||||
# the same vehicle may map to multiple MYE ids (especially when engine
|
||||
# text is empty from the supplier catalog).
|
||||
cur.execute("""
|
||||
SELECT make, model, year, engine, model_year_engine_id, source
|
||||
FROM supplier_catalog_compat
|
||||
WHERE catalog_id = %s
|
||||
ORDER BY make, model, year, engine
|
||||
""", (item_id,))
|
||||
seen_compat = set()
|
||||
compatibilities = []
|
||||
for r in cur.fetchall():
|
||||
key = (r[0], r[1], r[2], r[3])
|
||||
if key in seen_compat:
|
||||
continue
|
||||
seen_compat.add(key)
|
||||
compatibilities.append({
|
||||
'make': r[0], 'model': r[1], 'year': r[2], 'engine': r[3],
|
||||
'model_year_engine_id': r[4], 'source': r[5]
|
||||
})
|
||||
item['compatibilities'] = compatibilities
|
||||
|
||||
# Interchanges
|
||||
cur.execute("""
|
||||
SELECT brand, part_number
|
||||
FROM supplier_catalog_interchange
|
||||
WHERE catalog_id = %s
|
||||
ORDER BY brand, part_number
|
||||
""", (item_id,))
|
||||
item['interchanges'] = [
|
||||
{'brand': r[0], 'part_number': r[1]}
|
||||
for r in cur.fetchall()
|
||||
]
|
||||
|
||||
cur.close(); conn.close()
|
||||
return jsonify(item)
|
||||
|
||||
|
||||
# ─── Categories ────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/categories', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_categories():
|
||||
"""Return distinct categories with counts."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT category, COUNT(*) as cnt
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
GROUP BY category
|
||||
ORDER BY cnt DESC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'categories': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Suppliers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/suppliers', methods=['GET'])
|
||||
@require_auth('catalog.view')
|
||||
def list_suppliers():
|
||||
"""Return distinct suppliers with counts."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT supplier_name, COUNT(*) as cnt
|
||||
FROM supplier_catalog
|
||||
WHERE is_active = true
|
||||
GROUP BY supplier_name
|
||||
ORDER BY supplier_name ASC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'suppliers': [{'name': r[0], 'count': r[1]} for r in rows]})
|
||||
|
||||
|
||||
# ─── Delete ────────────────────────────────────────────────────────────────
|
||||
|
||||
@supplier_catalog_bp.route('/items/<int:item_id>', methods=['DELETE'])
|
||||
@require_auth('inventory.edit')
|
||||
def delete_item(item_id):
|
||||
"""Soft-delete a supplier catalog item."""
|
||||
conn = _get_master_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("UPDATE supplier_catalog SET is_active = false WHERE id = %s", (item_id,))
|
||||
conn.commit()
|
||||
cur.close(); conn.close()
|
||||
return jsonify({'success': True})
|
||||
Reference in New Issue
Block a user