Files
Autoparts-DB/pos/services/thermal_printer.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

207 lines
7.6 KiB
Python

"""ESC/POS thermal printer commands for 58mm and 80mm printers.
Generates raw ESC/POS byte commands that can be sent to a thermal printer
via USB, serial, or network connection.
"""
# ESC/POS command constants
ESC = b'\x1b'
GS = b'\x1d'
INIT = ESC + b'@' # Initialize printer
CUT = GS + b'V' + b'\x00' # Full cut
PARTIAL_CUT = GS + b'V' + b'\x01'
FEED = ESC + b'd' # Feed N lines
ALIGN_LEFT = ESC + b'a' + b'\x00'
ALIGN_CENTER = ESC + b'a' + b'\x01'
ALIGN_RIGHT = ESC + b'a' + b'\x02'
BOLD_ON = ESC + b'E' + b'\x01'
BOLD_OFF = ESC + b'E' + b'\x00'
DOUBLE_HEIGHT = ESC + b'!' + b'\x10'
NORMAL_SIZE = ESC + b'!' + b'\x00'
LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height
def generate_ticket(sale_data, business_info, width=80):
"""Generate ESC/POS bytes for a sale ticket.
Args:
sale_data: dict with sale info (items, totals, payment, folio)
business_info: dict with business name, RFC, address
width: 58 or 80 (mm)
Returns: bytes ready to send to printer
"""
chars = 32 if width == 58 else 48 # characters per line
buf = bytearray()
buf += INIT
# Header: business name (centered, bold, large)
buf += ALIGN_CENTER
buf += LARGE_SIZE
buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace')
buf += NORMAL_SIZE
buf += (business_info.get('rfc', '') + '\n').encode('cp437', errors='replace')
buf += (business_info.get('address', '') + '\n').encode('cp437', errors='replace')
buf += b'\n'
# Folio + date
buf += ALIGN_LEFT
buf += BOLD_ON
folio = sale_data.get('folio', 'N/A')
date = sale_data.get('date', '')
buf += f'Folio: {folio}\n'.encode('cp437', errors='replace')
buf += BOLD_OFF
buf += f'Fecha: {date}\n'.encode('cp437', errors='replace')
buf += f'Cajero: {sale_data.get("employee", "")}\n'.encode('cp437', errors='replace')
if sale_data.get('customer'):
buf += f'Cliente: {sale_data["customer"]}\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 sale_data.get('items', []):
name = item.get('name', '')[:chars - 10]
qty = item.get('quantity', 1)
subtotal = item.get('subtotal', 0)
buf += f'{qty}x {name}\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:', sale_data.get('subtotal', 0), chars).encode('cp437', errors='replace')
if sale_data.get('discount_total', 0) > 0:
buf += _total_line('Descuento:', -sale_data['discount_total'], chars).encode('cp437', errors='replace')
buf += _total_line('IVA 16%:', sale_data.get('tax_total', 0), chars).encode('cp437', errors='replace')
buf += BOLD_ON + DOUBLE_HEIGHT
buf += _total_line('TOTAL:', sale_data.get('total', 0), chars).encode('cp437', errors='replace')
buf += NORMAL_SIZE + BOLD_OFF
# Payment
buf += ALIGN_LEFT
buf += f'\nPago: {sale_data.get("payment_method", "Efectivo")}\n'.encode('cp437', errors='replace')
if sale_data.get('amount_paid'):
buf += f'Recibido: ${sale_data["amount_paid"]:,.2f}\n'.encode('cp437', errors='replace')
if sale_data.get('change_given'):
buf += f'Cambio: ${sale_data["change_given"]:,.2f}\n'.encode('cp437', errors='replace')
# Footer
buf += b'\n'
buf += ALIGN_CENTER
buf += 'Gracias por su compra!\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 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)
if space < 1:
space = 1
return left + ' ' * space + right
def _total_line(label, amount, width):
"""Format a totals line like 'Subtotal: $1,234.56'."""
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
return _format_line(label, val, width) + '\n'