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

@@ -369,3 +369,61 @@ def delete_catalog_item(item_id):
return jsonify({'message': 'Catalog item deactivated'})
finally:
conn.close()
# ─── Thermal printing ─────────────────────────────
@service_order_bp.route('/<int:so_id>/print', methods=['POST'])
@require_auth()
def print_service_order_ticket(so_id):
"""Generate a printable ticket for a service order.
Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80}
- escpos_raw: returns raw ESC/POS bytes (application/octet-stream)
- browser: returns the data dict as JSON for browser-side rendering
"""
from flask import Response
from services.thermal_printer import generate_service_order_ticket
body = request.get_json(silent=True) or {}
printer_type = body.get('printer_type', 'escpos_raw')
width = int(body.get('width', 80))
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
order = get_service_order(conn, so_id)
if not order:
cur.close()
conn.close()
return jsonify({'error': 'Service order not found'}), 404
# Fetch business info from config
business_info = {'name': 'NEXUS AUTOPARTS', 'rfc': '', 'address': ''}
try:
cur.execute(
"SELECT key, value FROM config WHERE key IN ('business_name','rfc','address')"
)
for rw in cur.fetchall():
if rw[0] == 'business_name':
business_info['name'] = rw[1]
else:
business_info[rw[0]] = rw[1]
except Exception:
pass
cur.close()
conn.close()
if printer_type == 'browser':
return jsonify(order)
raw = generate_service_order_ticket(order, business_info, width=width)
return Response(
raw,
mimetype='application/octet-stream',
headers={
'Content-Disposition': f'attachment; filename=orden_{order.get("order_number", so_id)}.bin'
},
)

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)

View File

@@ -134,6 +134,31 @@ window.NexusPrinter = (function () {
return sendRaw(new Uint8Array(buf));
}
/**
* Print a workshop service order ticket.
* @param {number} soId
* @param {number} [width=80] — 58 or 80 mm
* @returns {Promise<boolean>}
*/
async function printServiceOrder(soId, width) {
width = width || 80;
const token = localStorage.getItem('pos_token');
const resp = await fetch('/pos/api/service-orders/' + soId + '/print', {
method: 'POST',
headers: {
'Authorization': 'Bearer ' + token,
'Content-Type': 'application/json'
},
body: JSON.stringify({ printer_type: 'escpos_raw', width: width })
});
if (!resp.ok) {
console.error('[NexusPrinter] backend error', resp.status);
return false;
}
const buf = await resp.arrayBuffer();
return sendRaw(new Uint8Array(buf));
}
// ── Persistence helpers ────────────────────────
function _save() {
@@ -147,6 +172,7 @@ window.NexusPrinter = (function () {
disconnect: disconnect,
isConnected: isConnected,
sendRaw: sendRaw,
printSale: printSale
printSale: printSale,
printServiceOrder: printServiceOrder
};
})();

View File

@@ -250,6 +250,7 @@ var Workshop = (function() {
var footer = document.getElementById('detailFooter');
footer.innerHTML =
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
'<button class="btn btn--secondary" onclick="Workshop.printOrder()">🖨️ Imprimir orden</button>' +
(o.status === 'ready' && !o.sale_id ? '<button class="btn btn--primary" onclick="Workshop.convertToSale()">Convertir a venta</button>' : '') +
(o.sale_id ? '<a class="btn btn--secondary" href="/pos/invoicing?sale_id=' + o.sale_id + '">Ver venta #' + o.sale_id + '</a>' : '');
}
@@ -328,6 +329,29 @@ var Workshop = (function() {
}).catch(function(e) { alert('Error: ' + e.message); });
}
function printOrder() {
if (!currentOrderId) return;
if (!window.NexusPrinter || !window.NexusPrinter.isConnected()) {
var connect = confirm('No hay impresora conectada. ¿Conectar ahora?');
if (connect) {
window.NexusPrinter.connect().then(function(r) {
if (r.ok) doPrint();
});
}
return;
}
doPrint();
function doPrint() {
window.NexusPrinter.printServiceOrder(currentOrderId, 80)
.then(function(ok) {
if (ok) alert('Orden enviada a la impresora');
else alert('No se pudo imprimir');
})
.catch(function(e) { alert('Error: ' + e.message); });
}
}
// ─── New order ───
function openNewOrderModal() {
@@ -469,6 +493,7 @@ var Workshop = (function() {
addItemPlaceholder: addItemPlaceholder,
addLabor: addLabor,
convertToSale: convertToSale,
printOrder: printOrder,
openNewOrderModal: openNewOrderModal,
closeNewOrderModal: closeNewOrderModal,
submitNewOrder: submitNewOrder,