""" 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 # ─── In-memory last-shown-part per phone ───────────────────────────── # Tracks what part the bot last showed so "cotizar" knows what to add. # Key: phone (clean, no @lid). Value: dict with inventory item info. _last_shown = {} def set_last_shown_part(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 """ _last_shown[phone] = part_info def get_last_shown_part(phone): return _last_shown.get(phone) def clear_last_shown(phone): _last_shown.pop(phone, None) # ─── 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)