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
Cântărire & Etichetare — produs finit
Aplicație internă pentru industria alimentară: cântărește un produs finit din 1–2 componente (ex. fasole + carne), calculează totalul, generează eticheta și o tipărește pe imprimanta termică prin QZ Tray. Cântarul este citit direct prin biblioteca vendorizată SyncPulse Scale Bridge.
Eticheta tipică:
Moto Adv SRL
Fasole cu carne
450 g
250 g Fasole
200 g Carne
Lot: LOT-2610-A · 18.10.2026 20:15
Operator: Maria
Fluxul de lucru
Aplicația are două taburi: Cântărire (ecranul de producție) și Setări (administrare: componente, produse finite, șabloane, câmpuri, istoric, monitor serial). În Setări, secțiunile sunt accesibile din bara secundară de sub antet sau din cardurile paginii principale.
- Se pregătesc caserolele.
- Se alege produsul finit (ex. Fasole cu carne) și lotul.
- Se pune prima componentă pe cântar; greutatea stabilă se acceptă cu Enter.
- Se adaugă a doua componentă; când greutatea este stabilă, Enter.
- Aplicația calculează totalul, salvează pachetul și tipărește eticheta.
- Eticheta se aplică pe caserolă după sigilare. Enter pornește următorul pachet.
Dacă cântarul nu este încă conectat, același flux merge cu introducere manuală a greutății (butonul Introducere manuală). Conectivitatea cântarului poate fi activată ulterior, fără modificări în aplicație.
Model de date
| Entitate | Rol |
|---|---|
| Componentă | Ce se pune pe cântar: fasole, carne, garnitură. Cod, nume, unitate. |
| Produs finit (rețetă) | Denumirea de pe etichetă + lista ordonată de componente, cu greutăți țintă opționale; poate avea propriul șablon de etichetă. |
| Pachet | O etichetă: produsul finit, lotul, operatorul, componentele cântărite, totalul. |
| Componentele pachetului | Fiecare cântărire, în ordine, cu greutate, unitate, sursă (cântar/manual). |
O rețetă poate avea 1–4 componente (fluxul curent folosește 1–2). Ordinea din rețetă este ordinea în care aplicația cere cântăririle.
Fiecare produs finit poate fi legat de un șablon de etichetă (câmpul Șablon de etichetă din formular). La pornirea unui pachet, șablonul se alege în ordinea:
- șablonul cerut explicit în momentul pornirii (dacă există);
- șablonul produsului finit;
- șablonul implicit din Setări;
- primul șablon disponibil.
Un șablon legat de un produs finit activ nu poate fi șters, ca să nu rămână produse fără etichetă.
Unitățile sunt convertite automat: dacă rețeta este în g și cântarul raportează kg, valoarea este transformată înainte de salvare.
Cerințe
- Python 3.10+
- Un profil de cântar în
instance/bridge.json(opțional; fără el se folosește introducerea manuală) - QZ Tray 2.2.x pe stație, cu agentul pornit (
localhost:8181), pentru tipărire directă - Imprimantă termică de etichete separată de cântar
Instalare și pornire
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt # sau requirements-dev.txt pentru teste
python run.py # dezvoltare: http://127.0.0.1:5000
python run.py --production # producție: waitress, 0.0.0.0:5000
python run.py --host 0.0.0.0 --port 5000
Baza de date, bridge.json și capturile se creează automat în instance/. La pornire, consola
arată URL-ul, calea bazei și ce mai lipsește pentru prima cântărire.
Ghid complet de punere în funcțiune (systemd, Windows, QZ Tray, backup, actualizare): DEPLOY.md.
Fără hardware, pentru demonstrație:
CANTARIRE_SIMULATE=toledo_cont python run.py
Securitate: aplicația nu are autentificare proprie. Implicit ascultă doar pe
127.0.0.1. Dacă o expui în rețea, folosește-o doar într-o rețea de încredere sau pune un reverse proxy cu autentificare în față. În producție seteazăCANTARIRE_SECRET_KEY(python run.py --productionte avertizează dacă lipsește).
Configurare
| Variabilă | Implicit | Semnificație |
|---|---|---|
CANTARIRE_SECRET_KEY |
dev-only-change-me |
cheia Flask |
CANTARIRE_HOST / CANTARIRE_PORT |
127.0.0.1 / 5000 |
adresa de ascultare |
CANTARIRE_INSTANCE |
./instance |
folderul de date |
CANTARIRE_DATABASE |
instance/cantarire.db |
baza SQLite |
CANTARIRE_BRIDGE_CONFIG |
instance/bridge.json |
configurația SyncPulse |
CANTARIRE_SIMULATE |
gol | cântare simulate |
CANTARIRE_MAX_AGE_MS |
1500 |
cât de veche poate fi o citire |
CANTARIRE_WAIT_MS |
2500 |
cât așteptăm o citire proaspătă |
CANTARIRE_AUTO_PRINT |
1 |
permite auto-printul (combinat cu setarea din UI) |
CANTARIRE_SERVER |
flask |
waitress pentru producție |
Din Setări se configurează: datele firmei, profilul de cântar activ, șablonul implicit, operatorul implicit și tipărirea automată.
Administrare
python -m cantarire.tools.admin status # cale, mărime, integritate, număr de înregistrări
python -m cantarire.tools.admin check # e aplicația gata de cântărit? (cod 1 dacă lipsește ceva)
python -m cantarire.tools.admin backup # backup în instance/backups/ (păstrează ultimele 20)
python -m cantarire.tools.admin reset --yes # golește datele, reface setările + șablonul (cu backup)
python -m cantarire.tools.admin seed-demo # date demo pentru instruire
GET /healthz întoarce starea bazei și setup.ready (util pentru monitorizare); din interfață,
Setări → Sistem arată aceleași informații și oferă Descarcă backup.
Cântarul: Bizerba BC800
BC800 este folosit doar ca cântar, conectat prin RJ11 / 10P4C la un adaptor USB–serial. Etichetele se tipăresc pe o imprimantă termică separată, prin QZ Tray.
Biblioteca vendorizată nu are un parser Bizerba. Ca să afli ce trimite cântarul, fără a scrie nimic către el:
python -m cantarire.tools.monitor --list
python -m cantarire.tools.monitor /dev/ttyUSB0 --seconds 8
python -m cantarire.tools.monitor COM3 --scan
python -m cantarire.tools.monitor COM3 --baud 9600 --framing 7E1 --save bizerba
sau din interfață: Setări → Monitor serial (/monitor). Pagina arată cadrele hex/text,
încearcă toate parserele existente și salvează captura în instance/captures/.
Setări de încercat: 9600 8N1, 9600 7E1, 4800 7E1, 9600 7O1. Dacă BC800 trimite doar la
apăsarea Print pe indicator, ține monitorul pornit și apasă.
Cablaj: adaptorul USB–RJ11 funcționează doar dacă priza cântarului are TX/RX/GND la nivel RS-232, în aceeași ordine cu adaptorul. Unele prize modulare sunt TTL sau current-loop. Verifică pinoutul în manualul BC800 înainte de conectare. Preferă adaptoare FTDI/CP210x.
Odată ce există o captură, se adaugă parserul în scale_bridge/protocols.py (sau, dacă formatul
este text simplu, se configurează un profil custom_ascii).
Etichete
Etichetele se definesc exclusiv în designerul vizual, la dimensiune fizică în milimetri (implicit 100 × 60 mm). Fiecare șablon este un set de blocuri poziționate pe etichetă; vechiul mod HTML/CSS (Jinja) a fost scos, ca să existe un singur fel de a construi o etichetă.
Șabloanele vechi care aveau doar HTML primesc automat designul implicit la prima pornire, deci nicio etichetă nu rămâne fără conținut.
Designer vizual
Din Șabloane → + Șablon nou (sau Designer pe un șablon existent):
- la creare scrii numele șablonului în câmpul de lângă buton; dacă lași gol, se generează „Sablon designer" / „Sablon designer 2" etc. Numele trebuie să fie unic (fără diferență între majuscule și minuscule)
- numele poate fi schimbat oricând direct din antetul designerului: îl editezi pe loc și apeși Redenumește
- bara de sus: butoanele de adaugare (Câmp variabil, Text, Cod QR, Linie, Imagine), dimensiunile etichetei în mm, zoom-ul și comutatorul pentru zona sigură
- Câmp variabil deschide un popup cu toate datele disponibile (grupate pe Produs finit /
Pachet / Firmă / Câmpuri proprii) plus intrarea „Defalcare componente"; click pe o intrare adaugă
blocul corespunzător (
fieldsauitems) cu câmpul deja setat. Popup-ul are căutare, se închide cu Escape sau click pe fundal, marchează „ultimul" câmp folosit și are link direct către Câmpuri (administrarea câmpurilor proprii). - coloana din stânga listează elementele deja adăugate, cu numărul lor; un click selectează elementul, care se editează în coloana din dreapta
- poziționare în mm, grid de 10 mm, mutare cu mouse-ul sau cu săgețile (Shift = 2 mm)
- redimensionare din colțul din dreapta-jos; proprietăți pentru mărime, aldine, aliniere, culoare
- previzualizare live cu date demo, identică cu eticheta tipărită
- Salvează scrie designul ca JSON în
label_templates.design_json
Zona sigură
Conturul roșu întrerupt de la 3 mm de margine (designer.SAFE_MARGIN_MM) arată zona în care
imprimanta termică poate tipări sigur. Elementele care ies din el sunt marcate cu roșu, iar în
bara de sus apare numărul lor („2 elemente ies din zona sigură"); la salvare primești un
avertisment, dar designul este salvat. Este o recomandare, nu o constrângere: limitele dure rămân
marginile fizice ale etichetei. Conturul poate fi ascuns din caseta „Contur roșu" din bara de sus
(alegerea se ține minte în browser).
Când eticheta este micșorată (de exemplu de la 150 × 80 mm la 60 × 40 mm), elementele nu mai sunt mutate automat — rămân acolo unde au fost proiectate și sunt marcate ca fiind în afara zonei sigure. Pentru a le readuce, apasă „Rearanjează în zona sigură" (apare doar când există elemente în afara conturului): întregul aranjament este scalat proporțional (poziții, mărimi și fonturi) până încape în contur și este translatat în colțul zonei sigure, apoi rotunjit la 0,1 mm. Se păstrează deci aspectul etichetei, doar la o scară mai mică, iar elementele nu se suprapun; mesajul de confirmare arată procentul folosit („8 elemente aduse în zona sigură (38% din mărime)"). Scalarea nu mărește niciodată designul, deci la refacerea unei etichete mai mari elementele rămân la dimensiunea lor.
Zoom-ul redimensionează canvas-ul, iar toate pozițiile și mărimile de font se recalculează în pixeli la scara curentă, deci ce vezi pe ecran corespunde 1:1 cu eticheta la tipărire.
Câmpuri variabile proprii
Pagina Câmpuri (/campuri) administrează câmpurile care apar în designer lângă cele fixe.
Un câmp propriu este un text liber care poate prelua date existente, scrise între acolade:
| Denumire | Text | Rezultat pe etichetă |
|---|---|---|
| Contact firmă | Tel. {{company.phone}} |
Tel. 0700 000 000 |
| Etichetă produs | {{recipe.name}} · {{package.lot}} |
Fasole cu carne · LOT-2610-A |
| Alergeni | Poate conține urme de gluten |
text fix |
- cheia (
custom.contact_firma) este generată automat din denumire, transliterată (ă→a, ș→s, ț→t) și rămâne aceeași la editare, ca designurile să nu se strice - formularul are previzualizare live cu date demo și butoane pentru inserarea unui câmp
- lista arată rezultatul demo și în câte șabloane este folosit câmpul
- ștergerea unui câmp lasă designurile valide, dar blocurile respective apar goale (avertisment la ștergere dacă e folosit)
- câmpurile proprii pot referi doar câmpuri fixe, deci nu pot intra în buclă; un nume necunoscut este respins la salvare
- câmpurile inactive rămân definite, dar nu mai apar în popup-ul din designer
Serverul expandează textul la fiecare randare, deci eticheta tipărită folosește datele reale ale pachetului, nu valorile demo.
Blocurile și câmpurile disponibile:
| Tip bloc | Ce afișează |
|---|---|
text |
text liber (ex. „PRODUS FINIT") |
field |
un câmp din tabelul de mai jos, cu prefix/sufix opțional |
items |
toate componentele cântărite (ex. 250 g Fasole, 200 g Carne), pe linii sau inline |
qr |
cod QR generat pe server (SVG); conținutul se compune din câmpuri alese |
line |
linie de separare |
image |
logo din /static/…, https://… sau data:image/… |
Câmpuri pentru blocul field: recipe.name, recipe.code, package.total_weight,
package.weight_unit, package.unit, package.lot, package.operator,
package.completed_at, package.id, company.name, company.address, company.phone.
Conținutul codului QR
Un bloc qr nu mai e legat de un singur câmp: are o listă ordonată de părți, fiecare fiind
un câmp variabil sau un text liber, plus un separator.
- + Adaugă câmp deschide selectorul în mod multiplu: bifezi câte câmpuri vrei, în ordinea în care le adaugi, și confirmi cu Adaugă (n). Intrarea „Conținut complet” adaugă tot pachetul (produs, lot, greutate, dată) ca o singură parte.
- + Text liber adaugă o parte fixă pe care o editezi direct în listă (ex.
LOT). - Fiecare parte are ↑ / ↓ pentru ordine și × pentru ștergere.
- Separator: bară verticală, liniuță, slash, spațiu, rând nou sau niciunul.
- Sub listă apare textul rezultat, calculat cu datele demo — exact ce va conține codul.
Exemplu: PF-001 + text LOT + package.lot, separator liniuță → PF-001 - LOT - LOT-2610-A.
Părțile goale (ex. un lot necompletat) sunt sărite automat. Dacă lista rămâne fără părți, se
folosește conținutul complet implicit, ca să nu iasă un cod gol.
Modelul salvat: {"type": "qr", "separator": "pipe", "parts": [{"field": "recipe.code"}, {"text": "LOT"}, {"field": "package.lot"}]}. Designurile vechi, cu un singur field, sunt
convertite automat într-o singură parte.
Designul este revalidat pe server la salvare și la randare: coordonatele sunt limitate la etichetă, textul este escapat, imaginile externe sunt restricționate la adrese permise.
Butonul Resetează designul readuce șablonul la aranjamentul implicit, la dimensiunile lui curente — util dacă vrei să o iei de la zero fără să creezi alt șablon.
Codul QR este generat pe server (qrcode, SVG inline), deci eticheta nu depinde de servicii
externe și funcționează și fără internet.
Tipărire prin QZ Tray
Biblioteca static/js/vendor/qz-tray.js este QZ Tray Connector 2.2.4, patch-uită pentru
autentificare cu pairing key (fără schimb de certificat), la fel ca în proiectul de referință
quality_app-v2. static/js/qz-print.js expune:
window.CantarirePrint.print(payload) // Promise<{ok, method:'qz'|'browser', printer?}>
window.CantarireQZ = { getState, connect, disconnect, refreshPrinters, setPrinter, printTest }
Eticheta este trimisă direct la imprimantă, la dimensiunea șablonului:
qz.configs.create(printer, {
scaleContent: false, rasterize: true,
size: { width: 100, height: 60 }, units: 'mm',
margins: { top: 0, right: 0, bottom: 0, left: 0 }
});
await qz.print(config, [{ type: 'html', format: 'plain', data: fullLabelHtml }]);
Dacă agentul QZ nu răspunde, eticheta se tipărește prin dialogul browserului (pagina de etichetă),
fără a bloca postul de lucru. Selecția imprimantei se face din Setări sau din cardul de
cântărire și se păstrează în localStorage.
Pentru QZ Tray standard (certificat + semnătură), definește înainte de qz-print.js:
<script>
window.CANTARIRE_QZ = { certificateUrl: '/qz/certificate', signatureUrl: '/qz/sign' };
</script>
API JSON
| Metodă | Rută | Descriere |
|---|---|---|
GET |
/api/scale |
stare bibliotecă, profile, profil activ |
GET |
/api/weight |
citire curentă a cântarului |
GET |
/api/package |
pachetul deschis (dacă există) |
POST |
/api/packages |
începe un pachet: {recipe_id, lot, operator} |
POST |
/api/packages/<id>/items |
adaugă următoarea cântărire |
POST |
/api/packages/<id>/undo |
anulează ultima cântărire |
POST |
/api/packages/<id>/void |
renunță la pachet |
GET |
/api/packages/<id>/label |
payload-ul de tipărire |
POST |
/api/packages/<id>/printed |
confirmă tipărirea |
POST /items acceptă:
{"weight": "250"}— introducere manuală;{}— citește cântarul (profilul activ);force: trueacceptă o greutate instabilă.
Răspunsul conține starea completă și, după ultima componentă, label:
{
"ok": true,
"package": { "id": 1, "status": "done", "total_weight": 450, "unit": "g" },
"items": [
{ "position": 1, "component_name": "Fasole", "weight": 250, "unit": "g" },
{ "position": 2, "component_name": "Carne", "weight": 200, "unit": "g" }
],
"step": null,
"complete": true,
"label": {
"html": "<div class=\"label\">…</div>",
"css": ".label { … }",
"fields": { "breakdown": "250 g Fasole, 200 g Carne", "total_weight": "450" },
"print_url": "/labels/1/print",
"ack_url": "/api/packages/1/printed"
}
}
Erori: 409 pachet deja început / citire veche, 422 greutate instabilă, 400 date invalide.
Structura proiectului
cantarire-etichetare/
├── run.py
├── cantarire/
│ ├── __init__.py # create_app
│ ├── config.py # configurare din mediu
│ ├── db.py / schema.sql # SQLite, migrare v1→v5, seed
│ ├── repositories.py # componente, rețete, pachete, șabloane, câmpuri proprii
│ ├── units.py # unități de masă și conversii
│ ├── scale.py # adaptor peste vendor/scale_bridge
│ ├── probe.py # monitor serial / identificare cântar nou
│ ├── maintenance.py # backup, reset, verificare de pregătire, stare bază
│ ├── designer.py # modelul designerului, validare, randare HTML + QR
│ ├── logging_setup.py # jurnal de acces fără zgomotul polling-ului
│ ├── labels.py # randare etichete (din design) + payload QZ
│ ├── routes/ # main, components, recipes, history, printing, api, monitor,
│ │ # label_templates, fields (câmpuri variabile)
│ ├── tools/monitor.py # CLI monitor serial
│ ├── tools/admin.py # CLI: status, check, backup, reset, seed-demo
│ ├── templates/ # Jinja (fără build frontend)
│ └── static/ # CSS + JS; js/designer.js; js/vendor/qz-tray.js (LGPL-2.1)
├── instance/ # date de rulare (generat, ignorat de git)
├── deploy/ # cantarire.service (systemd), start-windows.bat
├── DEPLOY.md # ghid de punere în funcțiune
├── tests/
└── vendor/syncpulse-scale-bridge/
Migrarea de la modelul vechi este automată: products → components, weighings →
legacy_weighings (arhivă), coloanele design_json și recipes.template_id sunt adăugate dacă
lipsesc, tabelul label_fields este creat automat, iar șablonul implicit este înlocuit cu cel
pentru produs finit.
Teste
pip install -r requirements-dev.txt
pytest
Testele folosesc un cântar fals și o bază SQLite temporară. Acoperă: crearea componentelor și rețetelor, fluxul în doi pași, conversia de unități, starea de instabilitate, anulare/renunțare, istoric + CSV, validarea șabloanelor, designerul vizual (validare, salvare, randare, QR, zonă sigură), câmpurile variabile proprii (CRUD, chei, expandare, randare pe etichetă), navigația (cele două taburi și subnavigația de administrare), legarea produs → șablon, backup/reset și verificarea de pregătire, monitorul serial și activele QZ.
Licență și atribuire
Codul aplicației aparține proiectului cantarire-etichetare.
Designerul vizual este inspirat de modelul de date al
luomus/label-designer (MIT, LUOMUS — Finnish Museum
of Natural History): blocuri poziționate în milimetri, separate de datele afișate. Implementarea
de aici este independentă (fără Angular, fără build frontend), iar randarea se face pe server.
Dacă vrei să extinzi designerul, proiectul original rămâne o referință bună pentru funcții
avansate (pagini multiple, import Excel, coduri QR cu nivel de corecție).
vendor/syncpulse-scale-bridgeeste GPL-3.0-or-later, Copyright (C) 2026 Farshad Zamani — Cloudtart, inclus nemodificat. PăstreazăLICENSE,NOTICE.mdși atribuirea autorului în interfață (condiție GPLv3 §7(b)).cantarire/static/js/vendor/qz-tray.jseste QZ Tray Connector 2.2.4, LGPL-2.1-only, copiat dinquality_app-v2cu patch-ul pentru pairing key. Header-ul de licență este păstrat; vezi șicantarire/static/js/vendor/README.md.