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:
@@ -105,6 +105,93 @@ def generate_ticket(sale_data, business_info, width=80):
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def generate_quotation_ticket(quote_data, business_info, width=80):
|
||||
"""Generate ESC/POS bytes for a quotation ticket.
|
||||
|
||||
Args:
|
||||
quote_data: dict with keys: id, items[{name, part_number, quantity, unit_price, subtotal}],
|
||||
subtotal, tax_total, total, valid_until, customer_name, wa_phone, created_at
|
||||
business_info: dict with name, rfc, address
|
||||
width: 58 or 80 (mm)
|
||||
|
||||
Returns: bytes ready to send to printer
|
||||
"""
|
||||
chars = 32 if width == 58 else 48
|
||||
buf = bytearray()
|
||||
buf += INIT
|
||||
|
||||
# Header
|
||||
buf += ALIGN_CENTER
|
||||
buf += LARGE_SIZE
|
||||
buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace')
|
||||
buf += NORMAL_SIZE
|
||||
if business_info.get('rfc'):
|
||||
buf += (business_info['rfc'] + '\n').encode('cp437', errors='replace')
|
||||
if business_info.get('address'):
|
||||
buf += (business_info['address'] + '\n').encode('cp437', errors='replace')
|
||||
buf += b'\n'
|
||||
|
||||
# Title
|
||||
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||
buf += 'COTIZACION\n'.encode('cp437', errors='replace')
|
||||
buf += NORMAL_SIZE + BOLD_OFF
|
||||
buf += b'\n'
|
||||
|
||||
# Folio + date
|
||||
buf += ALIGN_LEFT
|
||||
buf += BOLD_ON
|
||||
buf += f'Folio: COT-{quote_data.get("id", "")}\n'.encode('cp437', errors='replace')
|
||||
buf += BOLD_OFF
|
||||
buf += f'Fecha: {str(quote_data.get("created_at", ""))[:10]}\n'.encode('cp437', errors='replace')
|
||||
buf += f'Vigencia: {quote_data.get("valid_until", "7 dias")}\n'.encode('cp437', errors='replace')
|
||||
if quote_data.get('customer_name'):
|
||||
buf += f'Cliente: {quote_data["customer_name"]}\n'.encode('cp437', errors='replace')
|
||||
if quote_data.get('wa_phone'):
|
||||
buf += f'WhatsApp: {quote_data["wa_phone"]}\n'.encode('cp437', errors='replace')
|
||||
buf += ('-' * chars + '\n').encode()
|
||||
|
||||
# Column header
|
||||
buf += BOLD_ON
|
||||
hdr = _format_line('Cant Descripcion', 'Importe', chars)
|
||||
buf += (hdr + '\n').encode('cp437', errors='replace')
|
||||
buf += BOLD_OFF
|
||||
buf += ('-' * chars + '\n').encode()
|
||||
|
||||
# Items
|
||||
for item in quote_data.get('items', []):
|
||||
name = item.get('name', '')[:chars - 10]
|
||||
part_no = item.get('part_number', '')
|
||||
qty = item.get('quantity', 1)
|
||||
subtotal = item.get('subtotal', 0)
|
||||
buf += f'{qty}x {name}\n'.encode('cp437', errors='replace')
|
||||
if part_no:
|
||||
buf += f' #{part_no}\n'.encode('cp437', errors='replace')
|
||||
buf += ALIGN_RIGHT
|
||||
buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace')
|
||||
buf += ALIGN_LEFT
|
||||
|
||||
buf += ('-' * chars + '\n').encode()
|
||||
|
||||
# Totals
|
||||
buf += ALIGN_RIGHT
|
||||
buf += _total_line('Subtotal:', quote_data.get('subtotal', 0), chars).encode('cp437', errors='replace')
|
||||
buf += _total_line('IVA:', quote_data.get('tax_total', 0), chars).encode('cp437', errors='replace')
|
||||
buf += BOLD_ON + DOUBLE_HEIGHT
|
||||
buf += _total_line('TOTAL:', quote_data.get('total', 0), chars).encode('cp437', errors='replace')
|
||||
buf += NORMAL_SIZE + BOLD_OFF
|
||||
|
||||
# Footer
|
||||
buf += b'\n'
|
||||
buf += ALIGN_CENTER
|
||||
buf += 'Esta cotizacion no es comprobante fiscal\n'.encode('cp437', errors='replace')
|
||||
buf += 'Precios sujetos a disponibilidad\n'.encode('cp437', errors='replace')
|
||||
buf += 'Nexus Autoparts POS\n'.encode('cp437', errors='replace')
|
||||
buf += b'\n\n\n'
|
||||
buf += PARTIAL_CUT
|
||||
|
||||
return bytes(buf)
|
||||
|
||||
|
||||
def _format_line(left, right, width):
|
||||
"""Pad a left-right line to fill the ticket width."""
|
||||
space = width - len(left) - len(right)
|
||||
|
||||
Reference in New Issue
Block a user