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

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