feat(pos): add real thermal ticket printing via ESC/POS (#21)

- Add thermal_printer.py service generating raw ESC/POS bytes for 58mm/80mm printers
- Add /pos/api/sales/<id>/print endpoint (escpos_raw or browser mode)
- Add printer.js with WebUSB and Web Serial support for direct browser-to-printer
- Add thermal print button in ticket modal with connect/print workflow

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-04 08:16:44 +00:00
parent f9589f4a4e
commit ecdc3526a6
5 changed files with 413 additions and 1 deletions

View File

@@ -26,7 +26,11 @@ const POS = (() => {
let searchTimeout = null;
let customerSearchTimeout = null;
const fmt = (n) => '$' + parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
// Currency-aware formatter: reads pos_currency from localStorage
const _posCurrency = localStorage.getItem('pos_currency') || 'MXN';
const _currSymbols = { MXN: '$', USD: 'US$' };
const _currLocale = _posCurrency === 'USD' ? 'en-US' : 'es-MX';
const fmt = (n) => (_currSymbols[_posCurrency] || '$') + parseFloat(n || 0).toLocaleString(_currLocale, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
function headers() {
return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token };
@@ -940,6 +944,45 @@ const POS = (() => {
setTimeout(() => { if (area) area.style.display = 'none'; }, 500);
}
// ─── Thermal Printing ─────────────────
async function connectThermal() {
if (!window.NexusPrinter) { showToast('Printer module not loaded'); return; }
const result = await NexusPrinter.connect();
if (result.ok) {
showToast('Impresora conectada: ' + (result.name || result.type));
_updatePrinterButtons();
} else {
showToast(result.error || 'No se pudo conectar la impresora');
}
}
async function thermalPrint() {
if (!window.NexusPrinter || !NexusPrinter.isConnected()) {
showToast('Conecte una impresora termica primero');
return;
}
if (!lastSaleId) { showToast('No hay venta para imprimir'); return; }
const ok = await NexusPrinter.printSale(lastSaleId);
if (ok) {
showToast('Ticket enviado a impresora termica');
} else {
showToast('Error al imprimir. Reconecte la impresora.');
}
}
function _updatePrinterButtons() {
const connectBtn = document.getElementById('btnConnectPrinter');
const thermalBtn = document.getElementById('btnThermalPrint');
if (window.NexusPrinter && NexusPrinter.isConnected()) {
if (connectBtn) connectBtn.style.display = 'none';
if (thermalBtn) thermalBtn.style.display = '';
} else {
if (connectBtn) connectBtn.style.display = '';
if (thermalBtn) thermalBtn.style.display = 'none';
}
}
// ─── Last Sale ───────────────────────
async function showLastSale() {
if (!lastSaleId) { showToast('No hay venta reciente'); return; }
@@ -1078,5 +1121,6 @@ const POS = (() => {
creditSale, saveQuotation, createLayaway,
showLastSale, openDrawer,
showTicket, closeTicketModal, printTicket,
connectThermal, thermalPrint,
};
})();

152
pos/static/js/printer.js Normal file
View File

@@ -0,0 +1,152 @@
/**
* NexusPrinter — Web USB / Web Serial bridge for thermal printers.
*
* Connects to 58mm or 80mm ESC/POS printers via WebUSB or Web Serial APIs
* and sends raw byte commands generated by the backend.
*/
window.NexusPrinter = (function () {
'use strict';
let _device = null; // WebUSB device
let _endpoint = null; // USB bulk-out endpoint number
let _serialPort = null; // Web Serial port
let _writer = null; // Serial writable stream writer
let _type = null; // 'usb' | 'serial' | null
// ── Connection ─────────────────────────────────
async function connect() {
// Try WebUSB first
if ('usb' in navigator) {
try {
_device = await navigator.usb.requestDevice({ filters: [] });
await _device.open();
// Select first configuration if needed
if (_device.configuration === null) {
await _device.selectConfiguration(1);
}
// Claim first interface and find bulk-out endpoint
const iface = _device.configuration.interfaces[0];
await _device.claimInterface(iface.interfaceNumber);
const alt = iface.alternates[0];
const ep = alt.endpoints.find(e => e.direction === 'out' && e.type === 'bulk');
if (ep) {
_endpoint = ep.endpointNumber;
_type = 'usb';
_save();
return { ok: true, type: 'usb', name: _device.productName || 'USB Printer' };
}
} catch (e) {
// User cancelled or no USB device available
_device = null;
}
}
// Fall back to Web Serial
if ('serial' in navigator) {
try {
_serialPort = await navigator.serial.requestPort();
await _serialPort.open({ baudRate: 9600 });
_writer = _serialPort.writable.getWriter();
_type = 'serial';
_save();
return { ok: true, type: 'serial', name: 'Serial Printer' };
} catch (e) {
_serialPort = null;
_writer = null;
}
}
return { ok: false, error: 'No printer connected. Use a browser that supports WebUSB or Web Serial (Chrome/Edge).' };
}
async function disconnect() {
try {
if (_type === 'usb' && _device) {
await _device.close();
}
if (_type === 'serial') {
if (_writer) { _writer.releaseLock(); _writer = null; }
if (_serialPort) { await _serialPort.close(); _serialPort = null; }
}
} catch (e) { /* ignore */ }
_device = null;
_endpoint = null;
_type = null;
localStorage.removeItem('nexus_printer');
}
function isConnected() {
return _type !== null;
}
// ── Printing ───────────────────────────────────
async function sendRaw(bytes) {
if (!_type) return false;
try {
if (_type === 'usb' && _device && _endpoint !== null) {
// Send in chunks of 512 bytes (common USB max packet size)
const chunk = 512;
for (let i = 0; i < bytes.length; i += chunk) {
await _device.transferOut(_endpoint, bytes.slice(i, i + chunk));
}
return true;
}
if (_type === 'serial' && _writer) {
await _writer.write(bytes);
return true;
}
} catch (e) {
console.error('[NexusPrinter] send error:', e);
// Connection likely lost
await disconnect();
}
return false;
}
/**
* Print a sale ticket by fetching ESC/POS bytes from the backend.
* @param {number} saleId
* @param {number} [width=80] — 58 or 80 mm
* @returns {Promise<boolean>}
*/
async function printSale(saleId, width) {
width = width || 80;
const token = localStorage.getItem('pos_token');
const resp = await fetch('/pos/api/sales/' + saleId + '/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() {
try { localStorage.setItem('nexus_printer', _type || ''); } catch (e) {}
}
// ── Public API ─────────────────────────────────
return {
connect: connect,
disconnect: disconnect,
isConnected: isConnected,
sendRaw: sendRaw,
printSale: printSale
};
})();