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:
159
pos/services/meili_search.py
Normal file
159
pos/services/meili_search.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user