Files
Autoparts-DB/pos/services/wa_quotation.py
consultoria-as e95f7cf684 feat: complete session — catalog, marketplace, WhatsApp, peer-to-peer, install scripts
Major features:
- Pixel-Perfect glassmorphism design (landing + POS + public catalog)
- OEM/Local catalog toggle with Nexpart taxonomy (14 groups, 108 subgroups, 558 part types)
- Marketplace B2B Phase 1 (bodegas, POs, status machine, WA+email notifications)
- Peer-to-peer inventory (multi-instance, LAN discovery)
- WhatsApp: photo→Vision AI, voice→Whisper, conversational quotations
- Smart unified search (VIN/plate/part_number/keyword auto-detect)
- Shop Supplies tab (vehicle-independent parts)
- Chatbot AI fallback chain (5 models) + response cache
- CSV inventory import tool + setup_instance.sh installer
- Tablet-responsive CSS + sidebar toggle
- Filters, export CSV, employee edit, business data save
- Quotation system (WA→POS) with auto-print on confirmation
- Live stats on landing page

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:35:53 +00:00

285 lines
9.0 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):
"""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)