- 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%
279 lines
9.4 KiB
Python
279 lines
9.4 KiB
Python
"""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})
|