bff6d70b17
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
422 lines
21 KiB
Markdown
422 lines
21 KiB
Markdown
# 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`.
|