diff --git a/pos/blueprints/pos_bp.py b/pos/blueprints/pos_bp.py index 0b616bf..1860ae0 100644 --- a/pos/blueprints/pos_bp.py +++ b/pos/blueprints/pos_bp.py @@ -1698,3 +1698,96 @@ def push_test(): if ok: return jsonify({'message': 'Test notification sent'}) return jsonify({'error': 'No subscription found or push failed'}), 400 + + +# ─── Thermal Printing ────────────────────────────── + +@pos_bp.route('/sales//print', methods=['POST']) +@require_auth('pos.sell') +def print_ticket(sale_id): + """Generate a printable ticket for a sale. + + Body (optional): {printer_type: 'escpos_raw' | 'browser', width: 58 | 80} + - escpos_raw: returns raw ESC/POS bytes (application/octet-stream) + - browser: returns printable HTML fragment (text/html) + """ + from flask import Response + from services.thermal_printer import generate_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() + + # Fetch sale + cur.execute(""" + SELECT s.*, e.name as employee_name, c.name as customer_name + FROM sales s + LEFT JOIN employees e ON s.employee_id = e.id + LEFT JOIN customers c ON s.customer_id = c.id + WHERE s.id = %s + """, (sale_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Sale not found'}), 404 + + cols = [desc[0] for desc in cur.description] + sale = dict(zip(cols, row)) + for k in ('subtotal', 'discount_total', 'tax_total', 'total', 'amount_paid', 'change_given'): + if sale.get(k) is not None: + sale[k] = float(sale[k]) + + # Fetch items + cur.execute(""" + SELECT name, quantity, unit_price, subtotal + FROM sale_items WHERE sale_id = %s ORDER BY id + """, (sale_id,)) + items = [] + for r in cur.fetchall(): + items.append({ + 'name': r[0], 'quantity': r[1], + 'unit_price': float(r[2]) if r[2] else 0, + 'subtotal': float(r[3]) if r[3] else 0, + }) + + # 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() + + sale_data = { + 'folio': f'V-{sale["id"]}', + 'date': str(sale.get('created_at', '')), + 'employee': sale.get('employee_name', ''), + 'customer': sale.get('customer_name', ''), + 'items': items, + 'subtotal': sale.get('subtotal', 0), + 'discount_total': sale.get('discount_total', 0), + 'tax_total': sale.get('tax_total', 0), + 'total': sale.get('total', 0), + 'payment_method': sale.get('payment_method', 'efectivo'), + 'amount_paid': sale.get('amount_paid'), + 'change_given': sale.get('change_given'), + } + + if printer_type == 'browser': + # Return the sale data as JSON for browser-side rendering + return jsonify(sale_data) + + # Default: ESC/POS raw bytes + raw = generate_ticket(sale_data, business_info, width=width) + return Response(raw, mimetype='application/octet-stream', + headers={'Content-Disposition': f'attachment; filename=ticket_{sale_id}.bin'}) diff --git a/pos/services/thermal_printer.py b/pos/services/thermal_printer.py new file mode 100644 index 0000000..ec81b50 --- /dev/null +++ b/pos/services/thermal_printer.py @@ -0,0 +1,119 @@ +"""ESC/POS thermal printer commands for 58mm and 80mm printers. + +Generates raw ESC/POS byte commands that can be sent to a thermal printer +via USB, serial, or network connection. +""" + +# ESC/POS command constants +ESC = b'\x1b' +GS = b'\x1d' +INIT = ESC + b'@' # Initialize printer +CUT = GS + b'V' + b'\x00' # Full cut +PARTIAL_CUT = GS + b'V' + b'\x01' +FEED = ESC + b'd' # Feed N lines +ALIGN_LEFT = ESC + b'a' + b'\x00' +ALIGN_CENTER = ESC + b'a' + b'\x01' +ALIGN_RIGHT = ESC + b'a' + b'\x02' +BOLD_ON = ESC + b'E' + b'\x01' +BOLD_OFF = ESC + b'E' + b'\x00' +DOUBLE_HEIGHT = ESC + b'!' + b'\x10' +NORMAL_SIZE = ESC + b'!' + b'\x00' +LARGE_SIZE = ESC + b'!' + b'\x30' # Double width + double height + + +def generate_ticket(sale_data, business_info, width=80): + """Generate ESC/POS bytes for a sale ticket. + + Args: + sale_data: dict with sale info (items, totals, payment, folio) + business_info: dict with business name, RFC, address + width: 58 or 80 (mm) + + Returns: bytes ready to send to printer + """ + chars = 32 if width == 58 else 48 # characters per line + buf = bytearray() + buf += INIT + + # Header: business name (centered, bold, large) + buf += ALIGN_CENTER + buf += LARGE_SIZE + buf += (business_info.get('name', 'NEXUS POS') + '\n').encode('cp437', errors='replace') + buf += NORMAL_SIZE + buf += (business_info.get('rfc', '') + '\n').encode('cp437', errors='replace') + buf += (business_info.get('address', '') + '\n').encode('cp437', errors='replace') + buf += b'\n' + + # Folio + date + buf += ALIGN_LEFT + buf += BOLD_ON + folio = sale_data.get('folio', 'N/A') + date = sale_data.get('date', '') + buf += f'Folio: {folio}\n'.encode('cp437', errors='replace') + buf += BOLD_OFF + buf += f'Fecha: {date}\n'.encode('cp437', errors='replace') + buf += f'Cajero: {sale_data.get("employee", "")}\n'.encode('cp437', errors='replace') + if sale_data.get('customer'): + buf += f'Cliente: {sale_data["customer"]}\n'.encode('cp437', errors='replace') + buf += ('-' * chars + '\n').encode() + + # Column header + buf += BOLD_ON + hdr = _format_line('Cant Descripcion', 'Importe', chars) + buf += (hdr + '\n').encode('cp437', errors='replace') + buf += BOLD_OFF + buf += ('-' * chars + '\n').encode() + + # Items + for item in sale_data.get('items', []): + name = item.get('name', '')[:chars - 10] + qty = item.get('quantity', 1) + subtotal = item.get('subtotal', 0) + buf += f'{qty}x {name}\n'.encode('cp437', errors='replace') + buf += ALIGN_RIGHT + buf += f'${subtotal:,.2f}\n'.encode('cp437', errors='replace') + buf += ALIGN_LEFT + + buf += ('-' * chars + '\n').encode() + + # Totals + buf += ALIGN_RIGHT + buf += _total_line('Subtotal:', sale_data.get('subtotal', 0), chars).encode('cp437', errors='replace') + if sale_data.get('discount_total', 0) > 0: + buf += _total_line('Descuento:', -sale_data['discount_total'], chars).encode('cp437', errors='replace') + buf += _total_line('IVA 16%:', sale_data.get('tax_total', 0), chars).encode('cp437', errors='replace') + buf += BOLD_ON + DOUBLE_HEIGHT + buf += _total_line('TOTAL:', sale_data.get('total', 0), chars).encode('cp437', errors='replace') + buf += NORMAL_SIZE + BOLD_OFF + + # Payment + buf += ALIGN_LEFT + buf += f'\nPago: {sale_data.get("payment_method", "Efectivo")}\n'.encode('cp437', errors='replace') + if sale_data.get('amount_paid'): + buf += f'Recibido: ${sale_data["amount_paid"]:,.2f}\n'.encode('cp437', errors='replace') + if sale_data.get('change_given'): + buf += f'Cambio: ${sale_data["change_given"]:,.2f}\n'.encode('cp437', errors='replace') + + # Footer + buf += b'\n' + buf += ALIGN_CENTER + buf += 'Gracias por su compra!\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) + + +def _format_line(left, right, width): + """Pad a left-right line to fill the ticket width.""" + space = width - len(left) - len(right) + if space < 1: + space = 1 + return left + ' ' * space + right + + +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' diff --git a/pos/static/js/pos.js b/pos/static/js/pos.js index d37956d..f6246b8 100644 --- a/pos/static/js/pos.js +++ b/pos/static/js/pos.js @@ -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, }; })(); diff --git a/pos/static/js/printer.js b/pos/static/js/printer.js new file mode 100644 index 0000000..bf0a5f9 --- /dev/null +++ b/pos/static/js/printer.js @@ -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} + */ + 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 + }; +})(); diff --git a/pos/templates/pos.html b/pos/templates/pos.html index 49b123e..8c05a8b 100644 --- a/pos/templates/pos.html +++ b/pos/templates/pos.html @@ -1380,6 +1380,8 @@ @@ -1477,8 +1479,10 @@ + +