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:
@@ -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") |
|
| **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` |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **Tests** | `pos/tests/test_service_order_integration.py` | 11 tests con cursores mocks; validan reserva, liberación, conversión a venta y catálogo |
|
||||||
|
|
||||||
|
|||||||
@@ -369,3 +369,61 @@ def delete_catalog_item(item_id):
|
|||||||
return jsonify({'message': 'Catalog item deactivated'})
|
return jsonify({'message': 'Catalog item deactivated'})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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'
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|||||||
@@ -204,3 +204,140 @@ def _total_line(label, amount, width):
|
|||||||
"""Format a totals line like 'Subtotal: $1,234.56'."""
|
"""Format a totals line like 'Subtotal: $1,234.56'."""
|
||||||
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
|
val = f'${abs(amount):,.2f}' if amount >= 0 else f'-${abs(amount):,.2f}'
|
||||||
return _format_line(label, val, width) + '\n'
|
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)
|
||||||
|
|||||||
@@ -134,6 +134,31 @@ window.NexusPrinter = (function () {
|
|||||||
return sendRaw(new Uint8Array(buf));
|
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 ────────────────────────
|
// ── Persistence helpers ────────────────────────
|
||||||
|
|
||||||
function _save() {
|
function _save() {
|
||||||
@@ -147,6 +172,7 @@ window.NexusPrinter = (function () {
|
|||||||
disconnect: disconnect,
|
disconnect: disconnect,
|
||||||
isConnected: isConnected,
|
isConnected: isConnected,
|
||||||
sendRaw: sendRaw,
|
sendRaw: sendRaw,
|
||||||
printSale: printSale
|
printSale: printSale,
|
||||||
|
printServiceOrder: printServiceOrder
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -250,6 +250,7 @@ var Workshop = (function() {
|
|||||||
var footer = document.getElementById('detailFooter');
|
var footer = document.getElementById('detailFooter');
|
||||||
footer.innerHTML =
|
footer.innerHTML =
|
||||||
'<button class="btn btn--ghost" onclick="Workshop.closeDetailModal()">Cerrar</button>' +
|
'<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.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>' : '');
|
(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); });
|
}).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 ───
|
// ─── New order ───
|
||||||
|
|
||||||
function openNewOrderModal() {
|
function openNewOrderModal() {
|
||||||
@@ -469,6 +493,7 @@ var Workshop = (function() {
|
|||||||
addItemPlaceholder: addItemPlaceholder,
|
addItemPlaceholder: addItemPlaceholder,
|
||||||
addLabor: addLabor,
|
addLabor: addLabor,
|
||||||
convertToSale: convertToSale,
|
convertToSale: convertToSale,
|
||||||
|
printOrder: printOrder,
|
||||||
openNewOrderModal: openNewOrderModal,
|
openNewOrderModal: openNewOrderModal,
|
||||||
closeNewOrderModal: closeNewOrderModal,
|
closeNewOrderModal: closeNewOrderModal,
|
||||||
submitNewOrder: submitNewOrder,
|
submitNewOrder: submitNewOrder,
|
||||||
|
|||||||
Reference in New Issue
Block a user