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:
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user