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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user