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
152 lines
4.2 KiB
Python
152 lines
4.2 KiB
Python
"""Multi-currency support for border refaccionarias.
|
|
|
|
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 _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: 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:
|
|
float: converted amount rounded to 2 decimals.
|
|
"""
|
|
if from_currency == to_currency:
|
|
return float(amount)
|
|
|
|
if rate is None:
|
|
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.
|
|
|
|
Returns:
|
|
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}"
|
|
|
|
|
|
def get_currency_info(code=None):
|
|
"""Return currency metadata dict. If code is None, return all."""
|
|
if code:
|
|
return CURRENCIES.get(code)
|
|
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
|