bff6d70b17
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
490 lines
16 KiB
JavaScript
490 lines
16 KiB
JavaScript
/**
|
|
* 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 '<!doctype html><html><head><meta charset="utf-8"><style>'
|
|
+ '@page{size:' + size.width + 'mm ' + size.height + 'mm;margin:0}'
|
|
+ 'html,body{margin:0;padding:0;width:' + size.width + 'mm;height:' + size.height
|
|
+ 'mm;overflow:hidden;background:#fff}'
|
|
+ '*,*::before,*::after{box-sizing:border-box}'
|
|
+ (payload.css || '')
|
|
+ '</style></head><body><div class="label-sheet" style="width:' + size.width
|
|
+ 'mm;height:' + size.height + 'mm;overflow:hidden">'
|
|
+ (payload.html || '')
|
|
+ '</div></body></html>';
|
|
}
|
|
|
|
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: '<div class="t"><h1>TEST IMPRIMANTA</h1>'
|
|
+ '<p>' + (state.printer || 'imprimanta implicita') + '</p>'
|
|
+ '<p>' + stamp + '</p>'
|
|
+ '<p>Cantarire & Etichetare</p></div>'
|
|
};
|
|
return print(payload);
|
|
}
|
|
|
|
// -- UI binding ----------------------------------------------------------
|
|
function statusText() {
|
|
if (state.status === 'ready') return 'QZ Tray: pregatit';
|
|
if (state.status === 'connecting') return 'QZ Tray: conectare…';
|
|
if (state.status === 'missing') return 'QZ Tray: biblioteca lipsa';
|
|
if (state.status === 'offline') return 'QZ Tray: deconectat';
|
|
return 'QZ Tray: neinitializat';
|
|
}
|
|
|
|
function renderPrinterSelect(select) {
|
|
var signature = state.printers.join('|') + '#' + state.printer;
|
|
if (select.getAttribute('data-qz-rendered') === signature) return;
|
|
select.setAttribute('data-qz-rendered', signature);
|
|
|
|
var current = state.printer || '';
|
|
select.innerHTML = '';
|
|
var auto = document.createElement('option');
|
|
auto.value = '';
|
|
auto.textContent = state.printers.length ? 'Implicit (sistem)' : 'Nicio imprimanta detectata';
|
|
select.appendChild(auto);
|
|
|
|
state.printers.forEach(function (name) {
|
|
var option = document.createElement('option');
|
|
option.value = name;
|
|
option.textContent = name;
|
|
if (name === current) option.selected = true;
|
|
select.appendChild(option);
|
|
});
|
|
select.value = current;
|
|
select.disabled = state.printers.length === 0;
|
|
}
|
|
|
|
function syncUi() {
|
|
if (typeof document === 'undefined') return;
|
|
|
|
document.querySelectorAll('[data-qz-status]').forEach(function (node) {
|
|
node.textContent = statusText();
|
|
var pill = node.closest('[data-qz-pill]');
|
|
if (pill) pill.setAttribute('data-qz-state', state.status);
|
|
if (state.error) node.title = state.error;
|
|
else node.removeAttribute('title');
|
|
});
|
|
|
|
document.querySelectorAll('[data-qz-printer]').forEach(renderPrinterSelect);
|
|
|
|
document.querySelectorAll('[data-qz-version]').forEach(function (node) {
|
|
node.textContent = state.version ? 'v' + state.version : '';
|
|
});
|
|
|
|
document.querySelectorAll('[data-qz-connect]').forEach(function (node) {
|
|
node.disabled = state.connected || state.status === 'connecting';
|
|
});
|
|
}
|
|
|
|
function bindUi() {
|
|
document.addEventListener('change', function (event) {
|
|
var select = event.target.closest ? event.target.closest('[data-qz-printer]') : null;
|
|
if (select) setPrinter(select.value);
|
|
});
|
|
|
|
document.addEventListener('click', function (event) {
|
|
var target = event.target.closest ? event.target.closest('[data-qz-connect],[data-qz-test],[data-qz-refresh]') : null;
|
|
if (!target) return;
|
|
if (target.hasAttribute('data-qz-connect')) {
|
|
event.preventDefault();
|
|
connect();
|
|
} else if (target.hasAttribute('data-qz-test')) {
|
|
event.preventDefault();
|
|
printTest().catch(function (error) { alert('Test esuat: ' + messageOf(error)); });
|
|
} else if (target.hasAttribute('data-qz-refresh')) {
|
|
event.preventDefault();
|
|
if (!state.connected) connect();
|
|
else loadPrinters();
|
|
}
|
|
});
|
|
}
|
|
|
|
// -- boot ----------------------------------------------------------------
|
|
window.CantarireQZ = {
|
|
getState: snapshot,
|
|
connect: connect,
|
|
disconnect: disconnect,
|
|
refreshPrinters: loadPrinters,
|
|
setPrinter: setPrinter,
|
|
printTest: printTest
|
|
};
|
|
window.CantarirePrint = { print: print };
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function () {
|
|
bindUi();
|
|
syncUi();
|
|
if (config.autoConnect) connect();
|
|
});
|
|
} else {
|
|
bindUi();
|
|
syncUi();
|
|
if (config.autoConnect) connect();
|
|
}
|
|
})();
|