/** * QZ Tray printing for Cantarire & Etichetare. * * Mirrors the working integration from `quality_app-v2`: * - the patched `qz-tray.js` (2.2.4) that skips the certificate exchange and * talks to a custom QZ Tray service using pairing-key auth; * - `qz.printers.find()` + auto-selection of the thermal/label printer; * - `qz.configs.create(printer, { scaleContent:false, rasterize:.., size, units:'mm' })`; * - `qz.print(config, [{ type:'pdf'|'html', format:'base64'|'plain', data }])`. * * Public API (kept simple so the rest of the app does not care about QZ): * window.CantarirePrint.print(payload) -> Promise<{ok, method, printer?}> * window.CantarireQZ -> status / connect / printers / test * * `payload` is produced by `cantarire/labels.py::print_payload`: * { job_id, weighing_id, template:{width_mm,height_mm}, html, css, * fields, print_url, ack_url, pdf_url? } * * If `pdf_url` is present the label is fetched as PDF and sent with * `type:'pdf'` (the exact flow used by quality_app-v2). Otherwise the HTML * label is rasterized by QZ Tray at the physical size from the template. */ (function () { 'use strict'; var STORAGE_KEY = 'cantarire.qz.printer'; var config = Object.assign({ autoConnect: true, fallbackToBrowser: true, connectTimeoutMs: 6000, storageKey: STORAGE_KEY, thermalHints: ['thermal', 'label', 'zebra', 'godex', 'argox', 'tsc', 'citizen', 'xprinter', 'bixolon', 'epson', 'datamax', 'sato'], // Optional: standard QZ Tray (with certificate + signature) instead of the // patched pairing-key service. Set via window.CANTARIRE_QZ before this file. certificateUrl: '', signatureUrl: '' }, window.CANTARIRE_QZ || {}); var state = { status: 'idle', // idle | connecting | ready | offline | missing connected: false, printers: [], printer: '', version: '', error: null, lastFailureAt: 0 }; var connectPromise = null; var lastRendered = ''; // -- helpers ------------------------------------------------------------- function lib() { return window.qz; } function snapshot() { return { status: state.status, connected: state.connected, printers: state.printers.slice(), printer: state.printer, version: state.version, error: state.error }; } function messageOf(error) { if (!error) return 'Eroare necunoscuta'; return error.message || String(error); } function setState(patch) { Object.assign(state, patch); emit(); } function emit() { var detail = snapshot(); try { document.dispatchEvent(new CustomEvent('cantarire:qz', { detail: detail })); } catch (error) { /* browsere foarte vechi */ } syncUi(); } function savedPrinter() { try { return localStorage.getItem(config.storageKey) || ''; } catch (error) { return ''; } } function savePrinter(name) { try { if (name) localStorage.setItem(config.storageKey, name); else localStorage.removeItem(config.storageKey); } catch (error) { /* mod privat */ } } function choosePrinter(printers) { var saved = savedPrinter(); if (saved && printers.indexOf(saved) >= 0) return saved; for (var i = 0; i < printers.length; i++) { var name = printers[i].toLowerCase(); for (var j = 0; j < config.thermalHints.length; j++) { if (name.indexOf(config.thermalHints[j]) >= 0) return printers[i]; } } return printers.length ? printers[0] : ''; } function setupSecurity(qz) { if (config.certificateUrl && qz.security && qz.security.setCertificatePromise) { qz.security.setCertificatePromise(function (resolve, reject) { fetch(config.certificateUrl, { cache: 'no-store' }).then(function (response) { if (!response.ok) throw new Error('certificat: HTTP ' + response.status); return response.text(); }).then(resolve, reject); }, { rejectOnFailure: true }); } if (config.signatureUrl && qz.security && qz.security.setSignaturePromise) { qz.security.setSignaturePromise(function (toSign) { return function (resolve, reject) { fetch(config.signatureUrl, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ request: toSign }) }).then(function (response) { if (!response.ok) throw new Error('semnatura: HTTP ' + response.status); return response.text(); }).then(resolve, reject); }; }); } } // -- connection ---------------------------------------------------------- function connect() { if (state.connected) return Promise.resolve(true); if (connectPromise) return connectPromise; var qz = lib(); if (!qz) { setState({ status: 'missing', connected: false, error: 'Biblioteca qz-tray.js nu este incarcata.', version: '' }); return Promise.resolve(false); } setState({ status: 'connecting', error: null }); // The QZ client sweeps several hosts/ports before giving up; reflect a // failure in the UI early. A connection that succeeds later still wins. var timeout = Number(config.connectTimeoutMs) || 6000; var watchdog = setTimeout(function () { if (!state.connected && state.status === 'connecting') { setState({ status: 'offline', error: 'QZ Tray nu a raspuns in ' + timeout + ' ms (agentul este pornit?).' }); } }, timeout); connectPromise = Promise.resolve() .then(function () { try { setupSecurity(qz); } catch (error) { console.warn('[QZ] security:', error); } return qz.websocket.isActive() ? null : qz.websocket.connect(); }) .then(function () { return qz.api.getVersion(); }) .then(function (version) { setState({ status: 'ready', connected: true, version: version, error: null }); return loadPrinters(); }) .then(function () { return true; }) .catch(function (error) { setState({ status: 'offline', connected: false, version: '', error: messageOf(error), lastFailureAt: Date.now() }); return false; }) .finally(function () { clearTimeout(watchdog); connectPromise = null; }); return connectPromise; } function disconnect() { var qz = lib(); var done = qz && qz.websocket.isActive() ? qz.websocket.disconnect() : Promise.resolve(); return Promise.resolve(done).catch(function () { /* ignoram */ }).then(function () { setState({ status: 'idle', connected: false, version: '', printers: [] }); return false; }); } function loadPrinters() { var qz = lib(); if (!qz || !state.connected) return Promise.resolve([]); return qz.printers.find().then(function (found) { var list = Array.isArray(found) ? found.map(String) : (found ? [String(found)] : []); var chosen = choosePrinter(list); state.printers = list; state.printer = chosen; if (chosen) savePrinter(chosen); emit(); return list; }).catch(function (error) { setState({ error: messageOf(error) }); return []; }); } function setPrinter(name) { state.printer = name || ''; savePrinter(state.printer); emit(); } // -- payload ------------------------------------------------------------- function dimensions(payload) { var template = payload.template || {}; var width = Number(template.width_mm); var height = Number(template.height_mm); return { width: isFinite(width) && width > 0 ? width : 100, height: isFinite(height) && height > 0 ? height : 60 }; } function labelDocument(payload) { var size = dimensions(payload); return '
'; } function fetchBase64(url) { return fetch(url, { cache: 'no-store' }).then(function (response) { if (!response.ok) throw new Error('PDF: HTTP ' + response.status); return response.blob(); }).then(function (blob) { return new Promise(function (resolve, reject) { var reader = new FileReader(); reader.onloadend = function () { resolve(String(reader.result).split(',')[1]); }; reader.onerror = reject; reader.readAsDataURL(blob); }); }); } function printPdf(qz, payload) { var size = dimensions(payload); var printerConfig = qz.configs.create(state.printer || '', { scaleContent: false, rasterize: false, size: { width: size.width, height: size.height }, units: 'mm', margins: { top: 0, right: 0, bottom: 0, left: 0 } }); return fetchBase64(payload.pdf_url).then(function (base64) { return qz.print(printerConfig, [{ type: 'pdf', format: 'base64', data: base64 }]); }); } function printHtml(qz, payload) { var size = dimensions(payload); var printerConfig = qz.configs.create(state.printer || '', { scaleContent: false, rasterize: true, size: { width: size.width, height: size.height }, units: 'mm', margins: { top: 0, right: 0, bottom: 0, left: 0 }, interpolation: 'optimizeQuality' }); return qz.print(printerConfig, [{ type: 'html', format: 'plain', data: labelDocument(payload) }]); } function browserPrint(payload) { // On the label page itself, the browser dialog is exactly what we want. if (document.getElementById('label-sheet')) { window.print(); return { ok: true, method: 'browser' }; } // Anywhere else (dashboard), print the label page, not the current page. var url = payload && payload.print_url; if (url) { var frame = document.getElementById('print-frame'); if (frame) { frame.src = url + '?auto=1'; return { ok: true, method: 'browser', framed: true }; } window.open(url + '?auto=1', '_blank', 'noopener'); return { ok: true, method: 'browser' }; } window.print(); return { ok: true, method: 'browser' }; } /** * Do not hold the operator for the full host/port sweep of the QZ client: * wait at most `connectTimeoutMs`, then let the caller fall back. * A connection that succeeds later is still used by the next print. */ function ensureConnected() { if (state.connected) return Promise.resolve(true); // QZ was not running a moment ago: do not make every label wait again. if (state.status === 'offline' && state.lastFailureAt && Date.now() - state.lastFailureAt < 30000) { return Promise.resolve(false); } var timeout = Number(config.connectTimeoutMs) || 6000; return Promise.race([ connect(), new Promise(function (resolve) { setTimeout(function () { resolve(false); }, timeout); }) ]); } // -- public print -------------------------------------------------------- function print(payload) { payload = payload || {}; return ensureConnected().then(function (connected) { if (!connected) { if (!config.fallbackToBrowser) { throw new Error(state.error || 'QZ Tray nu este disponibil.'); } console.warn('[QZ] fallback la tiparirea din browser:', state.error); return browserPrint(payload); } var qz = lib(); var job = payload.pdf_url ? printPdf(qz, payload).catch(function (error) { // PDF-ul poate lipsi (endpoint nedisponibil): incercam HTML-ul. console.warn('[QZ] PDF indisponibil, folosesc eticheta HTML:', error); return printHtml(qz, payload); }) : printHtml(qz, payload); return job.then(function () { if (state.error) setState({ error: null }); return { ok: true, method: 'qz', printer: state.printer }; }).catch(function (error) { setState({ error: messageOf(error), lastFailureAt: Date.now() }); if (!config.fallbackToBrowser) throw error; console.warn('[QZ] tiparirea a esuat, folosesc browserul:', error); return browserPrint(payload); }); }); } function printTest() { var stamp = new Date().toLocaleString('ro-RO'); var payload = { template: { width_mm: 60, height_mm: 40 }, css: '.t{font-family:Arial,sans-serif;text-align:center;padding:4mm;color:#000}' + '.t h1{font-size:5mm;margin:0 0 2mm}.t p{font-size:3.5mm;margin:1mm 0}', html: '' + (state.printer || 'imprimanta implicita') + '
' + '' + stamp + '
' + 'Cantarire & Etichetare