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:
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user