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:
@@ -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,
|
||||
}
|
||||
|
||||
129
pos/services/catalog_modes.py
Normal file
129
pos/services/catalog_modes.py
Normal 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
|
||||
@@ -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 {}
|
||||
|
||||
|
||||
810
pos/services/marketplace_service.py
Normal file
810
pos/services/marketplace_service.py
Normal 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
|
||||
745
pos/services/nexpart_taxonomy.py
Normal file
745
pos/services/nexpart_taxonomy.py
Normal 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),
|
||||
}
|
||||
240
pos/services/peer_service.py
Normal file
240
pos/services/peer_service.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
284
pos/services/wa_quotation.py
Normal file
284
pos/services/wa_quotation.py
Normal 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)
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
151
pos/services/whisper_local.py
Normal file
151
pos/services/whisper_local.py
Normal 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
|
||||
Reference in New Issue
Block a user