# /home/Autopartes/pos/blueprints/storefront_bp.py """Storefront público por tenant: vitrina web con carrito → pedido al POS. Endpoints públicos (sin auth) protegidos por rate-limit por IP: GET /pos/api/store/info — nombre del negocio GET /pos/api/store/search — búsqueda de inventario con stock y precio público POST /pos/api/store/order — crea pedido (venta pendiente de pago) con reserva de stock """ import time from collections import defaultdict, deque from flask import Blueprint, request, jsonify, g from middleware_tenant import resolve_tenant from tenant_db import get_tenant_conn, get_master_conn from services.pos_engine import process_sale storefront_bp = Blueprint('storefront', __name__, url_prefix='/pos/api') _RATE = defaultdict(deque) RATE_LIMIT = 60 # requests por minuto por IP RATE_WINDOW = 60 def _rate_ok(): ip = (request.headers.get('X-Real-IP') or request.remote_addr or 'x') now = time.time() dq = _RATE[ip] while dq and dq[0] < now - RATE_WINDOW: dq.popleft() if len(dq) >= RATE_LIMIT: return False dq.append(now) return True def _require_tenant(): if not getattr(g, 'tenant_id', None): return None return g.tenant_id def _business_name(tenant_id): """Nombre del negocio desde la DB maestra (cache 5 min por proceso).""" cache = getattr(_business_name, '_cache', None) if cache is None: cache = _business_name._cache = {} now = time.time() hit = cache.get(tenant_id) if hit and hit[1] > now: return hit[0] try: conn = get_master_conn() cur = conn.cursor() cur.execute("SELECT name FROM tenants WHERE id = %s", (tenant_id,)) row = cur.fetchone() cur.close() conn.close() name = row[0] if row else 'Refaccionaria' except Exception: name = 'Refaccionaria' cache[tenant_id] = (name, now + 300) return name @storefront_bp.route('/store/info', methods=['GET']) def store_info(): tid = _require_tenant() if not tid: return jsonify({'error': 'tenant'}), 404 return jsonify({'name': _business_name(tid)}) @storefront_bp.route('/store/search', methods=['GET']) def store_search(): tid = _require_tenant() if not tid: return jsonify({'error': 'tenant'}), 404 if not _rate_ok(): return jsonify({'error': 'Demasiadas búsquedas. Intenta en un minuto.'}), 429 q = (request.args.get('q') or '').strip() def _int_arg(name, default, lo, hi): try: return max(lo, min(hi, int(request.args.get(name, default) or default))) except (ValueError, TypeError): return default page = _int_arg('page', 1, 1, 10000) per_page = _int_arg('per_page', 20, 1, 30) offset = (page - 1) * per_page conn = get_tenant_conn(tid) cur = conn.cursor() params = [] where = "i.is_active = true" if q: where += """ AND (i.name ILIKE %s OR i.part_number ILIKE %s OR i.id IN (SELECT a.inventory_id FROM inventory_sku_aliases a WHERE a.sku ILIKE %s))""" like = f"%{q}%" params += [like, like, like] cur.execute(f"SELECT count(*) FROM inventory i WHERE {where}", params) total = cur.fetchone()[0] cur.execute(f""" SELECT i.id, i.part_number, i.name, i.brand, i.price_1, i.image_url, i.description, COALESCE(s.stock, 0) AS stock FROM inventory i LEFT JOIN ( SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE {where} ORDER BY (COALESCE(s.stock, 0) > 0) DESC, i.name LIMIT %s OFFSET %s """, params + [per_page, offset]) items = [{ 'id': r[0], 'part_number': r[1], 'name': r[2], 'brand': r[3] or '', 'price': float(r[4] or 0), 'image_url': r[5] or '', 'description': (r[6] or '')[:200], 'stock': int(r[7] or 0), } for r in cur.fetchall()] cur.close() conn.close() return jsonify({'items': items, 'page': page, 'total': total, 'total_pages': max(1, (total + per_page - 1) // per_page)}) @storefront_bp.route('/store/order', methods=['POST']) def store_order(): tid = _require_tenant() if not tid: return jsonify({'error': 'tenant'}), 404 if not _rate_ok(): return jsonify({'error': 'Demasiadas solicitudes. Intenta en un minuto.'}), 429 data = request.get_json() or {} name = (data.get('customer_name') or '').strip() phone = ''.join(ch for ch in (data.get('customer_phone') or '') if ch.isdigit()) items = data.get('items') or [] notes = (data.get('notes') or '').strip() delivery = bool(data.get('delivery')) if not name or not phone or len(phone) < 10: return jsonify({'error': 'Nombre y teléfono (10 dígitos) son requeridos'}), 400 if not items: return jsonify({'error': 'El carrito está vacío'}), 400 conn = get_tenant_conn(tid) cur = conn.cursor() # Sucursal principal cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1") row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'Tienda no disponible'}), 503 branch_id = row[0] # Cliente: buscar por teléfono, si no existe crearlo cur.execute("SELECT id FROM customers WHERE phone = %s ORDER BY id LIMIT 1", (phone,)) row = cur.fetchone() if row: customer_id = row[0] else: cur.execute(""" INSERT INTO customers (name, phone, price_tier, is_active) VALUES (%s, %s, 1, true) RETURNING id """, (name, phone)) customer_id = cur.fetchone()[0] # Validar stock y precios (precio SIEMPRE del inventario, no del cliente) resolved = [] for it in items: inv_id = it.get('inventory_id') qty = int(it.get('quantity', 0) or 0) if not inv_id or qty <= 0: continue cur.execute(""" SELECT i.id, i.name, i.part_number, i.price_1, i.tax_rate, COALESCE(s.stock, 0) AS stock FROM inventory i LEFT JOIN ( SELECT inventory_id, SUM(stock) AS stock FROM inventory_stock GROUP BY inventory_id ) s ON s.inventory_id = i.id WHERE i.id = %s AND i.is_active """, (inv_id,)) r = cur.fetchone() if not r: cur.close(); conn.close() return jsonify({'error': f'Producto no disponible (#{inv_id})'}), 400 if (r[5] or 0) < qty: cur.close(); conn.close() return jsonify({'error': f'Sin stock suficiente: {r[2]}. Disponible: {int(r[5] or 0)}'}), 400 resolved.append({ 'inventory_id': r[0], 'quantity': qty, 'unit_price': float(r[3] or 0), 'tax_rate': float(r[4] or 0), 'discount_pct': 0, }) if not resolved: cur.close(); conn.close() return jsonify({'error': 'El carrito está vacío'}), 400 delivery_note = ' | Envío a domicilio solicitado' if delivery else '' sale_data = { 'items': resolved, 'customer_id': customer_id, 'payment_method': 'pendiente', 'sale_type': 'cash', 'branch_id': branch_id, 'amount_paid': 0, 'notes': f'Pedido web de {name}{delivery_note}' + (f' | {notes}' if notes else ''), } try: sale = process_sale(conn, sale_data) conn.commit() except ValueError as e: conn.rollback() cur.close(); conn.close() return jsonify({'error': str(e)}), 400 except Exception: conn.rollback() cur.close(); conn.close() return jsonify({'error': 'No se pudo registrar el pedido'}), 500 cur.close() # Notificar al dueño (best effort) try: from services.notification_engine import notify_new_sale notify_new_sale(conn, tid, sale['id'], sale['total'], 'web') except Exception: pass conn.close() return jsonify({ 'ok': True, 'order_id': sale['id'], 'total': sale['total'], 'message': 'Pedido registrado. Te contactaremos para confirmar pago y entrega.', })