Files
Autoparts-DB/pos/services/wa_quotation.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

469 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
WhatsApp Quotation Service — conversational quote builder.
Tracks per-phone "open quotations" so a customer can ask about multiple
parts over several messages and receive a single formatted quotation at
the end.
Flow:
1. Customer asks about a part → bot shows local inventory match
2. Customer says "cotizar" / "agregar" → last-shown part added to quote
3. Repeat for more parts
4. Customer says "enviar cotización" / "listo" → formatted quote sent
5. Customer says "limpiar" / "nueva cotización" → quote cleared
The quotation is stored in the tenant's existing `quotations` +
`quotation_items` tables so it also appears in the POS quotation list.
"""
import re
from datetime import date, timedelta
# ─── Intent detection ────────────────────────────────────────────────
# Commands the customer can type (case-insensitive, accent-insensitive)
# NOTE: "si" is NOT here — it's handled as 'confirm' to avoid ambiguity
# with "si" after a quotation was sent (which means "confirm order").
_ADD_PATTERNS = re.compile(
r'^(cotizar|agregar|agregalo|agrega|añadir|quiero ese|ese mero|'
r'dame ese|lo quiero|me lo apartas|si.?cotiza)$',
re.IGNORECASE
)
_SEND_PATTERNS = re.compile(
r'^(enviar cotizaci[oó]n|listo|enviar|mandar cotizaci[oó]n|ya es todo|'
r'eso es todo|mandame la cotizaci[oó]n|terminé|termine|ver cotizaci[oó]n|'
r'mi cotizaci[oó]n|total|cuanto es)$',
re.IGNORECASE
)
_CLEAR_PATTERNS = re.compile(
r'^(limpiar|nueva cotizaci[oó]n|borrar cotizaci[oó]n|empezar de nuevo|cancelar cotizaci[oó]n)$',
re.IGNORECASE
)
# "si", "va", "confirmo" — confirm the quotation (close it as accepted)
_CONFIRM_PATTERNS = re.compile(
r'^(si|sí|va|confirmo|confirmar|acepto|de acuerdo|ok|okay|dale)$',
re.IGNORECASE
)
_QTY_PATTERN = re.compile(
r'^(cotizar|agregar|dame|quiero)\s+(\d+)$',
re.IGNORECASE
)
def detect_quote_intent(text, has_open_quote=False):
"""Detect if the message is a quotation command.
Args:
text: the user's message
has_open_quote: True if this phone has an active quotation
Returns:
('add', quantity) — add last part to quote
('send', None) — send the full quotation
('clear', None) — clear the quotation
('confirm', None) — confirm/accept the quotation
(None, None) — not a quote command, pass to AI
"""
if not text:
return None, None
t = text.strip()
# Check for quantity: "cotizar 3", "agregar 5"
qty_match = _QTY_PATTERN.match(t)
if qty_match:
return 'add', int(qty_match.group(2))
if _ADD_PATTERNS.match(t):
return 'add', 1
if _SEND_PATTERNS.match(t):
return 'send', None
if _CLEAR_PATTERNS.match(t):
return 'clear', None
# "si" / "va" / "confirmo" — only counts as 'confirm' when there's
# an open quote. Otherwise pass to the AI as normal conversation.
if has_open_quote and _CONFIRM_PATTERNS.match(t):
return 'confirm', None
return None, None
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,))
cur.close()
tenant_conn.commit()
clear_last_shown(tenant_conn, phone)
# 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 ────────────────────────────
# Tracks what part the bot last showed so "cotizar" knows what to add.
# Stored in tenant DB table whatsapp_sessions so it survives restarts.
_WHATSAPP_SESSIONS_SQL = """
CREATE TABLE IF NOT EXISTS whatsapp_sessions (
phone VARCHAR(50) PRIMARY KEY,
last_shown JSONB,
vehicle JSONB,
updated_at TIMESTAMP DEFAULT NOW()
);
"""
def _ensure_sessions_table(tenant_conn):
cur = tenant_conn.cursor()
cur.execute(_WHATSAPP_SESSIONS_SQL)
# Migrate: add vehicle column if table already existed without it
cur.execute("""
ALTER TABLE whatsapp_sessions
ADD COLUMN IF NOT EXISTS vehicle JSONB
""")
tenant_conn.commit()
cur.close()
def set_last_shown_part(tenant_conn, phone, part_info):
"""Store the last part shown to this phone number.
part_info: dict with keys inventory_id, part_number, name, brand,
price, stock, unit
"""
try:
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
import json
cur.execute("""
INSERT INTO whatsapp_sessions (phone, last_shown, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET last_shown = EXCLUDED.last_shown, updated_at = NOW()
""", (phone, json.dumps(part_info)))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-SESSION] Failed to persist last_shown for {phone}: {e}")
def get_last_shown_part(tenant_conn, phone):
try:
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("SELECT last_shown FROM whatsapp_sessions WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
if row and row[0]:
return row[0]
except Exception as e:
print(f"[WA-SESSION] Failed to read last_shown for {phone}: {e}")
return None
def clear_last_shown(tenant_conn, phone):
try:
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-SESSION] Failed to clear last_shown for {phone}: {e}")
def set_vehicle(tenant_conn, phone, vehicle):
"""Store the detected vehicle for this phone number.
vehicle: dict with keys brand, model, year
"""
try:
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
import json
cur.execute("""
INSERT INTO whatsapp_sessions (phone, vehicle, updated_at)
VALUES (%s, %s, NOW())
ON CONFLICT (phone) DO UPDATE SET vehicle = EXCLUDED.vehicle, updated_at = NOW()
""", (phone, json.dumps(vehicle)))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-SESSION] Failed to persist vehicle for {phone}: {e}")
def get_vehicle(tenant_conn, phone):
"""Retrieve the stored vehicle for this phone number."""
try:
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("SELECT vehicle FROM whatsapp_sessions WHERE phone = %s", (phone,))
row = cur.fetchone()
cur.close()
if row and row[0]:
return row[0]
except Exception as e:
print(f"[WA-SESSION] Failed to read vehicle for {phone}: {e}")
return None
def clear_session(tenant_conn, phone):
"""Clear all session data (last_shown + vehicle) for this phone."""
try:
_ensure_sessions_table(tenant_conn)
cur = tenant_conn.cursor()
cur.execute("DELETE FROM whatsapp_sessions WHERE phone = %s", (phone,))
tenant_conn.commit()
cur.close()
except Exception as e:
print(f"[WA-SESSION] Failed to clear session for {phone}: {e}")
# ─── Quotation CRUD ─────────────────────────────────────────────────
def get_open_quotation(tenant_conn, phone):
"""Find an active quotation for this phone, or None."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT id FROM quotations
WHERE notes LIKE %s AND status = 'active'
ORDER BY created_at DESC LIMIT 1
""", (f'%WA:{phone}%',))
row = cur.fetchone()
cur.close()
return row[0] if row else None
def create_quotation(tenant_conn, phone):
"""Create a new quotation tagged with this phone number."""
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO quotations (subtotal, tax_total, total, status, notes, valid_until)
VALUES (0, 0, 0, 'active', %s, %s)
RETURNING id
""", (f'WA:{phone}', date.today() + timedelta(days=7)))
qid = cur.fetchone()[0]
tenant_conn.commit()
cur.close()
return qid
def add_item_to_quotation(tenant_conn, quote_id, part_info, quantity=1):
"""Add a part to an existing quotation and recalculate totals."""
price = float(part_info.get('price') or 0)
tax_rate = float(part_info.get('tax_rate') or 0.16)
subtotal = round(price * quantity, 2)
cur = tenant_conn.cursor()
cur.execute("""
INSERT INTO quotation_items
(quotation_id, inventory_id, part_number, name, quantity, unit_price, tax_rate, subtotal)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
quote_id,
part_info.get('inventory_id'),
part_info.get('part_number', ''),
part_info.get('name', ''),
quantity,
price,
tax_rate,
subtotal,
))
# Recalculate totals
cur.execute("""
SELECT COALESCE(SUM(subtotal), 0),
COALESCE(SUM(subtotal * tax_rate), 0)
FROM quotation_items WHERE quotation_id = %s
""", (quote_id,))
sub, tax = cur.fetchone()
cur.execute("""
UPDATE quotations SET subtotal = %s, tax_total = %s, total = %s
WHERE id = %s
""", (sub, tax, round(sub + tax, 2), quote_id))
tenant_conn.commit()
cur.close()
return subtotal
def get_quotation_detail(tenant_conn, quote_id):
"""Return full quotation with items."""
cur = tenant_conn.cursor()
cur.execute("""
SELECT id, subtotal, tax_total, total, status, valid_until, created_at
FROM quotations WHERE id = %s
""", (quote_id,))
q = cur.fetchone()
if not q:
cur.close()
return None
cur.execute("""
SELECT part_number, name, quantity, unit_price, tax_rate, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quote_id,))
items = cur.fetchall()
cur.close()
return {
'id': q[0],
'subtotal': float(q[1]),
'tax_total': float(q[2]),
'total': float(q[3]),
'status': q[4],
'valid_until': str(q[5]) if q[5] else None,
'created_at': str(q[6]) if q[6] else None,
'items': [{
'part_number': it[0],
'name': it[1],
'quantity': it[2],
'unit_price': float(it[3]),
'tax_rate': float(it[4]),
'subtotal': float(it[5]),
} for it in items],
}
def clear_quotation(tenant_conn, phone):
"""Cancel the open quotation for this phone."""
qid = get_open_quotation(tenant_conn, phone)
if qid:
cur = tenant_conn.cursor()
cur.execute("UPDATE quotations SET status = 'cancelled' WHERE id = %s", (qid,))
tenant_conn.commit()
cur.close()
clear_last_shown(tenant_conn, phone)
return qid
# ─── Format quotation for WhatsApp ──────────────────────────────────
def format_quotation_wa(detail):
"""Format a quotation as a WhatsApp-friendly text message."""
if not detail or not detail.get('items'):
return None
lines = [
f'📄 *COTIZACIÓN #{detail["id"]}*',
f'Fecha: {detail["created_at"][:10] if detail.get("created_at") else "hoy"}',
f'Vigencia: {detail.get("valid_until") or "7 días"}',
'',
'─────────────────────',
]
for i, item in enumerate(detail['items'], 1):
qty = item['quantity']
price = item['unit_price']
sub = item['subtotal']
lines.append(f'{i}. {item["name"]}')
lines.append(f' #{item["part_number"]} × {qty} = ${sub:,.2f}')
lines.append('─────────────────────')
lines.append(f' Subtotal: ${detail["subtotal"]:,.2f}')
lines.append(f' IVA: ${detail["tax_total"]:,.2f}')
lines.append(f' *TOTAL: ${detail["total"]:,.2f}*')
lines.append('')
lines.append('_Responde "si" para confirmar el pedido._')
lines.append('_Responde "limpiar" para empezar de nuevo._')
return '\n'.join(lines)