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
160 lines
4.3 KiB
Python
160 lines
4.3 KiB
Python
"""Meilisearch integration for sub-100ms catalog search.
|
|
|
|
Provides a thin wrapper over the meilisearch Python client with:
|
|
- Automatic index creation and settings configuration
|
|
- Bulk indexing from PostgreSQL
|
|
- Search with graceful fallback to PostgreSQL tsvector
|
|
- Incremental add/update/delete for real-time sync
|
|
|
|
Environment:
|
|
MEILI_URL — Meilisearch server URL (default: http://localhost:7700)
|
|
MEILI_API_KEY — Master key (default: nexus-master-key-change-me)
|
|
"""
|
|
|
|
import os
|
|
import meilisearch
|
|
from meilisearch.errors import MeilisearchApiError
|
|
|
|
MEILI_URL = os.environ.get('MEILI_URL', 'http://localhost:7700')
|
|
MEILI_API_KEY = os.environ.get('MEILI_API_KEY', 'nexus-master-key-change-me')
|
|
INDEX_NAME = 'nexus_parts'
|
|
|
|
# Searchable attributes and ranking
|
|
INDEX_SETTINGS = {
|
|
'searchableAttributes': [
|
|
'name_es',
|
|
'name_part',
|
|
'oem_part_number',
|
|
'description',
|
|
'description_es',
|
|
],
|
|
'rankingRules': [
|
|
'words',
|
|
'typo',
|
|
'proximity',
|
|
'attribute',
|
|
'sort',
|
|
'exactness',
|
|
],
|
|
'filterableAttributes': ['group_id'],
|
|
'typoTolerance': {'enabled': True, 'minWordSizeForTypos': {'oneTypo': 4, 'twoTypos': 8}},
|
|
}
|
|
|
|
_client = None
|
|
_client_url = None
|
|
|
|
|
|
def get_client():
|
|
"""Get or create Meilisearch client (lazy singleton, URL-aware)."""
|
|
global _client, _client_url
|
|
current_url = os.environ.get('MEILI_URL', 'http://localhost:7700')
|
|
if _client is None or _client_url != current_url:
|
|
_client = meilisearch.Client(current_url, MEILI_API_KEY)
|
|
_client_url = current_url
|
|
return _client
|
|
|
|
|
|
def reset_client():
|
|
"""Force client recreation on next use (useful for tests)."""
|
|
global _client, _client_url
|
|
_client = None
|
|
_client_url = None
|
|
|
|
|
|
def health_check():
|
|
"""Return True if Meilisearch is reachable."""
|
|
try:
|
|
return get_client().health().get('status') == 'available'
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def ensure_index():
|
|
"""Create index if it doesn't exist and configure settings."""
|
|
client = get_client()
|
|
try:
|
|
client.get_index(INDEX_NAME)
|
|
except MeilisearchApiError as e:
|
|
if e.code == 'index_not_found':
|
|
client.create_index(uid=INDEX_NAME, options={'primaryKey': 'id_part'})
|
|
else:
|
|
raise
|
|
|
|
index = client.index(INDEX_NAME)
|
|
index.update_settings(INDEX_SETTINGS)
|
|
return index
|
|
|
|
|
|
def index_parts_bulk(parts_iter, batch_size=1000):
|
|
"""Index a large number of parts from an iterable.
|
|
|
|
Args:
|
|
parts_iter: iterable of dicts with keys:
|
|
id_part, oem_part_number, name_part, name_es,
|
|
description, description_es, image_url, group_id
|
|
batch_size: documents per batch upload
|
|
"""
|
|
index = ensure_index()
|
|
batch = []
|
|
total = 0
|
|
for part in parts_iter:
|
|
batch.append(part)
|
|
if len(batch) >= batch_size:
|
|
index.add_documents(batch)
|
|
total += len(batch)
|
|
batch = []
|
|
if batch:
|
|
index.add_documents(batch)
|
|
total += len(batch)
|
|
return total
|
|
|
|
|
|
def search_parts(query, limit=50, offset=0):
|
|
"""Search parts via Meilisearch.
|
|
|
|
Returns:
|
|
dict: Meilisearch response with 'hits', 'offset', 'limit', 'totalHits'
|
|
or None on error.
|
|
"""
|
|
try:
|
|
index = get_client().index(INDEX_NAME)
|
|
return index.search(query, {'limit': limit, 'offset': offset})
|
|
except Exception as e:
|
|
print(f"[meili_search] Search error: {e}")
|
|
return None
|
|
|
|
|
|
def add_part(part_doc):
|
|
"""Add or update a single part document."""
|
|
try:
|
|
get_client().index(INDEX_NAME).add_documents([part_doc])
|
|
return True
|
|
except Exception as e:
|
|
print(f"[meili_search] Add error: {e}")
|
|
return False
|
|
|
|
|
|
def update_part(part_doc):
|
|
"""Update a single part document (same as add)."""
|
|
return add_part(part_doc)
|
|
|
|
|
|
def delete_part(part_id):
|
|
"""Remove a part from the index."""
|
|
try:
|
|
get_client().index(INDEX_NAME).delete_document(part_id)
|
|
return True
|
|
except Exception as e:
|
|
print(f"[meili_search] Delete error: {e}")
|
|
return False
|
|
|
|
|
|
def clear_index():
|
|
"""Delete all documents from the index."""
|
|
try:
|
|
get_client().index(INDEX_NAME).delete_all_documents()
|
|
return True
|
|
except Exception as e:
|
|
print(f"[meili_search] Clear error: {e}")
|
|
return False
|