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