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

@@ -505,7 +505,8 @@ def list_quotations():
where_clauses.append("q.status = %s")
params.append(status)
if g.branch_id:
where_clauses.append("q.branch_id = %s")
# Show both this branch's quotes AND branchless ones (e.g. WhatsApp)
where_clauses.append("(q.branch_id = %s OR q.branch_id IS NULL)")
params.append(g.branch_id)
where = " AND ".join(where_clauses)
@@ -515,7 +516,7 @@ def list_quotations():
cur.execute(f"""
SELECT q.id, q.customer_id, q.employee_id, q.subtotal, q.tax_total,
q.total, q.status, q.valid_until, q.created_at,
q.total, q.status, q.valid_until, q.created_at, q.notes,
c.name as customer_name, e.name as employee_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
@@ -527,6 +528,9 @@ def list_quotations():
quotations = []
for r in cur.fetchall():
notes = r[9] or ''
source = 'whatsapp' if notes.startswith('WA:') else 'pos'
wa_phone = notes.replace('WA:', '') if source == 'whatsapp' else None
quotations.append({
'id': r[0], 'customer_id': r[1], 'employee_id': r[2],
'subtotal': float(r[3]) if r[3] else 0,
@@ -534,7 +538,9 @@ def list_quotations():
'total': float(r[5]) if r[5] else 0,
'status': r[6], 'valid_until': str(r[7]) if r[7] else None,
'created_at': str(r[8]),
'customer_name': r[9], 'employee_name': r[10],
'customer_name': r[10], 'employee_name': r[11],
'source': source,
'wa_phone': wa_phone,
})
cur.close(); conn.close()
@@ -546,6 +552,146 @@ def list_quotations():
})
@pos_bp.route('/quotations/<int:quot_id>', methods=['DELETE'])
@require_auth('pos.sell')
def delete_quotation(quot_id):
"""Delete a quotation and its items."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("DELETE FROM quotation_items WHERE quotation_id = %s", (quot_id,))
cur.execute("DELETE FROM quotations WHERE id = %s", (quot_id,))
deleted = cur.rowcount
conn.commit()
cur.close()
conn.close()
if deleted == 0:
return jsonify({'error': 'Cotización no encontrada'}), 404
return jsonify({'ok': True, 'deleted_id': quot_id})
@pos_bp.route('/quotations/<int:quot_id>/print', methods=['POST'])
@require_auth('pos.sell')
def print_quotation_ticket(quot_id):
"""Generate a printable ticket for a quotation (ESC/POS or browser)."""
from flask import Response
from services.thermal_printer import generate_quotation_ticket
body = request.get_json(silent=True) or {}
printer_type = body.get('printer_type', 'escpos_raw')
width = int(body.get('width', 80))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.subtotal, q.tax_total, q.total, q.valid_until,
q.created_at, q.notes, c.name as customer_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.id = %s
""", (quot_id,))
row = cur.fetchone()
if not row:
cur.close(); conn.close()
return jsonify({'error': 'Quotation not found'}), 404
notes = row[6] or ''
wa_phone = notes.replace('WA:', '') if notes.startswith('WA:') else None
cur.execute("""
SELECT part_number, name, quantity, unit_price, subtotal
FROM quotation_items WHERE quotation_id = %s ORDER BY id
""", (quot_id,))
items = [{'part_number': r[0], 'name': r[1], 'quantity': r[2],
'unit_price': float(r[3]) if r[3] else 0,
'subtotal': float(r[4]) if r[4] else 0} for r in cur.fetchall()]
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'")
for rw in cur.fetchall():
if rw[0] == 'tenant_nombre': business_info['name'] = rw[1]
elif rw[0] == 'tenant_rfc': business_info['rfc'] = rw[1]
elif rw[0] == 'tenant_direccion': business_info['address'] = rw[1]
except Exception:
pass
cur.close(); conn.close()
quote_data = {
'id': row[0],
'subtotal': float(row[1]) if row[1] else 0,
'tax_total': float(row[2]) if row[2] else 0,
'total': float(row[3]) if row[3] else 0,
'valid_until': str(row[4]) if row[4] else None,
'created_at': str(row[5]) if row[5] else '',
'customer_name': row[7] or '',
'wa_phone': wa_phone,
'items': items,
}
if printer_type == 'browser':
return jsonify(quote_data)
raw = generate_quotation_ticket(quote_data, business_info, width=width)
return Response(raw, mimetype='application/octet-stream',
headers={'Content-Disposition': f'attachment; filename=cotizacion_{quot_id}.bin'})
@pos_bp.route('/quotations/print-queue', methods=['GET'])
@require_auth('pos.sell')
def quotation_print_queue():
"""Return quotations that were confirmed via WhatsApp and haven't been
printed yet. The POS browser polls this endpoint and auto-prints.
Returns: {data: [{id, total, customer_name, wa_phone, confirmed_at}]}
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
SELECT q.id, q.total, q.notes, q.created_at,
c.name as customer_name
FROM quotations q
LEFT JOIN customers c ON q.customer_id = c.id
WHERE q.status = 'converted'
AND q.notes LIKE 'WA:%%'
AND NOT EXISTS (
SELECT 1 FROM tenant_config
WHERE key = 'printed_quote_' || q.id::text
)
ORDER BY q.created_at DESC
LIMIT 10
""")
rows = cur.fetchall()
data = []
for r in rows:
notes = r[2] or ''
data.append({
'id': r[0],
'total': float(r[1]) if r[1] else 0,
'wa_phone': notes.replace('WA:', '') if notes.startswith('WA:') else None,
'created_at': str(r[3]) if r[3] else '',
'customer_name': r[4] or '',
})
cur.close(); conn.close()
return jsonify({'data': data})
@pos_bp.route('/quotations/<int:quot_id>/mark-printed', methods=['POST'])
@require_auth('pos.sell')
def mark_quotation_printed(quot_id):
"""Mark a quotation as printed so it doesn't appear in the print queue again."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("""
INSERT INTO tenant_config (key, value) VALUES (%s, %s)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value
""", (f'printed_quote_{quot_id}', 'true'))
conn.commit()
cur.close(); conn.close()
return jsonify({'ok': True})
@pos_bp.route('/quotations/<int:quot_id>', methods=['GET'])
@require_auth('pos.view')
def get_quotation(quot_id):