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,120 @@
# /home/Autopartes/pos/services/redis_stock_cache.py
"""Redis cache layer for inventory stock calculations.
Provides sub-millisecond stock lookups by caching SUM(inventory_operations)
results in Redis. Cache is invalidated on every stock mutation.
Fallback: if Redis is unavailable, queries PostgreSQL directly.
"""
import os
import json
import redis
from decimal import Decimal
# Connection settings from environment
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
REDIS_STOCK_TTL = int(os.environ.get('REDIS_STOCK_TTL', '300')) # 5 minutes default
REDIS_ENABLED = os.environ.get('REDIS_ENABLED', 'true').lower() == 'true'
# Lazy connection
_redis_client = None
def _get_redis():
"""Get or create Redis connection (lazy singleton)."""
global _redis_client
if _redis_client is None and REDIS_ENABLED:
try:
_redis_client = redis.from_url(REDIS_URL, decode_responses=True)
_redis_client.ping()
except Exception as e:
print(f"[redis_stock_cache] Redis unavailable: {e}")
_redis_client = False # Disable for this session
return _redis_client if _redis_client is not False else None
def _stock_key(inventory_id, branch_id=None):
"""Generate Redis key for a stock entry."""
if branch_id:
return f"nexus:stock:{inventory_id}:b{branch_id}"
return f"nexus:stock:{inventory_id}"
def get_cached_stock(inventory_id, branch_id=None):
"""Get stock from Redis cache.
Returns:
int/None: Stock quantity if cached, None if miss or Redis down.
"""
r = _get_redis()
if not r:
return None
try:
val = r.get(_stock_key(inventory_id, branch_id))
if val is not None:
return int(val)
except Exception as e:
print(f"[redis_stock_cache] GET error: {e}")
return None
def set_cached_stock(inventory_id, quantity, branch_id=None):
"""Store stock in Redis cache with TTL."""
r = _get_redis()
if not r:
return
try:
key = _stock_key(inventory_id, branch_id)
r.set(key, int(quantity), ex=REDIS_STOCK_TTL)
except Exception as e:
print(f"[redis_stock_cache] SET error: {e}")
def invalidate_stock(inventory_id, branch_id=None):
"""Remove stock entry from Redis cache.
Called after any inventory mutation (sale, purchase, adjust, transfer).
If branch_id is None, invalidates both global and branch-specific keys.
"""
r = _get_redis()
if not r:
return
try:
keys = [_stock_key(inventory_id)]
if branch_id:
keys.append(_stock_key(inventory_id, branch_id))
else:
# Wildcard invalidation for all branches of this item
pattern = _stock_key(inventory_id, '*')
keys = r.keys(pattern)
keys.append(_stock_key(inventory_id))
if keys:
r.delete(*keys)
except Exception as e:
print(f"[redis_stock_cache] DELETE error: {e}")
def invalidate_all_stock():
"""Flush all stock keys from Redis. Use with caution (e.g., after bulk import)."""
r = _get_redis()
if not r:
return
try:
keys = r.keys('nexus:stock:*')
if keys:
r.delete(*keys)
print(f"[redis_stock_cache] Flushed {len(keys)} stock keys")
except Exception as e:
print(f"[redis_stock_cache] FLUSH error: {e}")
def health_check():
"""Return True if Redis is reachable."""
r = _get_redis()
if not r:
return False
try:
return r.ping()
except Exception:
return False