Files
Autoparts-DB/pos/blueprints/storefront_bp.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

241 lines
8.1 KiB
Python

# /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.',
})