feat(pos/workshop): add 80mm thermal ticket printing for service orders

- 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.
This commit is contained in:
2026-06-15 06:18:33 +00:00
parent ce66212223
commit e201dce290
5 changed files with 248 additions and 1 deletions

View File

@@ -204,3 +204,140 @@ 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)