FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica

FASE 4:
- Redis cache de stock con fallback graceful
- Multi-moneda (MXN/USD) con contabilidad en MXN
- Proveedores y ordenes de compra completo
- Meilisearch 1.5M+ partes indexadas
- Metabase KPIs con dashboard auto-generado

FASE 5:
- CRM mejorado: activities, tags, loyalty program, analytics
- Imagenes de partes: upload, resize, thumbnails WebP
- Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered
- Garantias/RMA, alertas de reorden, multi-sucursal
- Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi)

FASE 6:
- Notificaciones automaticas: push/WhatsApp/email/in-app
- Reportes de ahorro vs retail_price
- Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber
- API Publica: API keys, rate limiting, catalog search

Migraciones: v1.9-v3.0
Tests: 93/93 pasando
Backup: nexus_backup_20260427_045859.tar.gz
This commit is contained in:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -1176,13 +1176,53 @@ def _get_alternatives(cur, part_id):
# SMART SEARCH
# ─────────────────────────────────────────────────────────────────────────────
def _search_meili_fallback(master_conn, q, limit):
"""Search Meilisearch and hydrate from PostgreSQL.
Returns list of tuples (id_part, oem_part_number, name_part, name_es,
image_url, group_id) or None if Meilisearch is unavailable.
"""
try:
from services.meili_search import search_parts
result = search_parts(q, limit=limit)
if result is None:
# Meilisearch error — signal fallback
return None
if not result.get('hits'):
return []
hits = result['hits']
part_ids = [h['id_part'] for h in hits]
cur = master_conn.cursor()
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE p.id_part = ANY(%s)
""", (part_ids,))
pg_rows = {r[0]: r for r in cur.fetchall()}
cur.close()
# Preserve Meilisearch ranking order
rows = []
for h in hits:
row = pg_rows.get(h['id_part'])
if row:
rows.append(row)
return rows
except Exception:
# Meilisearch unavailable — signal fallback
return None
def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
"""Search parts by part number or text. Enriches with local stock.
Strategy:
- If q looks like a part number (contains digits + hyphens): search oem_part_number ILIKE
- If q is text: use PostgreSQL full-text search (search_vector) with ILIKE fallback
- Always enriches results with local stock from tenant DB
1. Try Meilisearch first (sub-100ms full-text + typo tolerance)
2. Fallback to PostgreSQL tsvector / ILIKE if Meilisearch is down
3. Always enriches results with local stock from tenant DB
"""
q = q.strip()
if not q or len(q) < 2:
@@ -1191,37 +1231,41 @@ def smart_search(master_conn, q, tenant_conn, branch_id, limit=50):
limit = min(limit, 100)
cur = master_conn.cursor()
is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q))
if is_part_number:
# Search by OEM part number
clean_q = q.replace(' ', '').upper()
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE %s
ORDER BY p.oem_part_number
LIMIT %s
""", (f'%{clean_q}%', limit))
# ── Attempt Meilisearch first ───────────────────────────────────────────
meili_rows = _search_meili_fallback(master_conn, q, limit)
if meili_rows is not None:
rows = meili_rows
else:
# Full-text search using tsvector, fall back to ILIKE
tsquery = ' & '.join(q.split())
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE p.search_vector @@ to_tsquery('spanish', %s)
OR p.name_part ILIKE %s
OR p.name_es ILIKE %s
ORDER BY
CASE WHEN p.search_vector @@ to_tsquery('spanish', %s)
THEN 0 ELSE 1 END,
p.name_part
LIMIT %s
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
# PostgreSQL fallback
is_part_number = bool(re.search(r'\d.*[-/]|[-/].*\d|\d{3,}', q))
if is_part_number:
clean_q = q.replace(' ', '').upper()
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE REPLACE(UPPER(p.oem_part_number), ' ', '') LIKE %s
ORDER BY p.oem_part_number
LIMIT %s
""", (f'%{clean_q}%', limit))
else:
tsquery = ' & '.join(q.split())
cur.execute("""
SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es,
p.image_url, p.group_id
FROM parts p
WHERE p.search_vector @@ to_tsquery('spanish', %s)
OR p.name_part ILIKE %s
OR p.name_es ILIKE %s
ORDER BY
CASE WHEN p.search_vector @@ to_tsquery('spanish', %s)
THEN 0 ELSE 1 END,
p.name_part
LIMIT %s
""", (tsquery, f'%{q}%', f'%{q}%', tsquery, limit))
rows = cur.fetchall()
rows = cur.fetchall()
if not rows:
cur.close()
return []