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

@@ -22,8 +22,29 @@ pos_bp = Blueprint('pos', __name__, url_prefix='/pos/api')
def _enrich_items(cur, items, customer_id=None):
"""Look up inventory data for items that lack unit_price/tax_rate.
Uses batch queries to avoid N+1 performance issues.
Returns list of dicts with all fields needed by calculate_totals.
"""
inv_ids = [item.get('inventory_id') for item in items if item.get('inventory_id')]
if not inv_ids:
raise ValueError("No valid inventory items provided")
# Batch fetch all inventory items in one query
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id
FROM inventory WHERE id = ANY(%s) AND is_active = true
""", (inv_ids,))
inv_map = {r[0]: r for r in cur.fetchall()}
# Fetch customer price tier once (if provided)
price_tier = 1
if customer_id:
cur.execute("SELECT price_tier FROM customers WHERE id = %s", (customer_id,))
cust = cur.fetchone()
if cust and cust[0]:
price_tier = int(cust[0])
enriched = []
for item in items:
inv_id = item.get('inventory_id')
@@ -31,23 +52,10 @@ def _enrich_items(cur, items, customer_id=None):
if qty <= 0:
raise ValueError(f"Invalid quantity for inventory_id {inv_id}")
cur.execute("""
SELECT id, part_number, name, cost, price_1, price_2, price_3,
tax_rate, branch_id
FROM inventory WHERE id = %s AND is_active = true
""", (inv_id,))
inv = cur.fetchone()
inv = inv_map.get(inv_id)
if not inv:
raise ValueError(f"Inventory item {inv_id} not found or inactive")
# Determine price tier from customer if provided
price_tier = 1
if customer_id:
cur.execute("SELECT price_tier FROM customers WHERE id = %s", (customer_id,))
cust = cur.fetchone()
if cust and cust[0]:
price_tier = int(cust[0])
# price_1=inv[4], price_2=inv[5], price_3=inv[6]
tier_prices = {1: inv[4], 2: inv[5], 3: inv[6]}
default_price = float(tier_prices.get(price_tier, inv[4]) or inv[4])
@@ -85,7 +93,9 @@ def create_sale():
register_id: int,
amount_paid: float,
payment_details: [{method, amount, reference}], (for mixed payments)
notes: str
notes: str,
currency: 'MXN' | 'USD' (default 'MXN'),
exchange_rate: float (optional, auto-fetched from tenant config if omitted)
}
"""
data = request.get_json() or {}
@@ -402,7 +412,9 @@ def create_quotation():
items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}],
customer_id: int | null,
valid_days: int (default 7),
notes: str
notes: str,
currency: 'MXN' | 'USD' (default 'MXN'),
exchange_rate: float (optional, auto-fetched if not provided)
}
"""
data = request.get_json() or {}
@@ -426,17 +438,29 @@ def create_quotation():
valid_days = int(data.get('valid_days', 7))
valid_until = (date.today() + timedelta(days=valid_days)).isoformat()
# Multi-currency for quotations
from services.currency import get_exchange_rate
currency = data.get('currency', 'MXN')
if currency not in ('MXN', 'USD'):
cur.close(); conn.close()
return jsonify({'error': f'Unsupported currency: {currency}'}), 400
exchange_rate = data.get('exchange_rate')
if currency != 'MXN' and exchange_rate is None:
exchange_rate = float(get_exchange_rate(conn, currency, 'MXN'))
exchange_rate = float(exchange_rate) if exchange_rate else 1.0
try:
cur.execute("""
INSERT INTO quotations
(branch_id, customer_id, employee_id, subtotal,
tax_total, total, status, valid_until, notes)
VALUES (%s,%s,%s,%s,%s,%s,'active',%s,%s)
tax_total, total, status, valid_until, notes, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,'active',%s,%s,%s,%s)
RETURNING id, created_at
""", (
g.branch_id, data.get('customer_id'), g.employee_id,
totals['subtotal'], totals['tax_total'],
totals['total'], valid_until, data.get('notes')
totals['total'], valid_until, data.get('notes'),
currency, exchange_rate
))
quot_id, created_at = cur.fetchone()
@@ -452,12 +476,13 @@ def create_quotation():
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity,
unit_price, discount_pct, tax_rate, subtotal)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
unit_price, discount_pct, tax_rate, subtotal, currency, exchange_rate)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
""", (
quot_id, item['inventory_id'], part_number, name,
item['quantity'], item['unit_price'], item['discount_pct'],
item['tax_rate'], line_subtotal
item['tax_rate'], line_subtotal,
currency, exchange_rate
))
log_action(conn, 'QUOTATION_CREATE', 'quotation', quot_id,
@@ -930,8 +955,8 @@ def convert_quotation(quot_id):
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Get quotation
cur.execute("SELECT id, customer_id, status FROM quotations WHERE id = %s", (quot_id,))
# Get quotation (include currency)
cur.execute("SELECT id, customer_id, status, currency, exchange_rate FROM quotations WHERE id = %s", (quot_id,))
quot = cur.fetchone()
if not quot:
cur.close(); conn.close()
@@ -940,6 +965,9 @@ def convert_quotation(quot_id):
cur.close(); conn.close()
return jsonify({'error': f'Quotation is {quot[2]}, cannot convert'}), 400
quot_currency = quot[3] or 'MXN'
quot_rate = quot[4] or 1.0
# Get quotation items
cur.execute("""
SELECT inventory_id, quantity, unit_price, discount_pct, tax_rate
@@ -953,7 +981,7 @@ def convert_quotation(quot_id):
'tax_rate': float(r[4]) if r[4] else 0.16,
})
# Build sale_data
# Build sale_data (preserve quotation currency)
sale_data = {
'items': items,
'customer_id': quot[1],
@@ -963,6 +991,8 @@ def convert_quotation(quot_id):
'amount_paid': data.get('amount_paid', 0),
'payment_details': data.get('payment_details', []),
'notes': f'Convertida de cotizacion #{quot_id}',
'currency': quot_currency,
'exchange_rate': quot_rate,
}
try: