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
This commit is contained in:
@@ -96,17 +96,106 @@ def detect_quote_intent(text, has_open_quote=False):
|
||||
return None, None
|
||||
|
||||
|
||||
def confirm_quotation(tenant_conn, phone):
|
||||
"""Mark the open quotation as confirmed/accepted."""
|
||||
def confirm_quotation(tenant_conn, phone, customer_id=None, tenant_id=None, branch_id=None):
|
||||
"""Confirm the open quotation and convert it into a pending-payment sale.
|
||||
|
||||
- Validates stock for every line before touching anything.
|
||||
- Creates the sale via pos_engine.process_sale (status 'pending_payment',
|
||||
stock reserved, no cash movement — payable later at the counter).
|
||||
- Notifies owners via the notification engine (best effort).
|
||||
|
||||
Returns dict {'quotation_id', 'sale_id', 'total', 'items'} on success,
|
||||
dict {'error': ..., ...} on failure, or None if there was no open quote.
|
||||
"""
|
||||
qid = get_open_quotation(tenant_conn, phone)
|
||||
if not qid:
|
||||
return None
|
||||
|
||||
detail = get_quotation_detail(tenant_conn, qid)
|
||||
if not detail or not detail.get('items'):
|
||||
return None
|
||||
|
||||
# Resolve target branch (first active)
|
||||
if not branch_id:
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("SELECT id FROM branches ORDER BY id LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
cur.close()
|
||||
if not row:
|
||||
return {'error': 'no_branch', 'quotation_id': qid}
|
||||
branch_id = row[0]
|
||||
|
||||
# Resolve inventory ids and validate stock
|
||||
cur = tenant_conn.cursor()
|
||||
resolved = []
|
||||
for it in detail['items']:
|
||||
cur.execute("""
|
||||
SELECT i.id, COALESCE(s.stock, 0) 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.part_number = %s AND i.is_active LIMIT 1
|
||||
""", (it['part_number'],))
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
cur.close()
|
||||
return {'error': 'item_not_found', 'part_number': it['part_number'],
|
||||
'quotation_id': qid}
|
||||
inv_id, stock = row
|
||||
if (stock or 0) < it['quantity']:
|
||||
cur.close()
|
||||
return {'error': 'insufficient_stock', 'part_number': it['part_number'],
|
||||
'available': int(stock or 0), 'requested': it['quantity'],
|
||||
'quotation_id': qid}
|
||||
resolved.append({
|
||||
'inventory_id': inv_id,
|
||||
'quantity': it['quantity'],
|
||||
'unit_price': it['unit_price'],
|
||||
'tax_rate': it['tax_rate'],
|
||||
'discount_pct': 0,
|
||||
})
|
||||
cur.close()
|
||||
|
||||
from services.pos_engine import process_sale
|
||||
sale_data = {
|
||||
'items': resolved,
|
||||
'customer_id': customer_id,
|
||||
'payment_method': 'pendiente',
|
||||
'sale_type': 'cash',
|
||||
'branch_id': branch_id,
|
||||
'amount_paid': 0,
|
||||
'notes': f'Pedido por WhatsApp (cotizacion #{qid})',
|
||||
}
|
||||
try:
|
||||
sale = process_sale(tenant_conn, sale_data)
|
||||
except ValueError as e:
|
||||
tenant_conn.rollback()
|
||||
return {'error': 'sale_failed', 'message': str(e), 'quotation_id': qid}
|
||||
except Exception:
|
||||
tenant_conn.rollback()
|
||||
raise
|
||||
|
||||
cur = tenant_conn.cursor()
|
||||
cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,))
|
||||
tenant_conn.commit()
|
||||
cur.close()
|
||||
tenant_conn.commit()
|
||||
clear_last_shown(tenant_conn, phone)
|
||||
return qid
|
||||
|
||||
# Notify owners (best effort — never block the reply)
|
||||
try:
|
||||
if tenant_id:
|
||||
from services.notification_engine import notify_new_sale
|
||||
notify_new_sale(tenant_conn, tenant_id, sale['id'], sale['total'], 'whatsapp')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
'quotation_id': qid,
|
||||
'sale_id': sale['id'],
|
||||
'total': sale['total'],
|
||||
'items': detail['items'],
|
||||
}
|
||||
|
||||
|
||||
# ─── Persistent last-shown-part per phone ────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user