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
+141
View File
@@ -0,0 +1,141 @@
{% extends "base.html" %}
{% block title %}{{ "Produs finit nou" if is_new else "Editare produs finit" }}{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>{{ "Produs finit nou" if is_new else "Editare produs finit" }}</h1>
<p class="subtitle">Ordinea componentelor este ordinea în care se cântăresc.</p>
</div>
</section>
<div class="grid template-grid">
<section class="card">
<form method="post" class="form">
<div class="field-row">
<div class="field">
<label for="code">Cod <span class="req">*</span></label>
<input id="code" name="code" type="text" maxlength="40" required value="{{ recipe.code or '' }}">
</div>
<div class="field">
<label for="unit">Unitate</label>
<select id="unit" name="unit">
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
<option value="{{ u }}" {{ 'selected' if (recipe.unit or 'g') == u }}>{{ u }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="field">
<label for="name">Denumire pe etichetă <span class="req">*</span></label>
<input id="name" name="name" type="text" maxlength="120" required
value="{{ recipe.name or '' }}" placeholder="ex. Fasole cu carne">
</div>
<div class="field">
<label for="target_weight">Greutate totală țintă (opțional)</label>
<input id="target_weight" name="target_weight" type="number" step="0.001" min="0"
value="{{ recipe.target_weight|weight if recipe.target_weight else '' }}" placeholder="ex. 450">
<p class="muted small">Doar orientativă; eticheta afișează greutatea reală cântărită.</p>
</div>
<h2>Etichetă</h2>
<div class="field">
<label for="template_id">Șablon de etichetă</label>
<select id="template_id" name="template_id">
<option value="">— șablonul implicit din Setări —</option>
{% for t in templates %}
<option value="{{ t.id }}" {{ 'selected' if recipe.template_id|string == t.id|string }}>
{{ t.name }} · {{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm
</option>
{% endfor %}
</select>
<p class="muted small">
{% if templates %}
Eticheta acestui produs finit se randează cu șablonul ales aici.
<a href="{{ url_for('label_templates.index') }}">Vezi șabloanele</a> ·
<a href="{{ url_for('label_templates.index') }}">creează unul nou</a>
{% else %}
Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează întâi un șablon</a>,
altfel eticheta nu poate fi tipărită.
{% endif %}
</p>
</div>
<h2>Componente</h2>
<p class="muted small">Completează cel puțin primul rând. Ordinea rândurilor = ordinea cântăririi.</p>
{% for row in slot_rows %}
<div class="component-row">
<span class="component-index">{{ row.slot }}</span>
<div class="field grow">
<label for="component_{{ row.slot }}_id">
Component {{ row.slot }}{% if row.slot == 1 %} <span class="req">*</span>{% endif %}
</label>
<select id="component_{{ row.slot }}_id" name="component_{{ row.slot }}_id">
<option value="">— {{ "obligatoriu" if row.slot == 1 else "opțional" }} —</option>
{% for c in components %}
<option value="{{ c.id }}"
{{ 'selected' if row.component_id|string == c.id|string }}>
{{ c.code }} · {{ c.name }} ({{ c.unit }})
</option>
{% endfor %}
</select>
</div>
<div class="field component-target">
<label for="component_{{ row.slot }}_target">Țintă</label>
<input id="component_{{ row.slot }}_target" name="component_{{ row.slot }}_target"
type="number" step="0.001" min="0" value="{{ row.target|weight if row.target else '' }}">
</div>
</div>
{% endfor %}
<div class="field">
<label for="note">Observații interne</label>
<textarea id="note" name="note" rows="2" maxlength="500">{{ recipe.note or '' }}</textarea>
</div>
<label class="check">
<input type="checkbox" name="active" {{ 'checked' if recipe.active in [True, 1, '1', 'on', 'true'] or is_new }}>
Produs finit activ
</label>
<div class="actions">
<button class="btn primary" type="submit">Salvează</button>
<a class="btn ghost" href="{{ url_for('recipes.index') }}">Renunță</a>
</div>
</form>
</section>
<aside class="card help-card">
<h2>Cum funcționează</h2>
<p class="small">
La cântărire alegi produsul finit. Aplicația cere pe rând fiecare componentă, în ordinea de aici.
După ultima componentă eticheta se tipărește automat.
</p>
<h2>Eticheta produsului</h2>
<p class="small">
Șablonul ales aici este folosit automat la tipărirea etichetelor pentru acest produs finit;
dacă lași gol, se folosește șablonul implicit din Setări.
</p>
<h2>Exemplu</h2>
<p class="small">
<strong>Fasole cu carne</strong>, unitate <code>g</code>:
</p>
<ul class="var-list">
<li>1 · Fasole — țintă 250</li>
<li>2 · Carne — țintă 200</li>
</ul>
<p class="small muted">
Eticheta va arăta totalul real cântărit și defalcarea:
<code>250 g Fasole</code>, <code>200 g Carne</code>.
</p>
<h2>Fără componentă nouă?</h2>
<p class="small muted">
Dacă lipsește o componentă din listă, o adaugi întâi în
<a href="{{ url_for('components.create') }}">Componente</a>, apoi revii aici.
</p>
</aside>
</div>
{% endblock %}
+51
View File
@@ -0,0 +1,51 @@
{% extends "base.html" %}
{% block title %}Produse finite{% endblock %}
{% block content %}
<section class="page-head">
<div>
<h1>Produse finite</h1>
<p class="subtitle">Rețetele care se cântăresc și se etichetează.</p>
</div>
<a class="btn primary" href="{{ url_for('recipes.create') }}">+ Produs finit</a>
</section>
<section class="card">
{% if recipes %}
<div class="table-wrap">
<table class="table">
<thead>
<tr><th>Cod</th><th>Denumire</th><th class="num">Componente</th><th class="num">Țintă</th><th>Etichetă</th><th>Stare</th><th></th></tr>
</thead>
<tbody>
{% for r in recipes %}
<tr class="{{ 'row-muted' if not r.active }}">
<td><code>{{ r.code }}</code></td>
<td>{{ r.name }}</td>
<td class="num">{{ r.component_count }}</td>
<td class="num">{{ (r.target_weight|weight ~ ' ' ~ r.unit) if r.target_weight else "—" }}</td>
<td>
{% if r.template_name %}{{ r.template_name }}
{% else %}<span class="muted">implicit</span>{% endif %}
</td>
<td>
{% if r.active %}<span class="badge ok">activ</span>
{% else %}<span class="badge off">inactiv</span>{% endif %}
</td>
<td class="right nowrap">
<a class="link" href="{{ url_for('recipes.edit', recipe_id=r.id) }}">Editează</a>
<form class="inline" method="post" action="{{ url_for('recipes.remove', recipe_id=r.id) }}"
onsubmit="return confirm('Ștergi produsul finit {{ r.code }}? Dacă are pachete în istoric, va fi doar dezactivat.');">
<button class="link danger" type="submit">Șterge</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="muted">Nu există produse finite. <a href="{{ url_for('recipes.create') }}">Creează primul</a>.</p>
{% endif %}
</section>
{% endblock %}