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:
2026-05-26 04:24:07 +00:00
parent 50c0dbe7d4
commit a236187f3a
66 changed files with 7335 additions and 498 deletions

View File

@@ -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.",

View File

@@ -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]

View 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

View 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}

View File

@@ -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",

View 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
View 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
View 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')

View File

@@ -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}")

View File

@@ -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)}