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:
2026-06-09 07:47:42 +00:00
parent 5ea667b80e
commit ea29cc31c0
53 changed files with 7727 additions and 548 deletions

View File

@@ -98,7 +98,9 @@ def _filter_parts_by_allowed_brands(master_conn, parts_data, allowed_brands):
part_ids = []
for p in parts_data:
pid = p.get('id_part') or p.get('id')
if pid is not None:
# Skip local inventory IDs (strings like 'inv:3') — aftermarket filter
# only applies to catalog parts with integer OEM part IDs.
if pid is not None and isinstance(pid, int):
part_ids.append(pid)
if not part_ids:
return parts_data
@@ -124,10 +126,11 @@ def brands():
from services.catalog_modes import normalize_mode
year_id = request.args.get('year_id', type=int)
mode = normalize_mode(request.args.get('mode'))
def _do(master):
data = catalog_service.get_brands(master, year_id=year_id, mode=mode)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_brands(master, year_id=year_id, mode=mode, mye_ids=mye_ids)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/models', methods=['GET'])
@@ -137,10 +140,11 @@ def models():
year_id = request.args.get('year_id', type=int)
if not brand_id:
return jsonify({'error': 'brand_id required'}), 400
def _do(master):
data = catalog_service.get_models(master, brand_id, year_id=year_id)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_models(master, brand_id, year_id=year_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/years', methods=['GET'])
@@ -149,10 +153,11 @@ def years():
model_id = request.args.get('model_id', type=int)
if not model_id:
return jsonify({'error': 'model_id required'}), 400
def _do(master):
data = catalog_service.get_years(master, model_id)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_years(master, model_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/years-all', methods=['GET'])
@@ -175,10 +180,11 @@ def engines():
year_id = request.args.get('year_id', type=int)
if not model_id or not year_id:
return jsonify({'error': 'model_id and year_id required'}), 400
def _do(master):
data = catalog_service.get_engines(master, model_id, year_id)
def _do(master, tenant, branch_id):
mye_ids = catalog_service._get_mye_ids_with_parts(tenant, tenant_id=g.tenant_id, master_conn=master) if tenant else None
data = catalog_service.get_engines(master, model_id, year_id, mye_ids=mye_ids)
return jsonify({'data': data})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/categories', methods=['GET'])
@@ -198,7 +204,7 @@ def categories():
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
if mode == 'local':
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id)
data = catalog_service.get_nexpart_groups_for_vehicle(master, mye_id, tenant)
else:
data = catalog_service.get_categories(master, mye_id, allowed_brands)
return jsonify({'data': data, 'mode': mode, 'allowed_brands': allowed_brands or []})
@@ -220,17 +226,17 @@ def groups():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
def _do(master, tenant, branch_id):
if mode == 'local':
if not category_slug:
return jsonify({'error': 'category_slug required for local mode'}), 400
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug)
data = catalog_service.get_nexpart_subgroups_for_vehicle(master, mye_id, category_slug, tenant)
else:
if not category_id:
return jsonify({'error': 'category_id required for oem mode'}), 400
data = catalog_service.get_groups(master, mye_id, category_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
# ─── Parts with stock enrichment (master + tenant) ───
@@ -251,19 +257,19 @@ def part_types():
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
return jsonify({'error': 'mye_id required'}), 400
def _do(master):
def _do(master, tenant, branch_id):
if mode == 'local':
if not group_slug or not subgroup_slug:
return jsonify({'error': 'group_slug and subgroup_slug required for local mode'}), 400
data = catalog_service.get_nexpart_part_types_for_vehicle(
master, mye_id, group_slug, subgroup_slug
master, mye_id, group_slug, subgroup_slug, tenant
)
else:
if not group_id:
return jsonify({'error': 'group_id required for oem mode'}), 400
data = catalog_service.get_part_types(master, mye_id, group_id)
return jsonify({'data': data, 'mode': mode})
return _master_only(_do)
return _with_conns(_do)
@catalog_bp.route('/shop-supplies/groups', methods=['GET'])
@@ -307,8 +313,8 @@ def shop_supplies_parts():
group_slug = request.args.get('group_slug')
subgroup_slug = request.args.get('subgroup_slug')
part_type_slug = request.args.get('part_type_slug')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
if not group_slug or not subgroup_slug or not part_type_slug:
return jsonify({'error': 'group_slug, subgroup_slug, part_type_slug required'}), 400
def _do(master, tenant, branch_id):
@@ -344,8 +350,8 @@ def parts():
nexpart_subgroup = request.args.get('nexpart_subgroup')
nexpart_part_type = request.args.get('nexpart_part_type')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 30, type=int)
page = max(1, request.args.get('page', 1, type=int) or 1)
per_page = max(1, min(request.args.get('per_page', 30, type=int) or 30, 100))
mode = normalize_mode(request.args.get('mode'))
if not mye_id:
@@ -364,14 +370,20 @@ def parts():
def _do(master, tenant, branch_id):
allowed_brands = _get_allowed_brands(tenant) if tenant else None
# For local mode with allowed_brands, fetch everything first so filtering
# happens before pagination. OEM mode keeps post-filter for now.
fetch_all_for_filter = bool(allowed_brands) and (mode == 'local' or use_nexpart_nav)
_page = 1 if fetch_all_for_filter else page
_per_page = 9999 if fetch_all_for_filter else per_page
if use_nexpart_nav:
result = catalog_service.get_parts_for_nexpart_triple(
master, mye_id, nexpart_group, nexpart_subgroup, nexpart_part_type,
tenant, branch_id, page, per_page,
tenant, branch_id, _page, _per_page,
)
elif mode == 'local':
result = catalog_service.get_parts_local(
master, mye_id, group_id, tenant, branch_id, page, per_page, part_type=part_type,
master, mye_id, group_id, tenant, branch_id, _page, _per_page, part_type=part_type,
)
else:
result = catalog_service.get_parts(
@@ -379,6 +391,11 @@ def parts():
)
if allowed_brands:
result['data'] = _filter_parts_by_allowed_brands(master, result.get('data', []), allowed_brands)
if fetch_all_for_filter:
total = len(result['data'])
offset = (page - 1) * per_page
result['data'] = result['data'][offset:offset + per_page]
result['pagination'] = catalog_service._pagination(page, per_page, total)
result['allowed_brands'] = allowed_brands or []
return jsonify(result)
return _with_conns(_do)
@@ -811,7 +828,7 @@ def brand_parts():
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, oem_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
except Exception:
pass
@@ -886,7 +903,7 @@ def brand_parts():
if tenant and part_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, part_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
except Exception:
pass
@@ -1007,7 +1024,7 @@ def mye_parts():
if tenant and oem_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, oem_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, oem_ids, [])
except Exception:
pass
@@ -1082,7 +1099,7 @@ def mye_parts():
if tenant and part_ids:
try:
from services.catalog_service import _get_local_stock_bulk
local_stock = _get_local_stock_bulk(tenant, part_ids)
local_stock = _get_local_stock_bulk(tenant, branch_id, [], part_ids)
except Exception:
pass