feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search - New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py - New marketplace_external.html/js with ML management UI - Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors - Inventory: new .btn--meli styles, select/label CSS fixes - WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog - DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue - Add Celery tasks for ML sync and webhook processing - Sidebar: MercadoLibre navigation link
This commit is contained in:
@@ -86,11 +86,42 @@ def _post_chat_completion(url, api_key, model_id, messages, max_tokens=800, temp
|
||||
return None
|
||||
|
||||
|
||||
SYSTEM_PROMPT_SHORT = """Eres un asistente de refaccionaria automotriz mexicana. Ayuda a encontrar autopartes.
|
||||
SYSTEM_PROMPT_SHORT = """Eres Juan, vendedor estrella de Autopartes Estrada. Llevas 10 años ayudando a mecanicos y dueños de taller. Tu estilo: directo, calido, sin rollos tecnicos. Hablas como un compa que sabe de carros.
|
||||
|
||||
IMPORTANTE: NO prometas stock hasta verificar. Usa "Reviso...", "Busco...", "Déjame checar..." en vez de "Tengo..." a menos que estes 100% seguro.
|
||||
|
||||
Responde SIEMPRE en formato JSON: {"message":"...","search_query":"...","vehicle":{"brand":"...","model":"...","year":...}}
|
||||
search_query va EN INGLES cuando el usuario pide una parte. Traducciones: Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector.
|
||||
No preguntes mas si ya puedes buscar. Si el usuario describe un sintoma, diagnostica y sugiere partes.
|
||||
Cuando pida cotizacion o multiples partes, search_query DEBE usar | para separar cada parte: "Brake Pad|Air Filter|Oil Filter|Spark Plug".
|
||||
|
||||
REGLAS DE VENTA AVANZADAS:
|
||||
1. PRECIO AL FRENTE: Si hay stock, di precio y marca sin rodeos.
|
||||
2. KIT INTELIGENTE: Siempre sugiere 1-2 productos relacionados que se necesitan para el mismo trabajo.
|
||||
- Balatas → "Ya que vas a cambiar balatas, checa si los discos tambien estan gastados. Te armo paquete con descuento."
|
||||
- Alternador → "Mientras cambias alternador, conviene cambiar la banda serpentina para que no se te rompa despues."
|
||||
- Filtro de aceite → "¿Ya tienes filtro de aire y bujias? Para servicio completo conviene cambiar todo junto."
|
||||
3. MANEJO DE OBJECIONES:
|
||||
- "Esta caro" → "Te entiendo. Esta es marca original. Tambien manejo opcion economica. ¿Te mando las dos para comparar?"
|
||||
- "Voy a checar en otro lado" → "Dale, te espero. Guardame este precio. Si encuentras mas barato, mandame foto de la cotizacion y veo si te la mejoro."
|
||||
- "Lo necesito para hoy" / "Urgente" → "Perfecto. Tenemos entrega express en 2-4 horas o puedes pasar directo a la tienda. ¿Te lo armo ya?"
|
||||
- "No se si sea esa" → "No hay problema. Dame los ultimos 4 digitos de tu VIN y te confirmo compatibilidad exacta."
|
||||
- "Solo estoy cotizando" → "Claro, sin compromiso. Te armo la cotizacion y si decides despues, aqui queda guardada."
|
||||
4. CIERRE SUAVE (termina SIEMPRE con pregunta):
|
||||
- "¿Te lo aparto?"
|
||||
- "¿Lo mando a tu taller o lo pasas a recoger?"
|
||||
- "¿Con esto quedas o necesitas algo mas?"
|
||||
- "¿Te armo el paquete completo? Sale mejor que por separado."
|
||||
5. RECONOCIMIENTO DE CLIENTE: Si el contexto dice que compro antes, mencionalo. "Veo que compraste balatas hace 6 meses. ¿Ya es hora de cambiar las del otro eje?"
|
||||
6. DIAGNOSTICO RAPIDO: Si describe sintoma, diagnostica en 1-2 frases y sugiere 2-3 partes mas probables.
|
||||
|
||||
TRADUCCIONES search_query (EN INGLES):
|
||||
Balatas=Brake Pad, Disco de freno=Brake Disc, Amortiguador=Shock Absorber, Filtro de aceite=Oil Filter, Filtro de aire=Air Filter, Bujias=Spark Plug, Banda=V-Belt, Bomba de agua=Water Pump, Alternador=Alternator, Radiador=Radiator, Sensor de oxigeno=Oxygen Sensor, Terminal de direccion=Tie Rod End, Bomba de gasolina=Fuel Pump, Clutch=Clutch Kit, Mofle=Exhaust, Inyector=Injector, Banda de distribucion=Timing Belt, Tensor=Belt Tensioner, Junta homocinetica=CV Joint, Marcha=Starter Motor, Bateria=Battery, Aceite=Engine Oil, Refrigerante=Coolant.
|
||||
|
||||
FORMATO:
|
||||
- search_query EN INGLES. NUNCA null si pide algo.
|
||||
- vehicle: {"brand":"NISSAN","model":"Frontier","year":2019} marca en MAYUSCULAS.
|
||||
- Multiples partes: "Brake Pad|Brake Disc|Brake Fluid"
|
||||
- Mensaje maximo 4 lineas cortas. Lenguaje natural, nada robotico.
|
||||
- Si ya detectaste vehiculo en conversacion anterior, NO vuelvas a pedirlo.
|
||||
- Termina SIEMPRE con una pregunta de cierre.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """Eres un asistente de refaccionaria automotriz mexicana. Tu trabajo es ayudar a encontrar autopartes.
|
||||
@@ -195,11 +226,24 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
WHERE {where} AND i.brand IS NOT NULL AND i.brand != ''
|
||||
GROUP BY i.brand
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 15
|
||||
LIMIT 10
|
||||
""", params)
|
||||
brands = cur.fetchall()
|
||||
brand_list = ", ".join(f"{row[0]} ({row[1]})" for row in brands if row[0])
|
||||
|
||||
# Top categories with counts
|
||||
cur.execute(f"""
|
||||
SELECT c.name, COUNT(*) as cnt
|
||||
FROM inventory i
|
||||
JOIN part_categories c ON c.id = i.category_id
|
||||
WHERE {where} AND c.name IS NOT NULL AND c.name != ''
|
||||
GROUP BY c.name
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
""", params)
|
||||
categories = cur.fetchall()
|
||||
category_list = ", ".join(f"{row[0]} ({row[1]})" for row in categories if row[0])
|
||||
|
||||
# Products with low stock (<=3)
|
||||
cur.execute(f"""
|
||||
SELECT COUNT(*) FROM inventory i
|
||||
@@ -212,10 +256,12 @@ def get_inventory_context(tenant_conn, branch_id=None):
|
||||
"CONTEXTO DEL INVENTARIO:",
|
||||
f"Este negocio tiene {total} productos en inventario.",
|
||||
]
|
||||
if category_list:
|
||||
lines.append(f"Categorias principales: {category_list}")
|
||||
if brand_list:
|
||||
lines.append(f"Marcas disponibles: {brand_list}")
|
||||
lines.append(f"Marcas top: {brand_list}")
|
||||
lines.append(f"Productos con stock bajo (<=3 unidades): {low_stock}")
|
||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local.")
|
||||
lines.append("IMPORTANTE: Cuando busques partes, SIEMPRE prioriza lo que el negocio tiene en inventario local. Si no hay stock exacto, sugiere alternativa similar.")
|
||||
|
||||
return "\n".join(lines)
|
||||
except Exception:
|
||||
@@ -284,10 +330,10 @@ def chat_with_image(user_message, image_base64, conversation_history=None, inven
|
||||
]
|
||||
messages.append({"role": "user", "content": user_content})
|
||||
|
||||
# Try Hermes first for vision (if enabled), fallback to OpenRouter
|
||||
# Vision backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_VISION_MODEL))
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, VISION_MODEL))
|
||||
|
||||
@@ -339,10 +385,10 @@ def classify_part(part_number):
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
|
||||
# Try Hermes first (if enabled), fallback to OpenRouter
|
||||
# Backends: QWEN only, fallback to OpenRouter if key present
|
||||
backends = []
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL))
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL))
|
||||
if OPENROUTER_API_KEY:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, MODEL))
|
||||
|
||||
@@ -528,12 +574,10 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
|
||||
last_error = None
|
||||
|
||||
# Build backend list: QWEN first (fast, ~1s), then Hermes (specialized, ~30s), then OpenRouter
|
||||
# Build backend list: QWEN first, then OpenRouter fallback
|
||||
backends = []
|
||||
if QWEN_ENABLED:
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 35, SYSTEM_PROMPT_SHORT, 4000))
|
||||
if HERMES_ENABLED:
|
||||
backends.append((HERMES_CHAT_URL, HERMES_API_KEY, HERMES_MODEL, 45, SYSTEM_PROMPT, 800))
|
||||
backends.append((QWEN_CHAT_URL, QWEN_API_KEY, QWEN_MODEL, 18, SYSTEM_PROMPT_SHORT, 1200))
|
||||
if OPENROUTER_API_KEY:
|
||||
for m in FALLBACK_MODELS:
|
||||
backends.append((OPENROUTER_URL, OPENROUTER_API_KEY, m, 25, SYSTEM_PROMPT, 800))
|
||||
@@ -548,14 +592,22 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
if conversation_history:
|
||||
msgs.extend(conversation_history)
|
||||
msgs.append({"role": "user", "content": user_message})
|
||||
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
|
||||
|
||||
# Retry logic: QWEN gets 3 attempts with 2s delay because the API is flaky
|
||||
max_retries = 3 if url == QWEN_CHAT_URL else 1
|
||||
result = None
|
||||
for attempt in range(1, max_retries + 1):
|
||||
result = _post_chat_completion(url, key, model_id, msgs, max_tokens=max_tok, temperature=0.3, timeout=timeout_sec)
|
||||
if result is not None:
|
||||
break
|
||||
if attempt < max_retries:
|
||||
print(f"[AI] QWEN attempt {attempt} failed, retrying in 2s...")
|
||||
_time_chat.sleep(2)
|
||||
|
||||
if result is None:
|
||||
if url == QWEN_CHAT_URL:
|
||||
print(f"[AI] QWEN failed, trying Hermes fallback...")
|
||||
print(f"[AI] QWEN failed after {max_retries} attempts, trying fallback...")
|
||||
last_error = "qwen_failed"
|
||||
elif url == HERMES_CHAT_URL:
|
||||
print(f"[AI] Hermes failed, trying OpenRouter fallback...")
|
||||
last_error = "hermes_timeout"
|
||||
else:
|
||||
print(f"[AI] Rate limited on {model_id}, trying next model...")
|
||||
last_error = "rate_limit"
|
||||
@@ -589,7 +641,7 @@ def chat(user_message, conversation_history=None, inventory_context=None):
|
||||
# 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}
|
||||
if last_error == "hermes_timeout":
|
||||
if last_error == "qwen_failed":
|
||||
return {"message": "El asistente tardó mucho en responder. Intenta de nuevo en un momento.", "search_query": None, "vehicle": None}
|
||||
return {
|
||||
"message": "El asistente no está disponible en este momento. Intenta de nuevo en unos segundos.",
|
||||
|
||||
@@ -230,29 +230,42 @@ def get_engines(master_conn, model_id, year_id):
|
||||
return [{'id_mye': r[0], 'name_engine': r[1], 'trim_level': r[2] or ''} for r in rows]
|
||||
|
||||
|
||||
def get_categories(master_conn, mye_id):
|
||||
def get_categories(master_conn, mye_id, allowed_brands=None):
|
||||
"""Get part categories that have parts for this vehicle (mye_id).
|
||||
|
||||
Uses a subquery on vehicle_parts filtered by mye_id (indexed),
|
||||
then JOINs through parts -> part_groups -> part_categories.
|
||||
Uses COUNT with a safety LIMIT on the subquery.
|
||||
|
||||
If allowed_brands is provided, only counts parts that have at least one
|
||||
aftermarket equivalent from those manufacturers.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
cur.execute("""
|
||||
brand_filter = ""
|
||||
params = [mye_id]
|
||||
if allowed_brands:
|
||||
brand_filter = """AND EXISTS (
|
||||
SELECT 1 FROM aftermarket_parts ap2
|
||||
JOIN manufacturers m2 ON m2.id_manufacture = ap2.manufacturer_id
|
||||
WHERE ap2.oem_part_id = p.id_part AND UPPER(m2.name_manufacture) = ANY(%s)
|
||||
)"""
|
||||
params.append(allowed_brands)
|
||||
cur.execute(f"""
|
||||
SELECT pc.id_part_category,
|
||||
COALESCE(pc.name_es, pc.name_part_category) AS name,
|
||||
sub.cnt
|
||||
FROM (
|
||||
SELECT pg.category_id, COUNT(*) AS cnt
|
||||
SELECT pg.category_id, COUNT(DISTINCT p.id_part) AS cnt
|
||||
FROM vehicle_parts vp
|
||||
JOIN parts p ON p.id_part = vp.part_id
|
||||
JOIN part_groups pg ON pg.id_part_group = p.group_id
|
||||
WHERE vp.model_year_engine_id = %s
|
||||
{brand_filter}
|
||||
GROUP BY pg.category_id
|
||||
) sub
|
||||
JOIN part_categories pc ON pc.id_part_category = sub.category_id
|
||||
ORDER BY name
|
||||
""", (mye_id,))
|
||||
""", params)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
return [{'id_part_category': r[0], 'name': translate_category(r[1]), 'part_count': r[2]} for r in rows]
|
||||
|
||||
56
pos/services/geo_branches.py
Normal file
56
pos/services/geo_branches.py
Normal file
@@ -0,0 +1,56 @@
|
||||
import math
|
||||
|
||||
def haversine(lat1, lon1, lat2, lon2):
|
||||
"""Calculate the great-circle distance between two points on Earth in km."""
|
||||
R = 6371.0 # Earth radius in km
|
||||
phi1 = math.radians(lat1)
|
||||
phi2 = math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||
c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
|
||||
|
||||
def find_nearest_branch(tenant_conn, latitude, longitude):
|
||||
"""
|
||||
Find the nearest active branch with coordinates.
|
||||
Returns a dict with branch info + distance_km, or None.
|
||||
"""
|
||||
if not tenant_conn or latitude is None or longitude is None:
|
||||
return None
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, name, address, phone, latitude, longitude
|
||||
FROM branches
|
||||
WHERE is_active = TRUE AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
"""
|
||||
)
|
||||
branches = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
nearest = None
|
||||
min_dist = float('inf')
|
||||
|
||||
for row in branches:
|
||||
bid, name, address, phone, b_lat, b_lon = row
|
||||
if b_lat is None or b_lon is None:
|
||||
continue
|
||||
dist = haversine(float(latitude), float(longitude), float(b_lat), float(b_lon))
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
nearest = {
|
||||
'id': bid,
|
||||
'name': name,
|
||||
'address': address or '',
|
||||
'phone': phone or '',
|
||||
'latitude': float(b_lat),
|
||||
'longitude': float(b_lon),
|
||||
'distance_km': round(dist, 1),
|
||||
}
|
||||
|
||||
return nearest
|
||||
978
pos/services/marketplace_external_service.py
Normal file
978
pos/services/marketplace_external_service.py
Normal file
@@ -0,0 +1,978 @@
|
||||
"""Business logic for MercadoLibre external marketplace integration.
|
||||
|
||||
Depends on:
|
||||
- meli_service.py (HTTP client)
|
||||
- pos_engine.py (sale creation)
|
||||
- inventory_engine.py (stock queries)
|
||||
- image_service.py (image URLs)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
|
||||
from services.meli_service import MeliService, MeliError, MeliAuthError
|
||||
from services.pos_engine import process_sale, calculate_totals
|
||||
from services.inventory_engine import get_stock, get_stock_bulk
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONFIG HELPERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
MELI_CONFIG_KEYS = [
|
||||
"meli_access_token",
|
||||
"meli_refresh_token",
|
||||
"meli_user_id",
|
||||
"meli_site_id",
|
||||
"meli_enabled",
|
||||
"meli_auto_publish",
|
||||
"meli_sync_interval_min",
|
||||
"meli_order_sync_interval_min",
|
||||
"meli_default_category_id",
|
||||
"meli_shipping_mode",
|
||||
"meli_client_id",
|
||||
"meli_client_secret",
|
||||
]
|
||||
|
||||
|
||||
def _get_config_value(cur, key: str, default=None):
|
||||
cur.execute("SELECT value FROM tenant_config WHERE key = %s", (key,))
|
||||
row = cur.fetchone()
|
||||
return row[0] if row else default
|
||||
|
||||
|
||||
def get_meli_config(tenant_conn) -> dict:
|
||||
"""Read ML config from tenant_config."""
|
||||
cur = tenant_conn.cursor()
|
||||
cfg = {}
|
||||
for key in MELI_CONFIG_KEYS:
|
||||
cfg[key] = _get_config_value(cur, key)
|
||||
cur.close()
|
||||
# Normalize booleans
|
||||
cfg["meli_enabled"] = (cfg.get("meli_enabled") or "").lower() == "true"
|
||||
cfg["meli_auto_publish"] = (cfg.get("meli_auto_publish") or "").lower() == "true"
|
||||
cfg["meli_sync_interval_min"] = int(cfg.get("meli_sync_interval_min") or 15)
|
||||
cfg["meli_order_sync_interval_min"] = int(cfg.get("meli_order_sync_interval_min") or 5)
|
||||
return cfg
|
||||
|
||||
|
||||
def save_meli_config(tenant_conn, updates: dict) -> None:
|
||||
"""Upsert ML config keys into tenant_config."""
|
||||
cur = tenant_conn.cursor()
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
continue
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO tenant_config (key, value, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
|
||||
""",
|
||||
(key, str(value)),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def delete_meli_config(tenant_conn) -> None:
|
||||
"""Remove all ML config keys."""
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"DELETE FROM tenant_config WHERE key LIKE 'meli_%'"
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
|
||||
|
||||
def _get_meli_service(cfg: dict) -> Optional[MeliService]:
|
||||
"""Build MeliService from config dict."""
|
||||
token = cfg.get("meli_access_token")
|
||||
if not token:
|
||||
return None
|
||||
return MeliService(
|
||||
access_token=token,
|
||||
refresh_token=cfg.get("meli_refresh_token"),
|
||||
client_id=cfg.get("meli_client_id"),
|
||||
client_secret=cfg.get("meli_client_secret"),
|
||||
)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ITEM PAYLOAD BUILDER
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def _extract_meli_error(err: MeliError) -> str:
|
||||
"""Extract the most useful error message from a MeliError response."""
|
||||
base = str(err)
|
||||
body = err.response_body
|
||||
if not body:
|
||||
return base
|
||||
try:
|
||||
data = json.loads(body)
|
||||
msg = data.get("message") or data.get("error")
|
||||
causes = data.get("cause", [])
|
||||
if causes and isinstance(causes, list):
|
||||
cause_msgs = [c.get("message") for c in causes if c.get("message")]
|
||||
if cause_msgs:
|
||||
msg = (msg + " | " if msg else "") + "; ".join(cause_msgs)
|
||||
if msg:
|
||||
return msg
|
||||
except Exception:
|
||||
pass
|
||||
return base
|
||||
|
||||
|
||||
def build_item_payload(
|
||||
inventory_row: dict,
|
||||
images: list[str],
|
||||
meli_category_id: str,
|
||||
price: float,
|
||||
stock: int,
|
||||
shipping_mode: str = "me2",
|
||||
listing_type_id: str = "gold_special",
|
||||
) -> dict:
|
||||
"""Convert a Nexus inventory row into a MercadoLibre item payload."""
|
||||
title = f"{inventory_row['name']} {inventory_row['brand'] or ''} {inventory_row['part_number'] or ''}".strip()
|
||||
# ML title limit is 60 chars; truncate smartly
|
||||
if len(title) > 60:
|
||||
title = title[:57] + "..."
|
||||
|
||||
payload = {
|
||||
"title": title,
|
||||
"category_id": meli_category_id,
|
||||
"price": round(float(price), 2),
|
||||
"currency_id": "MXN",
|
||||
"available_quantity": max(int(stock), 0),
|
||||
"buying_mode": "buy_it_now",
|
||||
"listing_type_id": listing_type_id,
|
||||
"condition": "new",
|
||||
"pictures": [{"source": url} for url in images if url],
|
||||
"shipping": {"mode": shipping_mode},
|
||||
"attributes": [],
|
||||
}
|
||||
|
||||
if inventory_row.get("brand"):
|
||||
payload["attributes"].append(
|
||||
{"id": "BRAND", "value_name": inventory_row["brand"]}
|
||||
)
|
||||
|
||||
if inventory_row.get("part_number"):
|
||||
payload["attributes"].append(
|
||||
{"id": "PART_NUMBER", "value_name": inventory_row["part_number"]}
|
||||
)
|
||||
|
||||
# Vehicle compatibility as attributes (if available)
|
||||
vehicle_compat = inventory_row.get("vehicle_compatibility")
|
||||
if vehicle_compat:
|
||||
if isinstance(vehicle_compat, str):
|
||||
try:
|
||||
vehicle_compat = json.loads(vehicle_compat)
|
||||
except Exception:
|
||||
vehicle_compat = None
|
||||
if isinstance(vehicle_compat, list) and vehicle_compat:
|
||||
first = vehicle_compat[0]
|
||||
if isinstance(first, dict):
|
||||
if first.get("brand"):
|
||||
payload["attributes"].append(
|
||||
{"id": "VEHICLE_MODEL", "value_name": first["brand"]}
|
||||
)
|
||||
if first.get("model"):
|
||||
payload["attributes"].append(
|
||||
{"id": "VEHICLE_MODEL_NAME", "value_name": first["model"]}
|
||||
)
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# LISTINGS CRUD
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def publish_items(
|
||||
tenant_conn,
|
||||
inventory_ids: list[int],
|
||||
meli_category_id: str,
|
||||
listing_type_id: str = "gold_special",
|
||||
shipping_mode: str = "me2",
|
||||
) -> dict:
|
||||
"""Publish one or more inventory items to MercadoLibre.
|
||||
|
||||
Returns summary dict with success/failure per item.
|
||||
"""
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
|
||||
# Batch fetch inventory rows
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, part_number, name, brand, price_1, vehicle_compatibility,
|
||||
image_url, unit, is_active
|
||||
FROM inventory
|
||||
WHERE id = ANY(%s) AND is_active = true
|
||||
""",
|
||||
(inventory_ids,),
|
||||
)
|
||||
rows = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
# Batch fetch stock
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
|
||||
results = {"success": [], "failed": []}
|
||||
|
||||
for inv_id in inventory_ids:
|
||||
row = rows.get(inv_id)
|
||||
if not row:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "Not found or inactive"})
|
||||
continue
|
||||
|
||||
inv = {
|
||||
"id": row[0],
|
||||
"part_number": row[1],
|
||||
"name": row[2],
|
||||
"brand": row[3],
|
||||
"price_1": float(row[4]) if row[4] else 0,
|
||||
"vehicle_compatibility": row[5],
|
||||
"image_url": row[6],
|
||||
"unit": row[7],
|
||||
}
|
||||
|
||||
stock = stock_map.get(inv_id, 0)
|
||||
if stock <= 0:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "Sin stock disponible"})
|
||||
continue
|
||||
|
||||
if inv["price_1"] <= 0:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "El precio debe ser mayor a 0"})
|
||||
continue
|
||||
|
||||
# Build image list
|
||||
images = []
|
||||
if inv.get("image_url"):
|
||||
images.append(inv["image_url"])
|
||||
# TODO: fetch additional images from a separate table if we have gallery support
|
||||
|
||||
if not images:
|
||||
results["failed"].append({"inventory_id": inv_id, "error": "El producto no tiene imagen. ML requiere imagen para publicar."})
|
||||
continue
|
||||
|
||||
payload = build_item_payload(
|
||||
inv, images, meli_category_id, inv["price_1"], stock,
|
||||
shipping_mode=shipping_mode, listing_type_id=listing_type_id,
|
||||
)
|
||||
|
||||
try:
|
||||
ml_item = svc.create_item(payload)
|
||||
external_item_id = ml_item.get("id")
|
||||
permalink = ml_item.get("permalink")
|
||||
|
||||
# Persist listing
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO marketplace_listings
|
||||
(inventory_id, channel, external_item_id, external_status,
|
||||
external_permalink, title, meli_category_id, publish_price,
|
||||
last_sync_at, sync_errors, is_active)
|
||||
VALUES (%s, 'mercadolibre', %s, 'active', %s, %s, %s, %s, NOW(), NULL, true)
|
||||
ON CONFLICT (inventory_id, channel) WHERE is_active = true
|
||||
DO UPDATE SET
|
||||
external_item_id = EXCLUDED.external_item_id,
|
||||
external_status = EXCLUDED.external_status,
|
||||
external_permalink = EXCLUDED.external_permalink,
|
||||
title = EXCLUDED.title,
|
||||
meli_category_id = EXCLUDED.meli_category_id,
|
||||
publish_price = EXCLUDED.publish_price,
|
||||
last_sync_at = NOW(),
|
||||
sync_errors = NULL,
|
||||
is_active = true
|
||||
""",
|
||||
(
|
||||
inv_id,
|
||||
external_item_id,
|
||||
permalink,
|
||||
payload["title"],
|
||||
meli_category_id,
|
||||
inv["price_1"],
|
||||
),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
results["success"].append(
|
||||
{"inventory_id": inv_id, "external_item_id": external_item_id, "permalink": permalink}
|
||||
)
|
||||
except MeliError as e:
|
||||
tenant_conn.rollback()
|
||||
err_msg = _extract_meli_error(e)
|
||||
logger.warning("ML publish failed for inventory_id=%s: %s | payload=%s | response=%s", inv_id, err_msg, json.dumps(payload), e.response_body)
|
||||
results["failed"].append({"inventory_id": inv_id, "error": err_msg})
|
||||
except Exception as e:
|
||||
tenant_conn.rollback()
|
||||
logger.exception("Unexpected error publishing inventory_id=%s", inv_id)
|
||||
results["failed"].append({"inventory_id": inv_id, "error": str(e)})
|
||||
|
||||
cur.close()
|
||||
return results
|
||||
|
||||
|
||||
def get_listings(tenant_conn, page: int = 1, per_page: int = 50, status: str = None):
|
||||
cur = tenant_conn.cursor()
|
||||
where = ["1=1"]
|
||||
params = []
|
||||
if status:
|
||||
where.append("external_status = %s")
|
||||
params.append(status)
|
||||
|
||||
count_sql = f"SELECT COUNT(*) FROM marketplace_listings WHERE {' AND '.join(where)}"
|
||||
cur.execute(count_sql, params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
sql = f"""
|
||||
SELECT l.id, l.inventory_id, l.external_item_id, l.external_status,
|
||||
l.external_permalink, l.title, l.meli_category_id, l.publish_price,
|
||||
l.last_sync_at, l.sync_errors, l.is_active, l.created_at,
|
||||
i.part_number, i.name, i.price_1, i.brand
|
||||
FROM marketplace_listings l
|
||||
JOIN inventory i ON i.id = l.inventory_id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
cur.execute(sql, params + [per_page, (page - 1) * per_page])
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": r[0],
|
||||
"inventory_id": r[1],
|
||||
"external_item_id": r[2],
|
||||
"external_status": r[3],
|
||||
"external_permalink": r[4],
|
||||
"title": r[5],
|
||||
"meli_category_id": r[6],
|
||||
"publish_price": float(r[7]) if r[7] else None,
|
||||
"last_sync_at": str(r[8]) if r[8] else None,
|
||||
"sync_errors": r[9],
|
||||
"is_active": r[10],
|
||||
"created_at": str(r[11]),
|
||||
"part_number": r[12],
|
||||
"inventory_name": r[13],
|
||||
"current_price": float(r[14]) if r[14] else None,
|
||||
"brand": r[15],
|
||||
})
|
||||
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
def sync_listing(tenant_conn, listing_id: int) -> dict:
|
||||
"""Force a manual sync of stock/price for a single listing."""
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, inventory_id, external_item_id, meli_category_id
|
||||
FROM marketplace_listings WHERE id = %s AND is_active = true
|
||||
""",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
listing = {
|
||||
"id": row[0],
|
||||
"inventory_id": row[1],
|
||||
"external_item_id": row[2],
|
||||
"meli_category_id": row[3],
|
||||
}
|
||||
|
||||
# Get current stock/price
|
||||
cur.execute(
|
||||
"SELECT price_1 FROM inventory WHERE id = %s",
|
||||
(listing["inventory_id"],),
|
||||
)
|
||||
price_row = cur.fetchone()
|
||||
current_price = float(price_row[0]) if price_row and price_row[0] else 0
|
||||
|
||||
stock_map = get_stock_bulk(tenant_conn, branch_id=None)
|
||||
current_stock = stock_map.get(listing["inventory_id"], 0)
|
||||
|
||||
try:
|
||||
svc.update_item(
|
||||
listing["external_item_id"],
|
||||
{"price": round(current_price, 2), "available_quantity": max(current_stock, 0)},
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
UPDATE marketplace_listings
|
||||
SET last_sync_at = NOW(), sync_errors = NULL, publish_price = %s
|
||||
WHERE id = %s
|
||||
""",
|
||||
(current_price, listing_id),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "price": current_price, "stock": current_stock}
|
||||
except MeliError as e:
|
||||
tenant_conn.rollback()
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET sync_errors = %s WHERE id = %s",
|
||||
(str(e)[:500], listing_id),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
raise
|
||||
|
||||
|
||||
def pause_listing(tenant_conn, listing_id: int) -> dict:
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
svc.pause_item(row[0])
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET external_status = 'paused' WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "status": "paused"}
|
||||
|
||||
|
||||
def activate_listing(tenant_conn, listing_id: int) -> dict:
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
svc.activate_item(row[0])
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET external_status = 'active' WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "status": "active"}
|
||||
|
||||
|
||||
def close_listing(tenant_conn, listing_id: int) -> dict:
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
if not svc:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT external_item_id FROM marketplace_listings WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Listing not found")
|
||||
|
||||
svc.close_item(row[0])
|
||||
cur.execute(
|
||||
"UPDATE marketplace_listings SET external_status = 'closed', is_active = false WHERE id = %s",
|
||||
(listing_id,),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"ok": True, "status": "closed"}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def fetch_and_save_orders(tenant_conn, date_from: Optional[str] = None) -> dict:
|
||||
"""Pull orders from ML and upsert into marketplace_orders."""
|
||||
cfg = get_meli_config(tenant_conn)
|
||||
svc = _get_meli_service(cfg)
|
||||
user_id = cfg.get("meli_user_id")
|
||||
if not svc or not user_id:
|
||||
raise ValueError("MercadoLibre not configured")
|
||||
|
||||
ml_resp = svc.get_orders(user_id, status="paid", date_from=date_from)
|
||||
orders = ml_resp.get("results", [])
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for order_summary in orders:
|
||||
order_id = order_summary.get("id")
|
||||
if not order_id:
|
||||
continue
|
||||
|
||||
# Fetch full order detail
|
||||
try:
|
||||
full = svc.get_order(str(order_id))
|
||||
except MeliError:
|
||||
continue
|
||||
|
||||
external_status = full.get("status")
|
||||
buyer = full.get("buyer", {})
|
||||
shipping = full.get("shipping", {})
|
||||
order_items = full.get("order_items", [])
|
||||
|
||||
# Build shipping address JSON
|
||||
shipping_address = None
|
||||
if shipping:
|
||||
shipping_address = {
|
||||
"id": shipping.get("id"),
|
||||
"status": shipping.get("status"),
|
||||
"tracking_number": shipping.get("tracking_number"),
|
||||
"shipping_method": shipping.get("shipping_option", {}).get("name"),
|
||||
}
|
||||
|
||||
total_amount = full.get("total_amount")
|
||||
shipping_cost = shipping.get("cost") if shipping else None
|
||||
|
||||
# Upsert order
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO marketplace_orders
|
||||
(channel, external_order_id, external_status, buyer_name,
|
||||
buyer_email, buyer_phone, buyer_nickname, shipping_address,
|
||||
total_amount, shipping_cost, meli_shipping_id, raw_json, updated_at)
|
||||
VALUES ('mercadolibre', %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (external_order_id) DO UPDATE SET
|
||||
external_status = EXCLUDED.external_status,
|
||||
buyer_name = EXCLUDED.buyer_name,
|
||||
buyer_email = EXCLUDED.buyer_email,
|
||||
buyer_phone = EXCLUDED.buyer_phone,
|
||||
shipping_address = EXCLUDED.shipping_address,
|
||||
total_amount = EXCLUDED.total_amount,
|
||||
shipping_cost = EXCLUDED.shipping_cost,
|
||||
meli_shipping_id = EXCLUDED.meli_shipping_id,
|
||||
raw_json = EXCLUDED.raw_json,
|
||||
updated_at = NOW()
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
str(order_id),
|
||||
external_status,
|
||||
buyer.get("first_name", "") + " " + buyer.get("last_name", ""),
|
||||
buyer.get("email"),
|
||||
buyer.get("phone", {}).get("number"),
|
||||
buyer.get("nickname"),
|
||||
json.dumps(shipping_address) if shipping_address else None,
|
||||
total_amount,
|
||||
shipping_cost,
|
||||
str(shipping.get("id")) if shipping else None,
|
||||
json.dumps(full),
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
mpo_id = row[0] if row else None
|
||||
|
||||
if mpo_id:
|
||||
# Check if this was insert or update
|
||||
cur.execute(
|
||||
"SELECT created_at FROM marketplace_orders WHERE id = %s",
|
||||
(mpo_id,),
|
||||
)
|
||||
created_at = cur.fetchone()[0]
|
||||
# Simple heuristic: if updated_at == created_at (within 1s), it's new
|
||||
is_new = True # ON CONFLICT always returns id; we count as updated for simplicity
|
||||
updated += 1
|
||||
|
||||
# Upsert order items
|
||||
if mpo_id and order_items:
|
||||
# Clear old items and re-insert
|
||||
cur.execute(
|
||||
"DELETE FROM marketplace_order_items WHERE marketplace_order_id = %s",
|
||||
(mpo_id,),
|
||||
)
|
||||
for it in order_items:
|
||||
item_data = it.get("item", {})
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO marketplace_order_items
|
||||
(marketplace_order_id, external_item_id, title,
|
||||
quantity, unit_price, total_price)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
mpo_id,
|
||||
item_data.get("id"),
|
||||
item_data.get("title"),
|
||||
it.get("quantity"),
|
||||
it.get("unit_price"),
|
||||
it.get("full_unit_price"),
|
||||
),
|
||||
)
|
||||
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"processed": len(orders), "updated": updated}
|
||||
|
||||
|
||||
def get_orders(tenant_conn, page=1, per_page=50, status=None):
|
||||
cur = tenant_conn.cursor()
|
||||
where = ["1=1"]
|
||||
params = []
|
||||
if status:
|
||||
where.append("status = %s")
|
||||
params.append(status)
|
||||
|
||||
cur.execute(
|
||||
f"SELECT COUNT(*) FROM marketplace_orders WHERE {' AND '.join(where)}",
|
||||
params,
|
||||
)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT o.id, o.external_order_id, o.external_status, o.buyer_name,
|
||||
o.buyer_nickname, o.total_amount, o.status, o.created_at,
|
||||
o.nexus_sale_id
|
||||
FROM marketplace_orders o
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
params + [per_page, (page - 1) * per_page],
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
items = []
|
||||
for r in rows:
|
||||
items.append({
|
||||
"id": r[0],
|
||||
"external_order_id": r[1],
|
||||
"external_status": r[2],
|
||||
"buyer_name": r[3],
|
||||
"buyer_nickname": r[4],
|
||||
"total_amount": float(r[5]) if r[5] else None,
|
||||
"status": r[6],
|
||||
"created_at": str(r[7]),
|
||||
"nexus_sale_id": r[8],
|
||||
})
|
||||
return {"items": items, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
def get_order_detail(tenant_conn, order_id: int) -> dict:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, external_order_id, external_status, buyer_name, buyer_email,
|
||||
buyer_phone, buyer_nickname, shipping_address, total_amount,
|
||||
shipping_cost, meli_shipping_id, nexus_sale_id, status, notes,
|
||||
raw_json, created_at, updated_at
|
||||
FROM marketplace_orders WHERE id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
raise ValueError("Order not found")
|
||||
|
||||
order = {
|
||||
"id": row[0],
|
||||
"external_order_id": row[1],
|
||||
"external_status": row[2],
|
||||
"buyer_name": row[3],
|
||||
"buyer_email": row[4],
|
||||
"buyer_phone": row[5],
|
||||
"buyer_nickname": row[6],
|
||||
"shipping_address": row[7],
|
||||
"total_amount": float(row[8]) if row[8] else None,
|
||||
"shipping_cost": float(row[9]) if row[9] else None,
|
||||
"meli_shipping_id": row[10],
|
||||
"nexus_sale_id": row[11],
|
||||
"status": row[12],
|
||||
"notes": row[13],
|
||||
"raw_json": row[14],
|
||||
"created_at": str(row[15]),
|
||||
"updated_at": str(row[16]),
|
||||
}
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, inventory_id, external_item_id, title, quantity,
|
||||
unit_price, total_price
|
||||
FROM marketplace_order_items WHERE marketplace_order_id = %s
|
||||
""",
|
||||
(order_id,),
|
||||
)
|
||||
items = []
|
||||
for r in cur.fetchall():
|
||||
items.append({
|
||||
"id": r[0],
|
||||
"inventory_id": r[1],
|
||||
"external_item_id": r[2],
|
||||
"title": r[3],
|
||||
"quantity": r[4],
|
||||
"unit_price": float(r[5]) if r[5] else None,
|
||||
"total_price": float(r[6]) if r[6] else None,
|
||||
})
|
||||
order["items"] = items
|
||||
cur.close()
|
||||
return order
|
||||
|
||||
|
||||
def update_order_status(tenant_conn, order_id: int, new_status: str) -> dict:
|
||||
valid = {"pending", "confirmed", "packed", "shipped", "delivered", "cancelled", "rejected"}
|
||||
if new_status not in valid:
|
||||
raise ValueError(f"Invalid status. Allowed: {valid}")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute(
|
||||
"UPDATE marketplace_orders SET status = %s, updated_at = NOW() WHERE id = %s RETURNING external_order_id",
|
||||
(new_status, order_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
if not row:
|
||||
raise ValueError("Order not found")
|
||||
return {"ok": True, "status": new_status, "external_order_id": row[0]}
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# CONVERT ORDER → SALE
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
def convert_order_to_sale(
|
||||
tenant_conn, marketplace_order_id: int, employee_id: int = None, register_id: int = None
|
||||
) -> dict:
|
||||
"""Convert a marketplace order into a Nexus sale.
|
||||
|
||||
1. Look up marketplace_order + items
|
||||
2. Map items to inventory_id (via marketplace_listings external_item_id)
|
||||
3. Build sale_data compatible with process_sale()
|
||||
4. Call process_sale()
|
||||
5. Link sale back to marketplace_order
|
||||
"""
|
||||
order = get_order_detail(tenant_conn, marketplace_order_id)
|
||||
if order.get("nexus_sale_id"):
|
||||
raise ValueError("Order already converted to sale")
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
|
||||
# Build sale items
|
||||
sale_items = []
|
||||
for it in order["items"]:
|
||||
# Map external_item_id -> inventory_id via marketplace_listings
|
||||
cur.execute(
|
||||
"SELECT inventory_id FROM marketplace_listings WHERE external_item_id = %s LIMIT 1",
|
||||
(it["external_item_id"],),
|
||||
)
|
||||
inv_row = cur.fetchone()
|
||||
inventory_id = inv_row[0] if inv_row else None
|
||||
|
||||
if not inventory_id:
|
||||
# Try to match by title fuzzy? Skip for now.
|
||||
cur.close()
|
||||
raise ValueError(f"Could not map item {it['external_item_id']} to inventory")
|
||||
|
||||
sale_items.append({
|
||||
"inventory_id": inventory_id,
|
||||
"quantity": it["quantity"],
|
||||
"unit_price": float(it["unit_price"] or 0),
|
||||
"discount_pct": 0,
|
||||
"tax_rate": 0.16,
|
||||
})
|
||||
|
||||
# Find or create generic "MercadoLibre" customer
|
||||
cur.execute(
|
||||
"SELECT id FROM customers WHERE name = 'MercadoLibre' LIMIT 1"
|
||||
)
|
||||
cust = cur.fetchone()
|
||||
if cust:
|
||||
customer_id = cust[0]
|
||||
else:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO customers (name, email, phone, is_active, price_tier)
|
||||
VALUES ('MercadoLibre', 'marketplace@mercadolibre.com', '', true, 1)
|
||||
RETURNING id
|
||||
"""
|
||||
)
|
||||
customer_id = cur.fetchone()[0]
|
||||
|
||||
# Build sale_data
|
||||
sale_data = {
|
||||
"items": sale_items,
|
||||
"customer_id": customer_id,
|
||||
"payment_method": "transferencia",
|
||||
"sale_type": "cash",
|
||||
"register_id": register_id,
|
||||
"amount_paid": float(order["total_amount"] or 0),
|
||||
"notes": f"MercadoLibre order #{order['external_order_id']}",
|
||||
}
|
||||
|
||||
# We need to run process_sale inside the same connection.
|
||||
# process_sale expects the caller to commit. We'll do that.
|
||||
# However, process_sale uses flask.g for employee_id and branch_id.
|
||||
# We need to set those or pass them explicitly.
|
||||
# For now, we'll create the sale manually to avoid flask.g dependency.
|
||||
|
||||
sale = _create_sale_manual(tenant_conn, sale_data, employee_id=employee_id)
|
||||
|
||||
# Link order to sale
|
||||
cur.execute(
|
||||
"UPDATE marketplace_orders SET nexus_sale_id = %s, status = 'confirmed', updated_at = NOW() WHERE id = %s",
|
||||
(sale["id"], marketplace_order_id),
|
||||
)
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"sale_id": sale["id"], "marketplace_order_id": marketplace_order_id}
|
||||
|
||||
|
||||
def _create_sale_manual(tenant_conn, sale_data: dict, employee_id: int = None) -> dict:
|
||||
"""Create a sale record without relying on flask.g.
|
||||
|
||||
Simplified version of process_sale for background / external use.
|
||||
"""
|
||||
from services.inventory_engine import record_sale as inventory_record_sale
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
items = sale_data.get("items", [])
|
||||
customer_id = sale_data.get("customer_id")
|
||||
payment_method = sale_data.get("payment_method", "efectivo")
|
||||
sale_type = sale_data.get("sale_type", "cash")
|
||||
register_id = sale_data.get("register_id")
|
||||
amount_paid = float(sale_data.get("amount_paid", 0))
|
||||
notes = sale_data.get("notes")
|
||||
|
||||
if not items:
|
||||
raise ValueError("No items in sale")
|
||||
|
||||
# Enrich items
|
||||
inv_ids = [it["inventory_id"] for it in items]
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, part_number, name, cost, price_1, tax_rate
|
||||
FROM inventory WHERE id = ANY(%s) AND is_active = true
|
||||
ORDER BY id FOR UPDATE
|
||||
""",
|
||||
(inv_ids,),
|
||||
)
|
||||
inv_map = {r[0]: r for r in cur.fetchall()}
|
||||
|
||||
enriched = []
|
||||
for it in items:
|
||||
inv_id = it["inventory_id"]
|
||||
inv = inv_map.get(inv_id)
|
||||
if not inv:
|
||||
raise ValueError(f"Inventory item {inv_id} not found")
|
||||
qty = int(it.get("quantity", 1))
|
||||
unit_price = float(it.get("unit_price", inv[4] or 0))
|
||||
discount_pct = float(it.get("discount_pct", 0))
|
||||
tax_rate = float(it.get("tax_rate", inv[5] or 0.16))
|
||||
unit_cost = float(inv[3]) if inv[3] else 0
|
||||
|
||||
enriched.append({
|
||||
"inventory_id": inv_id,
|
||||
"part_number": inv[1],
|
||||
"name": inv[2],
|
||||
"quantity": qty,
|
||||
"unit_price": unit_price,
|
||||
"unit_cost": unit_cost,
|
||||
"discount_pct": discount_pct,
|
||||
"tax_rate": tax_rate,
|
||||
})
|
||||
|
||||
totals = calculate_totals(enriched)
|
||||
|
||||
# Insert sale
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sales
|
||||
(customer_id, employee_id, register_id, sale_type, payment_method,
|
||||
subtotal, discount_total, tax_total, total, amount_paid, change_given,
|
||||
status, notes, source, external_order_id)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, 'completed', %s, 'mercadolibre', %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
customer_id,
|
||||
employee_id,
|
||||
register_id,
|
||||
sale_type,
|
||||
payment_method,
|
||||
totals["subtotal"],
|
||||
totals["discount_total"],
|
||||
totals["tax_total"],
|
||||
totals["total"],
|
||||
amount_paid,
|
||||
0,
|
||||
notes,
|
||||
sale_data.get("external_order_id"),
|
||||
),
|
||||
)
|
||||
sale_id = cur.fetchone()[0]
|
||||
|
||||
# Insert sale_items
|
||||
for it in totals["items"]:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO sale_items
|
||||
(sale_id, inventory_id, part_number, name, quantity, unit_price,
|
||||
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
sale_id,
|
||||
it["inventory_id"],
|
||||
it["part_number"],
|
||||
it["name"],
|
||||
it["quantity"],
|
||||
it["unit_price"],
|
||||
it.get("unit_cost", 0),
|
||||
it.get("discount_pct", 0),
|
||||
it.get("discount_amount", 0),
|
||||
it.get("tax_rate", 0.16),
|
||||
it.get("tax_amount", 0),
|
||||
it["subtotal"],
|
||||
),
|
||||
)
|
||||
|
||||
# Deduct inventory
|
||||
for it in enriched:
|
||||
inventory_record_sale(
|
||||
tenant_conn, it["inventory_id"], it["quantity"], reference=f"ML sale {sale_id}"
|
||||
)
|
||||
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
return {"id": sale_id, **totals}
|
||||
@@ -136,15 +136,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
Expected columns (case-insensitive, whitespace-tolerant):
|
||||
part_number, stock, price
|
||||
Optional:
|
||||
min_order, warehouse_location, currency
|
||||
name, 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.
|
||||
- part_number matches `parts.oem_part_number` or `part_cross_references.cross_reference_number`.
|
||||
- If matched → linked to catalog (part_id set, seller fields NULL).
|
||||
- If NOT matched → created as seller listing (part_id NULL, seller_part_number set).
|
||||
- Existing rows are updated via UPSERT on the composite unique key.
|
||||
|
||||
Returns a summary dict: {ok, inserted, updated, skipped, errors}
|
||||
Returns a summary dict: {ok, inserted, updated, skipped, errors, oem_count, seller_count}
|
||||
"""
|
||||
reader = csv.DictReader(io.StringIO(csv_text))
|
||||
# Normalize header names
|
||||
@@ -166,9 +166,15 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
cur.close()
|
||||
return {'ok': False, 'error': f'bodega_id {bodega_id} no existe'}
|
||||
|
||||
# Pre-load cross-reference map for fast lookup
|
||||
cur.execute("SELECT cross_reference_number, oem_part_id FROM part_cross_references")
|
||||
xref_map = {row[0].strip(): row[1] for row in cur.fetchall()}
|
||||
|
||||
inserted = 0
|
||||
updated = 0
|
||||
skipped = 0
|
||||
oem_count = 0
|
||||
seller_count = 0
|
||||
errors = []
|
||||
|
||||
for i, row in enumerate(reader, start=2): # start=2 because row 1 is headers
|
||||
@@ -176,6 +182,7 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
part_number = norm.get('part_number', '')
|
||||
stock_str = norm.get('stock', '0')
|
||||
price_str = norm.get('price', '0')
|
||||
part_name = norm.get('name', '')
|
||||
|
||||
if not part_number:
|
||||
errors.append(f'Fila {i}: part_number vacio')
|
||||
@@ -190,17 +197,20 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Resolve part_number → part_id
|
||||
# Resolve part_number → part_id (OEM catalog or cross-reference)
|
||||
part_id = None
|
||||
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]
|
||||
if row_part:
|
||||
part_id = row_part[0]
|
||||
else:
|
||||
# Try cross-reference
|
||||
xref_id = xref_map.get(part_number)
|
||||
if xref_id:
|
||||
part_id = xref_id
|
||||
|
||||
# Resolve user_id from the bodega (use bodega_id as fallback if null)
|
||||
user_id = norm.get('user_id') or bodega_id # backward compat
|
||||
@@ -213,24 +223,48 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
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.
|
||||
# UPSERT on composite unique (bodega_id, part_id, seller_part_number, warehouse_location)
|
||||
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))
|
||||
if part_id:
|
||||
# OEM-matched listing
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, seller_part_number, seller_part_name,
|
||||
price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, part_id, warehouse_location) WHERE part_id IS NOT NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_id, price, stock, min_order, location, bodega_id, currency))
|
||||
oem_count += 1
|
||||
else:
|
||||
# Seller listing (no catalog match)
|
||||
cur.execute("""
|
||||
INSERT INTO warehouse_inventory
|
||||
(user_id, part_id, seller_part_number, seller_part_name,
|
||||
price, stock_quantity, min_order_quantity,
|
||||
warehouse_location, bodega_id, currency, updated_at)
|
||||
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, %s, %s, NOW())
|
||||
ON CONFLICT (bodega_id, seller_part_number, warehouse_location) WHERE part_id IS NULL
|
||||
DO UPDATE SET
|
||||
price = EXCLUDED.price,
|
||||
stock_quantity = EXCLUDED.stock_quantity,
|
||||
min_order_quantity = EXCLUDED.min_order_quantity,
|
||||
seller_part_name = EXCLUDED.seller_part_name,
|
||||
user_id = EXCLUDED.user_id,
|
||||
currency = EXCLUDED.currency,
|
||||
updated_at = NOW()
|
||||
RETURNING (xmax = 0) AS inserted
|
||||
""", (user_id, part_number, part_name or part_number, price, stock, min_order, location, bodega_id, currency))
|
||||
seller_count += 1
|
||||
|
||||
was_insert = cur.fetchone()[0]
|
||||
if was_insert:
|
||||
inserted += 1
|
||||
@@ -250,6 +284,8 @@ def upload_inventory_csv(master_conn, bodega_id: int, csv_text: str) -> dict:
|
||||
'inserted': inserted,
|
||||
'updated': updated,
|
||||
'skipped': skipped,
|
||||
'oem_count': oem_count,
|
||||
'seller_count': seller_count,
|
||||
'errors': errors[:20], # cap to avoid huge responses
|
||||
'total_errors': len(errors),
|
||||
}
|
||||
@@ -262,70 +298,114 @@ def search_inventory(master_conn, *, query: str = None, brand: str = None,
|
||||
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.
|
||||
|
||||
Includes both OEM-matched parts (part_id IS NOT NULL) and seller listings
|
||||
(part_id IS NULL) in a single unified result set.
|
||||
"""
|
||||
cur = master_conn.cursor()
|
||||
|
||||
clauses = ["wi.stock_quantity > 0", "b.verified = TRUE"]
|
||||
params = []
|
||||
like = f'%{query}%' if query else None
|
||||
city_lower = city.lower() if city else None
|
||||
params_common = []
|
||||
|
||||
# Build city filter once
|
||||
city_clause = ""
|
||||
if city_lower:
|
||||
city_clause = "AND LOWER(b.city) = LOWER(%s)"
|
||||
params_common.append(city)
|
||||
|
||||
# ─── Part A: OEM-matched parts (JOIN with parts catalog) ──────────
|
||||
clauses_oem = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NOT NULL"]
|
||||
params_oem = []
|
||||
|
||||
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])
|
||||
clauses_oem.append("(p.oem_part_number ILIKE %s OR p.name_part ILIKE %s OR COALESCE(p.name_es, '') ILIKE %s)")
|
||||
params_oem.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("""
|
||||
clauses_oem.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)
|
||||
params_oem.append(brand)
|
||||
|
||||
if city:
|
||||
clauses.append("LOWER(b.city) = LOWER(%s)")
|
||||
params.append(city)
|
||||
where_oem = " AND ".join(clauses_oem)
|
||||
|
||||
where_sql = " AND ".join(clauses)
|
||||
# ─── Part B: Seller listings (no parts catalog join) ──────────────
|
||||
clauses_seller = ["wi.stock_quantity > 0", "b.verified = TRUE", "wi.part_id IS NULL"]
|
||||
params_seller = []
|
||||
|
||||
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
|
||||
if query:
|
||||
clauses_seller.append("(wi.seller_part_number ILIKE %s OR wi.seller_part_name ILIKE %s)")
|
||||
params_seller.extend([like, like])
|
||||
|
||||
where_seller = " AND ".join(clauses_seller)
|
||||
|
||||
# Combined query with UNION ALL
|
||||
sql = f"""
|
||||
SELECT * FROM (
|
||||
-- OEM-matched parts
|
||||
SELECT
|
||||
p.id_part AS id,
|
||||
p.oem_part_number AS 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,
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||
'oem' AS listing_type
|
||||
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_oem} {city_clause}
|
||||
GROUP BY p.id_part, p.oem_part_number, p.name_es, p.name_part, p.image_url
|
||||
|
||||
UNION ALL
|
||||
|
||||
-- Seller listings
|
||||
SELECT
|
||||
wi.id_inventory AS id,
|
||||
wi.seller_part_number AS part_number,
|
||||
wi.seller_part_name AS name,
|
||||
NULL AS 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,
|
||||
ARRAY_AGG(DISTINCT b.name ORDER BY b.name) AS bodega_names,
|
||||
'seller' AS listing_type
|
||||
FROM warehouse_inventory wi
|
||||
JOIN bodegas b ON b.id_bodega = wi.bodega_id
|
||||
WHERE {where_seller} {city_clause}
|
||||
GROUP BY wi.id_inventory, wi.seller_part_number, wi.seller_part_name
|
||||
) combined
|
||||
ORDER BY total_stock DESC
|
||||
LIMIT %s
|
||||
""", params + [limit])
|
||||
"""
|
||||
|
||||
all_params = params_oem + params_common + params_seller + params_common + [limit]
|
||||
cur.execute(sql, all_params)
|
||||
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
return [
|
||||
{
|
||||
'id_part': r[0],
|
||||
'oem_part_number': r[1],
|
||||
'id': r[0],
|
||||
'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
|
||||
'bodega_names': r[8],
|
||||
'listing_type': r[9],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
@@ -358,6 +438,33 @@ def get_bodegas_with_part(master_conn, part_id: int) -> list[dict]:
|
||||
]
|
||||
|
||||
|
||||
def get_bodegas_with_listing(master_conn, wi_id: int) -> list[dict]:
|
||||
"""Return the list of verified bodegas that have a specific seller listing
|
||||
(warehouse_inventory row with part_id IS NULL) in stock.
|
||||
"""
|
||||
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.id_inventory = %s AND wi.stock_quantity > 0 AND b.verified = TRUE
|
||||
ORDER BY wi.price ASC
|
||||
""", (wi_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',
|
||||
'min_order': r[6] or 1,
|
||||
'currency': r[7] or 'MXN',
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
# PURCHASE ORDERS
|
||||
# ═══════════════════════════════════════════════════════════════════════════
|
||||
@@ -397,32 +504,60 @@ def create_po_draft(master_conn, *, buyer_tenant_id: int, buyer_user_id: int,
|
||||
# Insert items
|
||||
total = 0.0
|
||||
for item in items:
|
||||
part_id = int(item['part_id'])
|
||||
part_id = item.get('part_id')
|
||||
wi_id = item.get('wi_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
|
||||
if part_id:
|
||||
# OEM-matched part
|
||||
part_id = int(part_id)
|
||||
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')))
|
||||
cur.execute("""
|
||||
INSERT INTO purchase_order_items
|
||||
(po_id, part_id, oem_part_number, part_name, quantity, unit_price, subtotal, notes, is_seller_listing)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, FALSE)
|
||||
""", (po_id, part_id, oem, name, quantity, unit_price, subtotal, item.get('notes')))
|
||||
|
||||
elif wi_id:
|
||||
# Seller listing (no catalog match)
|
||||
wi_id = int(wi_id)
|
||||
cur.execute("""
|
||||
SELECT seller_part_number, seller_part_name, price
|
||||
FROM warehouse_inventory
|
||||
WHERE id_inventory = %s AND bodega_id = %s LIMIT 1
|
||||
""", (wi_id, bodega_id))
|
||||
r = cur.fetchone()
|
||||
if not r:
|
||||
continue
|
||||
seller_pn, seller_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, is_seller_listing)
|
||||
VALUES (%s, NULL, %s, %s, %s, %s, %s, %s, TRUE)
|
||||
""", (po_id, seller_pn, seller_name or seller_pn, quantity, unit_price, subtotal, item.get('notes')))
|
||||
|
||||
else:
|
||||
continue
|
||||
|
||||
# Update header total
|
||||
cur.execute("UPDATE purchase_orders SET total_amount = %s WHERE id_po = %s",
|
||||
|
||||
233
pos/services/meli_service.py
Normal file
233
pos/services/meli_service.py
Normal file
@@ -0,0 +1,233 @@
|
||||
"""MercadoLibre API client with OAuth2 auto-refresh.
|
||||
|
||||
Endpoints used:
|
||||
- GET /users/me
|
||||
- POST /items
|
||||
- PUT /items/{id}
|
||||
- GET /items/{id}
|
||||
- GET /orders/search
|
||||
- GET /orders/{id}
|
||||
- POST /shipments/{id}/dispatch
|
||||
- POST /oauth/token
|
||||
|
||||
References:
|
||||
https://developers.mercadolibre.com.ar/es_ar/api-docs-es
|
||||
"""
|
||||
|
||||
import time
|
||||
import requests
|
||||
from typing import Optional
|
||||
|
||||
BASE_URL = "https://api.mercadolibre.com"
|
||||
AUTH_URL = "https://api.mercadolibre.com/oauth/token"
|
||||
|
||||
|
||||
class MeliError(Exception):
|
||||
def __init__(self, message, status_code=None, response_body=None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.response_body = response_body
|
||||
|
||||
|
||||
class MeliAuthError(MeliError):
|
||||
pass
|
||||
|
||||
|
||||
class MeliService:
|
||||
def __init__(
|
||||
self,
|
||||
access_token: str,
|
||||
refresh_token: Optional[str] = None,
|
||||
client_id: Optional[str] = None,
|
||||
client_secret: Optional[str] = None,
|
||||
):
|
||||
self.access_token = access_token
|
||||
self.refresh_token = refresh_token
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
self._session = requests.Session()
|
||||
self._session.headers.update({"Authorization": f"Bearer {access_token}"})
|
||||
|
||||
# ─── Low-level request ───────────────────────────────────────────────
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
params: Optional[dict] = None,
|
||||
json_payload: Optional[dict] = None,
|
||||
retry_on_401: bool = True,
|
||||
) -> dict:
|
||||
url = f"{BASE_URL}{path}"
|
||||
resp = self._session.request(
|
||||
method, url, params=params, json=json_payload, timeout=30
|
||||
)
|
||||
|
||||
if resp.status_code == 401 and retry_on_401 and self.refresh_token:
|
||||
self._refresh_token()
|
||||
# Retry once with new token
|
||||
self._session.headers.update(
|
||||
{"Authorization": f"Bearer {self.access_token}"}
|
||||
)
|
||||
resp = self._session.request(
|
||||
method, url, params=params, json=json_payload, timeout=30
|
||||
)
|
||||
|
||||
if resp.status_code == 401:
|
||||
raise MeliAuthError(
|
||||
"Unauthorized. Token may be expired or invalid.",
|
||||
status_code=401,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
if not resp.ok:
|
||||
raise MeliError(
|
||||
f"Meli API error {resp.status_code}: {resp.text}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
|
||||
# Some endpoints return 204 No Content
|
||||
if resp.status_code == 204:
|
||||
return {}
|
||||
try:
|
||||
return resp.json()
|
||||
except Exception:
|
||||
return {"raw": resp.text}
|
||||
|
||||
def _refresh_token(self) -> dict:
|
||||
if not self.client_id or not self.client_secret or not self.refresh_token:
|
||||
raise MeliAuthError("Missing credentials for token refresh")
|
||||
payload = {
|
||||
"grant_type": "refresh_token",
|
||||
"client_id": self.client_id,
|
||||
"client_secret": self.client_secret,
|
||||
"refresh_token": self.refresh_token,
|
||||
}
|
||||
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||
if not resp.ok:
|
||||
raise MeliAuthError(
|
||||
f"Token refresh failed: {resp.status_code} {resp.text}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
data = resp.json()
|
||||
self.access_token = data["access_token"]
|
||||
if "refresh_token" in data:
|
||||
self.refresh_token = data["refresh_token"]
|
||||
return data
|
||||
|
||||
# ─── Auth / User ─────────────────────────────────────────────────────
|
||||
|
||||
def get_user(self) -> dict:
|
||||
return self._request("GET", "/users/me")
|
||||
|
||||
@staticmethod
|
||||
def exchange_code(
|
||||
code: str, client_id: str, client_secret: str, redirect_uri: str
|
||||
) -> dict:
|
||||
"""Exchange authorization code for tokens."""
|
||||
payload = {
|
||||
"grant_type": "authorization_code",
|
||||
"client_id": client_id,
|
||||
"client_secret": client_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
}
|
||||
resp = requests.post(AUTH_URL, data=payload, timeout=30)
|
||||
if not resp.ok:
|
||||
raise MeliAuthError(
|
||||
f"Code exchange failed: {resp.status_code} {resp.text}",
|
||||
status_code=resp.status_code,
|
||||
response_body=resp.text,
|
||||
)
|
||||
return resp.json()
|
||||
|
||||
# ─── Items (listings) ────────────────────────────────────────────────
|
||||
|
||||
def create_item(self, payload: dict) -> dict:
|
||||
return self._request("POST", "/items", json_payload=payload)
|
||||
|
||||
def update_item(self, item_id: str, payload: dict) -> dict:
|
||||
return self._request("PUT", f"/items/{item_id}", json_payload=payload)
|
||||
|
||||
def get_item(self, item_id: str) -> dict:
|
||||
return self._request("GET", f"/items/{item_id}")
|
||||
|
||||
def pause_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "paused"})
|
||||
|
||||
def activate_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "active"})
|
||||
|
||||
def close_item(self, item_id: str) -> dict:
|
||||
return self.update_item(item_id, {"status": "closed"})
|
||||
|
||||
# ─── Categories ──────────────────────────────────────────────────────
|
||||
|
||||
def get_category(self, category_id: str) -> dict:
|
||||
return self._request("GET", f"/categories/{category_id}")
|
||||
|
||||
def search_categories(self, site_id: str, query: str) -> dict:
|
||||
# ML does not have a direct category search; we use the predictor
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/sites/{site_id}/domain_discovery/search",
|
||||
params={"q": query},
|
||||
)
|
||||
|
||||
def get_category_attributes(self, category_id: str) -> list:
|
||||
return self._request("GET", f"/categories/{category_id}/attributes")
|
||||
|
||||
# ─── Orders ──────────────────────────────────────────────────────────
|
||||
|
||||
def get_orders(
|
||||
self,
|
||||
seller_id: str,
|
||||
status: Optional[str] = None,
|
||||
date_from: Optional[str] = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> dict:
|
||||
params = {"seller": seller_id, "limit": limit, "offset": offset}
|
||||
if status:
|
||||
params["order.status"] = status
|
||||
if date_from:
|
||||
params["order.date_created.from"] = date_from
|
||||
return self._request("GET", "/orders/search", params=params)
|
||||
|
||||
def get_order(self, order_id: str) -> dict:
|
||||
return self._request("GET", f"/orders/{order_id}")
|
||||
|
||||
# ─── Shipments ───────────────────────────────────────────────────────
|
||||
|
||||
def get_shipment(self, shipment_id: str) -> dict:
|
||||
return self._request("GET", f"/shipments/{shipment_id}")
|
||||
|
||||
def mark_ready_to_ship(self, shipment_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST",
|
||||
f"/shipments/{shipment_id}/dispatch",
|
||||
json_payload={},
|
||||
)
|
||||
|
||||
# ─── Notifications / Webhooks validation ─────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def validate_webhook_signature(
|
||||
secret: str, data: bytes, signature_header: str
|
||||
) -> bool:
|
||||
"""Validate MercadoLibre webhook signature.
|
||||
|
||||
ML sends: X-Signature: sha256=<hex_hmac>
|
||||
"""
|
||||
import hmac
|
||||
import hashlib
|
||||
|
||||
if not signature_header or "=" not in signature_header:
|
||||
return False
|
||||
_, expected_hex = signature_header.split("=", 1)
|
||||
computed = hmac.new(
|
||||
secret.encode(), data, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(computed, expected_hex)
|
||||
186
pos/services/part_kits.py
Normal file
186
pos/services/part_kits.py
Normal file
@@ -0,0 +1,186 @@
|
||||
"""
|
||||
Smart part kits — automatic cross-sell recommendations.
|
||||
|
||||
When a customer adds a part to their quotation, suggest related
|
||||
parts that are typically needed together for a complete job.
|
||||
"""
|
||||
|
||||
# Spanish keywords in part name → related parts to suggest (in Spanish)
|
||||
# These appear after a successful "cotizar" command.
|
||||
KIT_SUGGESTIONS = {
|
||||
"balata": ["disco de freno", "líquido de frenos", "balero de rueda"],
|
||||
"disco de freno": ["balata", "líquido de frenos"],
|
||||
"alternador": ["banda serpentina", "batería", "regulador de alternador"],
|
||||
"batería": ["alternador", "cable de bujía"],
|
||||
"marcha": ["batería", "solenoide de marcha"],
|
||||
"bujía": ["bobina de encendido", "filtro de aire", "filtro de gasolina"],
|
||||
"bobina": ["bujía", "cable de bujía"],
|
||||
"bomba de agua": ["termostato", "refrigerante", "manguera de radiador"],
|
||||
"radiador": ["manguera de radiador", "termostato", "tapón de radiador"],
|
||||
"termostato": ["refrigerante", "manguera de radiador"],
|
||||
"amortiguador": ["base de amortiguador", "goma de suspensión", "rótula"],
|
||||
"rótula": ["terminal de dirección", "brazo de suspensión", "bujes"],
|
||||
"terminal": ["rótula", "brazo de suspensión"],
|
||||
"filtro de aceite": ["filtro de aire", "filtro de gasolina", "filtro de habitáculo"],
|
||||
"filtro de aire": ["filtro de aceite", "filtro de gasolina", "bujía"],
|
||||
"filtro de gasolina": ["filtro de aire", "filtro de aceite", "inyector"],
|
||||
"clutch": ["collarín", "disco de clutch", "plato de presión"],
|
||||
"collarín": ["clutch", "disco de clutch"],
|
||||
"banda de distribución": ["bomba de agua", "tensor", "polea loca"],
|
||||
"banda serpentina": ["tensor de banda", "polea loca"],
|
||||
"foco": ["foco trasero", "cuarto"],
|
||||
"faro": ["foco trasero", "cuarto"],
|
||||
"aceite": ["filtro de aceite", "filtro de aire"],
|
||||
}
|
||||
|
||||
|
||||
def get_kit_suggestions(part_name: str) -> list:
|
||||
"""Return related part names for a given part (Spanish)."""
|
||||
if not part_name:
|
||||
return []
|
||||
name_lower = part_name.lower()
|
||||
for keyword, related in KIT_SUGGESTIONS.items():
|
||||
if keyword in name_lower:
|
||||
return related
|
||||
return []
|
||||
|
||||
|
||||
def build_kit_text(part_name: str) -> str:
|
||||
"""Build a WhatsApp-friendly kit suggestion text.
|
||||
|
||||
Returns empty string if no kit is found.
|
||||
"""
|
||||
suggestions = get_kit_suggestions(part_name)
|
||||
if not suggestions:
|
||||
return ""
|
||||
items = "\n".join(f" • {s.title()}" for s in suggestions[:3])
|
||||
return (
|
||||
"\n\n🔧 *¿Ya que estás en eso, checa si también necesitas:*\n"
|
||||
+ items
|
||||
+ '\n\n_Escribe la parte que te interese y la agregamos._'
|
||||
)
|
||||
|
||||
|
||||
# ── Urgency detection ────────────────────────────────────────────────
|
||||
URGENCY_KEYWORDS = [
|
||||
"urgente", "urgencia", "emergencia", "ya", "ahora", "hoy",
|
||||
"lo necesito", "se me paro", "no arranca", "no jala",
|
||||
"rapido", "apúrate", "apurate", "prisa", "de volada",
|
||||
"para hoy", "para ahora", "lo mas pronto", "lo más pronto",
|
||||
"inmediato", "express", "exprés",
|
||||
]
|
||||
|
||||
|
||||
def is_urgent(text: str) -> bool:
|
||||
"""Detect if the customer message signals urgency."""
|
||||
if not text:
|
||||
return False
|
||||
t = text.lower()
|
||||
return any(kw in t for kw in URGENCY_KEYWORDS)
|
||||
|
||||
|
||||
def urgency_note() -> str:
|
||||
return (
|
||||
"\n\n⚡ NOTA DE URGENCIA: El cliente necesita la pieza lo antes posible. "
|
||||
"Prioriza stock local y ofrece entrega express (2-4 horas) o recolección inmediata en tienda. "
|
||||
"Si no hay stock exacto, ofrece alternativa disponible inmediatamente."
|
||||
)
|
||||
|
||||
|
||||
# ── Abandoned quotation follow-up ────────────────────────────────────
|
||||
FOLLOW_UP_MINUTES = 15
|
||||
|
||||
|
||||
def should_send_followup(phone: str, tenant_conn) -> str:
|
||||
"""Check if we should send a follow-up message for an abandoned quotation.
|
||||
|
||||
Returns the follow-up text if yes, empty string if no.
|
||||
"""
|
||||
if not tenant_conn or not phone:
|
||||
return ""
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
# 1. Check if there's an active quotation for this phone
|
||||
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()
|
||||
if not row:
|
||||
cur.close()
|
||||
return ""
|
||||
|
||||
# 2. Check last bot message mentioning "cotización" or "cotizar"
|
||||
cur.execute("""
|
||||
SELECT created_at, message_text
|
||||
FROM whatsapp_messages
|
||||
WHERE phone = %s AND direction = 'outgoing'
|
||||
AND (message_text ILIKE '%cotización%' OR message_text ILIKE '%cotizar%')
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (phone,))
|
||||
last_quote_msg = cur.fetchone()
|
||||
cur.close()
|
||||
|
||||
if not last_quote_msg:
|
||||
return ""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
last_time = last_quote_msg[0]
|
||||
now = datetime.now(timezone.utc)
|
||||
if last_time.tzinfo is None:
|
||||
last_time = last_time.replace(tzinfo=timezone.utc)
|
||||
|
||||
minutes_since = (now - last_time).total_seconds() / 60
|
||||
if minutes_since >= FOLLOW_UP_MINUTES:
|
||||
return (
|
||||
"👋 *¿Todo bien?*\n\n"
|
||||
"Veo que estabas armando tu cotización. ¿Te falta algo más o quieres que te la envíe ahora?\n\n"
|
||||
"_Escribe *enviar cotización* para ver el total, o dime si necesitas otra parte._"
|
||||
)
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Follow-up check failed: {e}")
|
||||
return ""
|
||||
|
||||
|
||||
# ── Customer purchase history awareness ──────────────────────────────
|
||||
def get_purchase_history(phone: str, tenant_conn, limit: int = 3) -> str:
|
||||
"""Build a short text summary of recent confirmed quotations for this customer.
|
||||
|
||||
Returns empty string if no history.
|
||||
"""
|
||||
if not tenant_conn or not phone:
|
||||
return ""
|
||||
try:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("""
|
||||
SELECT q.id, q.created_at, q.total,
|
||||
ARRAY_AGG(qi.name ORDER BY qi.name) AS items
|
||||
FROM quotations q
|
||||
JOIN quotation_items qi ON qi.quotation_id = q.id
|
||||
WHERE q.notes LIKE %s AND q.status = 'converted'
|
||||
GROUP BY q.id, q.created_at, q.total
|
||||
ORDER BY q.created_at DESC
|
||||
LIMIT %s
|
||||
""", (f'%WA:{phone}%', limit))
|
||||
rows = cur.fetchall()
|
||||
cur.close()
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
now = datetime.now(timezone.utc)
|
||||
parts = []
|
||||
for qid, created, total, items in rows:
|
||||
if created.tzinfo is None:
|
||||
created = created.replace(tzinfo=timezone.utc)
|
||||
months_ago = (now - created).days // 30
|
||||
time_str = f"hace {months_ago} meses" if months_ago > 0 else "recientemente"
|
||||
item_list = ", ".join(items[:3])
|
||||
parts.append(f"- {time_str}: {item_list} (total ${float(total):,.2f})")
|
||||
|
||||
return "HISTORIAL DE COMPRAS DEL CLIENTE:\n" + "\n".join(parts)
|
||||
except Exception as e:
|
||||
print(f"[WA-AI] Purchase history failed: {e}")
|
||||
return ""
|
||||
127
pos/services/quote_image.py
Normal file
127
pos/services/quote_image.py
Normal file
@@ -0,0 +1,127 @@
|
||||
import io
|
||||
import base64
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
def generate_quote_image(quote_items, totals, tenant_name="Autopartes", logo_text="NEXUS"):
|
||||
"""
|
||||
Generate a visually appealing quote image.
|
||||
|
||||
quote_items: list of dicts with keys: name, sku, qty, price, total
|
||||
totals: dict with keys: subtotal, tax, total
|
||||
Returns: base64 encoded PNG string
|
||||
"""
|
||||
# Dimensions
|
||||
WIDTH = 800
|
||||
HEADER_H = 120
|
||||
FOOTER_H = 100
|
||||
ITEM_H = 60
|
||||
PADDING = 30
|
||||
|
||||
total_height = HEADER_H + len(quote_items) * ITEM_H + FOOTER_H + PADDING * 3
|
||||
|
||||
# Colors
|
||||
BG_COLOR = (250, 250, 252)
|
||||
PRIMARY = (0, 82, 155) # Dark blue
|
||||
ACCENT = (230, 57, 70) # Red accent
|
||||
TEXT_DARK = (30, 30, 30)
|
||||
TEXT_MED = (80, 80, 80)
|
||||
TEXT_LIGHT = (150, 150, 150)
|
||||
WHITE = (255, 255, 255)
|
||||
ROW_ALT = (245, 247, 250)
|
||||
|
||||
img = Image.new('RGB', (WIDTH, total_height), BG_COLOR)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
# Try to load fonts, fallback to default
|
||||
try:
|
||||
font_title = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 32)
|
||||
font_sub = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 18)
|
||||
font_item = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20)
|
||||
font_bold = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 22)
|
||||
font_small = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14)
|
||||
except Exception:
|
||||
font_title = ImageFont.load_default()
|
||||
font_sub = font_title
|
||||
font_item = font_title
|
||||
font_bold = font_title
|
||||
font_small = font_title
|
||||
|
||||
# --- Header ---
|
||||
draw.rectangle([0, 0, WIDTH, HEADER_H], fill=PRIMARY)
|
||||
|
||||
# Logo text
|
||||
draw.text((PADDING, 25), logo_text, font=font_title, fill=WHITE)
|
||||
draw.text((PADDING, 70), tenant_name, font=font_sub, fill=(200, 210, 230))
|
||||
|
||||
# Date and Quote label
|
||||
from datetime import datetime
|
||||
date_str = datetime.now().strftime("%d/%m/%Y %H:%M")
|
||||
draw.text((WIDTH - PADDING - 200, 30), "COTIZACIÓN", font=font_title, fill=WHITE)
|
||||
draw.text((WIDTH - PADDING - 200, 75), date_str, font=font_sub, fill=(200, 210, 230))
|
||||
|
||||
# --- Items Header ---
|
||||
y = HEADER_H + PADDING
|
||||
draw.rectangle([PADDING, y, WIDTH - PADDING, y + ITEM_H], fill=(230, 235, 240))
|
||||
draw.text((PADDING + 10, y + 18), "PRODUCTO", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 220, y + 18), "CANT.", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 130, y + 18), "P.UNIT", font=font_bold, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 50, y + 18), "TOTAL", font=font_bold, fill=TEXT_DARK)
|
||||
y += ITEM_H
|
||||
|
||||
# --- Items ---
|
||||
for idx, item in enumerate(quote_items):
|
||||
row_y = y + idx * ITEM_H
|
||||
bg = ROW_ALT if idx % 2 == 0 else WHITE
|
||||
draw.rectangle([PADDING, row_y, WIDTH - PADDING, row_y + ITEM_H], fill=bg)
|
||||
|
||||
name = item.get('name', 'Producto')
|
||||
sku = item.get('sku', '')
|
||||
qty = str(item.get('qty', 1))
|
||||
price = f"${item.get('price', 0):,.2f}"
|
||||
total = f"${item.get('total', 0):,.2f}"
|
||||
|
||||
# Truncate name if too long
|
||||
name_display = name
|
||||
if len(name_display) > 35:
|
||||
name_display = name_display[:32] + "..."
|
||||
|
||||
draw.text((PADDING + 10, row_y + 8), name_display, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((PADDING + 10, row_y + 32), f"SKU: {sku}", font=font_small, fill=TEXT_MED)
|
||||
|
||||
draw.text((WIDTH - PADDING - 220, row_y + 18), qty, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 130, row_y + 18), price, font=font_item, fill=TEXT_DARK)
|
||||
draw.text((WIDTH - PADDING - 50, row_y + 18), total, font=font_item, fill=TEXT_DARK)
|
||||
|
||||
y += len(quote_items) * ITEM_H + PADDING
|
||||
|
||||
# --- Totals ---
|
||||
draw.line([(PADDING, y), (WIDTH - PADDING, y)], fill=(200, 200, 200), width=2)
|
||||
y += 20
|
||||
|
||||
subtotal = totals.get('subtotal', 0)
|
||||
tax = totals.get('tax', 0)
|
||||
total = totals.get('total', 0)
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "Subtotal:", font=font_sub, fill=TEXT_MED)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${subtotal:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||
y += 30
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "IVA (16%):", font=font_sub, fill=TEXT_MED)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${tax:,.2f}", font=font_sub, fill=TEXT_DARK)
|
||||
y += 35
|
||||
|
||||
draw.text((WIDTH - PADDING - 300, y), "TOTAL:", font=font_bold, fill=ACCENT)
|
||||
draw.text((WIDTH - PADDING - 50, y), f"${total:,.2f}", font=font_bold, fill=ACCENT)
|
||||
y += 50
|
||||
|
||||
# --- Footer ---
|
||||
draw.rectangle([0, total_height - FOOTER_H, WIDTH, total_height], fill=PRIMARY)
|
||||
footer_text = "Validez: 5 días hábiles | Envíos a todo México | Contacto: ventas@nexusautoparts.com"
|
||||
draw.text((PADDING, total_height - FOOTER_H + 35), footer_text, font=font_small, fill=(200, 210, 230))
|
||||
|
||||
# Convert to base64
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, format='PNG')
|
||||
buffer.seek(0)
|
||||
return base64.b64encode(buffer.read()).decode('utf-8')
|
||||
|
||||
@@ -135,41 +135,34 @@ def _ensure_sessions_table(tenant_conn):
|
||||
cur.close()
|
||||
|
||||
|
||||
def set_last_shown_part(phone, part_info):
|
||||
def set_last_shown_part(tenant_conn, 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
|
||||
"""
|
||||
# In-memory fallback for when tenant_conn is not available
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
import json
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
|
||||
""", (phone, json.dumps(part_info)))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
|
||||
|
||||
|
||||
def get_last_shown_part(phone):
|
||||
from tenant_db import get_tenant_conn
|
||||
def get_last_shown_part(tenant_conn, phone):
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
@@ -177,54 +170,45 @@ def get_last_shown_part(phone):
|
||||
return None
|
||||
|
||||
|
||||
def clear_last_shown(phone):
|
||||
from tenant_db import get_tenant_conn
|
||||
def clear_last_shown(tenant_conn, phone):
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
|
||||
|
||||
|
||||
def set_vehicle(phone, vehicle):
|
||||
def set_vehicle(tenant_conn, phone, vehicle):
|
||||
"""Store the detected vehicle for this phone number.
|
||||
|
||||
vehicle: dict with keys brand, model, year
|
||||
"""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
import json
|
||||
cur.execute("""
|
||||
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
|
||||
VALUES (%s, %s, NOW())
|
||||
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
|
||||
""", (phone, json.dumps(vehicle)))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
|
||||
|
||||
|
||||
def get_vehicle(phone):
|
||||
def get_vehicle(tenant_conn, phone):
|
||||
"""Retrieve the stored vehicle for this phone number."""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
conn.close()
|
||||
if row and row[0]:
|
||||
return row[0]
|
||||
except Exception as e:
|
||||
@@ -232,17 +216,14 @@ def get_vehicle(phone):
|
||||
return None
|
||||
|
||||
|
||||
def clear_session(phone):
|
||||
def clear_session(tenant_conn, phone):
|
||||
"""Clear all session data (last_shown + vehicle) for this phone."""
|
||||
from tenant_db import get_tenant_conn
|
||||
try:
|
||||
conn = get_tenant_conn(11)
|
||||
_ensure_sessions_table(conn)
|
||||
cur = conn.cursor()
|
||||
_ensure_sessions_table(tenant_conn)
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
|
||||
conn.commit()
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ def process_incoming(webhook_data):
|
||||
# - For 'text' messages → conversation or extendedTextMessage
|
||||
# - For 'image'/'video' → the caption (may be empty)
|
||||
# - For 'audio' → empty (filled in later by Whisper transcription)
|
||||
# - For 'location' → synthetic text with coordinates
|
||||
if media_kind == 'text':
|
||||
text = (
|
||||
message.get('conversation', '')
|
||||
@@ -114,6 +115,10 @@ def process_incoming(webhook_data):
|
||||
else:
|
||||
text = media_caption
|
||||
|
||||
# Location fields (from bridge classification)
|
||||
latitude = data.get('latitude')
|
||||
longitude = data.get('longitude')
|
||||
|
||||
return {
|
||||
'phone': phone,
|
||||
'jid': remote_jid,
|
||||
@@ -125,4 +130,20 @@ def process_incoming(webhook_data):
|
||||
'media_mimetype': media_mimetype,
|
||||
'is_voice_note': is_voice_note,
|
||||
'push_name': push_name,
|
||||
'latitude': latitude,
|
||||
'longitude': longitude,
|
||||
}
|
||||
|
||||
|
||||
def send_image(phone, caption, base64_image, bridge_url=None):
|
||||
"""Send an image message via the Baileys bridge."""
|
||||
url = _get_url(bridge_url)
|
||||
try:
|
||||
return requests.post(
|
||||
f'{url}/send-image',
|
||||
headers=HEADERS,
|
||||
json={'phone': phone, 'caption': caption, 'base64': base64_image},
|
||||
timeout=15
|
||||
).json()
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
|
||||
Reference in New Issue
Block a user