- Add generate_service_order_ticket() in thermal_printer.py with ESC/POS commands for 58mm and 80mm printers. - Add POST /pos/api/service-orders/:id/print endpoint returning raw bytes or JSON for browser rendering. - Extend printer.js with printServiceOrder() using WebUSB/Web Serial. - Add Imprimir orden button in workshop.js detail modal. - Update FASES_IMPLEMENTADAS.md.
344 lines
13 KiB
Python
344 lines
13 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'
|
|
|
|
|
|
def generate_service_order_ticket(so_data, business_info, width=80):
|
|
"""Generate ESC/POS bytes for a workshop service order ticket.
|
|
|
|
Args:
|
|
so_data: dict with service order info:
|
|
order_number, status, customer_name, vehicle_plate, vehicle_make,
|
|
vehicle_model, mileage_in, fuel_level, reception_notes,
|
|
employee_name, created_at, items[{name, part_number, quantity,
|
|
unit_price}], labor[{description, hours, hourly_rate, total_cost}],
|
|
estimated_cost, total
|
|
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 += "ORDEN DE SERVICIO\n".encode("cp437", errors="replace")
|
|
buf += NORMAL_SIZE + BOLD_OFF
|
|
buf += b"\n"
|
|
|
|
# Order info
|
|
buf += ALIGN_LEFT
|
|
buf += BOLD_ON
|
|
buf += f"Folio: {so_data.get('order_number', 'N/A')}\n".encode("cp437", errors="replace")
|
|
buf += BOLD_OFF
|
|
buf += f"Estado: {so_data.get('status', '')}\n".encode("cp437", errors="replace")
|
|
buf += f"Fecha: {str(so_data.get('created_at', ''))[:19]}\n".encode("cp437", errors="replace")
|
|
if so_data.get("employee_name"):
|
|
buf += f"Mecanico: {so_data['employee_name']}\n".encode("cp437", errors="replace")
|
|
buf += ("-" * chars + "\n").encode()
|
|
|
|
# Customer / vehicle
|
|
if so_data.get("customer_name"):
|
|
buf += BOLD_ON
|
|
buf += f"Cliente: {so_data['customer_name']}\n".encode("cp437", errors="replace")
|
|
buf += BOLD_OFF
|
|
vehicle = " ".join(
|
|
str(v) for v in [
|
|
so_data.get("vehicle_plate", ""),
|
|
so_data.get("vehicle_make", ""),
|
|
so_data.get("vehicle_model", ""),
|
|
] if v
|
|
).strip()
|
|
if vehicle:
|
|
buf += f"Vehiculo: {vehicle}\n".encode("cp437", errors="replace")
|
|
if so_data.get("mileage_in"):
|
|
buf += f"Kilometraje: {so_data['mileage_in']}\n".encode("cp437", errors="replace")
|
|
if so_data.get("fuel_level"):
|
|
buf += f"Gasolina: {so_data['fuel_level']}\n".encode("cp437", errors="replace")
|
|
buf += ("-" * chars + "\n").encode()
|
|
|
|
# Reception notes
|
|
if so_data.get("reception_notes"):
|
|
buf += BOLD_ON
|
|
buf += "Falla / Observaciones:\n".encode("cp437", errors="replace")
|
|
buf += BOLD_OFF
|
|
for line in str(so_data["reception_notes"]).splitlines():
|
|
buf += (line[:chars] + "\n").encode("cp437", errors="replace")
|
|
buf += ("-" * chars + "\n").encode()
|
|
|
|
# Parts
|
|
items = so_data.get("items", [])
|
|
if items:
|
|
buf += BOLD_ON
|
|
buf += "REFACCIONES\n".encode("cp437", errors="replace")
|
|
buf += BOLD_OFF
|
|
for item in items:
|
|
name = item.get("name", "")[:chars - 10]
|
|
part_no = item.get("part_number", "")
|
|
qty = item.get("quantity", 1)
|
|
unit_price = item.get("unit_price", 0)
|
|
line_total = qty * unit_price
|
|
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"${line_total:,.2f}\n".encode("cp437", errors="replace")
|
|
buf += ALIGN_LEFT
|
|
buf += ("-" * chars + "\n").encode()
|
|
|
|
# Labor
|
|
labor_items = so_data.get("labor", [])
|
|
if labor_items:
|
|
buf += BOLD_ON
|
|
buf += "MANO DE OBRA\n".encode("cp437", errors="replace")
|
|
buf += BOLD_OFF
|
|
for labor in labor_items:
|
|
desc = labor.get("description", "")[:chars - 10]
|
|
hours = labor.get("hours", 0)
|
|
rate = labor.get("hourly_rate", 0)
|
|
total = labor.get("total_cost", hours * rate)
|
|
buf += f"{desc}\n".encode("cp437", errors="replace")
|
|
buf += f" {hours} hrs x ${rate:,.2f}\n".encode("cp437", errors="replace")
|
|
buf += ALIGN_RIGHT
|
|
buf += f"${total:,.2f}\n".encode("cp437", errors="replace")
|
|
buf += ALIGN_LEFT
|
|
buf += ("-" * chars + "\n").encode()
|
|
|
|
# Totals
|
|
buf += ALIGN_RIGHT
|
|
if items or labor_items:
|
|
total = so_data.get("total") or sum(
|
|
i.get("quantity", 1) * i.get("unit_price", 0) for i in items
|
|
) + sum(labor.get("total_cost", 0) for labor in labor_items)
|
|
buf += BOLD_ON + DOUBLE_HEIGHT
|
|
buf += _total_line("TOTAL ESTIMADO:", total, chars).encode("cp437", errors="replace")
|
|
buf += NORMAL_SIZE + BOLD_OFF
|
|
if so_data.get("estimated_cost"):
|
|
buf += _total_line("Costo estimado:", so_data["estimated_cost"], chars).encode("cp437", errors="replace")
|
|
|
|
# Footer
|
|
buf += b"\n"
|
|
buf += ALIGN_CENTER
|
|
buf += "No es comprobante fiscal\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)
|