Initial commit: aplicatie de cantarire si etichetare

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
This commit is contained in:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+489
View File
@@ -0,0 +1,489 @@
/**
* 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 &amp; 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();
}
})();
+16
View File
@@ -0,0 +1,16 @@
# QZ Tray connector (`qz-tray.js`)
- **Version:** 2.2.4
- **License:** LGPL-2.1-only (see the header of `qz-tray.js`)
- **Source:** copied unmodified from
`quality_app-v2/app/static/js/qz-tray.js` (Gitea: `ske087/quality_app-v2`).
- **Local patch:** it **skips the certificate exchange** and connects to the
custom QZ Tray service that authenticates with a **pairing key**
(`PATCHED` markers around the original `_qz.websocket.connection.sendData({ certificate … })`).
Because the file is a modified LGPL-2.1 library, keep it under
`static/js/vendor/` and ship this notice plus the license header with it.
If you later use stock QZ Tray (certificate + signature), set
`window.CANTARIRE_QZ = { certificateUrl, signatureUrl }` before loading
`qz-print.js`; `qz-print.js` wires `qz.security.setCertificatePromise` and
`setSignaturePromise` when those URLs are provided.
+2871
View File
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
/* Fluxul de cântărire a unui produs finit din 1..N componente.
*
* State machine:
* fără pachet -> alegi produsul finit și începi
* pas curent -> cântărești componenta (cântar sau manual), Enter acceptă
* ultimul pas -> serverul finalizează pachetul și întoarce eticheta
* finalizat -> eticheta se tipărește; Enter începe următorul pachet
*/
(function () {
'use strict';
var cfg = window.CANTARIRE || {};
var $ = function (id) { return document.getElementById(id); };
// Polling: rapid cat timp se cantareste, rar cand pagina doar asteapta.
var POLL_ACTIVE_MS = Number(cfg.pollMs) || 700;
var POLL_IDLE_MS = 2500;
var pollTimer = null;
var el = {
flow: $('flow-body'),
title: $('flow-title'),
progress: $('flow-progress'),
message: $('flow-message'),
weightValue: $('weight-value'),
weightUnit: $('weight-unit'),
stability: $('stability'),
age: $('age'),
raw: $('raw'),
scaleDot: $('scale-dot'),
scaleState: $('scale-state'),
scaleProfile: $('scale-profile'),
scaleError: $('scale-error'),
profileSelect: $('profile-select'),
toast: $('toast'),
frame: $('print-frame')
};
var state = cfg.initial || null;
var recipes = cfg.recipes || [];
var activeProfile = cfg.activeProfile || '';
var scaleReady = Boolean(cfg.scaleReady);
var manualMode = !scaleReady;
var busy = false;
var polling = false;
var reading = null;
var forceArmedAt = 0;
var lastLabel = null;
var lastRecipeId = null;
var lastLot = '';
var lastOperator = cfg.defaultOperator || '';
var toastTimer = null;
// -- utilitare -----------------------------------------------------------
function esc(value) {
return String(value === null || value === undefined ? '' : value)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function fmt(value) {
var number = Number(value);
if (!isFinite(number)) return '--';
var text = number.toFixed(3);
return text.replace(/\.?0+$/, '') || '0';
}
function message(text, kind) {
if (!el.message) return;
el.message.textContent = text || '';
el.message.className = 'message ' + (kind || '');
}
function toast(text, kind) {
if (!el.toast) return;
el.toast.textContent = text;
el.toast.className = 'toast show ' + (kind || '');
clearTimeout(toastTimer);
toastTimer = setTimeout(function () { el.toast.className = 'toast ' + (kind || ''); }, 3200);
}
function setProgress(text) {
if (el.progress) el.progress.textContent = text || '';
}
function getJSON(url) {
return fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' })
.then(function (response) { return response.json(); });
}
function postJSON(url, body) {
return fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify(body || {})
}).then(function (response) {
return response.json().then(function (data) {
return { status: response.status, data: data };
});
});
}
function setBusy(value) {
busy = value;
document.querySelectorAll('#flow-body button, #flow-body input, #flow-body select')
.forEach(function (node) { node.disabled = value; });
}
// -- stare ---------------------------------------------------------------
function adopt(data) {
if (!data) return;
if (data.state === null) {
state = null;
} else if (data.package) {
state = {
package: data.package,
recipe: data.recipe,
items: data.items || [],
step: data.step,
planned_total: data.planned_total,
total_weight: data.total_weight,
complete: Boolean(data.complete),
open: Boolean(data.open)
};
if (data.recipe) lastRecipeId = data.recipe.id;
if (data.package) {
lastLot = data.package.lot || lastLot;
lastOperator = data.package.operator || lastOperator;
}
}
render();
}
async function refresh() {
try {
var data = await getJSON('/api/package');
adopt(data);
} catch (error) {
message('Nu pot citi starea pachetului: ' + error, 'error');
}
}
// -- randare -------------------------------------------------------------
function render() {
if (!el.flow) return;
if (!state) {
renderStart();
} else if (state.complete) {
renderDone();
} else {
renderStep();
}
}
function recipeOptions(selected) {
return recipes.map(function (recipe) {
var suffix = ' · ' + recipe.components.length + ' comp.';
return '<option value="' + recipe.id + '"' + (recipe.id === selected ? ' selected' : '') +
'>' + esc(recipe.code + ' · ' + recipe.name) + suffix + '</option>';
}).join('');
}
function breakdown(recipe) {
return (recipe.components || []).map(function (part) {
return '<li><span class="w">' + (part.target_weight ? fmt(part.target_weight) + ' ' + esc(recipe.unit) : '—')
+ '</span> ' + esc(part.name) + '</li>';
}).join('');
}
function renderStart() {
if (el.title) el.title.textContent = 'Pachet nou';
setProgress('');
if (!recipes.length) {
el.flow.innerHTML = '<p class="muted">Nu există produse finite active. ' +
'<a class="link" href="/retete/nou">Creează unul</a>.</p>';
return;
}
var selected = lastRecipeId || (recipes[0] && recipes[0].id) || null;
var recipe = recipes.filter(function (r) { return r.id === selected; })[0] || recipes[0];
el.flow.innerHTML =
'<div class="field">' +
' <label for="start-recipe">Produs finit</label>' +
' <select id="start-recipe">' + recipeOptions(recipe.id) + '</select>' +
'</div>' +
'<ul class="plan" id="start-plan">' + breakdown(recipe) + '</ul>' +
'<div class="field-row">' +
' <div class="field"><label for="start-lot">Lot</label>' +
' <input id="start-lot" type="text" maxlength="64" value="' + esc(lastLot) + '" placeholder="ex. LOT-2610-A"></div>' +
' <div class="field"><label for="start-operator">Operator</label>' +
' <input id="start-operator" type="text" maxlength="64" value="' + esc(lastOperator) + '"></div>' +
'</div>' +
'<div class="actions">' +
' <button class="btn primary" type="button" id="btn-start">Începe pachetul</button>' +
'</div>' +
'<p class="hint muted small">Enter pornește pachetul. Apoi cântărești fiecare componentă în ordinea de mai sus.</p>';
var select = $('start-recipe');
if (select) {
select.addEventListener('change', function () {
var chosen = recipes.filter(function (r) { return String(r.id) === select.value; })[0];
if (chosen) {
lastRecipeId = chosen.id;
var plan = $('start-plan');
if (plan) plan.innerHTML = breakdown(chosen);
}
});
}
var button = $('btn-start');
if (button) button.addEventListener('click', startPackage);
}
function renderStep() {
var step = state.step;
if (!step) { refresh(); return; }
if (el.title) el.title.textContent = state.recipe ? state.recipe.name : 'Cântărire';
setProgress('Pas ' + step.position + ' din ' + step.total);
var accepted = (state.items || []).map(function (item) {
return '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
esc(item.component_name) + '</li>';
}).join('');
el.flow.innerHTML =
'<div class="step">' +
' <div class="step-label">Pune pe cântar</div>' +
' <div class="step-component">' + esc(step.component_name) + '</div>' +
(step.target_weight
? '<div class="step-target">Țintă: ' + fmt(step.target_weight) + ' ' + esc(step.unit) + '</div>'
: '') +
'</div>' +
(manualMode
? '<div class="field"><label for="manual-weight">Greutate (' + esc(step.unit) + ')</label>' +
'<input id="manual-weight" type="number" step="0.001" min="0" inputmode="decimal" placeholder="0">' +
'</div>'
: '<div class="actions"><button class="btn ghost" type="button" id="btn-manual-toggle">Introducere manuală</button></div>') +
'<div class="actions">' +
' <button class="btn primary" type="button" id="btn-accept">Acceptă' + (manualMode ? '' : ' (Enter)') + '</button>' +
(accepted ? ' <button class="btn" type="button" id="btn-undo">Anulează ultima</button>' : '') +
' <button class="btn ghost" type="button" id="btn-void">Renunță la pachet</button>' +
'</div>' +
(accepted ? '<h3>Acceptate</h3><ul class="plan accepted">' + accepted + '</ul>' : '');
if (manualMode) {
var input = $('manual-weight');
if (input) { input.focus(); input.select(); }
}
var accept = $('btn-accept');
if (accept) accept.addEventListener('click', acceptStep);
var undo = $('btn-undo');
if (undo) undo.addEventListener('click', undoStep);
var voidButton = $('btn-void');
if (voidButton) voidButton.addEventListener('click', voidPackage);
var toggle = $('btn-manual-toggle');
if (toggle) {
toggle.addEventListener('click', function () {
manualMode = true;
renderStep();
});
}
}
function renderDone() {
var pkg = state.package || {};
if (el.title) el.title.textContent = pkg.recipe_name || 'Pachet finalizat';
setProgress('Finalizat');
var accepted = (state.items || []).map(function (item) {
return '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
esc(item.component_name) + '</li>';
}).join('');
el.flow.innerHTML =
'<div class="done">' +
' <div class="done-mark">✓</div>' +
' <div class="done-total"><span class="value">' + fmt(pkg.total_weight) + '</span>' +
' <span class="unit">' + esc(pkg.unit) + '</span></div>' +
' <div class="muted">Pachet #' + pkg.id + (pkg.lot ? ' · Lot ' + esc(pkg.lot) : '') + '</div>' +
'</div>' +
'<ul class="plan accepted">' + accepted + '</ul>' +
'<div class="actions">' +
' <button class="btn primary" type="button" id="btn-next">Pachet nou (Enter)</button>' +
(lastLabel ? ' <button class="btn" type="button" id="btn-reprint">Tipărește eticheta</button>' : '') +
' <a class="btn ghost" target="_blank" rel="noopener" href="/labels/' + pkg.id + '/print?auto=0">Vezi eticheta</a>' +
'</div>';
var next = $('btn-next');
if (next) next.addEventListener('click', startPackage);
var reprint = $('btn-reprint');
if (reprint) {
reprint.addEventListener('click', function () {
if (lastLabel) printLabel(lastLabel);
});
}
if (next) next.focus();
}
// -- acțiuni -------------------------------------------------------------
async function startPackage() {
if (busy) return;
var select = $('start-recipe');
var recipeId = select ? Number(select.value) : lastRecipeId;
if (!recipeId) {
toast('Alege produsul finit.', 'error');
return;
}
setBusy(true);
message('Se pregătește pachetul…', 'info');
try {
var result = await postJSON('/api/packages', {
recipe_id: recipeId,
operator: ($('start-operator') ? $('start-operator').value.trim() : '') || lastOperator,
lot: ($('start-lot') ? $('start-lot').value.trim() : '') || lastLot,
// Serverul alege sablonul: produsul finit -> setarea implicita -> primul sablon.
template_id: null
});
if (!result.data.ok) {
message(result.data.error || 'Pachetul nu a putut fi început.', 'error');
toast(result.data.error || 'Eroare.', 'error');
return;
}
lastLabel = null;
adopt(result.data);
message('Pachet început. Cântărește prima componentă.', 'info');
} catch (error) {
message('Eroare de rețea: ' + error, 'error');
} finally {
setBusy(false);
render();
}
}
function manualWeight() {
var input = $('manual-weight');
if (!input) return null;
var raw = String(input.value || '').trim().replace(',', '.');
return raw === '' ? null : raw;
}
async function acceptStep() {
if (busy || !state || !state.package) return;
var force = forceArmedAt && Date.now() - forceArmedAt < 5000;
var body = {};
if (force) body.force = true;
if (manualMode) {
var value = manualWeight();
if (value === null) {
toast('Introdu greutatea sau conectează cântarul.', 'error');
return;
}
body.weight = value;
}
setBusy(true);
message('Se înregistrează…', 'info');
try {
var result = await postJSON('/api/packages/' + state.package.id + '/items', body);
var data = result.data;
if (!result.status || result.status >= 400 || !data.ok) {
if (data.reading) applyReading(data.reading);
var unstable = /stabil/i.test(data.error || '');
if (unstable) {
forceArmedAt = Date.now();
message((data.error || 'Greutate instabilă.') +
' Apasă din nou pe Acceptă pentru a folosi valoarea curentă.', 'error');
} else {
message(data.error || 'Cântărirea nu a putut fi salvată.', 'error');
toast(data.error || 'Eroare la salvare.', 'error');
}
return;
}
forceArmedAt = 0;
adopt(data);
if (data.label) {
lastLabel = data.label;
if (cfg.autoPrint !== false) {
await printLabel(data.label);
message('Etichetă tipărită. Apasă Enter pentru următorul pachet.', 'success');
} else {
message('Pachet finalizat. Apasă „Tipărește eticheta” sau Enter pentru următorul pachet.', 'success');
}
updateStats();
} else {
message('Acceptat: ' + fmt(state.total_weight) + ' ' + (state.package.unit || ''), 'success');
}
} catch (error) {
message('Eroare de rețea la salvare.', 'error');
console.error(error);
} finally {
setBusy(false);
render();
}
}
async function undoStep() {
if (busy || !state || !state.package) return;
setBusy(true);
try {
var result = await postJSON('/api/packages/' + state.package.id + '/undo', {});
if (result.data.ok) {
adopt(result.data);
message('Ultima cântărire a fost anulată.', 'info');
} else {
message(result.data.error || 'Nu se poate anula.', 'error');
}
} finally {
setBusy(false);
render();
}
}
async function voidPackage() {
if (busy || !state || !state.package) return;
if (!confirm('Renunți la pachetul început? Cântăririle lui nu vor apărea în istoric.')) return;
setBusy(true);
try {
await postJSON('/api/packages/' + state.package.id + '/void', {});
state = null;
lastLabel = null;
message('Pachet anulat.', 'info');
} finally {
setBusy(false);
render();
}
}
// -- tipărire ------------------------------------------------------------
function acknowledge(payload) {
if (!payload || !payload.ack_url) return;
fetch(payload.ack_url, { method: 'POST', headers: { Accept: 'application/json' } })
.catch(function () { /* confirmarea este auxiliară */ });
}
function printLabel(payload) {
if (!payload) return Promise.resolve();
if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') {
return Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) {
if (result && result.method === 'qz') acknowledge(payload);
return result;
}).catch(function (error) {
console.error('Tipărirea a eșuat:', error);
toast('Imprimanta nu a răspuns.', 'error');
});
}
if (el.frame) {
el.frame.src = payload.print_url + '?auto=1';
return Promise.resolve();
}
window.open(payload.print_url, '_blank', 'noopener');
return Promise.resolve();
}
function updateStats() {
var node = $('stat-today-count');
if (!node) return;
var value = Number(node.textContent) || 0;
node.textContent = String(value + 1);
}
// -- citirea cântarului --------------------------------------------------
function applyReading(data) {
reading = data;
if (!data) return;
var ok = Boolean(data.ok);
var stale = Boolean(data.stale);
var stable = Boolean(data.stable);
var hasValue = data.value !== null && data.value !== undefined;
if (el.weightValue) {
el.weightValue.textContent = hasValue ? fmt(data.value) : '--';
el.weightValue.className = !ok ? 'error' : (stable && !stale ? '' : 'unstable');
}
if (el.weightUnit) el.weightUnit.textContent = data.unit || '';
if (!ok) {
if (el.scaleDot) el.scaleDot.className = 'dot off';
if (el.scaleState) el.scaleState.textContent = 'Cântar indisponibil';
if (el.stability) { el.stability.textContent = 'fără date'; el.stability.className = 'badge off'; }
if (el.scaleError) el.scaleError.textContent = data.error || 'Cântarul nu poate fi citit.';
} else if (stale) {
if (el.scaleDot) el.scaleDot.className = 'dot off';
if (el.scaleState) el.scaleState.textContent = 'Citire veche';
if (el.stability) { el.stability.textContent = 'citire veche'; el.stability.className = 'badge warn'; }
if (el.scaleError) el.scaleError.textContent = '';
} else {
if (el.scaleDot) el.scaleDot.className = 'dot ok';
if (el.scaleState) el.scaleState.textContent = 'Cântar conectat';
if (el.stability) {
el.stability.textContent = stable ? 'stabil' : 'instabil';
el.stability.className = 'badge ' + (stable ? 'ok' : 'warn');
}
if (el.scaleError) el.scaleError.textContent = '';
}
if (el.scaleProfile) el.scaleProfile.textContent = (data.profile || activeProfile || 'fără profil') +
(data.port ? ' · ' + data.port : '');
if (el.age) {
el.age.textContent = typeof data.age_ms === 'number'
? (data.age_ms < 1000 ? '<1 s' : (data.age_ms / 1000).toFixed(1) + ' s')
: '';
}
if (el.raw) el.raw.textContent = data.raw_hex ? 'raw: ' + data.raw_hex.slice(0, 40) : '';
}
function poll() {
if (!scaleReady || polling || document.hidden) return;
polling = true;
var controller = new AbortController();
var timer = setTimeout(function () { controller.abort(); }, 6000);
var params = new URLSearchParams();
if (activeProfile) params.set('profile', activeProfile);
fetch('/api/weight?' + params.toString(), {
headers: { Accept: 'application/json' }, cache: 'no-store', signal: controller.signal
})
.then(function (response) { return response.json(); })
.then(applyReading)
.catch(function (error) {
if (error && error.name !== 'AbortError') {
if (el.scaleDot) el.scaleDot.className = 'dot off';
if (el.scaleState) el.scaleState.textContent = 'Conexiune întreruptă';
}
})
.finally(function () { clearTimeout(timer); polling = false; });
}
// -- tastatură -----------------------------------------------------------
function onKeyDown(event) {
if (event.key !== 'Enter' || event.ctrlKey || event.metaKey) return;
var tag = (event.target && event.target.tagName || '').toLowerCase();
if (tag === 'textarea' || tag === 'button' || tag === 'a') return;
if (!state) {
event.preventDefault();
startPackage();
} else if (state.complete) {
event.preventDefault();
startPackage();
} else if (state.step) {
event.preventDefault();
acceptStep();
}
}
// -- start ---------------------------------------------------------------
function schedulePoll() {
clearTimeout(pollTimer);
var delay = (state && state.open && state.step) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
pollTimer = setTimeout(function () {
poll();
schedulePoll();
}, delay);
}
if (el.profileSelect) {
el.profileSelect.addEventListener('change', function () {
activeProfile = el.profileSelect.value;
poll();
});
}
document.addEventListener('keydown', onKeyDown);
document.addEventListener('visibilitychange', function () {
if (!document.hidden) poll();
});
render();
if (scaleReady) {
poll();
schedulePoll();
}
})();