- 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
241 lines
8.1 KiB
Python
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.',
|
|
})
|