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

@@ -1,48 +1,131 @@
"""Multi-currency support for border refaccionarias.
Supports MXN and USD with configurable exchange rate.
Supports MXN and USD with configurable exchange rate per tenant.
Rates are cached in Redis for 60 seconds to avoid repeated DB hits.
Business rule: inventory prices are ALWAYS in MXN (base currency).
Sales can be recorded in USD with conversion at checkout time.
Accounting and CFDI always use MXN.
"""
from decimal import Decimal, ROUND_HALF_UP
from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN
from services.redis_stock_cache import _get_redis
CURRENCIES = {
'MXN': {'symbol': '$', 'name': 'Peso Mexicano', 'name_en': 'Mexican Peso', 'decimals': 2},
'USD': {'symbol': 'US$', 'name': 'Dolar Estadounidense', 'name_en': 'US Dollar', 'decimals': 2},
}
# Cache TTL for exchange rates in Redis (seconds)
_RATE_TTL = 60
def convert(amount, from_currency, to_currency, rate=None):
def _to_dec(val):
if val is None:
return Decimal('0')
return Decimal(str(val))
def get_exchange_rate(conn, from_currency, to_currency):
"""Get the exchange rate from tenant_config, with Redis cache.
Returns:
Decimal: rate such that amount * rate = converted amount
(e.g., USD->MXN returns ~17.5, MXN->USD returns ~0.057)
"""
if from_currency == to_currency:
return Decimal('1')
cache_key = f"nexus:rate:{from_currency}:{to_currency}"
# Try Redis first
r = _get_redis()
if r:
try:
cached = r.get(cache_key)
if cached:
return Decimal(str(cached))
except Exception:
pass
# Fallback: read from tenant_config DB
rate = None
if conn:
try:
cur = conn.cursor()
cur.execute(
"SELECT value FROM tenant_config WHERE key = 'exchange_rate_usd_mxn'"
)
row = cur.fetchone()
cur.close()
if row and row[0]:
rate = Decimal(str(row[0]))
except Exception:
pass
if rate is None:
rate = _to_dec(EXCHANGE_RATE_USD_MXN)
# Compute cross rate
if from_currency == 'USD' and to_currency == 'MXN':
result = rate
elif from_currency == 'MXN' and to_currency == 'USD':
result = (Decimal('1') / rate).quantize(Decimal('0.000001'), rounding=ROUND_HALF_UP)
else:
result = Decimal('1')
# Cache in Redis
if r:
try:
r.set(cache_key, str(result), ex=_RATE_TTL)
except Exception:
pass
return result
def convert(amount, from_currency, to_currency, rate=None, conn=None):
"""Convert an amount between currencies.
Args:
amount: The numeric amount to convert.
from_currency: Source currency code ('MXN' or 'USD').
to_currency: Target currency code ('MXN' or 'USD').
rate: Optional custom exchange rate (USD->MXN). Defaults to config value.
amount: numeric amount to convert.
from_currency: source currency code.
to_currency: target currency code.
rate: optional pre-computed rate (skips DB lookup).
conn: optional DB connection to look up tenant rate.
Returns:
The converted amount, rounded to 2 decimals.
float: converted amount rounded to 2 decimals.
"""
if from_currency == to_currency:
return amount
return float(amount)
if rate is None:
rate = EXCHANGE_RATE_USD_MXN
if from_currency == 'USD' and to_currency == 'MXN':
return round(amount * rate, 2)
if from_currency == 'MXN' and to_currency == 'USD':
return round(amount / rate, 2)
return amount
rate = get_exchange_rate(conn, from_currency, to_currency)
amt = _to_dec(amount)
rate_dec = _to_dec(rate)
result = (amt * rate_dec).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
return float(result)
def to_mxn(amount, currency, rate=None, conn=None):
"""Convert an amount to MXN (convenience wrapper)."""
return convert(amount, currency, 'MXN', rate=rate, conn=conn)
def from_mxn(amount, currency, rate=None, conn=None):
"""Convert an amount from MXN to target currency."""
return convert(amount, 'MXN', currency, rate=rate, conn=conn)
def format_currency(amount, currency='MXN'):
"""Format an amount with the appropriate currency symbol.
Args:
amount: Numeric value.
currency: Currency code.
Returns:
Formatted string like '$1,234.56' or 'US$1,234.56'.
str: e.g. '$1,234.56' or 'US$1,234.56'.
"""
info = CURRENCIES.get(currency, CURRENCIES['MXN'])
return f"{info['symbol']}{amount:,.{info['decimals']}f}"
@@ -52,4 +135,17 @@ def get_currency_info(code=None):
"""Return currency metadata dict. If code is None, return all."""
if code:
return CURRENCIES.get(code)
return CURRENCIES
return CURRENCIES.copy()
def invalidate_rate_cache():
"""Clear all cached exchange rates from Redis."""
r = _get_redis()
if not r:
return
try:
keys = r.keys('nexus:rate:*')
if keys:
r.delete(*keys)
except Exception:
pass