""" 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): """Mark the open quotation as confirmed/accepted.""" qid = get_open_quotation(tenant_conn, phone) if not qid: return None cur = tenant_conn.cursor() cur.execute("UPDATE quotations SET status = 'converted' WHERE id = %s", (qid,)) tenant_conn.commit() cur.close() clear_last_shown(phone) return qid # ─── 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(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)