feat(pos): add real thermal ticket printing via ESC/POS (#21)
- Add thermal_printer.py service generating raw ESC/POS bytes for 58mm/80mm printers - Add /pos/api/sales/<id>/print endpoint (escpos_raw or browser mode) - Add printer.js with WebUSB and Web Serial support for direct browser-to-printer - Add thermal print button in ticket modal with connect/print workflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
119
pos/services/thermal_printer.py
Normal file
119
pos/services/thermal_printer.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""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 _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'
|
||||
Reference in New Issue
Block a user