feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts

Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

View File

@@ -9,8 +9,20 @@ OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
# ⚠️ SOLO MODELOS GRATUITOS — No cambiar a modelos de pago.
# El modelo DEBE terminar en ":free" para garantizar costo $0.
# Alternativas gratuitas: "meta-llama/llama-4-scout:free", "google/gemma-3-27b-it:free"
MODEL = "qwen/qwen3.6-plus-preview:free"
MODEL = "qwen/qwen3.6-plus:free"
# Fallback chain: si el modelo principal tiene rate limit (429) o 404
# (deprecated), intenta los siguientes. Todos :free. Mezclamos proveedores
# distintos porque los rate limits aplican por-proveedor.
# Lista actualizada 2026-04-09 después de que qwen3.6-plus fue deprecated.
FALLBACK_MODELS = [
"openai/gpt-oss-120b:free", # OpenInference — gran cobertura
"google/gemma-4-31b-it:free", # Google — nuevo, 262K ctx
"qwen/qwen3-next-80b-a3b-instruct:free", # Alibaba — 262K ctx
"z-ai/glm-4.5-air:free", # Z.AI
"google/gemma-3-27b-it:free", # Google — backup vision
"meta-llama/llama-3.3-70b-instruct:free", # Meta — último fallback
]
def _validate_model(model_id):
"""Ensure only free models are used. Raises if model is not free."""
@@ -318,15 +330,155 @@ def classify_part(part_number):
return {"name": None, "brand": None, "vehicle": None, "category": None}
# ═══════════════════════════════════════════════════════════════════════════
# RESPONSE CACHE — reduces OpenRouter calls for repeated questions
# ═══════════════════════════════════════════════════════════════════════════
# Keyed by a normalized form of the user message. TTL 1 hour. Bypasses
# caching for messages containing VINs or specific part numbers (where the
# answer depends on the exact string).
import hashlib as _hashlib
import re as _re
import time as _time_chat
_RESPONSE_CACHE = {} # key → (expires_at, response_dict)
_CACHE_TTL_SECONDS = 3600 # 1 hour
_CACHE_MAX_SIZE = 1000
_CACHE_HITS = 0
_CACHE_MISSES = 0
# Stopwords that add noise but no meaning — stripped from cache keys.
_CACHE_STOPWORDS = {
'necesito', 'necesitas', 'me', 'das', 'dame', 'tienes', 'tiene', 'hay',
'quiero', 'quisiera', 'puedes', 'puede', 'favor', 'por', 'porfavor',
'hola', 'buenos', 'dias', 'tardes', 'noches', 'holaa',
'i', 'need', 'want', 'do', 'you', 'have', 'please',
}
# Patterns that disable caching — if the message contains any of these, we
# never cache the response because the answer is specific to that exact input.
# Rules designed to minimize false positives against normal Spanish queries
# like "necesito balatas para corolla 2018".
_CACHE_BYPASS_PATTERNS = [
# 17-char VIN (strict, no spaces, alphanumeric except I/O/Q)
_re.compile(r'\b[A-HJ-NPR-Z0-9]{17}\b'),
# Long numeric (12+ digits — too long to be a year/model code)
_re.compile(r'\b\d{12,}\b'),
# Mexican license plate: 3 letters + 3-4 digits
_re.compile(r'\b[A-Z]{3}[-\s]?\d{3,4}\b'),
# OEM with REQUIRED dash/slash separator(s), letters+digits on both sides,
# and a total length that makes it unlikely to be a brand+year collision.
# Example matches: "4G0-857-951-A", "0 986 4B7 013" (after normalizing).
_re.compile(r'\b[A-Z0-9]{2,}[-/][A-Z0-9]{2,}([-/][A-Z0-9]+)+\b'),
]
def _should_bypass_cache(message: str) -> bool:
"""True if the message has VIN / part number / plate — don't cache."""
if not message:
return True
upper = message.upper()
for pat in _CACHE_BYPASS_PATTERNS:
if pat.search(upper):
return True
return False
def _normalize_for_cache(message: str) -> str:
"""Lowercase, strip punctuation, collapse whitespace, drop stopwords."""
if not message:
return ''
s = message.lower().strip()
s = _re.sub(r'[¿?¡!.,;:()\[\]{}\'"]+', ' ', s)
s = _re.sub(r'\s+', ' ', s).strip()
tokens = [t for t in s.split() if t and t not in _CACHE_STOPWORDS]
return ' '.join(tokens)
def _cache_key(user_message: str, inventory_context: str | None) -> str | None:
"""Build a stable cache key for (message, inventory_context).
Returns None if the message should bypass the cache.
"""
if _should_bypass_cache(user_message):
return None
normalized = _normalize_for_cache(user_message)
if not normalized:
return None
# Hash the inventory context so same-tenant-same-question cache hits,
# different-tenant-same-question does NOT (inventory context differs).
ctx_hash = _hashlib.md5((inventory_context or '').encode()).hexdigest()[:12]
return f"{normalized}::{ctx_hash}"
def _cache_get(key: str):
global _CACHE_HITS, _CACHE_MISSES
if not key:
_CACHE_MISSES += 1
return None
entry = _RESPONSE_CACHE.get(key)
if not entry:
_CACHE_MISSES += 1
return None
expires_at, data = entry
if _time_chat.time() > expires_at:
_RESPONSE_CACHE.pop(key, None)
_CACHE_MISSES += 1
return None
_CACHE_HITS += 1
return data
def _cache_set(key: str, data: dict):
if not key or not data:
return
_RESPONSE_CACHE[key] = (_time_chat.time() + _CACHE_TTL_SECONDS, data)
# Bounded cache — evict oldest entries if we grow past the limit
if len(_RESPONSE_CACHE) > _CACHE_MAX_SIZE:
oldest_keys = sorted(
_RESPONSE_CACHE.items(), key=lambda kv: kv[1][0]
)[:200]
for k, _v in oldest_keys:
_RESPONSE_CACHE.pop(k, None)
def chat_cache_stats() -> dict:
"""Diagnostic helper: hit rate and cache size."""
total = _CACHE_HITS + _CACHE_MISSES
hit_rate = (_CACHE_HITS * 100 / total) if total else 0
return {
'entries': len(_RESPONSE_CACHE),
'hits': _CACHE_HITS,
'misses': _CACHE_MISSES,
'hit_rate_pct': round(hit_rate, 1),
'ttl_seconds': _CACHE_TTL_SECONDS,
}
def chat_cache_clear():
"""Manual cache invalidation — e.g. after inventory bulk changes."""
_RESPONSE_CACHE.clear()
def chat(user_message, conversation_history=None, inventory_context=None):
"""Send a message to the AI and get a response with search suggestions.
Caches responses for repeated identical questions (subject to bypass
rules — messages with VINs / part numbers / plates are never cached).
Args:
user_message: The user's chat message.
conversation_history: Previous messages in the conversation.
inventory_context: Optional inventory summary string to inject into the system prompt.
"""
_validate_model(MODEL) # Block paid models
# Cache lookup — only when there's no conversation history (stateless)
cache_key = None
if not conversation_history:
cache_key = _cache_key(user_message, inventory_context)
cached = _cache_get(cache_key)
if cached is not None:
print(f"[AI] Cache HIT for '{user_message[:40]}...'")
return cached
system_content = SYSTEM_PROMPT
if inventory_context:
@@ -337,10 +489,11 @@ def chat(user_message, conversation_history=None, inventory_context=None):
messages.extend(conversation_history)
messages.append({"role": "user", "content": user_message})
import time
max_retries = 3
last_error = None
for attempt in range(max_retries):
# Try each model in the fallback chain on 429 (rate limit)
for model_id in FALLBACK_MODELS:
_validate_model(model_id) # Block paid models
try:
resp = requests.post(
OPENROUTER_URL,
@@ -349,23 +502,32 @@ def chat(user_message, conversation_history=None, inventory_context=None):
"Content-Type": "application/json",
},
json={
"model": MODEL,
"model": model_id,
"messages": messages,
"max_tokens": 500,
"max_tokens": 800,
"temperature": 0.3,
},
timeout=20,
timeout=25,
)
if resp.status_code == 429:
# Rate limited — wait and retry
wait = (attempt + 1) * 5 # 5s, 10s, 15s
if attempt < max_retries - 1:
time.sleep(wait)
continue
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
resp.raise_for_status()
print(f"[AI] Rate limited on {model_id}, trying next model...")
last_error = "rate_limit"
continue
if resp.status_code >= 400:
print(f"[AI] HTTP {resp.status_code} on {model_id}: {resp.text[:200]}")
last_error = f"http_{resp.status_code}"
continue
data = resp.json()
content = data["choices"][0]["message"]["content"]
choice = data.get("choices", [{}])[0]
content = choice.get("message", {}).get("content", "").strip()
finish = choice.get("finish_reason", "")
if not content:
print(f"[AI] Empty response from {model_id} (finish={finish})")
last_error = "empty_response"
continue
print(f"[AI] Response from {model_id} (finish={finish}, {len(content)} chars)")
# Try to parse JSON response
try:
@@ -376,14 +538,27 @@ def chat(user_message, conversation_history=None, inventory_context=None):
parsed = json.loads(json_str)
else:
parsed = json.loads(stripped)
# Successful JSON response — cache it
if cache_key:
_cache_set(cache_key, parsed)
return parsed
except (json.JSONDecodeError, IndexError):
return {"message": content, "search_query": None, "vehicle": None}
fallback = {"message": content, "search_query": None, "vehicle": None}
# Cache the fallback too — the model gave us a real answer,
# it just wasn't JSON. Next hit saves the API call.
if cache_key:
_cache_set(cache_key, fallback)
return fallback
except Exception as e:
if attempt < max_retries - 1:
continue
return {
"message": f"Error de conexion: {str(e)}",
"search_query": None,
"vehicle": None,
}
print(f"[AI] Error with {model_id}: {e}")
last_error = str(e)
continue
# All models exhausted — DON'T cache errors, we want retries next time
if last_error == "rate_limit":
return {"message": "El asistente está ocupado. Intenta de nuevo en unos segundos.", "search_query": None, "vehicle": None}
return {
"message": f"Error de conexion: {last_error}",
"search_query": None,
"vehicle": None,
}

View File

@@ -0,0 +1,129 @@
"""
Catalog modes — OEM vs Local bodega filtering for brand lists.
Two catalog modes coexist:
- 'oem' : Full TecDoc catalog (36+ vehicle brands from Apify import).
Use this for any customer-facing "find your exact OEM part" flow.
- 'local' : Curated list of vehicle brands that local bodegas in Mexico
actually service. Used while the TecDoc/Apify import is paused
or to simplify navigation for customers who only care about
what's available locally.
Both modes use the SAME navigation hierarchy (brand > model > year > engine >
category > parts). Only the initial brand list is filtered.
Edit LOCAL_BODEGA_BRANDS below to add/remove brands as the bodega network grows.
Brand names must match the `brands.name_brand` column in nexus_autoparts
(case-sensitive, uppercase).
"""
# ─── OEM mode — full North America coverage (imported from TecDoc) ──────────
OEM_BRANDS_NA = (
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
'VOLVO', 'VW',
)
# ─── Local mode — brands actually stocked by Mexican bodegas ────────────────
# Popular Mexican market passenger cars + light trucks. Edit as needed.
LOCAL_BODEGA_BRANDS = (
'NISSAN', # Tsuru, Sentra, Versa, March, Tiida, Navara
'VW', # Jetta, Pointer, Vento, Gol, Polo, Beetle
'CHEVROLET', # Aveo, Chevy, Spark, Beat, Sonic, Sail
'FORD', # Fiesta, Focus, EcoSport, Ranger, Figo
'TOYOTA', # Corolla, Yaris, Hilux, Avanza, Tacoma
'HONDA', # Civic, City, CR-V, Fit, HR-V
'DODGE', # Attitude, Neon, Journey
'CHRYSLER',
'RAM', # Pickups
'HYUNDAI', # Accent, Grand i10, Tucson, Elantra
'KIA', # Rio, Forte, Sportage, Sorento
'MAZDA', # 2, 3, CX-5, CX-30
'MITSUBISHI', # Lancer, L200, Outlander
'RENAULT', # Logan, Sandero, Duster, Stepway
'SEAT', # Ibiza, Leon, Arona
'FIAT', # Uno, Palio, Mobi
'SUZUKI', # Swift, Vitara, Ignis, Ertiga
'JEEP', # Compass, Wrangler, Grand Cherokee, Renegade
'GMC', # Sierra, Terrain
'BUICK', # Encore, Enclave (GM)
)
def get_brands_for_mode(mode):
"""Return the tuple of allowed brand names for a given catalog mode.
Args:
mode: 'oem' or 'local'. Anything else defaults to 'oem'.
Returns:
A tuple of uppercase brand name strings.
"""
if mode == 'local':
return LOCAL_BODEGA_BRANDS
return OEM_BRANDS_NA
def normalize_mode(raw):
"""Normalize a raw mode string from a query param or header."""
if not raw:
return 'oem'
cleaned = str(raw).strip().lower()
return 'local' if cleaned == 'local' else 'oem'
# ─── Local mode — priority aftermarket manufacturer brands ─────────────────
# Ordered list. Brands earlier in the list are shown first and get the top
# "priority" badge in the UI. Match `manufacturers.name_manufacture` (uppercase).
#
# Tier 1 (most trusted / most stocked in Mexican bodegas) — shown first.
# Tier 2 (also popular but not always on every shelf) — shown second.
# Anything not in either list is "other" and shown last.
LOCAL_PRIORITY_MANUFACTURERS_TIER1 = (
'BOSCH', # Universal — ignition, sensors, filters, wipers
'GATES', # Bandas / timing belts
'MONROE', # Amortiguadores
'DENSO', # Ignition, cooling, AC
'MANN-FILTER', # Filtros
'MAHLE', # Filtros, pistones, termostatos
'NGK', # Bujias
'BREMBO', # Frenos premium
'KYB', # Amortiguadores
'SKF', # Rodamientos
)
LOCAL_PRIORITY_MANUFACTURERS_TIER2 = (
'DELPHI',
'VALEO',
'HELLA',
'DAYCO',
'SACHS',
'CHAMPION',
'WAGNER',
'FRAM',
'NSK',
)
# Combined flat tuple (Tier1 followed by Tier2) — used for SQL IN clauses
# and for determining "any priority" status.
LOCAL_PRIORITY_MANUFACTURERS = LOCAL_PRIORITY_MANUFACTURERS_TIER1 + LOCAL_PRIORITY_MANUFACTURERS_TIER2
def get_priority_tier(manufacturer_name):
"""Return 1 for tier 1, 2 for tier 2, 3 for everything else.
Used both by the sort order and by the UI to render a priority badge.
"""
if not manufacturer_name:
return 3
name = manufacturer_name.upper()
if name in LOCAL_PRIORITY_MANUFACTURERS_TIER1:
return 1
if name in LOCAL_PRIORITY_MANUFACTURERS_TIER2:
return 2
return 3

View File

@@ -42,19 +42,22 @@ def _clean_model_name(name):
# VEHICLE HIERARCHY NAVIGATION
# ─────────────────────────────────────────────────────────────────────────────
NORTH_AMERICA_BRANDS = (
'ACURA', 'AUDI', 'BMW', 'BUICK', 'CADILLAC', 'CHEVROLET', 'CHRYSLER',
'DODGE', 'FIAT', 'FORD', 'GMC', 'HONDA', 'HYUNDAI', 'INFINITI',
'JAGUAR', 'JEEP', 'KIA', 'LAND ROVER', 'LEXUS', 'LINCOLN', 'MAZDA',
'MERCEDES-BENZ', 'MINI', 'MITSUBISHI', 'NISSAN', 'PEUGEOT', 'PORSCHE',
'RAM', 'RENAULT', 'SEAT', 'SUBARU', 'SUZUKI', 'TESLA', 'TOYOTA',
'VOLVO', 'VW',
)
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_brands(master_conn, year_id=None):
"""Get vehicle brands available in Mexico/USA/Canada that have MYE entries.
If year_id is provided, only brands that have models for that year."""
def get_brands(master_conn, year_id=None, mode='oem'):
"""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 year_id:
cur.execute("""
@@ -64,7 +67,7 @@ def get_brands(master_conn, year_id=None):
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
""", (list(NORTH_AMERICA_BRANDS), year_id))
""", (allowed, year_id))
else:
cur.execute("""
SELECT DISTINCT b.id_brand, b.name_brand
@@ -73,7 +76,7 @@ def get_brands(master_conn, year_id=None):
JOIN model_year_engine mye ON mye.model_id = m.id_model
WHERE b.name_brand = ANY(%s)
ORDER BY b.name_brand
""", (list(NORTH_AMERICA_BRANDS),))
""", (allowed,))
rows = cur.fetchall()
cur.close()
return [{'id_brand': r[0], 'name_brand': r[1]} for r in rows]
@@ -189,6 +192,509 @@ def get_categories(master_conn, mye_id):
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
# ─────────────────────────────────────────────────────────────────────────────
# ─── In-memory cache for vehicle → Nexpart classification ─────────────────
# Key: mye_id (int). Value: (expires_at_timestamp, classified_dict).
# TTL is short (5 min) because catalog data rarely changes but we don't
# want stale data lingering across sessions. Single-process cache —
# Gunicorn workers each have their own, which is fine for this workload.
import time as _time
_CLASSIFY_CACHE = {}
_CLASSIFY_TTL_SECONDS = 300
def _classify_cache_get(mye_id):
entry = _CLASSIFY_CACHE.get(mye_id)
if entry is None:
return None
expires_at, data = entry
if _time.time() > expires_at:
_CLASSIFY_CACHE.pop(mye_id, None)
return None
return data
def _classify_cache_set(mye_id, data):
_CLASSIFY_CACHE[mye_id] = (_time.time() + _CLASSIFY_TTL_SECONDS, data)
# Simple unbounded-growth protection: if cache grows past 500 entries,
# evict the oldest half. Real production would use an LRU library.
if len(_CLASSIFY_CACHE) > 500:
sorted_keys = sorted(_CLASSIFY_CACHE.items(), key=lambda kv: kv[1][0])
for k, _v in sorted_keys[:250]:
_CLASSIFY_CACHE.pop(k, None)
def classify_cache_clear():
"""Manual cache invalidation — call after catalog import."""
_CLASSIFY_CACHE.clear()
def classify_cache_stats():
"""Diagnostic helper for the cache state."""
now = _time.time()
alive = sum(1 for expires, _ in _CLASSIFY_CACHE.values() if expires > now)
return {
'total_entries': len(_CLASSIFY_CACHE),
'alive': alive,
'expired': len(_CLASSIFY_CACHE) - alive,
'ttl_seconds': _CLASSIFY_TTL_SECONDS,
}
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
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()
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 get_nexpart_groups_for_vehicle(master_conn, mye_id):
"""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)
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):
"""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)
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 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)
part_ids = (
classified
.get(group_slug, {})
.get(subgroup_slug, {})
.get(part_type_slug, [])
)
if not part_ids:
return {
'data': [],
'pagination': _pagination(page, per_page, 0),
'mode': 'local',
}
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=part_ids,
)
def get_nexpart_part_types_for_vehicle(master_conn, mye_id, group_slug, subgroup_slug):
"""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)
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:
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()
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()
@@ -209,16 +715,62 @@ def get_groups(master_conn, mye_id, category_id):
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):
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)
@@ -226,13 +778,20 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
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
""", (mye_id, group_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
@@ -241,10 +800,10 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
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
WHERE vp.model_year_engine_id = %s AND p.group_id = %s""" + extra_where + """
ORDER BY p.name_part
LIMIT %s OFFSET %s
""", (mye_id, group_id, per_page, offset))
""", extra_params_fetch)
rows = cur.fetchall()
if not rows:
@@ -289,6 +848,185 @@ def get_parts(master_conn, mye_id, group_id, tenant_conn, branch_id, page=1, per
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)
# Count total aftermarket parts
cur.execute(
"SELECT COUNT(*) " + from_join_count + " WHERE " + where_clause,
where_params_count,
)
total = cur.fetchone()[0]
# Priority-sorted fetch — same WHERE clause as the COUNT, plus tiers + paging.
fetch_params = list(where_params_count) + [tier1, tier2, per_page, offset]
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
LIMIT %s OFFSET %s
""", fetch_params)
rows = cur.fetchall()
cur.close()
if not rows:
return {'data': [], 'pagination': _pagination(page, per_page, total), 'mode': 'local'}
# Enrich with tenant local stock (look up by OEM part number).
# Use a different name to avoid shadowing the `oem_part_ids` parameter.
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)
items = []
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]
# Tenant local stock (refaccionaria's own inventory)
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
items.append({
# Keep fields compatible with OEM mode output so the frontend
# can render with minimal branching.
'id_part': oem_part_id, # OEM id used for detail drill-down
'id_aftermarket': aft_id, # aftermarket row id (for future use)
'oem_part_number': oem_number,
'part_number': aft_number, # aftermarket SKU
'name': translate_part_name(aft_name or oem_name),
'description': oem_desc,
'image_url': image_url,
'manufacturer': manufacturer,
'priority_tier': tier, # 1, 2, or 3
'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,
})
return {'data': items, '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.
@@ -538,7 +1276,13 @@ def _get_local_stock_bulk(tenant_conn, branch_id, 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 {}

View File

@@ -0,0 +1,810 @@
"""
Marketplace B2B — service layer for bodegas, warehouse inventory and
Purchase Orders (Phase 1).
State machine:
draft → submitted → confirmed → ready → delivered → closed
↘ rejected (terminal)
Public API is grouped by concern:
- Bodegas: list_bodegas, get_bodega, verify_bodega
- Inventory: upload_inventory_csv, search_inventory
- POs: create_po_draft, submit_po, transition_po,
get_po_detail, list_pos_for_buyer, list_pos_for_seller
- Notifications: notify_po_status_change (used internally by transition_po)
All DB calls take a `master_conn` (psycopg2 connection to nexus_autoparts).
The caller is responsible for committing and closing.
"""
import csv
import io
import smtplib
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from typing import Optional
# ═══════════════════════════════════════════════════════════════════════════
# STATE MACHINE
# ═══════════════════════════════════════════════════════════════════════════
PO_STATUSES = ('draft', 'submitted', 'confirmed', 'rejected', 'ready', 'delivered', 'closed')
# Map: current_status → {new_status: {actor_kinds}}
# 'buyer' = user who created the PO; 'seller' = bodega owner/user
PO_TRANSITIONS = {
'draft': {'submitted': {'buyer'}},
'submitted': {'confirmed': {'seller'}, 'rejected': {'seller'}},
'confirmed': {'ready': {'seller'}},
'ready': {'delivered': {'buyer', 'seller'}},
'delivered': {'closed': {'buyer', 'seller'}},
# terminal: rejected, closed
}
def _is_valid_transition(from_status: str, to_status: str, actor_kind: str) -> bool:
allowed = PO_TRANSITIONS.get(from_status, {}).get(to_status)
if not allowed:
return False
return actor_kind in allowed
# ═══════════════════════════════════════════════════════════════════════════
# BODEGAS
# ═══════════════════════════════════════════════════════════════════════════
def list_bodegas(master_conn, verified_only: bool = True, city: str = None) -> list[dict]:
"""Return all bodegas, optionally filtered."""
cur = master_conn.cursor()
clauses = []
params = []
if verified_only:
clauses.append("verified = TRUE")
if city:
clauses.append("LOWER(city) = LOWER(%s)")
params.append(city)
where = "WHERE " + " AND ".join(clauses) if clauses else ""
cur.execute(f"""
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state, verified
FROM bodegas
{where}
ORDER BY name
""", params)
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
'verified': r[7],
}
for r in rows
]
def get_bodega(master_conn, bodega_id: int) -> Optional[dict]:
cur = master_conn.cursor()
cur.execute("""
SELECT id_bodega, name, owner_name, whatsapp_phone, email, city, state,
address, verified, commission_pct
FROM bodegas WHERE id_bodega = %s
""", (bodega_id,))
r = cur.fetchone()
cur.close()
if not r:
return None
return {
'id_bodega': r[0], 'name': r[1], 'owner_name': r[2],
'whatsapp_phone': r[3], 'email': r[4], 'city': r[5], 'state': r[6],
'address': r[7], 'verified': r[8], 'commission_pct': float(r[9] or 0),
}
def create_bodega(master_conn, *, name: str, whatsapp_phone: str,
owner_name: str = None, email: str = None,
city: str = None, state: str = None, address: str = None) -> int:
"""Register a new bodega (unverified by default). Admin verifies later."""
cur = master_conn.cursor()
cur.execute("""
INSERT INTO bodegas (name, owner_name, whatsapp_phone, email, city, state, address)
VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING id_bodega
""", (name, owner_name, whatsapp_phone, email, city, state, address))
bodega_id = cur.fetchone()[0]
cur.close()
return bodega_id
def verify_bodega(master_conn, bodega_id: int) -> bool:
cur = master_conn.cursor()
cur.execute("""
UPDATE bodegas SET verified = TRUE, verified_at = NOW() WHERE id_bodega = %s
""", (bodega_id,))
ok = cur.rowcount > 0
cur.close()
return ok
# ═══════════════════════════════════════════════════════════════════════════
# INVENTORY — warehouse_inventory CSV upload + search
# ═══════════════════════════════════════════════════════════════════════════
def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
"""Bulk-upload a bodega's inventory from a CSV string.
Expected columns (case-insensitive, whitespace-tolerant):
part_number, stock, price
Optional:
min_order, warehouse_location, currency
Resolution rules:
- part_number matches `parts.oem_part_number` exactly (case-sensitive).
- Parts not found in the master catalog are skipped and reported.
- Existing rows for (bodega_id, part_id, warehouse_location) are updated
via UPSERT; new rows are inserted.
Returns a summary dict: {ok, inserted, updated, skipped, errors}
"""
reader = csv.DictReader(io.StringIO(csv_text))
# Normalize header names
fieldnames = [f.strip().lower() for f in (reader.fieldnames or [])]
required = {'part_number', 'stock', 'price'}
missing = required - set(fieldnames)
if missing:
return {
'ok': False,
'error': f'Columnas faltantes en CSV: {", ".join(sorted(missing))}',
'inserted': 0, 'updated': 0, 'skipped': 0,
}
# Resolve bodega → its legacy user_id (warehouse_inventory still requires it)
cur = master_conn.cursor()
cur.execute("SELECT id_bodega FROM bodegas WHERE id_bodega = %s", (bodega_id,))
if not cur.fetchone():
cur.close()
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
inserted = 0
updated = 0
skipped = 0
errors = []
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
norm = {k.strip().lower(): (v or '').strip() for k, v in row.items()}
part_number = norm.get('part_number', '')
stock_str = norm.get('stock', '0')
price_str = norm.get('price', '0')
if not part_number:
errors.append(f'Fila {i}: part_number vacio')
skipped += 1
continue
try:
stock = int(stock_str)
price = float(price_str)
except ValueError:
errors.append(f'Fila {i}: stock o price invalido')
skipped += 1
continue
# Resolve part_number → part_id
cur.execute(
"SELECT id_part FROM parts WHERE oem_part_number = %s LIMIT 1",
(part_number,)
)
row_part = cur.fetchone()
if not row_part:
errors.append(f'Fila {i}: part_number "{part_number}" no encontrado en catalogo')
skipped += 1
continue
part_id = row_part[0]
# Resolve user_id from the bodega (use bodega_id as fallback if null)
user_id = norm.get('user_id') or bodega_id # backward compat
try:
user_id = int(user_id)
except (ValueError, TypeError):
user_id = bodega_id
location = norm.get('warehouse_location') or 'Principal'
currency = (norm.get('currency') or 'MXN').upper()
min_order = int(norm.get('min_order') or 1)
# UPSERT on (user_id, part_id, warehouse_location) — the existing
# unique constraint. Don't block if user_id FK fails.
try:
cur.execute("""
INSERT INTO warehouse_inventory
(user_id, part_id, price, stock_quantity, min_order_quantity,
warehouse_location, bodega_id, currency, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
ON CONFLICT (user_id, part_id, warehouse_location)
DO UPDATE SET
price = EXCLUDED.price,
stock_quantity = EXCLUDED.stock_quantity,
min_order_quantity = EXCLUDED.min_order_quantity,
bodega_id = EXCLUDED.bodega_id,
currency = EXCLUDED.currency,
updated_at = NOW()
RETURNING (xmax = 0) AS inserted
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
was_insert = cur.fetchone()[0]
if was_insert:
inserted += 1
else:
updated += 1
except Exception as e:
errors.append(f'Fila {i}: DB error: {str(e)[:100]}')
skipped += 1
master_conn.rollback() # so next INSERTs can proceed
continue
cur.close()
master_conn.commit()
return {
'ok': True,
'inserted': inserted,
'updated': updated,
'skipped': skipped,
'errors': errors[:20], # cap to avoid huge responses
'total_errors': len(errors),
}
def search_inventory(master_conn, *, query: str = None, brand: str = None,
city: str = None, limit: int = 50) -> list[dict]:
"""Browse warehouse_inventory filtered by query / brand / city.
Returns parts WITH stock > 0 from VERIFIED bodegas only.
Aggregates identical parts across bodegas so the buyer sees each part once
with a list of bodegas that have it in stock.
"""
cur = master_conn.cursor()
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
params = []
if query:
clauses.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
like = f'%{query}%'
params.extend([like, like, like])
if brand:
# Search by vehicle brand via vehicle_parts → model_year_engine → models → brands.
# Too slow for this MVP. Instead, match on aftermarket manufacturer name.
clauses.append("""
EXISTS (
SELECT 1 FROM aftermarket_parts ap
JOIN manufacturers m ON m.id_manufacture = ap.manufacturer_id
WHERE ap.oem_part_id = p.id_part AND UPPER(m.name_manufacture) = UPPER(%s)
)
""")
params.append(brand)
if city:
clauses.append("LOWER(b.city) = LOWER(%s)")
params.append(city)
where_sql = " AND ".join(clauses)
cur.execute(f"""
SELECT
p.id_part,
p.oem_part_number,
COALESCE(p.name_es, p.name_part) AS name,
p.image_url,
COUNT(DISTINCT b.id_bodega) AS bodega_count,
MIN(wi.price) AS min_price,
MAX(wi.price) AS max_price,
SUM(wi.stock_quantity) AS total_stock,
-- List of bodega names that have this part in stock
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
JOIN parts p ON p.id_part = wi.part_id
WHERE {where_sql}
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
ORDER BY total_stock DESC
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id_part': r[0],
'oem_part_number': r[1],
'name': r[2],
'image_url': r[3],
'bodega_count': r[4],
'min_price': float(r[5]) if r[5] is not None else None,
'max_price': float(r[6]) if r[6] is not None else None,
'total_stock_hint': 'En stock' if (r[7] or 0) > 0 else 'Consultar',
'bodega_names': r[8], # may expose; adjust if sensitive
}
for r in rows
]
def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
"""Return the list of verified bodegas that currently have a given OEM part
in stock. Used when the buyer wants to pick WHICH bodega to order from.
"""
cur = master_conn.cursor()
cur.execute("""
SELECT b.id_bodega, b.name, b.city, b.whatsapp_phone,
wi.price, wi.stock_quantity, wi.min_order_quantity, wi.currency
FROM warehouse_inventory wi
JOIN bodegas b ON b.id_bodega = wi.bodega_id
WHERE wi.part_id = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
ORDER BY wi.price ASC
""", (part_id,))
rows = cur.fetchall()
cur.close()
return [
{
'id_bodega': r[0], 'name': r[1], 'city': r[2], 'whatsapp_phone': r[3],
'price': float(r[4]) if r[4] is not None else None,
'stock_hint': 'En stock', # don't expose exact quantity
'min_order': r[6] or 1,
'currency': r[7] or 'MXN',
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# PURCHASE ORDERS
# ═══════════════════════════════════════════════════════════════════════════
def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
buyer_display_name: str, buyer_phone: str, buyer_email: str,
bodega_id: int, items: list,
delivery_method: str = 'pickup',
delivery_address: str = None,
buyer_notes: str = None) -> int:
"""Create a PO in 'draft' status with its items.
Args:
items: list of dicts with keys: part_id, quantity, unit_price (optional)
If unit_price is missing, it's pulled from warehouse_inventory.
Returns the new po_id.
"""
if not items:
raise ValueError('A PO must have at least one item')
cur = master_conn.cursor()
# Create header
cur.execute("""
INSERT INTO purchase_orders (
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
bodega_id, status, delivery_method, delivery_address, buyer_notes
) VALUES (%s, %s, %s, %s, %s, %s, 'draft', %s, %s, %s)
RETURNING id_po
""", (
buyer_tenant_id, buyer_user_id, buyer_display_name, buyer_phone, buyer_email,
bodega_id, delivery_method, delivery_address, buyer_notes,
))
po_id = cur.fetchone()[0]
# Insert items
total = 0.0
for item in items:
part_id = int(item['part_id'])
quantity = int(item['quantity'])
if quantity < 1:
continue
# Lookup part info + price
cur.execute("""
SELECT p.oem_part_number, COALESCE(p.name_es, p.name_part), wi.price
FROM parts p
LEFT JOIN warehouse_inventory wi
ON wi.part_id = p.id_part AND wi.bodega_id = %s
WHERE p.id_part = %s LIMIT 1
""", (bodega_id, part_id))
r = cur.fetchone()
if not r:
continue
oem, name, db_price = r
unit_price = float(item.get('unit_price') or db_price or 0)
subtotal = round(unit_price * quantity, 2)
total += subtotal
cur.execute("""
INSERT INTO purchase_order_items
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
# Update header total
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
(round(total, 2), po_id))
# Log initial status
cur.execute("""
INSERT INTO po_status_history (po_id, from_status, to_status, actor_user_id, actor_kind, note)
VALUES (%s, NULL, 'draft', %s, 'buyer', 'PO creado')
""", (po_id, buyer_user_id))
cur.close()
master_conn.commit()
return po_id
def transition_po(master_conn, *, po_id: int, new_status: str,
actor_user_id: int, actor_kind: str,
note: str = None) -> dict:
"""Transition a PO to a new status with full validation and notification.
Returns: {ok, from_status, to_status, notified} or {ok: False, error}
"""
if new_status not in PO_STATUSES:
return {'ok': False, 'error': f'Invalid status: {new_status}'}
cur = master_conn.cursor()
cur.execute("SELECT status FROM purchase_orders WHERE id_po = %s FOR UPDATE", (po_id,))
row = cur.fetchone()
if not row:
cur.close()
return {'ok': False, 'error': f'PO {po_id} not found'}
from_status = row[0]
if not _is_valid_transition(from_status, new_status, actor_kind):
cur.close()
return {
'ok': False,
'error': f'Transition {from_status}{new_status} not allowed for {actor_kind}',
}
# Timestamp columns per state
ts_field = {
'submitted': 'submitted_at',
'confirmed': 'confirmed_at',
'ready': 'ready_at',
'delivered': 'delivered_at',
'closed': 'closed_at',
}.get(new_status)
if ts_field:
cur.execute(
f"UPDATE purchase_orders SET status = %s, {ts_field} = NOW() WHERE id_po = %s",
(new_status, po_id),
)
else:
cur.execute("UPDATE purchase_orders SET status = %s WHERE id_po = %s",
(new_status, po_id))
# Log history row
cur.execute("""
INSERT INTO po_status_history
(po_id, from_status, to_status, actor_user_id, actor_kind, note)
VALUES (%s, %s, %s, %s, %s, %s)
""", (po_id, from_status, new_status, actor_user_id, actor_kind, note))
cur.close()
master_conn.commit()
# Fire notifications — non-blocking (failures logged, not raised)
notified = []
try:
notified = notify_po_status_change(master_conn, po_id, new_status)
except Exception as e:
print(f'[marketplace] notification failed for PO {po_id}: {e}')
return {
'ok': True,
'from_status': from_status,
'to_status': new_status,
'notified': notified,
}
def get_po_detail(master_conn, po_id: int) -> Optional[dict]:
cur = master_conn.cursor()
cur.execute("""
SELECT po.id_po, po.buyer_tenant_id, po.buyer_user_id, po.buyer_display_name,
po.buyer_phone, po.buyer_email,
po.bodega_id, b.name AS bodega_name, b.whatsapp_phone AS bodega_phone,
b.email AS bodega_email,
po.status, po.total_amount, po.currency,
po.buyer_notes, po.seller_notes,
po.delivery_method, po.delivery_address,
po.created_at, po.submitted_at, po.confirmed_at, po.ready_at,
po.delivered_at, po.closed_at
FROM purchase_orders po
JOIN bodegas b ON b.id_bodega = po.bodega_id
WHERE po.id_po = %s
""", (po_id,))
r = cur.fetchone()
if not r:
cur.close()
return None
po = {
'id_po': r[0], 'buyer_tenant_id': r[1], 'buyer_user_id': r[2],
'buyer_display_name': r[3], 'buyer_phone': r[4], 'buyer_email': r[5],
'bodega_id': r[6], 'bodega_name': r[7],
'bodega_phone': r[8], 'bodega_email': r[9],
'status': r[10],
'total_amount': float(r[11]) if r[11] is not None else 0.0,
'currency': r[12],
'buyer_notes': r[13], 'seller_notes': r[14],
'delivery_method': r[15], 'delivery_address': r[16],
'created_at': r[17].isoformat() if r[17] else None,
'submitted_at': r[18].isoformat() if r[18] else None,
'confirmed_at': r[19].isoformat() if r[19] else None,
'ready_at': r[20].isoformat() if r[20] else None,
'delivered_at': r[21].isoformat() if r[21] else None,
'closed_at': r[22].isoformat() if r[22] else None,
}
# Items
cur.execute("""
SELECT id_po_item, part_id, oem_part_number, part_name, manufacturer,
quantity, unit_price, subtotal, confirmed_qty, notes
FROM purchase_order_items WHERE po_id = %s ORDER BY id_po_item
""", (po_id,))
po['items'] = [
{
'id_po_item': ir[0], 'part_id': ir[1], 'oem_part_number': ir[2],
'part_name': ir[3], 'manufacturer': ir[4],
'quantity': ir[5],
'unit_price': float(ir[6]) if ir[6] is not None else 0.0,
'subtotal': float(ir[7]) if ir[7] is not None else 0.0,
'confirmed_qty': ir[8],
'notes': ir[9],
}
for ir in cur.fetchall()
]
# Status history
cur.execute("""
SELECT from_status, to_status, actor_kind, note, created_at
FROM po_status_history WHERE po_id = %s ORDER BY created_at
""", (po_id,))
po['history'] = [
{
'from_status': h[0], 'to_status': h[1], 'actor_kind': h[2],
'note': h[3], 'at': h[4].isoformat() if h[4] else None,
}
for h in cur.fetchall()
]
cur.close()
return po
def list_pos_for_buyer(master_conn, buyer_tenant_id: int, buyer_user_id: int = None,
limit: int = 50) -> list[dict]:
"""Return POs created by a buyer (filtered by tenant or user)."""
cur = master_conn.cursor()
clauses = ['po.buyer_tenant_id = %s']
params = [buyer_tenant_id]
if buyer_user_id is not None:
clauses.append('po.buyer_user_id = %s')
params.append(buyer_user_id)
where = ' AND '.join(clauses)
cur.execute(f"""
SELECT po.id_po, po.status, po.total_amount, po.currency,
po.bodega_id, b.name AS bodega_name,
po.created_at, po.submitted_at,
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
FROM purchase_orders po
JOIN bodegas b ON b.id_bodega = po.bodega_id
WHERE {where}
ORDER BY po.created_at DESC
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id_po': r[0], 'status': r[1],
'total_amount': float(r[2]) if r[2] is not None else 0.0,
'currency': r[3],
'bodega_id': r[4], 'bodega_name': r[5],
'created_at': r[6].isoformat() if r[6] else None,
'submitted_at': r[7].isoformat() if r[7] else None,
'item_count': r[8],
}
for r in rows
]
def list_pos_for_seller(master_conn, bodega_id: int, limit: int = 50) -> list[dict]:
"""Inbox: POs addressed to a seller (bodega)."""
cur = master_conn.cursor()
cur.execute("""
SELECT po.id_po, po.status, po.total_amount, po.currency,
po.buyer_tenant_id, po.buyer_display_name, po.buyer_phone,
po.created_at, po.submitted_at,
(SELECT COUNT(*) FROM purchase_order_items WHERE po_id = po.id_po) AS item_count
FROM purchase_orders po
WHERE po.bodega_id = %s AND po.status != 'draft'
ORDER BY
CASE po.status
WHEN 'submitted' THEN 1
WHEN 'confirmed' THEN 2
WHEN 'ready' THEN 3
ELSE 4
END,
po.submitted_at DESC
LIMIT %s
""", (bodega_id, limit))
rows = cur.fetchall()
cur.close()
return [
{
'id_po': r[0], 'status': r[1],
'total_amount': float(r[2]) if r[2] is not None else 0.0,
'currency': r[3],
'buyer_tenant_id': r[4], 'buyer_display_name': r[5], 'buyer_phone': r[6],
'created_at': r[7].isoformat() if r[7] else None,
'submitted_at': r[8].isoformat() if r[8] else None,
'item_count': r[9],
}
for r in rows
]
# ═══════════════════════════════════════════════════════════════════════════
# NOTIFICATIONS — WhatsApp + Email
# ═══════════════════════════════════════════════════════════════════════════
# Per-status message templates. Each is a (subject, body) tuple.
# The body is plain text — same text goes to WA and email, with an optional
# HTML wrapper for email.
_PO_MESSAGE_TEMPLATES = {
'submitted': (
'Nuevo pedido Nexus #{po_id}',
'Tienes un nuevo pedido en Nexus Marketplace.\n\n'
'Pedido: #{po_id}\n'
'Comprador: {buyer_display_name}\n'
'Total: ${total_amount:,.2f} {currency}\n'
'Items: {item_count}\n\n'
'Entra al marketplace para confirmar o rechazar.'
),
'confirmed': (
'Pedido #{po_id} confirmado por {bodega_name}',
'Tu pedido fue confirmado.\n\n'
'Pedido: #{po_id}\n'
'Bodega: {bodega_name}\n'
'Total: ${total_amount:,.2f} {currency}\n\n'
'Te avisaremos cuando este listo para recoger / entregar.'
),
'rejected': (
'Pedido #{po_id} rechazado',
'Tu pedido fue rechazado por {bodega_name}.\n\n'
'Pedido: #{po_id}\n'
'Puedes intentar con otra bodega en el marketplace.'
),
'ready': (
'Pedido #{po_id} listo',
'Tu pedido esta listo.\n\n'
'Pedido: #{po_id}\n'
'Bodega: {bodega_name}\n'
'Metodo: {delivery_method}\n\n'
'Pasa a recogerlo o espera la entrega.'
),
'delivered': (
'Pedido #{po_id} entregado',
'El pedido #{po_id} fue marcado como entregado.\n'
'Gracias por usar Nexus Marketplace.'
),
'closed': (
'Pedido #{po_id} cerrado',
'El pedido #{po_id} fue cerrado.'
),
}
def notify_po_status_change(master_conn, po_id: int, new_status: str) -> list[str]:
"""Send WhatsApp + email notification about a PO status change.
Returns a list of channel names that were successfully notified
(e.g. ['whatsapp', 'email']). Failures are logged but not raised.
"""
template = _PO_MESSAGE_TEMPLATES.get(new_status)
if not template:
return [] # no message defined for this status
po = get_po_detail(master_conn, po_id)
if not po:
return []
# Resolve context variables for the template
ctx = {
'po_id': po_id,
'buyer_display_name': po.get('buyer_display_name') or 'Cliente',
'bodega_name': po.get('bodega_name') or 'Bodega',
'total_amount': po.get('total_amount') or 0,
'currency': po.get('currency') or 'MXN',
'delivery_method': po.get('delivery_method') or 'pickup',
'item_count': len(po.get('items') or []),
}
subject_tpl, body_tpl = template
try:
subject = subject_tpl.format(**ctx)
body = body_tpl.format(**ctx)
except (KeyError, ValueError) as e:
print(f'[marketplace] template format error for {new_status}: {e}')
return []
# Decide the recipient based on who should be notified for this status
# - submitted → notify seller (new order arrived)
# - confirmed/rejected/ready → notify buyer (status update)
# - delivered → notify both (handled as 2 sends)
# - closed → notify buyer
recipients = []
if new_status == 'submitted':
recipients = [{
'kind': 'seller',
'phone': po.get('bodega_phone'),
'email': po.get('bodega_email'),
}]
elif new_status in ('confirmed', 'rejected', 'ready', 'closed'):
recipients = [{
'kind': 'buyer',
'phone': po.get('buyer_phone'),
'email': po.get('buyer_email'),
}]
elif new_status == 'delivered':
recipients = [
{'kind': 'buyer', 'phone': po.get('buyer_phone'), 'email': po.get('buyer_email')},
{'kind': 'seller', 'phone': po.get('bodega_phone'), 'email': po.get('bodega_email')},
]
channels_used = []
for recipient in recipients:
# WhatsApp
if recipient.get('phone'):
try:
from services import whatsapp_service
result = whatsapp_service.send_message(recipient['phone'], body)
if result and not result.get('error'):
channels_used.append(f"whatsapp:{recipient['kind']}")
except Exception as e:
print(f'[marketplace] WA send failed: {e}')
# Email
if recipient.get('email'):
try:
sent = _send_email(recipient['email'], subject, body)
if sent:
channels_used.append(f"email:{recipient['kind']}")
except Exception as e:
print(f'[marketplace] email send failed: {e}')
return channels_used
def _send_email(to_email: str, subject: str, body_text: str) -> bool:
"""Send a plain-text email via SMTP (config in pos/config.py).
Returns True if the mail was actually sent, False if SMTP is not
configured (silent no-op so dev environments don't crash).
"""
import config
if not config.SMTP_USER or not config.SMTP_PASS:
print('[marketplace] SMTP not configured — skipping email')
return False
msg = MIMEMultipart('alternative')
msg['From'] = config.SMTP_FROM
msg['To'] = to_email
msg['Subject'] = subject
msg.attach(MIMEText(body_text, 'plain', 'utf-8'))
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=15) as server:
server.starttls()
server.login(config.SMTP_USER, config.SMTP_PASS)
server.send_message(msg)
print(f'[marketplace] email sent to {to_email}: {subject}')
return True

View File

@@ -0,0 +1,745 @@
"""
Nexpart Taxonomy — Universal parts classification used in Local catalog mode.
Source of truth: /home/Autopartes/CapturasWeb/nexpart_hierarchy.txt
Total: 14 Groups → 103 Subgroups → 558 Part Types
This module loads the Nexpart hierarchy from the .txt file and provides
helpers to:
1. List all groups / subgroups / part types
2. Map a TecDoc `parts.name_part` value to (group, subgroup, part_type)
3. Translate any node name to Spanish using the existing translations.py
Business decisions (locked in by user 2026-04-08):
1. AMBIGUITY: first match wins (the order in nexpart_hierarchy.txt is
Nexpart's own canonical order, so the first match is also Nexpart's
primary classification).
2. UNMAPPED: drop. Parts without a clean Nexpart match do NOT appear in
Local mode. Local mode is intentionally smaller and more consistent.
3. LANGUAGE: bilingual via translations.py — single source of truth.
The hierarchy is stored in English; the UI translates each node
on-the-fly using `translate_taxonomy_node()`.
"""
import os
import re
from typing import Optional
# ============================================================================
# CONSTANTS
# ============================================================================
UNMAPPED_STRATEGY = "drop"
LANGUAGE_STRATEGY = "bilingual_taxonomy"
# Path to the source-of-truth hierarchy text file
_HIERARCHY_PATH = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..", "..", "CapturasWeb", "nexpart_hierarchy.txt"
)
# ============================================================================
# HIERARCHY PARSER
# ============================================================================
# The list of valid groups, in canonical order (matches Nexpart's own order
# from the screenshots). Used to disambiguate "is this line a group header?"
# from "is this line a subgroup name?" — both can be capitalized.
_KNOWN_GROUPS = (
"IGNITION & FILTERS",
"BELTS, HOSES, WATER PUMPS & COOLING SYSTEM PARTS",
"STARTING & CHARGING SYSTEM PARTS (ALTERNATORS, BATTERIES & CABLES)",
"BRAKE SYSTEM, WHEEL BEARINGS, STUDS, NUTS & HARDWARE",
"FUEL & EMISSIONS PARTS",
"HEATING & AIR CONDITIONING",
"ENGINE PARTS",
"DRIVETRAIN PARTS",
"STEERING & SUSPENSION PARTS",
"EXHAUST, CLUTCH & FLYWHEEL PARTS",
"WIPERS, LAMPS & FUSES",
"BODY PARTS, CABLES, CAPS, ELECTRICAL MOTORS, SWITCHES & OTHER MISCELLANEOUS PARTS",
"CHEMICALS, WAXES & LUBRICANTS",
"TIRES, WHEELS, TOOLS & ACCESSORY PARTS",
)
def _parse_hierarchy_file() -> dict:
"""Parse nexpart_hierarchy.txt into a nested dict.
Returns:
{
"Ignition & Filters": {
"Computers & Relays": ["Engine Control Module (ECM)", ...],
...
},
...
}
"""
taxonomy = {}
current_group = None
current_subgroup = None
if not os.path.exists(_HIERARCHY_PATH):
return taxonomy
with open(_HIERARCHY_PATH, "r", encoding="utf-8") as f:
for line in f:
line = line.rstrip("\n")
# Skip comments, blank lines, and decoration rules
if not line or line.startswith("#"):
continue
if set(line.strip()) <= {"", " "}:
continue
if line.strip() == "SUMMARY":
break # End-of-file marker
# Group header: ALL CAPS line that matches a known group
if line.strip().upper() in _KNOWN_GROUPS:
# Convert to title case for display, preserving the original
# casing from the .txt file (which already mixes Title Case)
current_group = line.strip().title() \
.replace("Ac ", "AC ") \
.replace("Pcv", "PCV") \
.replace("Ecm", "ECM") \
.replace("Cv ", "CV ") \
.replace("Vvt", "VVT") \
.replace("Tpms", "TPMS") \
.replace("Hvac", "HVAC") \
.replace("Abs ", "ABS ") \
.replace("Egr", "EGR")
taxonomy.setdefault(current_group, {})
current_subgroup = None
continue
# Part type: lines with leading " - "
if line.lstrip().startswith("- "):
if current_group and current_subgroup:
pt = line.lstrip()[2:].strip()
taxonomy[current_group][current_subgroup].append(pt)
continue
# Subgroup: a non-empty line that's not a comment, not a header,
# not a part type, and starts with a non-space character.
if line[0] not in (" ", "\t"):
current_subgroup = line.strip()
if current_group:
taxonomy[current_group].setdefault(current_subgroup, [])
return taxonomy
# Load at import time
NEXPART_TAXONOMY = _parse_hierarchy_file()
# ============================================================================
# FLAT INDEX FOR FAST LOOKUP
# ============================================================================
# Building these once at import time means O(1) lookups during requests.
def _build_indexes():
"""Build flat lookup tables from the nested taxonomy."""
# part_type_lower → list of (group, subgroup, original_part_type)
# We use lowercase keys so the matcher is case-insensitive.
part_type_index = {}
all_part_types = [] # ordered list, in canonical Nexpart order
for group, subgroups in NEXPART_TAXONOMY.items():
for subgroup, part_types in subgroups.items():
for pt in part_types:
key = pt.strip().lower()
part_type_index.setdefault(key, []).append((group, subgroup, pt))
all_part_types.append((group, subgroup, pt))
return part_type_index, all_part_types
_PART_TYPE_INDEX, _ALL_PART_TYPES = _build_indexes()
# ============================================================================
# DECISION 1 — RESOLVE AMBIGUITY (first-match wins)
# ============================================================================
# Manual overrides for ambiguous part names. Key = lowercase TecDoc name
# (as fed to the matcher). Value = the subgroup WHERE the part should
# canonically live when a mechanic thinks about it.
#
# These beat the first-match rule. Add entries when you see that your users
# expect a part in a different subgroup than the one Nexpart's canonical
# order picks. Leave empty at start — grow incrementally from feedback.
#
# Example: a Mexican mechanic troubleshooting a failed emissions test will
# look for an O2 sensor under "Catalytic Converter" (system-level thinking),
# not "Emission Sensors, Relays, Solenoids & Switches" (component-level).
AMBIGUITY_OVERRIDES = {
# tecdoc name (lowercase) -> preferred subgroup name (exact string)
# (populated as real usage surfaces mismatches)
# 'oxygen sensor': 'Catalytic Converter',
}
def resolve_ambiguous_subgroup(tecdoc_name: str, candidates: list) -> tuple:
"""Pick the canonical (group, subgroup, part_type) for an ambiguous name.
Resolution order:
1. AMBIGUITY_OVERRIDES dict — manual curation wins over everything.
2. First-match in canonical Nexpart order (Decision 1 locked in).
Search by the user still finds the part from anywhere via the flat
index; the override only affects which subgroup the part "lives in"
during hierarchical navigation.
Args:
tecdoc_name: e.g. "Oxygen Sensor"
candidates: list of (group, subgroup, part_type) tuples
Returns:
A single (group, subgroup, part_type) tuple.
"""
# 1. Manual override wins
key = (tecdoc_name or '').strip().lower()
preferred_subgroup = AMBIGUITY_OVERRIDES.get(key)
if preferred_subgroup:
for cand in candidates:
if cand[1] == preferred_subgroup:
return cand
# Override pointed to a subgroup not in the candidate set —
# log and fall through to first-match.
# (Using print to stay import-free; swap for logger if available.)
print(f"[taxonomy] AMBIGUITY_OVERRIDES['{key}'] = '{preferred_subgroup}' "
f"not in candidates {[c[1] for c in candidates]}; falling back")
# 2. First-match in canonical order
return candidates[0]
# ============================================================================
# DECISION 2 — UNMAPPED HANDLING (drop)
# ============================================================================
# When a TecDoc name doesn't match any Nexpart Part Type, the matcher
# returns None and the caller filters it out of Local mode results.
# ============================================================================
# CORE MATCHER: tecdoc_to_nexpart()
# ============================================================================
def tecdoc_to_nexpart(tecdoc_name: str) -> Optional[tuple]:
"""Map a TecDoc part name to its Nexpart (group, subgroup, part_type).
Matching strategy (in order of preference):
1. Exact match (case-insensitive) on the full Part Type name.
2. Substring match — TecDoc name CONTAINS a known Part Type.
Example: "Front Brake Pad Set" contains "Brake Pad Set" → match.
3. Reverse substring — known Part Type contains the TecDoc name.
Example: TecDoc "Wiper" matches Nexpart "Wiper Arm". Less precise,
used as last resort.
Args:
tecdoc_name: value from `parts.name_part` (English)
Returns:
(group, subgroup, part_type) if matched, None otherwise.
Per Decision 2, callers should filter out None values.
"""
if not tecdoc_name:
return None
name_lower = tecdoc_name.strip().lower()
if not name_lower:
return None
# 1. Exact match
if name_lower in _PART_TYPE_INDEX:
candidates = _PART_TYPE_INDEX[name_lower]
return resolve_ambiguous_subgroup(tecdoc_name, candidates)
# 2. Substring match (TecDoc contains Nexpart Part Type)
# Prefer the LONGEST match — more specific wins on a tie of position.
best_match = None
best_len = 0
for pt_key, candidates in _PART_TYPE_INDEX.items():
if pt_key in name_lower and len(pt_key) > best_len:
best_match = candidates
best_len = len(pt_key)
if best_match:
return resolve_ambiguous_subgroup(tecdoc_name, best_match)
# 3. Reverse substring (Nexpart Part Type contains TecDoc) — last resort
for pt_key, candidates in _PART_TYPE_INDEX.items():
if name_lower in pt_key and len(name_lower) >= 4:
# Min length 4 to avoid false matches on short words like "Cap"
return resolve_ambiguous_subgroup(tecdoc_name, candidates)
return None
# ============================================================================
# DECISION 3 — BILINGUAL VIA translations.py
# ============================================================================
# Curated translations for the 14 top-level groups + common subgroups.
# These are full-string (not substring) so they always win over the partial
# matcher in translations.py and produce clean Spanish display.
TAXONOMY_OVERRIDES_ES = {
# ─── Top-level groups (14) ───
"Ignition & Filters": "Encendido y Filtros",
"Belts, Hoses, Water Pumps & Cooling System Parts": "Bandas, Mangueras, Bombas de Agua y Sistema de Enfriamiento",
"Starting & Charging System Parts (Alternators, Batteries & Cables)": "Sistema de Arranque y Carga (Alternadores, Baterías y Cables)",
"Brake System, Wheel Bearings, Studs, Nuts & Hardware": "Sistema de Frenos, Baleros, Birlos, Tuercas y Ferretería",
"Fuel & Emissions Parts": "Combustible y Emisiones",
"Heating & Air Conditioning": "Calefacción y Aire Acondicionado",
"Engine Parts": "Partes de Motor",
"Drivetrain Parts": "Tren Motriz",
"Steering & Suspension Parts": "Dirección y Suspensión",
"Exhaust, Clutch & Flywheel Parts": "Escape, Clutch y Volante",
"Wipers, Lamps & Fuses": "Limpiaparabrisas, Luces y Fusibles",
"Body Parts, Cables, Caps, Electrical Motors, Switches & Other Miscellaneous Parts": "Carrocería, Cables, Tapones, Motores Eléctricos, Switches y Misceláneos",
"Chemicals, Waxes & Lubricants": "Químicos, Ceras y Lubricantes",
"Tires, Wheels, Tools & Accessory Parts": "Llantas, Rines, Herramientas y Accesorios",
# ─── Common subgroups (the most-used ones; expand as needed) ───
"Filters & PCV": "Filtros y PCV",
"Spark Plugs & Glow Plugs": "Bujías",
"Tune-Up & Ignition Parts": "Afinación y Encendido",
"Belts, Tensioners & Pulleys": "Bandas, Tensores y Poleas",
"Radiators & Electric Fan Motors": "Radiadores y Motoventiladores",
"Thermostats, Housings & Radiator Caps": "Termostatos, Carcasas y Tapones de Radiador",
"Water Pumps, Fan Blades & Clutches": "Bombas de Agua, Aspas y Fan Clutches",
"Alternators & Voltage Regulators": "Alternadores y Reguladores de Voltaje",
"Batteries": "Baterías",
"Starters": "Marchas / Arrancadores",
"ABS Controls & Parts": "Controles y Partes de ABS",
"Front Friction, Drums & Rotors": "Frenos Delanteros: Pastillas, Tambores y Discos",
"Rear Friction, Drums & Rotors": "Frenos Traseros: Pastillas, Tambores y Discos",
"Front Wheel Bearings & Seals": "Baleros y Sellos de Rueda Delantera",
"Rear Wheel Bearings & Seals": "Baleros y Sellos de Rueda Trasera",
"Master Cylinders, Boosters & Switches": "Cilindros Maestros, Boosters y Switches",
"Fuel Pumps & Tanks": "Bombas y Tanques de Gasolina",
"Fuel Injection Parts, Mass Air Flow Sensors": "Inyección, Sensores MAF",
"Turbochargers & Superchargers": "Turbos y Compresores",
"AC Compressors, Kits & Parts": "Compresores de A/C y Kits",
"AC Condensers & Evaporators": "Condensadores y Evaporadores de A/C",
"Cams, Lifters & Timing Parts": "Árboles de Levas, Buzos y Distribución",
"Crankshafts & Bearings": "Cigüeñales y Metales",
"Pistons, Rings & Rods": "Pistones, Anillos y Bielas",
"Heads & Manifolds": "Cabezas y Múltiples",
"Engine Mounts & Other Miscellaneous Engine Parts": "Soportes de Motor y Otros",
"Driveshafts, U-Joints & CV (Constant Velocity) Parts": "Flechas, Crucetas y Juntas Homocinéticas",
"Automatic Transmission Seals": "Sellos de Transmisión Automática",
"Manual Transmission Seals": "Sellos de Transmisión Manual",
"Transmission & Parts": "Transmisión y Partes",
"Ball Joints & Control Arms": "Rótulas y Horquillas",
"Shock Absorbers & Struts": "Amortiguadores y Strut",
"Steering Linkages, Rods & Arms": "Direcciones, Bieletas y Brazos",
"Sway Bars, Stabilizer Bars, Strut Rods & Parts": "Barras Estabilizadoras y Tornillos",
"All Exhaust & Diagrams": "Sistema de Escape Completo",
"Catalytic Converter": "Convertidor Catalítico",
"Clutches & Clutch Kits": "Clutches y Kits",
"Manifolds & Headers": "Múltiples y Headers",
"Arms, Blades & Refills": "Brazos, Plumas y Repuestos",
"Headlamps & Flashers": "Faros y Direccionales",
"Exterior Lamps": "Luces Exteriores",
"Interior Lamps": "Luces Interiores",
"Wiper Motors & Washer Pumps": "Motores de Limpia y Bombas de Agua",
"Bumpers & License Plates": "Defensas y Placas",
"Door, Window & Tailgate Parts": "Puertas, Ventanas y Cajuela",
"Engine & Transmission Lubricants & Additives": "Aceites de Motor y Transmisión",
"Tires & Wheels": "Llantas y Rines",
"Tools, Jacks, Hardware & Manuals": "Herramientas, Gatos y Hardware",
# ─── Remaining subgroups (phase 2 translation coverage) ───
"Computers & Relays": "Computadoras y Relés",
"Ignition Wires": "Cables de Bujía",
"Miscellaneous Ignition Parts": "Conectores y Misceláneos de Encendido",
"Engine Coolant & Bypass Hoses": "Mangueras de Refrigerante y Bypass",
"Heater & Other Hoses": "Mangueras de Calefacción y Otras",
"Sensors, Switches & Relays": "Sensores, Switches y Relés",
"Starter Solenoids, Switches & Relays": "Solenoides de Marcha, Switches y Relés",
"Brake Cables, Studs, Nuts & Spindle Nuts": "Cables, Birlos y Tuercas de Freno",
"Front Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Delanteros",
"Front Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Delanteras",
"Miscellaneous Disc Hardware": "Ferretería Misceláneo de Disco",
"Miscellaneous Drum Hardware": "Ferretería Misceláneo de Tambor",
"Miscellaneous Hydraulic Parts & Brake Specifications": "Hidráulica y Especificaciones de Freno",
"Rear Brake Hardware & ABS Sensors": "Ferretería y Sensores ABS Traseros",
"Rear Calipers, Wheel Cylinders, Hoses": "Calipers, Cilindros y Mangueras Traseras",
"Carburetors, Carburetor Kits & Components": "Carburadores, Kits y Componentes",
"EGR & Emissions Valves": "EGR y Válvulas de Emisiones",
"Emission Sensors, Relays, Solenoids & Switches": "Sensores de Emisiones, Relés, Solenoides y Switches",
"Fuel Injection Harnesses, Connectors & Miscellaneous Parts": "Arneses, Conectores e Inyección Misceláneos",
"Fuel Injection Sensors, Relays & Switches": "Sensores, Relés y Switches de Inyección",
"AC Accumulators, Receiver Driers & Valves": "Acumuladores, Secadores y Válvulas de A/C",
"AC Hose Assemblies & Fittings": "Mangueras y Conexiones de A/C",
"AC Relays & Switches": "Relés y Switches de A/C",
"AC, Heating & Ventilation Gaskets, O-Rings, Kits, Doors & Actuators": "Juntas, O-Rings, Puertas y Actuadores A/C",
"Blower Motors & Parts": "Motores de Ventilador y Partes",
"Heater Cores & Heater Control Valves": "Radiadores de Calefacción y Válvulas",
"Engine Block Parts": "Partes de Bloque de Motor",
"Engines & Kits": "Motores y Kits",
"Gasket Sets": "Juegos de Juntas",
"Individual Gaskets & Seals": "Juntas y Sellos Individuales",
"Intake & Exhaust Valves": "Válvulas de Admisión y Escape",
"Rockers & Push Rods": "Balancines y Varillas de Empuje",
"Vacuum & Oil Pumps": "Bombas de Vacío y Aceite",
"Axle & Differential Parts": "Partes de Eje y Diferencial",
"Electronics, Sensors, Relays & Miscellaneous Parts": "Electrónica, Sensores y Misceláneos",
"Manual Transmission Bearings": "Baleros de Transmisión Manual",
"Spindles & Hubs": "Husillos y Mazas",
"Transmission Kits & Gaskets": "Kits y Juntas de Transmisión",
"Alignment Kits & Tools": "Kits y Herramientas de Alineación",
"King Pins, Trailing Arms, Alignment & Other Chassis": "Pivotes, Brazos y Otros de Chasis",
"Power Steering Pumps, Hoses & Kits": "Bombas, Mangueras y Kits de Dirección Hidráulica",
"Rack & Pinion, Gear Box, Power Cylinder": "Cremallera, Caja de Dirección y Cilindro",
"Clutch Hydraulics": "Hidráulica de Clutch",
"Individual Exhaust Parts": "Partes de Escape Individuales",
"Miscellaneous Clutch Parts": "Partes Misceláneas de Clutch",
"Lighting Modules & Switches": "Módulos y Switches de Iluminación",
"Lighting Relays & Sensors": "Relés y Sensores de Luces",
"Caps": "Tapones",
"Cruise Control Parts": "Partes de Control de Crucero",
"Electrical Motors": "Motores Eléctricos",
"Glass": "Cristales",
"Hood & Tailgate Parts": "Partes de Cofre y Cajuela",
"Hoods Fenders & Body Parts": "Cofres, Salpicaderas y Carrocería",
"Lift Supports": "Amortiguadores de Cofre/Cajuela",
"Switches, Relays & Miscellaneous Parts": "Switches, Relés y Misceláneos",
"Wheel & Hardware": "Rines y Ferretería",
"Bumper & License Plate": "Defensas y Placas",
"Electronics Audio/Visual & Mirrors": "Electrónica, Audio y Espejos",
"Hood, Fender & Body Parts": "Cofre, Salpicaderas y Carrocería",
"Interior & Steering Wheel": "Interior y Volante",
# ─── High-value part types (most-searched in real use) ───
# Ignition & Filters
"Engine Control Module (ECM)": "Módulo de Control del Motor (ECM)",
"Ignition Relay": "Relé de Encendido",
"Transmission Control Module": "Módulo de Control de Transmisión",
"Engine Air Filter": "Filtro de Aire del Motor",
"Engine Oil Filter": "Filtro de Aceite del Motor",
"Engine Oil Filter Adapter": "Adaptador de Filtro de Aceite",
"Engine Oil Filter Housing": "Carcasa de Filtro de Aceite",
"Vapor Canister": "Canister de Vapor",
"Vapor Canister Purge Valve": "Válvula de Purga del Canister",
"Vapor Canister Purge Solenoid": "Solenoide de Purga del Canister",
"Spark Plug Set": "Juego de Bujías",
"Direct Ignition Coil": "Bobina de Encendido Directo",
"Ignition Coil": "Bobina de Encendido",
"Ignition Kit": "Kit de Encendido",
# Belts / Cooling
"Engine Timing Belt": "Banda de Distribución",
"Engine Timing Belt Component Kit": "Kit de Componentes de Distribución",
"Engine Timing Belt Kit with Water Pump": "Kit de Distribución con Bomba de Agua",
"Engine Timing Chain": "Cadena de Distribución",
"Engine Timing Chain Guide": "Guía de Cadena de Distribución",
"Engine Timing Chain Tensioner": "Tensor de Cadena de Distribución",
"Accessory Drive Belt Tensioner Assembly": "Tensor de Banda Accesoria",
"Accessory Drive Belt Tensioner Pulley": "Polea Tensora de Banda Accesoria",
"Serpentine Belt": "Banda Serpentina",
"Radiator": "Radiador",
"Radiator Coolant Hose": "Manguera de Refrigerante del Radiador",
"Engine Coolant Reservoir": "Depósito de Refrigerante",
"Engine Water Pump": "Bomba de Agua del Motor",
"Engine Water Pump Gasket": "Junta de Bomba de Agua",
"Engine Water Pump Pulley": "Polea de Bomba de Agua",
"Engine Coolant Thermostat": "Termostato de Refrigerante",
"Engine Coolant Thermostat Housing": "Carcasa de Termostato",
"Engine Coolant Temperature Sensor": "Sensor de Temperatura de Refrigerante",
"Engine Cooling Fan": "Ventilador de Enfriamiento",
"Engine Cooling Fan Assembly": "Conjunto de Ventilador de Enfriamiento",
"HVAC Heater Hose": "Manguera de Calefacción HVAC",
# Starting & Charging
"Alternator": "Alternador",
"Vehicle Battery": "Batería del Vehículo",
"Starter": "Marcha / Arrancador",
"Ignition Lock Cylinder": "Switch de Encendido (Cilindro)",
"Ignition Switch": "Switch de Encendido",
# Brake System
"ABS Wheel Speed Sensor": "Sensor de Velocidad de Rueda ABS",
"Front Disc Brake Pad Set": "Juego de Pastillas Delanteras",
"Rear Disc Brake Pad Set": "Juego de Pastillas Traseras",
"Front Disc Brake Rotor": "Disco de Freno Delantero",
"Rear Disc Brake Rotor": "Disco de Freno Trasero",
"Front Disc Brake Caliper": "Caliper de Freno Delantero",
"Rear Disc Brake Caliper": "Caliper de Freno Trasero",
"Front Brake Hydraulic Hose": "Manguera Hidráulica Delantera",
"Rear Brake Hydraulic Hose": "Manguera Hidráulica Trasera",
"Brake Master Cylinder": "Cilindro Maestro de Frenos",
"Power Brake Booster": "Booster de Frenos",
"Front Wheel Bearing": "Balero de Rueda Delantera",
"Rear Wheel Bearing": "Balero de Rueda Trasera",
"Front Wheel Bearing and Hub Assembly": "Balero y Maza Delantera",
"Rear Wheel Bearing and Hub Assembly": "Balero y Maza Trasera",
"Wheel Lug Nut": "Tuerca de Rueda (Birlo)",
"Wheel Lug Stud": "Birlo de Rueda",
# Fuel & Emissions
"Electric Fuel Pump": "Bomba Eléctrica de Gasolina",
"Fuel Pump Module Assembly": "Conjunto de Módulo de Bomba de Gasolina",
"Fuel Level Sensor": "Sensor de Nivel de Gasolina",
"Fuel Tank Cap": "Tapón de Tanque de Gasolina",
"Fuel Injector": "Inyector de Gasolina",
"Fuel Injector Set": "Juego de Inyectores",
"Fuel Injection Throttle Body": "Cuerpo de Aceleración",
"Mass Air Flow Sensor": "Sensor MAF (Flujo de Aire)",
"Oxygen Sensor": "Sensor de Oxígeno",
"Engine Camshaft Position Sensor": "Sensor de Posición de Árbol de Levas",
"Engine Crankshaft Position Sensor": "Sensor de Posición del Cigüeñal",
"Engine Knock Sensor": "Sensor de Detonación",
"Manifold Absolute Pressure Sensor": "Sensor MAP (Presión Absoluta)",
"Turbocharger": "Turbocargador",
# Heating & AC
"A/C Compressor": "Compresor de A/C",
"A/C Condenser": "Condensador de A/C",
"A/C Evaporator Core": "Evaporador de A/C",
"A/C Expansion Valve": "Válvula de Expansión de A/C",
"A/C Receiver Drier/Desiccant Element": "Filtro Deshidratador de A/C",
"A/C Hose Assembly": "Manguera de A/C",
"HVAC Blower Motor": "Motor de Ventilador HVAC",
"HVAC Blower Motor Resistor": "Resistencia de Ventilador HVAC",
"HVAC Heater Core": "Radiador de Calefacción",
"HVAC Blend Door Actuator": "Actuador de Puerta de Mezcla",
# Engine Parts
"Engine Camshaft": "Árbol de Levas",
"Engine Harmonic Balancer": "Damper / Polea del Cigüeñal",
"Engine Crankshaft Main Bearing Set": "Juego de Metales de Bancada",
"Engine Piston": "Pistón",
"Engine Piston Ring Set": "Juego de Anillos de Pistón",
"Engine Connecting Rod Bearing Set": "Juego de Metales de Biela",
"Engine Cylinder Head Gasket": "Junta de Cabeza de Cilindros",
"Engine Cylinder Head Bolt Set": "Juego de Tornillos de Cabeza",
"Engine Intake Manifold": "Múltiple de Admisión",
"Engine Intake Manifold Gasket": "Junta de Múltiple de Admisión",
"Engine Valve Cover": "Tapa de Válvulas",
"Engine Valve Cover Gasket": "Junta de Tapa de Válvulas",
"Engine Oil Pan": "Cárter de Aceite",
"Engine Oil Pan Gasket": "Junta de Cárter",
"Engine Oil Pump": "Bomba de Aceite",
"Engine Oil Pressure Sender": "Sensor de Presión de Aceite",
"Engine Oil Pressure Switch": "Switch de Presión de Aceite",
"Engine Mount": "Soporte de Motor",
"Engine Rocker Arm": "Balancín",
"Engine Exhaust Valve": "Válvula de Escape",
"Engine Intake Valve": "Válvula de Admisión",
"Engine Valve Spring": "Resorte de Válvula",
"Engine Valve Stem Oil Seal": "Sello de Válvula",
# Drivetrain
"CV Axle Assembly": "Flecha Homocinética Completa",
"CV Axle Shaft": "Flecha Homocinética",
"Automatic Transmission Mount": "Soporte de Transmisión Automática",
"Automatic Transmission Oil Cooler": "Enfriador de Aceite de Transmisión",
"Automatic Transmission Oil Pan": "Cárter de Transmisión Automática",
"Manual Transmission Mount": "Soporte de Transmisión Manual",
"Transmission Filter Kit": "Kit de Filtro de Transmisión",
"Transmission Oil Pan": "Cárter de Transmisión",
"Spindle Nut": "Tuerca de Husillo",
"Vehicle Speed Sensor": "Sensor de Velocidad del Vehículo",
# Steering & Suspension
"Suspension Ball Joint": "Rótula de Suspensión",
"Suspension Control Arm Bushing": "Buje de Horquilla",
"Suspension Control Arm and Ball Joint Assembly": "Horquilla con Rótula",
"Suspension Shock Absorber": "Amortiguador",
"Suspension Strut": "Strut de Suspensión",
"Suspension Strut Assembly": "Conjunto de Strut",
"Suspension Strut Mount": "Base de Strut",
"Suspension Stabilizer Bar Link": "Terminal de Barra Estabilizadora",
"Steering Tie Rod End": "Terminal de Dirección",
"Rack and Pinion Assembly": "Cremallera de Dirección",
"Steering Column": "Columna de Dirección",
# Exhaust/Clutch
"Catalytic Converter": "Convertidor Catalítico",
"Catalytic Converter Gasket": "Junta de Convertidor Catalítico",
"Exhaust Manifold": "Múltiple de Escape",
"Exhaust Manifold Gasket": "Junta de Múltiple de Escape",
"Exhaust Muffler": "Mofle",
"Exhaust Muffler Assembly": "Conjunto de Mofle",
"Exhaust Pipe": "Tubo de Escape",
"Exhaust Clamp": "Abrazadera de Escape",
"Clutch Slave Cylinder": "Cilindro Esclavo de Clutch",
"Transmission Clutch Kit": "Kit de Clutch",
# Wipers/Lamps
"Wiper Arm": "Brazo de Limpiaparabrisas",
"Wiper Blade": "Pluma Limpiaparabrisas",
"Wiper Motor": "Motor de Limpiaparabrisas",
"Wiper Switch": "Switch de Limpiaparabrisas",
"Headlight Bulb": "Foco de Faro",
"Tail Light Bulb": "Foco de Calavera",
"Brake Light Bulb": "Foco de Freno",
"Turn Signal Light Bulb": "Foco Direccional",
"Fog Light Bulb": "Foco Antiniebla",
"Back Up Light Bulb": "Foco de Reversa",
"License Plate Light Bulb": "Foco de Placa",
"Dome Light Bulb": "Foco de Domo",
"Washer Fluid Reservoir Cap": "Tapón de Depósito de Limpiaparabrisas",
"Headlight Switch": "Switch de Luces",
"Turn Signal Switch": "Switch de Direccionales",
"Multi-Function Switch": "Switch Multifunciones",
"Hazard Warning Switch": "Switch de Intermitentes",
# Body / Electrical / Misc
"Door Lock Actuator": "Actuador de Cerradura",
"Door Lock Actuator Motor": "Motor de Actuador de Cerradura",
"Window Motor": "Motor de Ventana",
"Window Regulator": "Elevador de Ventana",
"Window Motor and Regulator Assembly": "Motor y Elevador de Ventana",
"Sunroof Motor": "Motor de Quemacocos",
"Exterior Door Handle": "Manija Exterior de Puerta",
"Interior Door Handle": "Manija Interior de Puerta",
"Door Mirror Glass": "Cristal de Espejo",
"Horn Relay": "Relé de Claxon",
"Liftgate Lift Support": "Amortiguador de Cajuela",
"Cruise Control Switch": "Switch de Control de Crucero",
"Engine Coolant Reservoir Cap": "Tapón de Depósito de Refrigerante",
"Engine Oil Filler Cap": "Tapón de Llenado de Aceite",
"Radiator Cap": "Tapón de Radiador",
"TPMS Sensor": "Sensor TPMS",
"TPMS Programmable Sensor": "Sensor TPMS Programable",
# Chemicals / Tools
"Automatic Transmission Fluid": "Aceite de Transmisión Automática",
"Engine Oil": "Aceite de Motor",
}
def translate_taxonomy_node(english_name: str) -> str:
"""Translate a Nexpart group / subgroup / part type to Spanish.
STRICT lookup only — no partial substitution. The order:
1. TAXONOMY_OVERRIDES_ES — full-string curated translations.
2. PART_TRANSLATIONS exact match (from services.translations).
3. Fallback: return the English original UNCHANGED.
Why strict-only: partial substitution within a compound name produces
ugly hybrids ("Front Tambor de Freno", "Engine Filtro de Aceite").
For taxonomy display we'd rather show clean English than dirty Spanish.
Untranslated entries are visible reminders to extend the override dict.
Args:
english_name: the canonical English name (group, subgroup, or part type)
Returns:
Spanish display string, or the English original if no exact match.
"""
if not english_name:
return english_name
# 1. Curated overrides (highest priority)
if english_name in TAXONOMY_OVERRIDES_ES:
return TAXONOMY_OVERRIDES_ES[english_name]
# 2. Exact match in PART_TRANSLATIONS
try:
from services.translations import PART_TRANSLATIONS
if english_name in PART_TRANSLATIONS:
return PART_TRANSLATIONS[english_name]
except ImportError:
pass
# 3. Fallback — return English unchanged
return english_name
def list_untranslated_nodes() -> dict:
"""Diagnostic helper: list every taxonomy node missing a Spanish entry.
Useful for filling in TAXONOMY_OVERRIDES_ES incrementally — run this
in a one-off script to see exactly what still needs translation.
Returns:
{"groups": [...], "subgroups": [...], "part_types": [...]}
"""
try:
from services.translations import PART_TRANSLATIONS
known = set(PART_TRANSLATIONS.keys()) | set(TAXONOMY_OVERRIDES_ES.keys())
except ImportError:
known = set(TAXONOMY_OVERRIDES_ES.keys())
missing = {"groups": [], "subgroups": [], "part_types": []}
for group, subgroups in NEXPART_TAXONOMY.items():
if group not in known:
missing["groups"].append(group)
for subgroup, part_types in subgroups.items():
if subgroup not in known:
missing["subgroups"].append(subgroup)
for pt in part_types:
if pt not in known:
missing["part_types"].append(pt)
return missing
# ============================================================================
# PUBLIC API — used by catalog_service / blueprints
# ============================================================================
def get_groups() -> list:
"""Return the 14 top-level groups in canonical order.
Each item: {"name": english, "name_es": spanish, "subgroup_count": int}
"""
return [
{
"name": group,
"name_es": translate_taxonomy_node(group),
"subgroup_count": len(subgroups),
}
for group, subgroups in NEXPART_TAXONOMY.items()
]
def get_subgroups(group_name: str) -> list:
"""Return all subgroups for a given group.
Each item: {"name": english, "name_es": spanish, "part_type_count": int}
"""
subgroups = NEXPART_TAXONOMY.get(group_name, {})
return [
{
"name": subgroup,
"name_es": translate_taxonomy_node(subgroup),
"part_type_count": len(part_types),
}
for subgroup, part_types in subgroups.items()
]
def get_part_types(group_name: str, subgroup_name: str) -> list:
"""Return all part types within a group + subgroup.
Each item: {"name": english, "name_es": spanish}
"""
subgroups = NEXPART_TAXONOMY.get(group_name, {})
part_types = subgroups.get(subgroup_name, [])
return [
{
"name": pt,
"name_es": translate_taxonomy_node(pt),
}
for pt in part_types
]
def stats() -> dict:
"""Return totals — useful for healthcheck and debugging."""
total_subgroups = sum(len(sg) for sg in NEXPART_TAXONOMY.values())
total_part_types = sum(
len(pts)
for sg in NEXPART_TAXONOMY.values()
for pts in sg.values()
)
return {
"groups": len(NEXPART_TAXONOMY),
"subgroups": total_subgroups,
"part_types": total_part_types,
"indexed_keys": len(_PART_TYPE_INDEX),
}

View File

@@ -0,0 +1,240 @@
"""
Peer-to-peer inventory service for multi-instance Nexus deployments.
Each Nexus instance is autonomous (own DB, own POS) but can see inventory
from other instances on the network. The marketplace fans out to all peers
and merges results so users see stock from the whole Nexus network.
Architecture:
- peers.json: config file listing known peer instances (name + URL)
- /pos/api/peer/inventory: public endpoint each instance exposes (no auth)
- search_all_peers(): fan-out query to all enabled peers + local DB
For the demo (LAN), peers are static IPs in peers.json.
For production (clients on own networks), this will evolve into a central
hub model where each instance reports to a cloud server.
"""
import json
import os
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Optional
# ─── Config ──────────────────────────────────────────────────────────────
_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'peers.json')
_config_cache = None
def _load_config():
"""Load peers.json, cached in memory after first read."""
global _config_cache
if _config_cache is not None:
return _config_cache
try:
with open(_CONFIG_PATH, 'r') as f:
_config_cache = json.load(f)
except (FileNotFoundError, json.JSONDecodeError) as e:
print(f'[peer] Warning: could not load {_CONFIG_PATH}: {e}')
_config_cache = {'instance_name': 'Unknown', 'peers': [], 'peer_timeout_seconds': 3}
return _config_cache
def reload_config():
"""Force-reload peers.json (call after editing the file)."""
global _config_cache
_config_cache = None
return _load_config()
def get_instance_name() -> str:
return _load_config().get('instance_name', 'Unknown')
def get_instance_id() -> str:
return _load_config().get('instance_id', 'unknown')
def get_peers() -> list[dict]:
"""Return list of enabled peers: [{name, url, enabled}]"""
cfg = _load_config()
return [p for p in cfg.get('peers', []) if p.get('enabled', True)]
def get_timeout() -> int:
return _load_config().get('peer_timeout_seconds', 3)
# ─── Local inventory query (what WE expose to peers) ─────────────────────
def get_local_inventory(tenant_conn, query: str = None, limit: int = 50) -> list[dict]:
"""Query this instance's inventory for the peer endpoint.
Returns parts WITH stock > 0, with enough detail for the marketplace
to render results (part number, name, brand, price, stock hint).
No exact stock numbers — just 'En stock' (per business decision).
"""
cur = tenant_conn.cursor()
# Build WHERE clause
clauses = ["COALESCE(s.stock, 0) > 0", "i.is_active = TRUE"]
params = []
if query:
clauses.append("(i.part_number ILIKE %s OR i.name ILIKE %s OR i.brand ILIKE %s)")
like = f'%{query}%'
params.extend([like, like, like])
where = " AND ".join(clauses)
cur.execute(f"""
SELECT i.id, i.part_number, i.name, i.brand, i.price_1,
COALESCE(s.stock, 0) AS stock,
i.unit, i.catalog_part_id
FROM inventory i
LEFT JOIN (
SELECT inventory_id, SUM(quantity) AS stock
FROM inventory_operations
GROUP BY inventory_id
) s ON s.inventory_id = i.id
WHERE {where}
ORDER BY i.name
LIMIT %s
""", params + [limit])
rows = cur.fetchall()
cur.close()
return [
{
'id': r[0],
'part_number': r[1],
'name': r[2],
'brand': r[3] or '',
'price': float(r[4]) if r[4] else None,
'stock_hint': 'En stock' if r[5] > 0 else 'Agotado',
'unit': r[6] or 'PZA',
'catalog_part_id': r[7],
}
for r in rows
]
# ─── Peer fan-out query ──────────────────────────────────────────────────
def _query_one_peer(peer: dict, query: str, limit: int) -> dict:
"""Send a search request to one peer instance. Returns results or error."""
url = peer['url'].rstrip('/') + '/pos/api/peer/inventory'
params = {'limit': limit}
if query:
params['q'] = query
try:
resp = requests.get(url, params=params, timeout=get_timeout())
if resp.status_code == 200:
data = resp.json()
# Tag each result with the source instance name
items = data.get('data', [])
for item in items:
item['source_instance'] = peer['name']
item['source_url'] = peer['url']
return {'ok': True, 'name': peer['name'], 'data': items}
else:
return {'ok': False, 'name': peer['name'], 'error': f'HTTP {resp.status_code}'}
except requests.exceptions.Timeout:
return {'ok': False, 'name': peer['name'], 'error': 'timeout'}
except requests.exceptions.ConnectionError:
return {'ok': False, 'name': peer['name'], 'error': 'offline'}
except Exception as e:
return {'ok': False, 'name': peer['name'], 'error': str(e)[:100]}
def search_all_peers(tenant_conn, query: str = None, limit: int = 50) -> dict:
"""Search local inventory + all enabled peers in parallel.
Returns:
{
"local": { "name": "...", "data": [...] },
"peers": [
{"name": "Refac B", "data": [...], "ok": True},
{"name": "Refac C", "data": [...], "ok": True},
...
],
"merged": [...], # all results combined, local first
"total": N,
"errors": [...] # peers that failed
}
"""
peers = get_peers()
# Local results
local_data = get_local_inventory(tenant_conn, query=query, limit=limit)
for item in local_data:
item['source_instance'] = get_instance_name()
item['source_url'] = 'local'
# Fan-out to peers in parallel
peer_results = []
errors = []
if peers:
with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor:
futures = {
executor.submit(_query_one_peer, p, query, limit): p
for p in peers
}
for future in as_completed(futures):
result = future.result()
if result['ok']:
peer_results.append(result)
else:
errors.append(result)
print(f'[peer] {result["name"]}: {result["error"]}')
# Merge: local first, then peers (sorted by name within each source)
merged = list(local_data)
for pr in peer_results:
merged.extend(pr.get('data', []))
return {
'local': {
'name': get_instance_name(),
'data': local_data,
'count': len(local_data),
},
'peers': peer_results,
'merged': merged,
'total': len(merged),
'errors': errors,
}
# ─── Health check for the peer network ───────────────────────────────────
def check_peer_health() -> list[dict]:
"""Ping all peers and return status. Useful for the admin dashboard."""
peers = get_peers()
results = []
def _ping(peer):
try:
url = peer['url'].rstrip('/') + '/pos/api/peer/health'
resp = requests.get(url, timeout=get_timeout())
if resp.status_code == 200:
data = resp.json()
return {
'name': peer['name'],
'url': peer['url'],
'status': 'online',
'instance_name': data.get('instance_name'),
'inventory_count': data.get('inventory_count'),
}
return {'name': peer['name'], 'url': peer['url'], 'status': f'error:{resp.status_code}'}
except Exception as e:
return {'name': peer['name'], 'url': peer['url'], 'status': f'offline:{str(e)[:50]}'}
if peers:
with ThreadPoolExecutor(max_workers=min(len(peers), 5)) as executor:
results = list(executor.map(_ping, peers))
return results

View File

@@ -105,6 +105,93 @@ def generate_ticket(sale_data, business_info, width=80):
return bytes(buf)
def generate_quotation_ticket(quote_data, business_info, width=80):
"""Generate ESC/POS bytes for a quotation ticket.
Args:
quote_data: dict with keys: id, items[{name, part_number, quantity, unit_price, subtotal}],
subtotal, tax_total, total, valid_until, customer_name, wa_phone, created_at
business_info: dict with name, rfc, address
width: 58 or 80 (mm)
Returns: bytes ready to send to printer
"""
chars = 32 if width == 58 else 48
buf = bytearray()
buf += INIT
# Header
buf += ALIGN_CENTER
buf += LARGE_SIZE
buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace')
buf += NORMAL_SIZE
if business_info.get('rfc'):
buf += (business_info['rfc'] + '\n').encode('cp437', errors='replace')
if business_info.get('address'):
buf += (business_info['address'] + '\n').encode('cp437', errors='replace')
buf += b'\n'
# Title
buf += BOLD_ON + DOUBLE_HEIGHT
buf += 'COTIZACION\n'.encode('cp437', errors='replace')
buf += NORMAL_SIZE + BOLD_OFF
buf += b'\n'
# Folio + date
buf += ALIGN_LEFT
buf += BOLD_ON
buf += f'Folio: COT-{quote_data.get("id", "")}\n'.encode('cp437', errors='replace')
buf += BOLD_OFF
buf += f'Fecha: {str(quote_data.get("created_at", ""))[:10]}\n'.encode('cp437', errors='replace')
buf += f'Vigencia: {quote_data.get("valid_until", "7 dias")}\n'.encode('cp437', errors='replace')
if quote_data.get('customer_name'):
buf += f'Cliente: {quote_data["customer_name"]}\n'.encode('cp437', errors='replace')
if quote_data.get('wa_phone'):
buf += f'WhatsApp: {quote_data["wa_phone"]}\n'.encode('cp437', errors='replace')
buf += ('-' * chars + '\n').encode()
# Column header
buf += BOLD_ON
hdr = _format_line('Cant Descripcion', 'Importe', chars)
buf += (hdr + '\n').encode('cp437', errors='replace')
buf += BOLD_OFF
buf += ('-' * chars + '\n').encode()
# Items
for item in quote_data.get('items', []):
name = item.get('name', '')[:chars - 10]
part_no = item.get('part_number', '')
qty = item.get('quantity', 1)
subtotal = item.get('subtotal', 0)
buf += f'{qty}x {name}\n'.encode('cp437', errors='replace')
if part_no:
buf += f' #{part_no}\n'.encode('cp437', errors='replace')
buf += ALIGN_RIGHT
buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace')
buf += ALIGN_LEFT
buf += ('-' * chars + '\n').encode()
# Totals
buf += ALIGN_RIGHT
buf += _total_line('Subtotal:', quote_data.get('subtotal', 0), chars).encode('cp437', errors='replace')
buf += _total_line('IVA:', quote_data.get('tax_total', 0), chars).encode('cp437', errors='replace')
buf += BOLD_ON + DOUBLE_HEIGHT
buf += _total_line('TOTAL:', quote_data.get('total', 0), chars).encode('cp437', errors='replace')
buf += NORMAL_SIZE + BOLD_OFF
# Footer
buf += b'\n'
buf += ALIGN_CENTER
buf += 'Esta cotizacion no es comprobante fiscal\n'.encode('cp437', errors='replace')
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
buf += b'\n\n\n'
buf += PARTIAL_CUT
return bytes(buf)
def _format_line(left, right, width):
"""Pad a left-right line to fill the ticket width."""
space = width - len(left) - len(right)

View File

@@ -14,8 +14,20 @@ def decode_vin(vin):
return {"error": "VIN debe tener exactamente 17 caracteres alfanumericos (sin I, O, Q)."}
url = f"https://vpic.nhtsa.dot.gov/api/vehicles/DecodeVinValues/{vin}?format=json"
resp = requests.get(url, timeout=10)
resp.raise_for_status()
# NHTSA's free API can be slow (5-30s). Retry once on timeout.
import time
for attempt in range(2):
try:
resp = requests.get(url, timeout=25)
resp.raise_for_status()
break
except requests.exceptions.Timeout:
if attempt == 0:
time.sleep(2)
continue
return {"error": "El servidor NHTSA no respondio. Intenta de nuevo en unos segundos."}
except requests.exceptions.RequestException as e:
return {"error": f"Error de conexion con NHTSA: {str(e)[:100]}"}
data = resp.json()["Results"][0]
error_text = data.get("ErrorText", "") or ""

View File

@@ -0,0 +1,284 @@
"""
WhatsApp Quotation Service — conversational quote builder.
Tracks per-phone "open quotations" so a customer can ask about multiple
parts over several messages and receive a single formatted quotation at
the end.
Flow:
1. Customer asks about a part → bot shows local inventory match
2. Customer says "cotizar" / "agregar" → last-shown part added to quote
3. Repeat for more parts
4. Customer says "enviar cotización" / "listo" → formatted quote sent
5. Customer says "limpiar" / "nueva cotización" → quote cleared
The quotation is stored in the tenant's existing `quotations` +
`quotation_items` tables so it also appears in the POS quotation list.
"""
import re
from datetime import date, timedelta
# ─── Intent detection ────────────────────────────────────────────────
# Commands the customer can type (case-insensitive, accent-insensitive)
# NOTE: "si" is NOT here — it's handled as 'confirm' to avoid ambiguity
# with "si" after a quotation was sent (which means "confirm order").
_ADD_PATTERNS = re.compile(
r'^(cotizar|agregar|agregalo|agrega|añadir|quiero ese|ese mero|'
r'dame ese|lo quiero|me lo apartas|si.?cotiza)$',
re.IGNORECASE
)
_SEND_PATTERNS = re.compile(
r'^(enviar cotizaci[oó]n|listo|enviar|mandar cotizaci[oó]n|ya es todo|'
r'eso es todo|mandame la cotizaci[oó]n|terminé|termine|ver cotizaci[oó]n|'
r'mi cotizaci[oó]n|total|cuanto es)$',
re.IGNORECASE
)
_CLEAR_PATTERNS = re.compile(
r'^(limpiar|nueva cotizaci[oó]n|borrar cotizaci[oó]n|empezar de nuevo|cancelar cotizaci[oó]n)$',
re.IGNORECASE
)
# "si", "va", "confirmo" — confirm the quotation (close it as accepted)
_CONFIRM_PATTERNS = re.compile(
r'^(si|sí|va|confirmo|confirmar|acepto|de acuerdo|ok|okay|dale)$',
re.IGNORECASE
)
_QTY_PATTERN = re.compile(
r'^(cotizar|agregar|dame|quiero)\s+(\d+)$',
re.IGNORECASE
)
def detect_quote_intent(text, has_open_quote=False):
"""Detect if the message is a quotation command.
Args:
text: the user's message
has_open_quote: True if this phone has an active quotation
Returns:
('add', quantity) — add last part to quote
('send', None) — send the full quotation
('clear', None) — clear the quotation
('confirm', None) — confirm/accept the quotation
(None, None) — not a quote command, pass to AI
"""
if not text:
return None, None
t = text.strip()
# Check for quantity: "cotizar 3", "agregar 5"
qty_match = _QTY_PATTERN.match(t)
if qty_match:
return 'add', int(qty_match.group(2))
if _ADD_PATTERNS.match(t):
return 'add', 1
if _SEND_PATTERNS.match(t):
return 'send', None
if _CLEAR_PATTERNS.match(t):
return 'clear', None
# "si" / "va" / "confirmo" — only counts as 'confirm' when there's
# an open quote. Otherwise pass to the AI as normal conversation.
if has_open_quote and _CONFIRM_PATTERNS.match(t):
return 'confirm', None
return None, None
def confirm_quotation(tenant_conn, phone):
"""Mark the open quotation as confirmed/accepted."""
qid = get_open_quotation(tenant_conn, phone)
if not qid:
return None
cur = tenant_conn.cursor()
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
return qid
# ─── In-memory last-shown-part per phone ─────────────────────────────
# Tracks what part the bot last showed so "cotizar" knows what to add.
# Key: phone (clean, no @lid). Value: dict with inventory item info.
_last_shown = {}
def set_last_shown_part(phone, part_info):
"""Store the last part shown to this phone number.
part_info: dict with keys inventory_id, part_number, name, brand,
price, stock, unit
"""
_last_shown[phone] = part_info
def get_last_shown_part(phone):
return _last_shown.get(phone)
def clear_last_shown(phone):
_last_shown.pop(phone, None)
# ─── Quotation CRUD ─────────────────────────────────────────────────
def get_open_quotation(tenant_conn, phone):
"""Find an active quotation for this phone, or None."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT id FROM quotations
WHERE notes LIKE %s AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", (f'%WA:{phone}%',))
row = cur.fetchone()
cur.close()
return row[0] if row else None
def create_quotation(tenant_conn, phone):
"""Create a new quotation tagged with this phone number."""
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO quotations (subtotal, tax_total, total, status, notes, valid_until)
VALUES (0, 0, 0, 'active', %s, %s)
RETURNING id
""", (f'WA:{phone}', date.today() + timedelta(days=7)))
qid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return qid
def add_item_to_quotation(tenant_conn, quote_id, part_info, quantity=1):
"""Add a part to an existing quotation and recalculate totals."""
price = float(part_info.get('price') or 0)
tax_rate = float(part_info.get('tax_rate') or 0.16)
subtotal = round(price * quantity, 2)
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity, unit_price, tax_rate, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
quote_id,
part_info.get('inventory_id'),
part_info.get('part_number', ''),
part_info.get('name', ''),
quantity,
price,
tax_rate,
subtotal,
))
# Recalculate totals
cur.execute("""
SELECT COALESCE(SUM(subtotal), 0),
COALESCE(SUM(subtotal * tax_rate), 0)
FROM quotation_items WHERE quotation_id = %s
""", (quote_id,))
sub, tax = cur.fetchone()
cur.execute("""
UPDATE quotations SET subtotal = %s, tax_total = %s, total = %s
WHERE id = %s
""", (sub, tax, round(sub + tax, 2), quote_id))
tenant_conn.commit()
cur.close()
return subtotal
def get_quotation_detail(tenant_conn, quote_id):
"""Return full quotation with items."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, subtotal, tax_total, total, status, valid_until, created_at
FROM quotations WHERE id = %s
""", (quote_id,))
q = cur.fetchone()
if not q:
cur.close()
return None
cur.execute("""
SELECT part_number, name, quantity, unit_price, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quote_id,))
items = cur.fetchall()
cur.close()
return {
'id': q[0],
'subtotal': float(q[1]),
'tax_total': float(q[2]),
'total': float(q[3]),
'status': q[4],
'valid_until': str(q[5]) if q[5] else None,
'created_at': str(q[6]) if q[6] else None,
'items': [{
'part_number': it[0],
'name': it[1],
'quantity': it[2],
'unit_price': float(it[3]),
'tax_rate': float(it[4]),
'subtotal': float(it[5]),
} for it in items],
}
def clear_quotation(tenant_conn, phone):
"""Cancel the open quotation for this phone."""
qid = get_open_quotation(tenant_conn, phone)
if qid:
cur = tenant_conn.cursor()
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(phone)
return qid
# ─── Format quotation for WhatsApp ──────────────────────────────────
def format_quotation_wa(detail):
"""Format a quotation as a WhatsApp-friendly text message."""
if not detail or not detail.get('items'):
return None
lines = [
f'📄 *COTIZACIÓN #{detail["id"]}*',
f'Fecha: {detail["created_at"][:10] if detail.get("created_at") else "hoy"}',
f'Vigencia: {detail.get("valid_until") or "7 días"}',
'',
'─────────────────────',
]
for i, item in enumerate(detail['items'], 1):
qty = item['quantity']
price = item['unit_price']
sub = item['subtotal']
lines.append(f'{i}. {item["name"]}')
lines.append(f' #{item["part_number"]} × {qty} = ${sub:,.2f}')
lines.append('─────────────────────')
lines.append(f' Subtotal: ${detail["subtotal"]:,.2f}')
lines.append(f' IVA: ${detail["tax_total"]:,.2f}')
lines.append(f' *TOTAL: ${detail["total"]:,.2f}*')
lines.append('')
lines.append('_Responde "si" para confirmar el pedido._')
lines.append('_Responde "limpiar" para empezar de nuevo._')
return '\n'.join(lines)

View File

@@ -55,12 +55,63 @@ def logout():
def process_incoming(webhook_data):
"""Extract a normalized dict from a Baileys webhook payload.
Supports text messages, image messages, audio (voice notes), and video.
Media content comes pre-downloaded as base64 from the bridge so Python
doesn't have to re-authenticate with WhatsApp servers.
Returns:
dict with keys:
phone — numeric phone (no JID suffix)
jid — full remote JID (may be @s.whatsapp.net or @lid)
text — text content (plain text or media caption)
from_me — bool, True if we sent the message
message_id — WhatsApp message ID
media_kind — 'text' | 'image' | 'audio' | 'video'
media_base64 — base64 string if media, else None
media_mimetype — e.g. 'image/jpeg', 'audio/ogg'
is_voice_note — True for WhatsApp voice notes (audioMessage ptt)
"""
data = webhook_data.get('data', {})
key = data.get('key', {})
message = data.get('message', {})
# remoteJid can be phone@s.whatsapp.net or LID@lid
remote_jid = key.get('remoteJid', '')
phone = remote_jid.replace('@s.whatsapp.net', '').replace('@lid', '')
# The bridge now classifies and passes these extra fields. Fall back to
# the old parsing if they're missing (older bridge version).
media_kind = data.get('media_kind', 'text')
media_base64 = data.get('media_base64')
media_mimetype = data.get('media_mimetype')
media_caption = data.get('media_caption') or ''
is_voice_note = bool(data.get('media_ptt'))
push_name = data.get('push_name') or ''
# Text content:
# - For 'text' messages → conversation or extendedTextMessage
# - For 'image'/'video' → the caption (may be empty)
# - For 'audio' → empty (filled in later by Whisper transcription)
if media_kind == 'text':
text = (
message.get('conversation', '')
or message.get('extendedTextMessage', {}).get('text', '')
or ''
)
else:
text = media_caption
return {
'phone': key.get('remoteJid', '').replace('@s.whatsapp.net', ''),
'text': message.get('conversation', '') or message.get('extendedTextMessage', {}).get('text', ''),
'phone': phone,
'jid': remote_jid,
'text': text,
'from_me': key.get('fromMe', False),
'message_id': key.get('id', ''),
'media_kind': media_kind,
'media_base64': media_base64,
'media_mimetype': media_mimetype,
'is_voice_note': is_voice_note,
'push_name': push_name,
}

View File

@@ -0,0 +1,151 @@
"""
Local Whisper transcription service.
Uses faster-whisper (a CTranslate2-based port of OpenAI Whisper) for
transcribing short audio clips (WhatsApp voice notes) on the CPU.
Runs fully offline after the first model download. No API keys, no
per-minute cost. Model is lazy-loaded on first call and cached in memory
for the lifetime of the process.
Default model: 'tiny' — the smallest and fastest variant (~75 MB), good
enough for conversational Spanish. Change WHISPER_MODEL below to 'base'
(150 MB, slightly better accuracy) or 'small' (500 MB, noticeably better)
if you have the RAM and don't mind 2-3x slower inference.
"""
import base64 as _b64
import os
import subprocess
import tempfile
import threading
# ─── Config ──────────────────────────────────────────────────────────────
# 'base' is the sweet spot for Mexican Spanish voice notes on CPU:
# tiny (75 MB) — too small, misses words in noisy/robot audio
# base (150 MB) — good accuracy, ~2s per 30s clip on a modern CPU ← default
# small (500 MB) — best accuracy, ~5s per 30s clip, worth it if RAM permits
WHISPER_MODEL = "base"
WHISPER_DEVICE = "cpu"
WHISPER_COMPUTE = "int8" # int8 quantization — CPU-friendly, minimal quality loss
# ─── Lazy singleton model loader ─────────────────────────────────────────
_model = None
_model_lock = threading.Lock()
def _get_model():
"""Load the Whisper model on first use. Thread-safe."""
global _model
if _model is not None:
return _model
with _model_lock:
if _model is not None:
return _model
from faster_whisper import WhisperModel
print(f"[whisper] Loading {WHISPER_MODEL} model ({WHISPER_DEVICE}, {WHISPER_COMPUTE})...")
_model = WhisperModel(
WHISPER_MODEL,
device=WHISPER_DEVICE,
compute_type=WHISPER_COMPUTE,
)
print("[whisper] Model ready.")
return _model
# ─── Public API ──────────────────────────────────────────────────────────
def transcribe_audio_base64(audio_base64: str, mimetype: str = "audio/ogg",
language: str = "es") -> str | None:
"""Transcribe a base64-encoded audio blob to text.
Args:
audio_base64: Raw base64 string (no data: prefix).
mimetype: MIME type from the sender (e.g. 'audio/ogg' for WA voice notes).
language: ISO 639-1 code to bias the model. 'es' for Spanish MX.
Returns:
The transcribed text, or None if transcription fails or is empty.
"""
if not audio_base64:
return None
# Decode base64 → write to a temp file with the right extension so
# ffmpeg (invoked by faster-whisper/CTranslate2) picks the decoder.
ext = _extension_for_mimetype(mimetype)
try:
audio_bytes = _b64.b64decode(audio_base64)
except Exception as e:
print(f"[whisper] base64 decode failed: {e}")
return None
tmp_in = None
tmp_wav = None
try:
# Write the original audio to a temp file
tmp_in = tempfile.NamedTemporaryFile(suffix=ext, delete=False)
tmp_in.write(audio_bytes)
tmp_in.close()
# WhatsApp voice notes are OGG/Opus — faster-whisper can handle it
# via its pyav decoder, but converting to 16kHz mono WAV first is
# more reliable across formats and ~2x faster.
tmp_wav = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)
tmp_wav.close()
rc = subprocess.run(
["ffmpeg", "-y", "-i", tmp_in.name,
"-ar", "16000", "-ac", "1",
"-f", "wav", tmp_wav.name],
capture_output=True,
)
if rc.returncode != 0:
print(f"[whisper] ffmpeg conversion failed: {rc.stderr.decode()[:200]}")
return None
# Run Whisper
# - beam_size=5 for better accuracy on short/noisy clips
# - no VAD filter (was trimming real speech in some tests)
# - condition_on_previous_text=False for short independent clips
model = _get_model()
segments, info = model.transcribe(
tmp_wav.name,
language=language,
beam_size=5,
vad_filter=False,
condition_on_previous_text=False,
)
text = " ".join(s.text.strip() for s in segments if s.text.strip())
text = text.strip()
if not text:
return None
print(f"[whisper] ({info.language}, {info.duration:.1f}s) → {text[:100]}")
return text
except Exception as e:
print(f"[whisper] transcription error: {e}")
return None
finally:
for f in (tmp_in, tmp_wav):
if f:
try:
os.unlink(f.name)
except Exception:
pass
def _extension_for_mimetype(mimetype: str) -> str:
"""Map a MIME type to a file extension ffmpeg understands."""
m = (mimetype or "").lower()
if "opus" in m or "ogg" in m:
return ".ogg"
if "mp3" in m or "mpeg" in m:
return ".mp3"
if "mp4" in m or "aac" in m:
return ".m4a"
if "wav" in m:
return ".wav"
if "webm" in m:
return ".webm"
return ".ogg" # WhatsApp voice notes are usually OGG/Opus