# /home/Autopartes/pos/services/catalog_service.py """Catalog service: queries nexus_autoparts (TecDoc) catalog with local stock enrichment. All functions receive database connections as parameters. This module NEVER imports tenant_db — the caller passes connections. PERFORMANCE: vehicle_parts has 14B+ rows. Every query MUST: - Filter by model_year_engine_id (indexed) - Use LIMIT - Use EXISTS instead of COUNT(*) on vehicle_parts where possible """ import json import os import re import redis from services.na_models import is_na_model from services.translations import translate_part_name, translate_category, PART_TRANSLATIONS from services.nexpart_taxonomy import translate_taxonomy_node # Lazy Redis client for catalog caches _redis_client = None _CLASSIFY_TTL_SECONDS = 300 _VEHICLE_TTL_SECONDS = 3600 def _get_redis(): global _redis_client if _redis_client is None: try: _redis_client = redis.from_url( os.environ.get('REDIS_URL', 'redis://localhost:6379/0'), decode_responses=True ) _redis_client.ping() except Exception: _redis_client = False return _redis_client if _redis_client is not False else None def _classify_cache_get(mye_id): r = _get_redis() if not r: return None try: val = r.get(f'nexus:classify:{mye_id}') return json.loads(val) if val else None except Exception: return None def _classify_cache_set(mye_id, data): r = _get_redis() if not r: return try: r.setex(f'nexus:classify:{mye_id}', _CLASSIFY_TTL_SECONDS, json.dumps(data)) except Exception: pass def classify_cache_clear(): r = _get_redis() if not r: return try: for key in r.scan_iter(match='nexus:classify:*'): r.delete(key) except Exception: pass def classify_cache_stats(): r = _get_redis() if not r: return {'redis_available': False} try: count = sum(1 for _ in r.scan_iter(match='nexus:classify:*')) return {'redis_available': True, 'total_entries': count, 'ttl_seconds': _CLASSIFY_TTL_SECONDS} except Exception: return {'redis_available': False} def _clean_model_name(name): """Parse TecDoc model name to show only the primary name. '4 RUNNER V (_N28_)' → '4 RUNNER' 'Corolla Hatchback (_E21_)' → 'COROLLA' 'CAMRY Saloon (_V4_)' → 'CAMRY' 'RAV 4 III (_A3_)' → 'RAV 4' """ s = name.strip() # Remove generation codes in parentheses: (_N28_), (B1_), (_E21_), etc. s = re.sub(r'\s*\([^)]*\)\s*', '', s) # Remove Roman numeral generation suffixes: I, II, III, IV, V, VI, VII, VIII, IX, X s = re.sub(r'\s+(?:VIII|VII|VI|IV|IX|III|II|V|X|I)(?:\s|$)', ' ', s) # Remove body type suffixes s = re.sub(r'\s+(?:Estate|Saloon|Hatchback|Van|Coupe|Coupé|Convertible|Wagon|Pickup|Cab|Sedan|SUV|MPV|Kombi|Kasten|Bus|Box|Platform|Chassis)\b', '', s, flags=re.IGNORECASE) # Remove truck cab/bed suffixes: CREW, EXTENDED, STANDARD, HD, etc. s = re.sub(r'\s+(?:CREW|EXTENDED|STANDARD|CUTAWAY|PASSENGER|CARGO)\b', '', s, flags=re.IGNORECASE) # Remove "HD", "&" suffixes that create fake variants s = re.sub(r'\s+HD\b', '', s, flags=re.IGNORECASE) s = re.sub(r'\s+&\s*$', '', s) # Remove "Hatchback Van", "Box Body" compound types s = re.sub(r'\s+(?:Hatchback|Box)\s+(?:Van|Body)\b', '', s, flags=re.IGNORECASE) # Clean up extra spaces s = re.sub(r'\s+', ' ', s).strip() return s.upper() if s else name.upper() # ───────────────────────────────────────────────────────────────────────────── # VEHICLE HIERARCHY NAVIGATION # ───────────────────────────────────────────────────────────────────────────── from services.catalog_modes import get_brands_for_mode # Legacy alias — kept for backwards compatibility with any existing imports. # Prefer `catalog_modes.OEM_BRANDS_NA` in new code. NORTH_AMERICA_BRANDS = get_brands_for_mode('oem') def _get_mye_ids_with_parts(tenant_conn, min_parts=1, tenant_id=None, master_conn=None): """Return MYE ids that have at least min_parts total (local inventory + supplier catalog). Results are cached in Redis per-tenant for 60s to avoid repeated expensive UNION ALL + GROUP BY queries during navigation. """ if tenant_id: r = _get_redis() if r: cache_key = f'nexus:mye_ids:{tenant_id}:{min_parts}' try: cached = r.get(cache_key) if cached: return json.loads(cached) except Exception: pass # Inventory from tenant DB cur = tenant_conn.cursor() cur.execute(""" SELECT model_year_engine_id, COUNT(*) as cnt FROM inventory_vehicle_compat WHERE model_year_engine_id IS NOT NULL GROUP BY model_year_engine_id """) inventory_counts = {r[0]: r[1] for r in cur.fetchall()} cur.close() # Supplier catalog from master DB supplier_counts = {} if master_conn: cur = master_conn.cursor() cur.execute(""" SELECT model_year_engine_id, COUNT(*) as cnt FROM supplier_catalog_compat WHERE model_year_engine_id IS NOT NULL GROUP BY model_year_engine_id """) supplier_counts = {r[0]: r[1] for r in cur.fetchall()} cur.close() # Combine and filter all_myes = set(inventory_counts.keys()) | set(supplier_counts.keys()) result = [] for mye_id in all_myes: total = inventory_counts.get(mye_id, 0) + supplier_counts.get(mye_id, 0) if total >= min_parts: result.append(mye_id) if tenant_id: r = _get_redis() if r: try: r.setex(cache_key, 60, json.dumps(result)) except Exception: pass return result def get_brands(master_conn, year_id=None, mode='oem', mye_ids=None): """Get vehicle brands that have MYE entries, filtered by catalog mode. Args: master_conn: Connection to the nexus_autoparts master DB. year_id: Optional — only return brands with models for that year. mode: 'oem' (full TecDoc coverage) or 'local' (curated bodega list). """ allowed = list(get_brands_for_mode(mode)) cur = master_conn.cursor() if mye_ids: if year_id: cur.execute(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(%s) AND mye.year_id = %s AND mye.id_mye = ANY(%s) ORDER BY b.name_brand """, (allowed, year_id, mye_ids)) else: cur.execute(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(%s) AND mye.id_mye = ANY(%s) ORDER BY b.name_brand """, (allowed, mye_ids)) else: if year_id: cur.execute(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(%s) AND mye.year_id = %s ORDER BY b.name_brand """, (allowed, year_id)) else: cur.execute(""" SELECT DISTINCT b.id_brand, b.name_brand FROM brands b JOIN models m ON m.brand_id = b.id_brand JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE b.name_brand = ANY(%s) ORDER BY b.name_brand """, (allowed,)) rows = cur.fetchall() cur.close() return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows] def get_models(master_conn, brand_id, year_id=None, brand_name=None, mye_ids=None): """Get models for a brand that have MYE entries, filtered to North America. If year_id is provided, only models available for that year. brand_name is used for NA filtering; looked up from DB if not provided.""" cur = master_conn.cursor() # Resolve brand_name for NA filter if not provided if not brand_name: cur.execute("SELECT name_brand FROM brands WHERE id_brand = %s", (brand_id,)) row = cur.fetchone() brand_name = row[0] if row else '' if mye_ids: if year_id: cur.execute(""" SELECT DISTINCT m.id_model, m.name_model FROM models m JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE m.brand_id = %s AND mye.year_id = %s AND mye.id_mye = ANY(%s) ORDER BY m.name_model """, (brand_id, year_id, mye_ids)) else: cur.execute(""" SELECT DISTINCT m.id_model, m.name_model FROM models m JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE m.brand_id = %s AND mye.id_mye = ANY(%s) ORDER BY m.name_model """, (brand_id, mye_ids)) else: if year_id: cur.execute(""" SELECT DISTINCT m.id_model, m.name_model FROM models m JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE m.brand_id = %s AND mye.year_id = %s ORDER BY m.name_model """, (brand_id, year_id)) else: cur.execute(""" SELECT DISTINCT m.id_model, m.name_model FROM models m JOIN model_year_engine mye ON mye.model_id = m.id_model WHERE m.brand_id = %s ORDER BY m.name_model """, (brand_id,)) rows = cur.fetchall() cur.close() # Filter to North America models only, add clean display name, deduplicate filtered = [r for r in rows if is_na_model(brand_name, r[1])] # Group by clean name — keep all id_models but show one display name seen = {} # display_name → first row results = [] for r in filtered: display = _clean_model_name(r[1]) if display not in seen: seen[display] = True results.append({ 'id_model': r[0], 'name_model': r[1], 'display_name': display, }) # Sort by display_name results.sort(key=lambda x: x['display_name']) return results def get_years(master_conn, model_id, mye_ids=None): """Get distinct years for a model via MYE (fast, no vehicle_parts scan). Ordered DESC.""" cur = master_conn.cursor() if mye_ids: cur.execute(""" SELECT DISTINCT y.id_year, y.year_car FROM years y JOIN model_year_engine mye ON mye.year_id = y.id_year WHERE mye.model_id = %s AND mye.id_mye = ANY(%s) ORDER BY y.year_car DESC """, (model_id, mye_ids)) else: cur.execute(""" SELECT DISTINCT y.id_year, y.year_car FROM years y JOIN model_year_engine mye ON mye.year_id = y.id_year WHERE mye.model_id = %s ORDER BY y.year_car DESC """, (model_id,)) rows = cur.fetchall() cur.close() return [{'id_year': r[0], 'year_car': r[1]} for r in rows] def get_engines(master_conn, model_id, year_id, mye_ids=None): """Get MYE entries (engine + trim) for a model+year combo.""" cur = master_conn.cursor() mye_filter = "" params = [model_id, year_id] if mye_ids: mye_filter = " AND mye.id_mye = ANY(%s)" params.append(mye_ids) cur.execute(f""" SELECT mye.id_mye, e.name_engine, mye.trim_level FROM model_year_engine mye JOIN engines e ON e.id_engine = mye.engine_id WHERE mye.model_id = %s AND mye.year_id = %s{mye_filter} ORDER BY e.name_engine, mye.trim_level """, tuple(params)) rows = cur.fetchall() cur.close() return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows] def get_categories(master_conn, mye_id, allowed_brands=None): """Get part categories that have parts for this vehicle (mye_id). Uses a subquery on vehicle_parts filtered by mye_id (indexed), then JOINs through parts -> part_groups -> part_categories. Uses COUNT with a safety LIMIT on the subquery. If allowed_brands is provided, only counts parts that have at least one aftermarket equivalent from those manufacturers. """ cur = master_conn.cursor() brand_filter = "" params = [mye_id] if allowed_brands: brand_filter = """AND EXISTS ( SELECT 1 FROM aftermarket_parts ap2 JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s) )""" params.append(allowed_brands) cur.execute(f""" SELECT pc.id_part_category, COALESCE(pc.name_es, pc.name_part_category) AS name, sub.cnt FROM ( SELECT pg.category_id, COUNT(DISTINCT p.id_part) AS cnt FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id JOIN part_groups pg ON pg.id_part_group = p.group_id WHERE vp.model_year_engine_id = %s {brand_filter} GROUP BY pg.category_id ) sub JOIN part_categories pc ON pc.id_part_category = sub.category_id ORDER BY name """, params) rows = cur.fetchall() cur.close() return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows] # ───────────────────────────────────────────────────────────────────────────── # NEXPART HIERARCHY (Local mode) — filtered by what TecDoc has for this vehicle # ───────────────────────────────────────────────────────────────────────────── # ─── Redis-backed cache for vehicle → Nexpart classification ────────────── # Replaces the old in-process dict so all Gunicorn workers share the same # cache. TTL 5 min. If Redis is down, classification still works (cache miss). def _classify_vehicle_parts(master_conn, mye_id): """Classify all TecDoc parts for a vehicle into Nexpart triples. Runs the matcher once per distinct part name, builds a nested dict: { "Brake System...": { "Front Friction, Drums & Rotors": { "Front Disc Brake Rotor": [oem_part_id, ...], ... }, ... }, ... } Drops parts whose name has no Nexpart equivalent (UNMAPPED_STRATEGY=drop). Used by all 3 Nexpart-filtered functions below — cached by mye_id so one navigation sequence (categories → subgroups → part types → parts) does the classification work exactly once. """ # Cache hit — skip the query and matcher entirely cached = _classify_cache_get(mye_id) if cached is not None: return cached from services.nexpart_taxonomy import tecdoc_to_nexpart rows = [] try: cur = master_conn.cursor() cur.execute(""" SELECT p.id_part, p.name_part FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = %s """, (mye_id,)) rows = cur.fetchall() cur.close() except Exception: # vehicle_parts or parts table may not exist (TecDoc removed) try: master_conn.rollback() except Exception: pass classified = {} for part_id, name_part in rows: triple = tecdoc_to_nexpart(name_part) if not triple: continue # drop unmapped (Decision 2) group, subgroup, part_type = triple classified.setdefault(group, {}) \ .setdefault(subgroup, {}) \ .setdefault(part_type, []) \ .append(part_id) _classify_cache_set(mye_id, classified) return classified def _classify_inventory_parts(tenant_conn, mye_id): """Classify local inventory parts for a vehicle into Nexpart triples. Uses inventory_vehicle_compat to find local items linked to the MYE, then classifies each item's name via tecdoc_to_nexpart. Returns the same nested dict shape as _classify_vehicle_parts but uses inventory id values as the leaf list items. """ from services.nexpart_taxonomy import tecdoc_to_nexpart cur = tenant_conn.cursor() cur.execute(""" SELECT i.id, i.name FROM inventory i JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id WHERE ivc.model_year_engine_id = %s AND i.is_active = true """, (mye_id,)) rows = cur.fetchall() cur.close() classified = {} for inv_id, name in rows: triple = tecdoc_to_nexpart(name) if not triple: continue group, subgroup, part_type = triple classified.setdefault(group, {}) \ .setdefault(subgroup, {}) \ .setdefault(part_type, []) \ .append(f"inv:{inv_id}") return classified # Keyword-based Spanish → Nexpart mapping for supplier catalog items. # Each entry is ((group, subgroup, part_type), [spanish_keywords...]) # Keywords are checked in order; first match wins. _SPANISH_KEYWORDS = [ # ── Steering & Suspension ── (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Shock Absorber Mount"), ["base amortiguador", "base de amortiguador"]), (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Shock Absorber"), ["amortiguador"]), (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Strut"), ["strut", "torre"]), (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm and Ball Joint Assembly"), ["horquilla", "mango de suspension", "mangueta"]), (("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"), ["bieleta", "estabilizador"]), (("Steering & Suspension Parts", "Steering Linkages, Rods & Arms", "Steering Tie Rod End"), ["terminal", "rotula direccion", "rotula de direccion"]), (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Ball Joint"), ["rotula"]), (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm Bushing"), ["buje", "bujes"]), (("Steering & Suspension Parts", "Rack & Pinion, Gear Box, Power Cylinder", "Rack and Pinion Assembly"), ["caja de direccion", "cremallera"]), # ── Brake System ── (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Wheel Bearings & Seals", "Front Wheel Bearing"), ["balero", "rodamiento", "maza", "cubo"]), (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Friction, Drums & Rotors", "Front Disc Brake Pad Set"), ["balata", "pastilla de freno"]), (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Friction, Drums & Rotors", "Front Disc Brake Rotor"), ["disco de freno", "freno de disco", "rotor"]), (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Front Calipers, Wheel Cylinders, Hoses", "Front Disc Brake Caliper"), ["caliper", "calipers"]), (("Brake System, Wheel Bearings, Studs, Nuts & Hardware", "Master Cylinders, Boosters & Switches", "Brake Master Cylinder"), ["cilindro maestro"]), # ── Ignition & Filters ── (("Ignition & Filters", "Filters & PCV", "Engine Oil Filter"), ["filtro de aceite", "filtro aceite"]), (("Ignition & Filters", "Filters & PCV", "Engine Air Filter"), ["filtro de aire", "filtro aire"]), (("Ignition & Filters", "Filters & PCV", "Cabin Air Filter"), ["filtro de cabina", "filtro cabina"]), (("Ignition & Filters", "Filters & PCV", "Fuel Filter"), ["filtro de gasolina", "filtro gasolina"]), # ── Cooling System ── (("Belts, Hoses, Water Pumps & Cooling System Parts", "Radiators & Electric Fan Motors", "Engine Coolant Reservoir"), ["radiador"]), (("Belts, Hoses, Water Pumps & Cooling System Parts", "Water Pumps, Fan Blades & Clutches", "Engine Water Pump"), ["bomba de agua"]), (("Belts, Hoses, Water Pumps & Cooling System Parts", "Thermostats, Housings & Radiator Caps", "Engine Coolant Thermostat"), ["termostato"]), (("Belts, Hoses, Water Pumps & Cooling System Parts", "Belts, Tensioners & Pulleys", "Accessory Drive Belt Tensioner Assembly"), ["tensor", "polea", "bandas"]), # ── Engine ── (("Engine Parts", "Engine Parts", "Engine Oil Pump"), ["bomba de aceite"]), # ── Drivetrain ── (("Drivetrain Parts", "Driveshafts, U-Joints & CV (Constant Velocity) Parts", "CV Axle Assembly"), ["cople flecha", "junta homocinetica", "juntas homocineticas"]), (("Drivetrain Parts", "Axle & Differential Parts", "Manual Transmission Differential Bearing"), ["collarin", "collar"]), # ── Clutch ── (("Exhaust, Clutch & Flywheel Parts", "Clutches & Clutch Kits", "Transmission Clutch Kit"), ["clutch", "kit de clutch"]), # ── Engine Mounts ── (("Engine Parts", "Engine Mounts & Other Miscellaneous Engine Parts", "Engine Mount"), ["soporte motor", "soporte de motor"]), (("Engine Parts", "Engine Mounts & Other Miscellaneous Engine Parts", "Automatic Transmission Mount"), ["soporte transmision", "soporte de transmision"]), # ── Dust Boots ── (("Steering & Suspension Parts", "Shock Absorbers & Struts", "Suspension Strut Bellows"), ["cubre polvo"]), # ── Suspension Arms / Links ── (("Steering & Suspension Parts", "Ball Joints & Control Arms", "Suspension Control Arm and Ball Joint Assembly"), ["tirante"]), (("Steering & Suspension Parts", "Sway Bars, Stabilizer Bars, Strut Rods & Parts", "Suspension Stabilizer Bar Link"), ["tornillo estabilizador"]), ] # Fallback: supplier category → Nexpart group _SUPPLIER_CATEGORY_TO_GROUP = { "SUSPENSION": "Steering & Suspension Parts", "DIRECCION": "Steering & Suspension Parts", "RODAMIENTOS": "Brake System, Wheel Bearings, Studs, Nuts & Hardware", "CAT05_Filtros_Juntas_Bomb": "Ignition & Filters", "CAT06_Radiadores": "Belts, Hoses, Water Pumps & Cooling System Parts", "CAT07_Balatas": "Brake System, Wheel Bearings, Studs, Nuts & Hardware", "CAT08_Collarines": "Drivetrain Parts", # Raybestos "FRENO_DE_DISCO": "Brake System, Wheel Bearings, Studs, Nuts & Hardware", # LUK "KIT_CLUTCH": "Exhaust, Clutch & Flywheel Parts", # VAZLO "AMORTIGUADOR": "Steering & Suspension Parts", "BASE_AMORTIGUADOR": "Steering & Suspension Parts", "BUJES": "Steering & Suspension Parts", "COPLE_FLECHA": "Drivetrain Parts", "CUBRE_POLVO": "Steering & Suspension Parts", "GOMAS_BARRA_ESTABILIZADORA": "Steering & Suspension Parts", "HORQUILLAS": "Steering & Suspension Parts", "JUNTAS_HOMOCINETICAS": "Drivetrain Parts", "SOPORTE_MOTOR": "Engine Parts", "SOPORTE_TRANSMISION": "Engine Parts", "TERMINAL_DIRECCION": "Steering & Suspension Parts", "TIRANTE": "Steering & Suspension Parts", "TORNILLO_ESTABILIZADOR": "Steering & Suspension Parts", } def _spanish_name_to_nexpart(name, category=None): """Map a Spanish part name to a Nexpart (group, subgroup, part_type). Uses keyword matching against known Spanish auto-part terms. Returns None if no match and no category fallback available. """ if not name: return None name_lower = name.lower().replace('_', ' ') # 1. Keyword match (most specific first) for triple, keywords in _SPANISH_KEYWORDS: for kw in keywords: if kw in name_lower: return triple # 2. Category fallback → group + first available subgroup/part_type if category: group = _SUPPLIER_CATEGORY_TO_GROUP.get(category.upper()) if group: from services.nexpart_taxonomy import NEXPART_TAXONOMY subgroups = NEXPART_TAXONOMY.get(group, {}) if subgroups: # Pick the first subgroup and its first part_type subgroup = next(iter(subgroups.keys())) part_types = subgroups[subgroup] if part_types: return (group, subgroup, part_types[0]) return (group, subgroup, subgroup) return (group, group, group) return None def _classify_supplier_catalog_parts(master_conn, mye_id): """Classify supplier catalog parts for a vehicle into Nexpart triples. Uses supplier_catalog_compat (in master DB) to find supplier items linked to the MYE, then classifies each item's name via _spanish_name_to_nexpart. Returns the same nested dict shape but uses supplier_catalog id values prefixed with 'sc:' as the leaf list items. """ rows = [] try: cur = master_conn.cursor() cur.execute(""" SELECT sc.id, sc.name, sc.category FROM supplier_catalog sc JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id WHERE scc.model_year_engine_id = %s AND sc.is_active = true """, (mye_id,)) rows = cur.fetchall() cur.close() except Exception: # supplier_catalog table may not exist try: master_conn.rollback() except Exception: pass classified = {} for sc_id, name, category in rows: triple = _spanish_name_to_nexpart(name, category) if not triple: continue group, subgroup, part_type = triple classified.setdefault(group, {}) \ .setdefault(subgroup, {}) \ .setdefault(part_type, []) \ .append(f"sc:{sc_id}") return classified def _merge_classified(base, extra): """Merge two classified dicts (group -> subgroup -> part_type -> ids). Modifies base in place and returns it.""" if not extra: return base for group, subgroups in extra.items(): sg_base = base.setdefault(group, {}) for subgroup, part_types in subgroups.items(): pt_base = sg_base.setdefault(subgroup, {}) for part_type, ids in part_types.items(): pt_base.setdefault(part_type, []).extend(ids) return base def get_nexpart_groups_for_vehicle(master_conn, mye_id, tenant_conn=None): """Local mode: return Nexpart top-level groups that have parts for this vehicle. Output shape mirrors get_categories() but uses `slug` (string) instead of integer category_id. Empty groups are dropped so the user only sees categories with at least one matched part. """ from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, translate_taxonomy_node, ) classified = _classify_vehicle_parts(master_conn, mye_id) # Merge local inventory and supplier catalog classifications if tenant_conn: _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) result = [] # Iterate in canonical Nexpart order so the UI is stable for group in NEXPART_TAXONOMY.keys(): if group not in classified: continue # Count distinct part_types matched in this group across all subgroups part_count = sum( len(parts) for subgroup_dict in classified[group].values() for parts in subgroup_dict.values() ) result.append({ 'slug': group, 'name': translate_taxonomy_node(group), 'name_en': group, 'part_count': part_count, }) return result def get_nexpart_subgroups_for_vehicle(master_conn, mye_id, group_slug, tenant_conn=None): """Local mode: return Nexpart subgroups within a group that have vehicle parts.""" from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, translate_taxonomy_node, ) classified = _classify_vehicle_parts(master_conn, mye_id) if tenant_conn: _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) group_data = classified.get(group_slug, {}) if not group_data: return [] # Iterate in the canonical order from NEXPART_TAXONOMY for stability canonical_order = list(NEXPART_TAXONOMY.get(group_slug, {}).keys()) result = [] for subgroup in canonical_order: if subgroup not in group_data: continue part_count = sum(len(p) for p in group_data[subgroup].values()) result.append({ 'slug': subgroup, 'name': translate_taxonomy_node(subgroup), 'name_en': subgroup, 'part_count': part_count, }) return result # ═══════════════════════════════════════════════════════════════════════════ # SHOP SUPPLIES — vehicle-independent parts (Network Shop Supplies tab) # ═══════════════════════════════════════════════════════════════════════════ # These live under 2 Nexpart groups that don't require a vehicle selection: # - Chemicals, Waxes & Lubricants (oils, fluids, additives) # - Tires, Wheels, Tools & Accessory Parts (TPMS, lug nuts, universal clips) # # The navigation skips the Year→Make→Model→Engine chain and goes directly # to group selection. The query scans `parts` globally without joining # `vehicle_parts` (which is HUGE), so it's fast. # The 2 Nexpart groups that are safely vehicle-independent. _SHOP_SUPPLIES_GROUPS = ( "Chemicals, Waxes & Lubricants", "Tires, Wheels, Tools & Accessory Parts", ) # Maps each Nexpart Part Type in the universal groups to a list of SQL ILIKE # patterns that match the actual TecDoc name_part values. This inverts the # forward matcher (which goes TecDoc → Nexpart) — here we're asking "which # TecDoc part names should be classified into this Nexpart Part Type?" # # Built by inspecting real name_part values in the parts table. Grow this # map when you see shop supplies that are missing from the results. SHOP_SUPPLIES_PATTERNS = { # Chemicals, Waxes & Lubricants "Engine Oil": ["Engine Oil"], "Automatic Transmission Fluid": ["Automatic Transmission Fluid", "Transmission Oil"], # Tires & Wheels (TPMS + lug hardware) "TPMS Sensor": ["TPMS%", "Tire Pressure Sensor%"], "TPMS Programmable Sensor": ["%TPMS%Programmable%"], "TPMS Sensor Service Kit": ["%TPMS%Service Kit%", "%TPMS%Repair Kit%"], "TPMS Sensor Valve Assembly": ["%TPMS%Valve%"], "TPMS Valve Kit": ["%TPMS%Valve Kit%", "Valve, tyre pressure control system"], "TPMS Programmable Sensor Valve Kit": ["%TPMS%Programmable%Valve%"], "Wheel Lug Nut": ["Wheel Nut"], "Wheel Lug Stud": ["Wheel Bolt", "Wheel Stud"], # Bumper & License Plate (universal clips) "Bumper Clip": ["%Clip%bumper%", "%bumper%Clip%"], "Bumper Cover Retainer": ["%bumper cover%", "Retainer%bumper%"], "Bumper Cover Trim Panel Retainer": ["%Retainer%trim%", "%bumper trim%"], "License Plate Bracket Rivet": ["%license plate%rivet%", "%plate%bracket%"], # Hood, Fender & Body Parts (universal clips) "Cowl Panel Retainer": ["%cowl%retainer%", "Clip, cowl%"], "Door Sill Plate Clip": ["%door sill%clip%", "Clip, sill%"], "Exterior Molding Clip": ["%Clip%trim%", "%trim%strip%Clip%"], "Interior Panel Clip": ["Clip, trim%"], "Rocker Panel Molding Retainer": ["%rocker%retainer%"], "Spoiler Clip": ["Clip, spoiler%", "%spoiler%Clip%"], "Undercar Shield Clip": ["%undercar shield%", "%underbody%Clip%"], # Tools, Jacks, Hardware & Manuals — mostly not present in TecDoc "Cooling System Flush Gun Kit": ["%cooling system flush%"], "Molding Clip": ["Clip, moulding%", "Clip, molding%"], "Multi-Purpose Retainer": ["Retainer%", "Clip, mounting%"], "Interior Panel Retainer": ["Retainer%panel%", "Clip, interior panel%"], # Interior & Steering Wheel — mostly connectors (sparse in TecDoc) "Accelerator Pedal Sensor Connector": ["%accelerator pedal%connector%"], "Clutch Pedal Position Switch Connector": ["%clutch pedal%connector%"], "Console Trim Panel Clip": ["%console%clip%"], # Electronics Audio/Visual & Mirrors "Antenna Mast": ["%antenna mast%", "%antenna%"], "Interior Rear View Mirror Connector": ["%rear view mirror%connector%"], "Interior Rear View Mirror Mounting Base": ["%mirror%mounting base%"], "Keyless Entry Transmitter Cover": ["%keyless%cover%"], "Lane Departure System Camera": ["%lane departure%"], } def _shop_supplies_count_by_part_type(master_conn, part_type_names): """Given a list of Nexpart Part Type names (the Shop Supplies-eligible ones), return {part_type: total_count} using the SHOP_SUPPLIES_PATTERNS map. Uses one query per Part Type because the patterns are OR'd via ILIKE and we need a per-PT count. Still fast because patterns are indexed via trigram if enabled, or just full-scan on 1.5M rows (~500ms total). """ result = {} cur = master_conn.cursor() for pt in part_type_names: patterns = SHOP_SUPPLIES_PATTERNS.get(pt) if not patterns: continue # Build a WHERE clause with multiple ILIKE ORs like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns)) cur.execute( f"SELECT COUNT(*) FROM parts WHERE {like_parts}", patterns, ) count = cur.fetchone()[0] or 0 if count > 0: result[pt] = count cur.close() return result def _shop_supplies_oem_ids(master_conn, part_type_name, limit=5000): """Return the OEM id_part values that match a Shop Supplies Part Type.""" patterns = SHOP_SUPPLIES_PATTERNS.get(part_type_name) if not patterns: return [] cur = master_conn.cursor() like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns)) cur.execute( f"SELECT id_part FROM parts WHERE {like_parts} LIMIT %s", patterns + [limit], ) ids = [row[0] for row in cur.fetchall()] cur.close() return ids def get_shop_supplies_groups(): """Return the 2 Nexpart groups that don't require a vehicle. Unlike the vehicle-scoped get_nexpart_groups_for_vehicle(), this returns ALL subgroups of these groups regardless of whether there are matching parts in the DB — that check happens at the subgroup level to avoid scanning `parts` multiple times. """ from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, translate_taxonomy_node, ) result = [] for group in _SHOP_SUPPLIES_GROUPS: if group not in NEXPART_TAXONOMY: continue subgroup_count = len(NEXPART_TAXONOMY[group]) part_type_count = sum(len(pts) for pts in NEXPART_TAXONOMY[group].values()) result.append({ 'slug': group, 'name': translate_taxonomy_node(group), 'name_en': group, 'part_count': part_type_count, # count of distinct Part Types, not parts 'subgroup_count': subgroup_count, }) return result def get_shop_supplies_subgroups(master_conn, group_slug): """Return subgroups in a Shop Supplies group that have actual TecDoc parts.""" from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, translate_taxonomy_node, ) if group_slug not in _SHOP_SUPPLIES_GROUPS: return [] if group_slug not in NEXPART_TAXONOMY: return [] subgroups = NEXPART_TAXONOMY[group_slug] # Count each Part Type via the SHOP_SUPPLIES_PATTERNS map (ILIKE-based # inverse search that handles naming gaps between Nexpart and TecDoc). all_part_types = [pt for pts in subgroups.values() for pt in pts] counts_by_pt = _shop_supplies_count_by_part_type(master_conn, all_part_types) result = [] for sg_name, pt_list in subgroups.items(): total = sum(counts_by_pt.get(pt, 0) for pt in pt_list) if total == 0: continue result.append({ 'slug': sg_name, 'name': translate_taxonomy_node(sg_name), 'name_en': sg_name, 'part_count': total, }) return result def get_shop_supplies_part_types(master_conn, group_slug, subgroup_slug): """Return Part Types within a Shop Supplies subgroup that have TecDoc parts.""" from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, translate_taxonomy_node, ) if group_slug not in _SHOP_SUPPLIES_GROUPS: return [] subgroups = NEXPART_TAXONOMY.get(group_slug, {}) part_types = subgroups.get(subgroup_slug, []) if not part_types: return [] counts_by_pt = _shop_supplies_count_by_part_type(master_conn, part_types) # Also fetch a sample image for each matched Part Type cur = master_conn.cursor() result = [] for pt in part_types: cnt = counts_by_pt.get(pt, 0) if cnt == 0: continue patterns = SHOP_SUPPLIES_PATTERNS.get(pt, []) if patterns: like_parts = " OR ".join(["name_part ILIKE %s"] * len(patterns)) cur.execute( f"SELECT image_url FROM parts WHERE ({like_parts}) AND image_url IS NOT NULL LIMIT 1", patterns, ) row = cur.fetchone() sample_image = row[0] if row else None else: sample_image = None result.append({ 'slug': pt, 'name': translate_taxonomy_node(pt), 'name_en': pt, 'variant_count': cnt, 'sample_image': sample_image, }) cur.close() return result def get_shop_supplies_parts(master_conn, group_slug, subgroup_slug, part_type_slug, tenant_conn, branch_id, page=1, per_page=30): """Return paginated parts (with aftermarket enrichment) for a Shop Supplies triple. Same output shape as get_parts_for_nexpart_triple() — reuses get_parts_local with an explicit OEM part ID list. """ from services.nexpart_taxonomy import NEXPART_TAXONOMY if group_slug not in _SHOP_SUPPLIES_GROUPS: return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'} # Validate that the requested part type exists in the taxonomy valid_pts = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, []) if part_type_slug not in valid_pts: return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'} # Fetch OEM IDs via the inverse-pattern map (handles TecDoc/Nexpart name gaps) oem_part_ids = _shop_supplies_oem_ids(master_conn, part_type_slug) if not oem_part_ids: return {'data': [], 'pagination': _pagination(page, per_page, 0), 'mode': 'local'} # Reuse the aftermarket-enriched query path return get_parts_local( master_conn, mye_id=None, group_id=None, tenant_conn=tenant_conn, branch_id=branch_id, page=page, per_page=per_page, oem_part_ids=oem_part_ids, ) def _normalize_es(text): """Lowercase and strip accents for Spanish text matching.""" if not text: return '' text = text.lower() for a, b in [('á', 'a'), ('é', 'e'), ('í', 'i'), ('ó', 'o'), ('ú', 'u'), ('ü', 'u'), ('ñ', 'n')]: text = text.replace(a, b) return text def _local_name_matches_part_type(name, part_type_slug): """Check if a local inventory item name matches a Nexpart part_type. Uses translation layers: 1. Direct substring (original slug in name) — legacy 2. Full Spanish translation via translate_taxonomy_node 3. Sub-phrase translations via PART_TRANSLATIONS 4. Word-level matching (handles plurals and partial matches) 5. Extra synonym mappings for Mexican aftermarket terminology """ if not name or not part_type_slug: return True name_norm = _normalize_es(name) slug_lower = part_type_slug.lower() # 1. Legacy direct match if slug_lower in name_norm: return True candidates = [] # 2. Full translation of the part_type slug translated = translate_taxonomy_node(part_type_slug) if translated and translated != part_type_slug: candidates.append(_normalize_es(translated)) # 3. Sub-phrase translation: find the longest PART_TRANSLATIONS key # that is contained in the part_type_slug. best_key = None best_len = 0 for en_key, es_val in PART_TRANSLATIONS.items(): if en_key.lower() in slug_lower and len(en_key) > best_len: best_key = en_key best_len = len(en_key) if best_key: candidates.append(_normalize_es(PART_TRANSLATIONS[best_key])) # 4. Word-level matching: any significant word (4+ chars) from the # candidate translations must appear in the local name. # Also strip trailing 's' to handle plurals (balatas -> balata). for cand in candidates: if cand in name_norm: return True words = [w for w in cand.split() if len(w) >= 4] for w in words: if w in name_norm: return True # plural fallback if w.endswith('s') and w[:-1] in name_norm: return True if w.endswith('es') and w[:-2] in name_norm: return True # 5. Extra synonyms for common Mexican aftermarket terms # Map English sub-phrases to additional Spanish keywords. EXTRA_SYNONYMS = { 'brake pad': ['balata', 'pastilla'], 'brake shoe': ['zapata', 'balata'], 'brake disc': ['disco', 'rotor'], 'brake rotor': ['disco', 'rotor'], 'shock absorber': ['amortiguador', 'amortiguadores'], 'strut': ['amortiguador', 'torre', 'estrut'], 'spark plug': ['bujia', 'bujía', 'bujias'], 'air filter': ['filtro de aire', 'filtro aire'], 'oil filter': ['filtro de aceite', 'filtro aceite'], 'fuel filter': ['filtro de gasolina', 'filtro gasolina'], 'cabin filter': ['filtro de cabina', 'filtro cabina', 'filtro de polen'], 'timing belt': ['banda de tiempo', 'banda distribucion', 'correa de distribucion'], 'drive belt': ['banda de accesorios', 'banda alternador'], 'water pump': ['bomba de agua'], 'alternator': ['alternador'], 'starter': ['marcha', 'motor de arranque'], 'radiator': ['radiador'], 'thermostat': ['termostato'], 'wheel bearing': ['balero', 'rodamiento'], 'hub assembly': ['maza', 'cubo'], 'control arm': ['horquilla', 'brazo'], 'tie rod': ['terminal', 'rotula'], 'ball joint': ['rotula', 'rotula'], 'clutch kit': ['kit de clutch', 'kit de embrague'], 'clutch disc': ['disco de clutch', 'disco de embrague'], 'axle': ['flecha', 'punta de eje', 'homocinetica'], 'cv joint': ['homocinetica', 'punta de eje'], 'oxygen sensor': ['sensor de oxigeno', 'sensor o2'], 'ignition coil': ['bobina', 'bobina de encendido'], 'wiper': ['pluma', 'limpiaparabrisas', 'escobilla'], 'headlight': ['faro', 'faro delantero'], 'taillight': ['calavera', 'faro trasero'], 'turn signal': ['direccional', 'cuarto'], 'fog light': ['faro de niebla'], 'battery': ['bateria', 'acumulador'], 'horn': ['claxon', 'bocina'], } for en_phrase, es_keywords in EXTRA_SYNONYMS.items(): if en_phrase in slug_lower: for kw in es_keywords: if _normalize_es(kw) in name_norm: return True return False def get_parts_for_nexpart_triple(master_conn, mye_id, group_slug, subgroup_slug, part_type_slug, tenant_conn, branch_id, page=1, per_page=30): """Local mode: fetch parts (aftermarket-prioritized) for a Nexpart triple. Steps: 1. Classify the vehicle's parts to find which OEM id_part values map to (group, subgroup, part_type). 2. Delegate to get_parts_local() with the resulting OEM part IDs. Returns the same shape as get_parts_local(). """ classified = _classify_vehicle_parts(master_conn, mye_id) # Also merge inventory and supplier catalog so the leaf IDs may be a mix if tenant_conn: _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) all_ids = ( classified .get(group_slug, {}) .get(subgroup_slug, {}) .get(part_type_slug, []) ) # Separate OEM (TecDoc) IDs, inventory prefixed IDs, and supplier-catalog prefixed IDs oem_ids = [pid for pid in all_ids if isinstance(pid, int)] inv_ids = [pid for pid in all_ids if isinstance(pid, str) and pid.startswith('inv:')] sc_ids = [pid for pid in all_ids if isinstance(pid, str) and pid.startswith('sc:')] # If no OEM IDs, skip get_parts_local (TecDoc tables may not exist) if oem_ids: try: result = get_parts_local( master_conn, mye_id=None, group_id=None, tenant_conn=tenant_conn, branch_id=branch_id, page=1, per_page=999999, oem_part_ids=oem_ids, ) except Exception: # TecDoc tables (parts, aftermarket_parts, vehicle_parts) may not exist result = { 'data': [], 'pagination': { 'page': 1, 'per_page': 999999, 'total': 0, 'total_pages': 0, }, 'mode': 'local', } else: result = { 'data': [], 'pagination': { 'page': 1, 'per_page': 999999, 'total': 0, 'total_pages': 0, }, 'mode': 'local', } # Inject local inventory items linked to this vehicle if tenant_conn and mye_id: from services.inventory_vehicle_compat import get_inventory_by_vehicle local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id) # If specific inventory IDs were classified for this triple, filter to those only if inv_ids: allowed_inv_ids = {int(pid.replace('inv:', '')) for pid in inv_ids} else: allowed_inv_ids = None for lr in local_rows: inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr if allowed_inv_ids is not None and inv_id not in allowed_inv_ids: continue # Only apply name-based filtering when we don't have explicit classified IDs. # If _classify_inventory_parts already matched these IDs to the triple, # trust that classification and skip the expensive name check. if allowed_inv_ids is None and part_type_slug and not _local_name_matches_part_type(name, part_type_slug): continue result['data'].append({ 'id_part': f'inv:{inv_id}', 'id_aftermarket': None, 'oem_part_number': None, 'part_number': pn, 'name': name, 'description': desc, 'image_url': img, 'manufacturer': brand, 'priority_tier': 1, 'local_stock': int(stock) if stock else 0, 'local_price': float(p1) if p1 else None, 'bodega_count': 0, 'warehouse_stock': 0, 'warehouse_price': None, 'in_stock_network': False, 'price_usd': None, 'source': 'local_inventory', }) # Inject supplier catalog items linked to this vehicle (from master DB) if master_conn and sc_ids: cur = master_conn.cursor() sc_id_values = [int(pid.replace('sc:', '')) for pid in sc_ids] cur.execute(""" SELECT id, supplier_name, sku, name, category, description, image_url FROM supplier_catalog WHERE id = ANY(%s) ORDER BY name """, (sc_id_values,)) for row in cur.fetchall(): sc_id, supplier, sku, name, category, desc, img = row result['data'].append({ 'id_part': f'sc:{sc_id}', 'id_aftermarket': None, 'oem_part_number': sku, 'part_number': sku, 'name': name.replace('\\n', ' ') if name else '', 'description': desc or category, 'image_url': img, 'manufacturer': supplier, 'priority_tier': 3, 'local_stock': None, 'local_price': None, 'bodega_count': 0, 'warehouse_stock': 0, 'warehouse_price': None, 'in_stock_network': False, 'price_usd': None, 'source': 'supplier_catalog', }) cur.close() # Sort combined list and paginate in Python all_items = result['data'] all_items.sort(key=lambda x: ( x.get('priority_tier', 3), 0 if (x.get('in_stock_network') or (x.get('local_stock') or 0) > 0) else 1, (x.get('manufacturer') or '').lower(), (x.get('name') or '').lower() )) total = len(all_items) offset = (page - 1) * per_page result['data'] = all_items[offset:offset + per_page] result['pagination'] = _pagination(page, per_page, total) return result def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug, tenant_conn=None): """Local mode: return Nexpart part types within a subgroup that have vehicle parts. Output shape matches get_part_types() so the frontend can render with minimal branching: each item has slug + name + variant_count + sample_image. """ from services.nexpart_taxonomy import ( NEXPART_TAXONOMY, translate_taxonomy_node, ) classified = _classify_vehicle_parts(master_conn, mye_id) if tenant_conn: _merge_classified(classified, _classify_inventory_parts(tenant_conn, mye_id)) _merge_classified(classified, _classify_supplier_catalog_parts(master_conn, mye_id)) subgroup_data = classified.get(group_slug, {}).get(subgroup_slug, {}) if not subgroup_data: return [] # Pull a sample image for each part type — single query, all part_ids at once all_part_ids = [ pid for pids in subgroup_data.values() for pid in pids ] image_map = {} if all_part_ids: try: cur = master_conn.cursor() cur.execute(""" SELECT id_part, image_url FROM parts WHERE id_part = ANY(%s) AND image_url IS NOT NULL """, (all_part_ids,)) for pid, url in cur.fetchall(): image_map[pid] = url cur.close() except Exception: # parts table may not exist (TecDoc removed) pass canonical_order = NEXPART_TAXONOMY.get(group_slug, {}).get(subgroup_slug, []) result = [] for pt in canonical_order: if pt not in subgroup_data: continue part_ids = subgroup_data[pt] sample_image = next((image_map[pid] for pid in part_ids if pid in image_map), None) result.append({ 'slug': pt, 'name': translate_taxonomy_node(pt), 'name_en': pt, 'variant_count': len(part_ids), 'sample_image': sample_image, }) return result def get_groups(master_conn, mye_id, category_id): """Get part groups (subcategories) for this vehicle + category, with part counts.""" cur = master_conn.cursor() cur.execute(""" SELECT pg.id_part_group, COALESCE(pg.name_es, pg.name_part_group) AS name, COUNT(*) AS cnt FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id JOIN part_groups pg ON pg.id_part_group = p.group_id WHERE vp.model_year_engine_id = %s AND pg.category_id = %s GROUP BY pg.id_part_group, name ORDER BY name """, (mye_id, category_id)) rows = cur.fetchall() cur.close() return [{'id_part_group': r[0], 'name': r[1], 'part_count': r[2]} for r in rows] def get_part_types(master_conn, mye_id, group_id): """Get distinct part types within a vehicle+group (Nexpart-style 3rd subcategory level). A "part type" is a unique part name within a group — e.g. within "Brake System" group, the types might be "Brake Disc", "Brake Pad", "Brake Caliper", each with multiple OEM/aftermarket variants. Returns: [{name, slug, variant_count, sample_image}] - name: display name (Spanish if available, else original) - slug: URL-safe key used to filter parts (the original English name_part) - variant_count: how many distinct OEM parts exist for this type - sample_image: image URL of the first variant (for thumbnail) """ cur = master_conn.cursor() # Use ORIGINAL name_part as the slug (matches DB column for filtering), # but display the Spanish translation if available. cur.execute(""" SELECT p.name_part AS slug, COALESCE(p.name_es, p.name_part) AS display_name, COUNT(*) AS variants, (ARRAY_AGG(p.image_url) FILTER (WHERE p.image_url IS NOT NULL))[1] AS sample_image FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = %s AND p.group_id = %s GROUP BY p.name_part, COALESCE(p.name_es, p.name_part) ORDER BY variants DESC, display_name ASC """, (mye_id, group_id)) rows = cur.fetchall() cur.close() return [ { 'slug': r[0], 'name': translate_part_name(r[1]), 'variant_count': r[2], 'sample_image': r[3], } for r in rows ] # ───────────────────────────────────────────────────────────────────────────── # PARTS LIST + DETAIL (with stock enrichment) # ───────────────────────────────────────────────────────────────────────────── def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30, part_type=None): """Get parts for a vehicle + part group, enriched with local stock + bodega indicator. 1. Fetch parts from nexus_autoparts (vehicle_parts + parts) — paginated 2. For each OEM number, look up tenant inventory for local stock 3. For each part_id, check warehouse_inventory for bodega availability Optional part_type filter (string): when provided, only returns parts whose name_part matches exactly. Used for the 3rd subcategory level (Nexpart-style). Returns: {data: [...], pagination: {...}} """ per_page = min(per_page, 100) offset = (page - 1) * per_page cur = master_conn.cursor() extra_where = "" extra_params_count = (mye_id, group_id) extra_params_fetch = (mye_id, group_id, per_page, offset) if part_type: extra_where = " AND p.name_part = %s" extra_params_count = (mye_id, group_id, part_type) extra_params_fetch = (mye_id, group_id, part_type, per_page, offset) # Count total (bounded — uses indexed mye_id + group_id join) cur.execute(""" SELECT COUNT(*) FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where, extra_params_count) total = cur.fetchone()[0] # Fetch page of parts cur.execute(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.description, p.description_es, p.image_url FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where + """ ORDER BY p.name_part LIMIT %s OFFSET %s """, extra_params_fetch) rows = cur.fetchall() if not rows: cur.close() return {'data': [], 'pagination': _pagination(page, per_page, total)} part_ids = [r[0] for r in rows] oem_numbers = [r[1] for r in rows] # Bodega availability: count distinct bodegas with stock > 0 per part cur.execute(""" SELECT part_id, COUNT(*) AS bodega_count FROM warehouse_inventory WHERE part_id = ANY(%s) AND stock_quantity > 0 GROUP BY part_id """, (part_ids,)) bodega_map = {r[0]: r[1] for r in cur.fetchall()} cur.close() # Local stock enrichment from tenant DB local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, part_ids) items = [] for r in rows: part_id = r[0] oem = r[1] local = local_map.get(oem) or local_map.get(f'cat:{part_id}') # Prefer local inventory image over catalog image image_url = (local.get('image_url') if local else None) or r[6] raw_name = r[3] or r[2] # prefer Spanish name items.append({ 'id_part': part_id, 'oem_part_number': oem, 'name': translate_part_name(raw_name), 'description': r[5] or r[4], 'image_url': image_url, 'local_stock': local['stock'] if local else 0, 'local_price': local['price_1'] if local else None, 'bodega_count': bodega_map.get(part_id, 0), }) return {'data': items, 'pagination': _pagination(page, per_page, total)} def get_parts_local(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per_page=30, part_type=None, oem_part_ids=None): """Local catalog mode: show aftermarket parts instead of OEM. Two filtering modes: A) `oem_part_ids` provided → fetch aftermarket equivalents for that specific list of OEM IDs. Used by get_parts_for_nexpart_triple() (Nexpart navigation in Local mode). B) `oem_part_ids` is None → use (mye_id, group_id, optional part_type) to find OEM parts via vehicle_parts join. Legacy path for the TecDoc-style Local navigation. Flow (mode B; mode A skips step 1 since IDs are already known): 1. Find OEM parts for the vehicle+group. 2. For each OEM part, pull all aftermarket equivalents. 3. Join manufacturers to get brand name. 4. Join warehouse_inventory to check bodega availability. 5. Sort by priority tier, then in-stock first, then manufacturer name. 6. Paginate. Returns: {data: [...], pagination: {...}, mode: 'local'} Each part item: manufacturer, priority_tier, in_stock_network, warehouse_price, plus the standard fields. """ from services.catalog_modes import ( LOCAL_PRIORITY_MANUFACTURERS_TIER1, LOCAL_PRIORITY_MANUFACTURERS_TIER2, get_priority_tier, ) per_page = min(per_page, 100) offset = (page - 1) * per_page cur = master_conn.cursor() tier1 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER1) tier2 = list(LOCAL_PRIORITY_MANUFACTURERS_TIER2) # ─── Build the WHERE clause for the OEM-side filter ─── if oem_part_ids is not None: # Mode A: explicit OEM ID list (Nexpart navigation) where_clause = "p.id_part = ANY(%s)" where_params_count = (oem_part_ids,) from_join_count = """ FROM parts p JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id """ else: # Mode B: vehicle+group filter (legacy TecDoc navigation) from_join_count = """ FROM vehicle_parts vp JOIN parts p ON p.id_part = vp.part_id JOIN aftermarket_parts ap ON ap.oem_part_id = p.id_part JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id """ where_clause = "vp.model_year_engine_id = %s AND p.group_id = %s" where_params_count = (mye_id, group_id) if part_type: where_clause += " AND p.name_part = %s" where_params_count = (mye_id, group_id, part_type) # Priority-sorted fetch — same WHERE clause, plus tiers. No SQL paging here; # we combine with local inventory and supplier catalog, then paginate in Python. fetch_params = list(where_params_count) + [tier1, tier2] rows = [] try: cur.execute(""" WITH aftermarket_for_vehicle AS ( SELECT DISTINCT ap.id_aftermarket_parts, ap.oem_part_id, ap.part_number, COALESCE(ap.name_es, ap.name_aftermarket_parts) AS am_name, ap.price_usd, m.name_manufacture, p.oem_part_number, COALESCE(p.name_es, p.name_part) AS oem_name, COALESCE(p.description_es, p.description) AS oem_desc, p.image_url AS oem_image """ + from_join_count + """ WHERE """ + where_clause + """ ), stock_per_oem AS ( SELECT part_id, COUNT(*) AS bodega_count, MIN(price) AS min_price, SUM(stock_quantity) AS total_stock FROM warehouse_inventory WHERE stock_quantity > 0 GROUP BY part_id ) SELECT afv.id_aftermarket_parts, afv.oem_part_id, afv.part_number, afv.am_name, afv.price_usd, afv.name_manufacture, afv.oem_part_number, afv.oem_name, afv.oem_desc, afv.oem_image, COALESCE(s.bodega_count, 0) AS bodega_count, s.min_price AS warehouse_price, COALESCE(s.total_stock, 0) AS warehouse_stock, CASE WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 1 WHEN UPPER(afv.name_manufacture) = ANY(%s) THEN 2 ELSE 3 END AS tier FROM aftermarket_for_vehicle afv LEFT JOIN stock_per_oem s ON s.part_id = afv.oem_part_id ORDER BY tier ASC, (COALESCE(s.bodega_count, 0) > 0) DESC, afv.name_manufacture ASC, afv.am_name ASC """, fetch_params) rows = cur.fetchall() except Exception: # TecDoc tables (parts, aftermarket_parts, vehicle_parts) may not exist try: master_conn.rollback() except Exception: pass finally: cur.close() items = [] seen_part_numbers = set() if rows: # Enrich with tenant local stock (look up by OEM part number). oem_numbers = list({r[6] for r in rows if r[6]}) result_oem_ids = list({r[1] for r in rows if r[1]}) local_map = _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, result_oem_ids) for r in rows: aft_id = r[0] oem_part_id = r[1] aft_number = r[2] aft_name = r[3] price_usd = r[4] manufacturer = r[5] oem_number = r[6] oem_name = r[7] oem_desc = r[8] oem_image = r[9] bodega_count = r[10] warehouse_price = r[11] warehouse_stock = r[12] tier = r[13] local = local_map.get(oem_number) or local_map.get(f'cat:{oem_part_id}') image_url = (local.get('image_url') if local else None) or oem_image part_number = aft_number or oem_number if part_number: seen_part_numbers.add(part_number.upper()) items.append({ 'id_part': oem_part_id, 'id_aftermarket': aft_id, 'oem_part_number': oem_number, 'part_number': aft_number, 'name': translate_part_name(aft_name or oem_name), 'description': oem_desc, 'image_url': image_url, 'manufacturer': manufacturer, 'priority_tier': tier, 'local_stock': local['stock'] if local else 0, 'local_price': local['price_1'] if local else None, 'bodega_count': bodega_count, 'warehouse_stock': warehouse_stock, 'warehouse_price': float(warehouse_price) if warehouse_price is not None else None, 'in_stock_network': bodega_count > 0, 'price_usd': float(price_usd) if price_usd is not None else None, 'source': 'aftermarket', }) # ─── Inject local inventory items linked to this vehicle ────────────────── if mye_id and tenant_conn: from services.inventory_vehicle_compat import get_inventory_by_vehicle local_rows = get_inventory_by_vehicle(tenant_conn, master_conn, mye_id, branch_id) for lr in local_rows: inv_id, pn, name, brand, p1, p2, p3, img, desc, stock = lr if pn and pn.upper() in seen_part_numbers: continue seen_part_numbers.add(pn.upper() if pn else '') items.append({ 'id_part': f'inv:{inv_id}', 'id_aftermarket': None, 'oem_part_number': None, 'part_number': pn, 'name': name, 'description': desc, 'image_url': img, 'manufacturer': brand, 'priority_tier': 1, 'local_stock': int(stock) if stock else 0, 'local_price': float(p1) if p1 else None, 'bodega_count': 0, 'warehouse_stock': 0, 'warehouse_price': None, 'in_stock_network': False, 'price_usd': None, 'source': 'local_inventory', }) # Sort combined list (aftermarket + local) by tier, in-stock, manufacturer/name items.sort(key=lambda x: ( x.get('priority_tier', 3), 0 if (x.get('in_stock_network') or (x.get('local_stock') or 0) > 0) else 1, (x.get('manufacturer') or '').lower(), (x.get('name') or '').lower() )) total = len(items) offset = (page - 1) * per_page paginated = items[offset:offset + per_page] return {'data': paginated, 'pagination': _pagination(page, per_page, total), 'mode': 'local'} def get_part_detail(master_conn, part_id, tenant_conn, branch_id): """Get full detail for a single part: catalog info, local stock, bodegas, alternatives. Returns: { part: {id, oem, name, description, image_url, group_name, category_name}, local: {stock, price_1, price_2, price_3, cost, inventory_id} | null, bodegas: [{business_name, price, stock, location}], alternatives: [{part_number, manufacturer, name, type, local_stock, bodega_count}] } """ cur = master_conn.cursor() # Part info with group + category names cur.execute(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.description, p.description_es, p.image_url, COALESCE(pg.name_es, pg.name_part_group) AS group_name, COALESCE(pc.name_es, pc.name_part_category) AS category_name FROM parts p LEFT JOIN part_groups pg ON pg.id_part_group = p.group_id LEFT JOIN part_categories pc ON pc.id_part_category = pg.category_id WHERE p.id_part = %s """, (part_id,)) row = cur.fetchone() if not row: cur.close() return None oem = row[1] part_info = { 'id_part': row[0], 'oem_part_number': oem, 'name': translate_part_name(row[3] or row[2]), 'description': row[5] or row[4], 'image_url': row[6], 'group_name': translate_category(row[7]) if row[7] else row[7], 'category_name': translate_category(row[8]) if row[8] else row[8], } # Bodegas with stock cur.execute(""" SELECT u.business_name, wi.price, wi.stock_quantity, wi.warehouse_location FROM warehouse_inventory wi JOIN users u ON u.id_user = wi.user_id WHERE wi.part_id = %s AND wi.stock_quantity > 0 ORDER BY wi.price ASC LIMIT 20 """, (part_id,)) bodegas = [ {'business_name': r[0], 'price': float(r[1]) if r[1] else None, 'stock': r[2], 'location': r[3]} for r in cur.fetchall() ] # Alternatives: cross-references + aftermarket alternatives = _get_alternatives(cur, part_id) cur.close() # Local stock local = _get_local_stock_single(tenant_conn, branch_id, oem, part_id) # Enrich alternatives with local stock + bodega count if alternatives: alt_oems = [a['part_number'] for a in alternatives] alt_local = _get_local_stock_bulk(tenant_conn, branch_id, alt_oems, []) cur2 = master_conn.cursor() # Find part_ids for cross-ref numbers to check bodega stock cur2.execute(""" SELECT oem_part_number, id_part FROM parts WHERE oem_part_number = ANY(%s) """, (alt_oems,)) oem_to_part = {r[0]: r[1] for r in cur2.fetchall()} alt_part_ids = [pid for pid in oem_to_part.values() if pid] bodega_map = {} if alt_part_ids: cur2.execute(""" SELECT part_id, COUNT(*) FROM warehouse_inventory WHERE part_id = ANY(%s) AND stock_quantity > 0 GROUP BY part_id """, (alt_part_ids,)) bodega_map = {r[0]: r[1] for r in cur2.fetchall()} cur2.close() for a in alternatives: l = alt_local.get(a['part_number']) a['local_stock'] = l['stock'] if l else 0 pid = oem_to_part.get(a['part_number']) a['bodega_count'] = bodega_map.get(pid, 0) if pid else 0 return { 'part': part_info, 'local': local, 'bodegas': bodegas, 'alternatives': alternatives, } def _get_alternatives(cur, part_id): """Get cross-references + aftermarket parts for a given OEM part.""" results = [] # Cross-references (other OEM numbers that reference this part) cur.execute(""" SELECT pcr.cross_reference_number, pcr.source_ref FROM part_cross_references pcr WHERE pcr.part_id = %s LIMIT 50 """, (part_id,)) for r in cur.fetchall(): results.append({ 'part_number': r[0], 'manufacturer': r[1] or 'OEM Cross-Ref', 'name': None, 'type': 'cross_reference', 'local_stock': 0, 'bodega_count': 0, }) # Aftermarket alternatives cur.execute(""" SELECT ap.part_number, m.name_manufacture, COALESCE(ap.name_es, ap.name_aftermarket_parts) AS name FROM aftermarket_parts ap JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id WHERE ap.oem_part_id = %s LIMIT 50 """, (part_id,)) for r in cur.fetchall(): results.append({ 'part_number': r[0], 'manufacturer': r[1], 'name': r[2], 'type': 'aftermarket', 'local_stock': 0, 'bodega_count': 0, }) return results # ───────────────────────────────────────────────────────────────────────────── # SMART SEARCH # ───────────────────────────────────────────────────────────────────────────── def _search_meili_fallback(master_conn, q, limit): """Search Meilisearch and hydrate from PostgreSQL. Returns list of tuples (id_part, oem_part_number, name_part, name_es, image_url, group_id) or None if Meilisearch is unavailable. """ try: from services.meili_search import search_parts result = search_parts(q, limit=limit) if result is None: # Meilisearch error — signal fallback return None if not result.get('hits'): return [] hits = result['hits'] part_ids = [h['id_part'] for h in hits] cur = master_conn.cursor() cur.execute(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.image_url, p.group_id FROM parts p WHERE p.id_part = ANY(%s) """, (part_ids,)) pg_rows = {r[0]: r for r in cur.fetchall()} cur.close() # Preserve Meilisearch ranking order rows = [] for h in hits: row = pg_rows.get(h['id_part']) if row: rows.append(row) return rows except Exception: # Meilisearch unavailable — signal fallback return None def smart_search(master_conn, q, tenant_conn, branch_id, limit=50, mye_id=None): """Search parts by part number or text. Enriches with local stock. Strategy: 1. Try Meilisearch first (sub-100ms full-text + typo tolerance) 2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down 3. Search local inventory items by part_number or name 4. Always enriches results with local stock from tenant DB """ q = q.strip() if not q or len(q) < 2: return [] limit = min(limit, 100) # ── Attempt Meilisearch first ─────────────────────────────────────────── # NOTE: TecDoc parts table was removed. Central catalog search is disabled. # Only local inventory search remains active. rows = [] seen_local_ids = set() results = [] # ── Inject local inventory items that match the query directly ────────── if tenant_conn: local_items = _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit) for li in local_items: if li['inventory_id'] in seen_local_ids: continue results.append({ 'id_part': f"inv:{li['inventory_id']}", 'oem_part_number': li['part_number'], 'name': li['name'], 'image_url': li['image_url'], 'local_stock': li['stock'], 'local_price': li['price_1'], 'vehicle_info': '', 'source': 'local_inventory', }) seen_local_ids.add(li['inventory_id']) if len(results) >= limit: break # ── Inject supplier catalog items ─────────────────────────────────────── if tenant_conn: supplier_items = _search_supplier_catalog(tenant_conn, q, mye_id, limit) for si in supplier_items: if f"sc:{si['id']}" in seen_local_ids: continue results.append({ 'id_part': f"sc:{si['id']}", 'oem_part_number': si['sku'], 'name': si['name'], 'image_url': si['image_url'], 'local_stock': None, 'local_price': None, 'vehicle_info': si['category'] or '', 'source': 'supplier_catalog', }) seen_local_ids.add(f"sc:{si['id']}") if len(results) >= limit: break return results def _search_supplier_catalog(tenant_conn, q, mye_id, limit): """Search supplier catalog items by SKU or name. If mye_id is provided, only returns items compatible with that vehicle. """ if tenant_conn is None: return [] cur = tenant_conn.cursor() clean_q = q.replace(' ', '').upper() _SQL_UNACCENT = """ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( UPPER(sc.name) , 'Á', 'A'), 'É', 'E'), 'Í', 'I'), 'Ó', 'O'), 'Ú', 'U') , 'À', 'A'), 'È', 'E'), 'Ì', 'I'), 'Ò', 'O'), 'Ù', 'U') """ _q_unaccent = q.upper() for a, b in [('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'), ('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'), ('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N')]: _q_unaccent = _q_unaccent.replace(a, b) if mye_id: cur.execute(f""" SELECT DISTINCT sc.id, sc.sku, sc.name, sc.image_url, sc.category FROM supplier_catalog sc JOIN supplier_catalog_compat scc ON scc.catalog_id = sc.id WHERE sc.is_active = true AND scc.model_year_engine_id = %s AND (REPLACE(UPPER(sc.sku), ' ', '') LIKE %s OR {_SQL_UNACCENT} LIKE %s) ORDER BY sc.name LIMIT %s """, (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) else: cur.execute(f""" SELECT DISTINCT sc.id, sc.sku, sc.name, sc.image_url, sc.category FROM supplier_catalog sc WHERE sc.is_active = true AND (REPLACE(UPPER(sc.sku), ' ', '') LIKE %s OR {_SQL_UNACCENT} LIKE %s) ORDER BY sc.name LIMIT %s """, (f'%{clean_q}%', f'%{_q_unaccent}%', limit)) rows = cur.fetchall() cur.close() return [ {'id': r[0], 'sku': r[1], 'name': r[2], 'image_url': r[3], 'category': r[4]} for r in rows ] def _search_local_inventory(tenant_conn, q, mye_id, branch_id, limit): """Search tenant inventory items by part_number or name. If mye_id is provided, only returns items compatible with that vehicle. """ if tenant_conn is None: return [] cur = tenant_conn.cursor() clean_q = q.replace(' ', '').upper() # Helper to strip accents in SQL for case-insensitive matching _SQL_UNACCENT = """ REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( REPLACE(REPLACE(REPLACE(REPLACE(REPLACE( UPPER(i.name) , 'Á', 'A'), 'É', 'E'), 'Í', 'I'), 'Ó', 'O'), 'Ú', 'U') , 'À', 'A'), 'È', 'E'), 'Ì', 'I'), 'Ò', 'O'), 'Ù', 'U') """ _q_unaccent = q.upper() for a, b in [('Á', 'A'), ('É', 'E'), ('Í', 'I'), ('Ó', 'O'), ('Ú', 'U'), ('À', 'A'), ('È', 'E'), ('Ì', 'I'), ('Ò', 'O'), ('Ù', 'U'), ('Ä', 'A'), ('Ë', 'E'), ('Ï', 'I'), ('Ö', 'O'), ('Ü', 'U'), ('Ñ', 'N')]: _q_unaccent = _q_unaccent.replace(a, b) if mye_id: # Search only items linked to the given vehicle if branch_id: cur.execute(f""" SELECT i.id, i.part_number, i.name, i.image_url, i.price_1, COALESCE(s.stock, 0) as stock FROM inventory i JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id AND s.branch_id = %s WHERE ivc.model_year_engine_id = %s AND i.is_active = true AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s OR {_SQL_UNACCENT} LIKE %s) ORDER BY i.name LIMIT %s """, (branch_id, mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) else: cur.execute(f""" SELECT i.id, i.part_number, i.name, i.image_url, i.price_1, COALESCE(SUM(s.stock), 0) as stock FROM inventory i JOIN inventory_vehicle_compat ivc ON ivc.inventory_id = i.id LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE ivc.model_year_engine_id = %s AND i.is_active = true AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s OR {_SQL_UNACCENT} LIKE %s) GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1 ORDER BY i.name LIMIT %s """, (mye_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) else: # Search all active inventory items if branch_id: cur.execute(f""" SELECT i.id, i.part_number, i.name, i.image_url, i.price_1, COALESCE(s.stock, 0) as stock FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id AND s.branch_id = %s WHERE i.is_active = true AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s OR {_SQL_UNACCENT} LIKE %s) ORDER BY i.name LIMIT %s """, (branch_id, f'%{clean_q}%', f'%{_q_unaccent}%', limit)) else: cur.execute(f""" SELECT i.id, i.part_number, i.name, i.image_url, i.price_1, COALESCE(SUM(s.stock), 0) as stock FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE i.is_active = true AND (REPLACE(UPPER(i.part_number), ' ', '') LIKE %s OR {_SQL_UNACCENT} LIKE %s) GROUP BY i.id, i.part_number, i.name, i.image_url, i.price_1 ORDER BY i.name LIMIT %s """, (f'%{clean_q}%', f'%{_q_unaccent}%', limit)) rows = cur.fetchall() cur.close() return [ { 'inventory_id': r[0], 'part_number': r[1], 'name': r[2], 'image_url': r[3], 'price_1': float(r[4]) if r[4] is not None else None, 'stock': int(r[5]) if r[5] is not None else 0, } for r in rows ] # ───────────────────────────────────────────────────────────────────────────── # LOCAL STOCK HELPERS # ───────────────────────────────────────────────────────────────────────────── def _get_local_stock_bulk(tenant_conn, branch_id, oem_numbers, catalog_part_ids): """Look up tenant inventory for a batch of OEM numbers / catalog part IDs. Returns: dict keyed by oem_number (or 'cat:{id}') -> {stock, price_1, ...} Matches by: part_number = oem_number OR catalog_part_id = id Public-catalog-safe: when tenant_conn is None (public browsing, no tenant context) returns an empty dict so the parts list still renders without local stock/price enrichment. """ if tenant_conn is None: return {} if not oem_numbers and not catalog_part_ids: return {} cur = tenant_conn.cursor() conditions = [] params = [] if oem_numbers: conditions.append("i.part_number = ANY(%s)") params.append(oem_numbers) if catalog_part_ids: conditions.append("i.catalog_part_id = ANY(%s)") params.append(catalog_part_ids) where = " OR ".join(conditions) branch_filter = "" if branch_id: branch_filter = " AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.id, i.part_number, i.catalog_part_id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate, COALESCE(s.stock, 0) AS stock, i.image_url FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE ({where}) AND i.is_active = true{branch_filter} """, params) result = {} for r in cur.fetchall(): entry = { 'inventory_id': r[0], 'part_number': r[1], 'catalog_part_id': r[2], 'price_1': float(r[3]) if r[3] else 0, 'price_2': float(r[4]) if r[4] else 0, 'price_3': float(r[5]) if r[5] else 0, 'cost': float(r[6]) if r[6] else 0, 'tax_rate': float(r[7]) if r[7] else 0.16, 'stock': r[8], 'image_url': r[9], } if r[1]: result[r[1]] = entry if r[2]: result[f'cat:{r[2]}'] = entry cur.close() return result def _get_local_stock_single(tenant_conn, branch_id, oem_part_number, catalog_part_id): """Look up a single part in the tenant inventory. Returns dict or None.""" cur = tenant_conn.cursor() branch_filter = "" params = [oem_part_number, catalog_part_id] if branch_id: branch_filter = " AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.id, i.price_1, i.price_2, i.price_3, i.cost, i.tax_rate, i.location, i.unit, i.barcode, COALESCE(s.stock, 0) AS stock, i.image_url FROM inventory i LEFT JOIN inventory_stock_summary s ON s.inventory_id = i.id WHERE (i.part_number = %s OR i.catalog_part_id = %s) AND i.is_active = true{branch_filter} LIMIT 1 """, params) row = cur.fetchone() cur.close() if not row: return None return { 'inventory_id': row[0], 'price_1': float(row[1]) if row[1] else 0, 'price_2': float(row[2]) if row[2] else 0, 'price_3': float(row[3]) if row[3] else 0, 'cost': float(row[4]) if row[4] else 0, 'tax_rate': float(row[5]) if row[5] else 0.16, 'location': row[6], 'unit': row[7] or 'PZA', 'barcode': row[8], 'stock': row[9], 'image_url': row[10], } # ───────────────────────────────────────────────────────────────────────────── # PUBLIC WRAPPERS (for direct use by callers) # ───────────────────────────────────────────────────────────────────────────── def get_local_stock(tenant_conn, oem_part_number, catalog_part_id, branch_id=None): """Public wrapper: look up a single part in the tenant inventory.""" return _get_local_stock_single(tenant_conn, branch_id, oem_part_number, catalog_part_id) def get_bodega_availability(master_conn, part_id): """Check warehouse_inventory for a part. Returns list of bodegas with stock.""" cur = master_conn.cursor() cur.execute(""" SELECT u.business_name, wi.price, wi.stock_quantity, wi.warehouse_location FROM warehouse_inventory wi JOIN users u ON u.id_user = wi.user_id WHERE wi.part_id = %s AND wi.stock_quantity > 0 ORDER BY wi.price ASC LIMIT 20 """, (part_id,)) rows = cur.fetchall() cur.close() return [ {'business_name': r[0], 'price': float(r[1]) if r[1] else None, 'stock': r[2], 'location': r[3]} for r in rows ] def get_alternatives(master_conn, part_id): """Public wrapper: get cross-references + aftermarket parts for a given OEM part.""" cur = master_conn.cursor() results = _get_alternatives(cur, part_id) cur.close() return results # ───────────────────────────────────────────────────────────────────────────── # HELPERS # ───────────────────────────────────────────────────────────────────────────── def _pagination(page, per_page, total): """Build standard pagination dict.""" total_pages = max(0, (total + per_page - 1) // per_page) return { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages, }