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>
This commit is contained in:
2026-04-18 05:35:53 +00:00
parent 6b097614a0
commit e95f7cf684
54 changed files with 11226 additions and 1422 deletions

View File

@@ -0,0 +1,284 @@
"""
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)