From e201dce2909961374f26149d58be5f0a601efdaf Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Mon, 15 Jun 2026 06:18:33 +0000 Subject: [PATCH] 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. --- docs/FASES_IMPLEMENTADAS.md | 1 + pos/blueprints/service_order_bp.py | 58 ++++++++++++ pos/services/thermal_printer.py | 137 +++++++++++++++++++++++++++++ pos/static/js/printer.js | 28 +++++- pos/static/js/workshop.js | 25 ++++++ 5 files changed, 248 insertions(+), 1 deletion(-) diff --git a/docs/FASES_IMPLEMENTADAS.md b/docs/FASES_IMPLEMENTADAS.md index 5f3352b..29de612 100644 --- a/docs/FASES_IMPLEMENTADAS.md +++ b/docs/FASES_IMPLEMENTADAS.md @@ -306,6 +306,7 @@ python3 scripts/check_facturapi_tenants.py | **Catálogo de servicios** | `service_order_engine.py`, `service_order_bp.py` | Conceptos reutilizables de mano de obra (ej. "Cambio de aceite") | | **Endpoints taller** | `service_order_bp.py` | `POST /:id/items/:item_id/reserve`, `POST /:id/convert-to-sale`, `PUT /:id/assign-mechanic`, CRUD `/service-catalog` | | **Interfaz Kanban** | `workshop.html`, `workshop.js`, `workshop.css` | Vista por columnas, tarjetas de orden, modal de detalle, cambio de estado, agregar refacciones/mano de obra | +| **Impresión de orden** | `thermal_printer.py`, `service_order_bp.py`, `printer.js`, `workshop.js` | Ticket ESC/POS optimizado para impresoras térmicas 80 mm (58 mm compatible) | | **Navegación** | `sidebar.js`, plantillas inline | Entrada "Taller" en el menú de gestión | | **Tests** | `pos/tests/test_service_order_integration.py` | 11 tests con cursores mocks; validan reserva, liberación, conversión a venta y catálogo | diff --git a/pos/blueprints/service_order_bp.py b/pos/blueprints/service_order_bp.py index d30be9f..5cf9fb5 100644 --- a/pos/blueprints/service_order_bp.py +++ b/pos/blueprints/service_order_bp.py @@ -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('//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' + }, + ) diff --git a/pos/services/thermal_printer.py b/pos/services/thermal_printer.py index 28b0b64..a20e8fd 100644 --- a/pos/services/thermal_printer.py +++ b/pos/services/thermal_printer.py @@ -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) diff --git a/pos/static/js/printer.js b/pos/static/js/printer.js index bf0a5f9..f565bdd 100644 --- a/pos/static/js/printer.js +++ b/pos/static/js/printer.js @@ -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} + */ + 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 }; })(); diff --git a/pos/static/js/workshop.js b/pos/static/js/workshop.js index c662ac3..4bd38a4 100644 --- a/pos/static/js/workshop.js +++ b/pos/static/js/workshop.js @@ -250,6 +250,7 @@ var Workshop = (function() { var footer = document.getElementById('detailFooter'); footer.innerHTML = '' + + '' + (o.status === 'ready' && !o.sale_id ? '' : '') + (o.sale_id ? 'Ver venta #' + o.sale_id + '' : ''); } @@ -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,