Files
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

422 lines
21 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
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](DEPLOY.md)**.
Fără hardware, pentru demonstrație:
```bash
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
```bash
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:
```bash
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:
```js
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:
```js
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`:
```html
<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`:
```json
{
"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
```bash
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`](https://github.com/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`.