Files
cantarire-etichetare/README.md
T
ske087 bff6d70b17 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
2026-10-18 23:38:45 +03:00

21 KiB
Raw Blame History

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.

  1. Se pregătesc caserolele.
  2. Se alege produsul finit (ex. Fasole cu carne) și lotul.
  3. Se pune prima componentă pe cântar; greutatea stabilă se acceptă cu Enter.
  4. Se adaugă a doua componentă; când greutatea este stabilă, Enter.
  5. Aplicația calculează totalul, salvează pachetul și tipărește eticheta.
  6. 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:

  1. șablonul cerut explicit în momentul pornirii (dacă există);
  2. șablonul produsului finit;
  3. șablonul implicit din Setări;
  4. 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 --production te 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 (field sau items) 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: true acceptă 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-bridge este 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.js este QZ Tray Connector 2.2.4, LGPL-2.1-only, copiat din quality_app-v2 cu patch-ul pentru pairing key. Header-ul de licență este păstrat; vezi și cantarire/static/js/vendor/README.md.