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
+1
View File
@@ -0,0 +1 @@
"""Blueprint-urile aplicatiei sunt importate explicit in `create_app`."""
+277
View File
@@ -0,0 +1,277 @@
"""API JSON pentru fluxul de cantarire si pentru plugin-ul de printare."""
from __future__ import annotations
import math
from typing import Any
from flask import Blueprint, current_app, jsonify, request
from ..db import all_settings, get_setting
from ..labels import LabelTemplateError, print_payload, validate_template
from ..repositories import (
ValidationError,
add_package_item,
complete_package,
get_default_template,
get_open_package,
get_package,
get_recipe,
get_template,
mark_printed,
package_state,
start_package,
undo_package_item,
void_package,
)
from ..scale import get_scale
bp = Blueprint("api", __name__, url_prefix="/api")
def _error(message: str, status: int = 400, **extra: Any):
return jsonify({"ok": False, "error": message, **extra}), status
def _active_profile() -> str:
return get_setting("active_scale_profile", "")
def _state(package_id: int) -> dict[str, Any]:
state = package_state(package_id)
if state is None:
raise ValidationError("Pachetul nu exista.")
return state
def _parse_weight(raw: Any) -> float:
if isinstance(raw, str):
raw = raw.strip().replace(",", ".")
try:
value = float(raw)
except (TypeError, ValueError) as exc:
raise ValidationError("Greutatea introdusa nu este un numar valid.") from exc
if not math.isfinite(value) or value <= 0:
raise ValidationError("Greutatea trebuie sa fie un numar pozitiv.")
return round(value, 3)
def _resolve_template(template_id: Any, recipe: dict | None = None) -> dict | None:
"""Prioritate: cerere explicita -> sablonul produsului finit -> setare -> implicit."""
if template_id not in (None, ""):
try:
template = get_template(int(template_id))
except (TypeError, ValueError):
template = None
if template:
return template
recipe_template = (recipe or {}).get("template_id")
if recipe_template:
template = get_template(int(recipe_template))
if template:
return template
configured = get_setting("default_template_id", "")
if configured.isdigit():
template = get_template(int(configured))
if template:
return template
return get_default_template()
def _label_for(package_id: int, template_id: Any = None) -> dict | None:
package = get_package(package_id)
if package is None:
return None
recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None
template = _resolve_template(template_id or package.get("template_id"), recipe)
if template is None:
raise LabelTemplateError("Nu exista niciun sablon de eticheta configurat.")
validate_template(template, all_settings())
payload = print_payload(package, template, all_settings(), recipe)
if package.get("template_id") != template["id"]:
from ..db import get_db
db = get_db()
db.execute("UPDATE packages SET template_id = ? WHERE id = ?", (template["id"], package_id))
db.commit()
return payload
# -- scale -------------------------------------------------------------------
@bp.get("/scale")
def scale_status():
scale = get_scale(current_app)
return jsonify({
"ok": scale.available,
"error": scale.error,
"simulate": bool(current_app.config["SIMULATE"]),
"profiles": scale.profiles(),
"active_profile": _active_profile(),
})
@bp.get("/scale/profiles")
def scale_profiles():
return jsonify({"ok": True, "profiles": get_scale(current_app).profiles()})
@bp.get("/weight")
def weight():
scale = get_scale(current_app)
profile = request.args.get("profile", "").strip() or _active_profile()
result = scale.read(
profile,
max_age_ms=int(request.args.get("max_age_ms") or current_app.config["WEIGHT_MAX_AGE_MS"]),
wait_ms=int(request.args.get("wait_ms") or current_app.config["WEIGHT_WAIT_MS"]),
)
return jsonify(result)
# -- packages ----------------------------------------------------------------
@bp.get("/package")
def current_package():
package = get_open_package()
if package is None:
return jsonify({"ok": True, "state": None})
return jsonify({"ok": True, "state": _state(package["id"])})
@bp.get("/package/<int:package_id>")
def package_detail(package_id: int):
try:
return jsonify({"ok": True, "state": _state(package_id)})
except ValidationError as exc:
return _error(str(exc), 404)
@bp.post("/packages")
def package_start():
data = request.get_json(silent=True) or {}
try:
recipe = get_recipe(int(data.get("recipe_id") or 0))
except (TypeError, ValueError):
recipe = None
if recipe is None or not recipe["active"]:
return _error("Alege un produs finit activ.")
template = _resolve_template(data.get("template_id"), recipe)
try:
package_id = start_package(
recipe["id"],
operator=str(data.get("operator") or ""),
lot=str(data.get("lot") or ""),
notes=str(data.get("notes") or ""),
template_id=template["id"] if template else None,
)
except ValidationError as exc:
return _error(str(exc), 409)
return jsonify({"ok": True, **_state(package_id)}), 201
@bp.post("/packages/<int:package_id>/items")
def package_add_item(package_id: int):
data = request.get_json(silent=True) or {}
package = get_package(package_id)
if package is None:
return _error("Pachetul nu exista.", 404)
if package["status"] != "open":
return _error("Pachetul este deja finalizat.", 409)
force = bool(data.get("force"))
raw_weight = data.get("weight")
reading: dict[str, Any] | None = None
unit = str(data.get("unit") or package["unit"]).strip().lower()
source = "manual"
stable = True
scale_profile = ""
raw_hex = ""
if raw_weight in (None, ""):
scale = get_scale(current_app)
reading = scale.read(
str(data.get("profile") or "").strip() or _active_profile(),
max_age_ms=current_app.config["WEIGHT_MAX_AGE_MS"],
wait_ms=current_app.config["WEIGHT_WAIT_MS"],
)
if not reading.get("ok"):
return _error(reading.get("error") or "Cantarul nu poate fi citit.", 409,
reading=reading)
if reading.get("stale"):
return _error("Citirea cantarului este prea veche. Asteapta o citire noua.", 409,
reading=reading)
if not reading.get("stable") and not force:
return _error("Greutatea nu este stabila. Asteapta stabilizarea sau accepta valoarea.",
422, reading=reading)
try:
value = _parse_weight(reading.get("value"))
except ValidationError:
return _error("Cantarul nu a returnat o greutate valida.", 409, reading=reading)
unit = str(reading.get("unit") or package["unit"])
source = "scale"
stable = bool(reading.get("stable"))
scale_profile = reading.get("profile") or ""
raw_hex = reading.get("raw_hex") or ""
else:
try:
value = _parse_weight(raw_weight)
except ValidationError as exc:
return _error(str(exc))
try:
add_package_item(
package_id,
weight=value,
unit=unit,
source=source,
stable=stable,
scale_profile=scale_profile,
raw_hex=raw_hex,
)
state = _state(package_id)
label = None
if state["step"] is None and state["open"]:
complete_package(package_id)
state = _state(package_id)
label = _label_for(package_id)
except (ValidationError, LabelTemplateError) as exc:
return _error(str(exc), 409, reading=reading)
response = {"ok": True, **state, "reading": reading, "label": label}
return jsonify(response), 201
@bp.post("/packages/<int:package_id>/undo")
def package_undo(package_id: int):
try:
undo_package_item(package_id)
return jsonify({"ok": True, **_state(package_id)})
except ValidationError as exc:
return _error(str(exc), 409)
@bp.post("/packages/<int:package_id>/void")
def package_void(package_id: int):
if get_package(package_id) is None:
return _error("Pachetul nu exista.", 404)
void_package(package_id)
return jsonify({"ok": True, "state": None})
@bp.get("/packages/<int:package_id>/label")
def package_label(package_id: int):
try:
label = _label_for(package_id, request.args.get("template_id"))
except LabelTemplateError as exc:
return _error(str(exc), 409)
if label is None:
return _error("Pachetul nu exista.", 404)
return jsonify({"ok": True, "label": label})
@bp.post("/packages/<int:package_id>/printed")
def package_printed(package_id: int):
package = mark_printed(package_id)
if package is None:
return _error("Pachetul nu exista.", 404)
return jsonify({"ok": True, "package": package})
+58
View File
@@ -0,0 +1,58 @@
"""Administrarea componentelor cantarite."""
from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from ..repositories import (
ValidationError,
create_component,
delete_component,
get_component,
list_components,
update_component,
)
bp = Blueprint("components", __name__, url_prefix="/componente")
@bp.get("")
def index():
return render_template("components/list.html",
components=list_components(include_inactive=True))
@bp.route("/nou", methods=["GET", "POST"])
def create():
if request.method == "POST":
try:
create_component(request.form)
flash("Componentul a fost adaugat.", "success")
return redirect(url_for("components.index"))
except ValidationError as exc:
flash(str(exc), "error")
return render_template("components/form.html", component=dict(request.form), is_new=True)
@bp.route("/<int:component_id>/editare", methods=["GET", "POST"])
def edit(component_id: int):
component = get_component(component_id)
if component is None:
flash("Componentul nu exista.", "error")
return redirect(url_for("components.index"))
if request.method == "POST":
try:
update_component(component_id, request.form)
flash("Componentul a fost actualizat.", "success")
return redirect(url_for("components.index"))
except ValidationError as exc:
flash(str(exc), "error")
component = {**component, **dict(request.form)}
return render_template("components/form.html", component=component, is_new=False)
@bp.post("/<int:component_id>/stergere")
def remove(component_id: int):
delete_component(component_id)
flash("Componentul a fost sters sau dezactivat (daca e folosit intr-o reteta).", "success")
return redirect(url_for("components.index"))
+113
View File
@@ -0,0 +1,113 @@
"""Administrarea campurilor variabile proprii (custom.*).
Un camp propriu este un text care poate include alte campuri fixe, de exemplu
`Tel. {{company.phone}}`. La randarea etichetei, textul este expandat cu
valorile reale ale pachetului.
"""
from __future__ import annotations
from flask import (Blueprint, jsonify, flash, redirect, render_template, request, url_for)
from .. import designer
from ..repositories import (
ValidationError,
count_label_field_usage,
create_label_field,
delete_label_field,
get_label_field,
list_label_fields,
update_label_field,
)
bp = Blueprint("fields", __name__, url_prefix="/campuri")
def _form_context(field: dict, *, is_new: bool) -> dict:
return {
"field": field,
"is_new": is_new,
"catalog": [
{"key": key, "label": label, "sample": sample}
for key, label, sample in designer.FIELD_CATALOG
],
"max_label": designer.MAX_CUSTOM_LABEL,
"max_template": designer.MAX_CUSTOM_TEMPLATE,
}
@bp.get("")
def index():
rows = list_label_fields(include_inactive=True)
for row in rows:
row["usage"] = count_label_field_usage(row["key"])
row["preview"] = designer.expand_custom(
row.get("template") or "",
designer.apply_custom_fields(designer.demo_fields(), [row]),
)
return render_template("fields/list.html", fields=rows)
@bp.route("/nou", methods=["GET", "POST"])
def create():
if request.method == "POST":
data = dict(request.form)
try:
designer.validate_custom_field(data)
create_label_field(data)
flash("Campul a fost adaugat.", "success")
return redirect(url_for("fields.index"))
except ValidationError as exc:
flash(str(exc), "error")
return render_template("fields/form.html", **_form_context(data, is_new=True))
return render_template("fields/form.html",
**_form_context({"active": 1}, is_new=True))
@bp.route("/<int:field_id>/editare", methods=["GET", "POST"])
def edit(field_id: int):
field = get_label_field(field_id)
if field is None:
flash("Campul nu exista.", "error")
return redirect(url_for("fields.index"))
if request.method == "POST":
data = dict(request.form)
try:
designer.validate_custom_field(data)
update_label_field(field_id, data)
flash("Campul a fost actualizat.", "success")
return redirect(url_for("fields.index"))
except ValidationError as exc:
flash(str(exc), "error")
field = {**field, **data}
return render_template("fields/form.html", **_form_context(field, is_new=False))
@bp.post("/<int:field_id>/stergere")
def remove(field_id: int):
field = get_label_field(field_id)
if field is None:
flash("Campul nu exista.", "error")
return redirect(url_for("fields.index"))
delete_label_field(field_id)
usage = count_label_field_usage(field["key"])
if usage:
flash(
f"Campul {field['label']} a fost sters. Atentie: {usage} sabloane il folosesc "
"si il vor afisa gol pana il inlocuiesti.",
"error",
)
else:
flash(f"Campul {field['label']} a fost sters.", "success")
return redirect(url_for("fields.index"))
@bp.get("/previzualizare")
def preview():
"""Valoarea rezultata pentru textul introdus, cu datele demo."""
text = (request.args.get("template") or "")[: designer.MAX_CUSTOM_TEMPLATE * 2]
errors = designer.custom_template_errors(text)
if errors:
return jsonify({"ok": False, "error": errors[0]})
demo = designer.demo_fields()
return jsonify({"ok": True, "value": designer.expand_custom(text, demo)})
+65
View File
@@ -0,0 +1,65 @@
"""Istoricul pachetelor si exportul CSV."""
from __future__ import annotations
import csv
import io
from flask import Blueprint, Response, render_template, request
from ..repositories import get_recipe, list_packages, list_recipes, to_csv_rows
from ..units import format_weight
bp = Blueprint("history", __name__, url_prefix="/istoric")
def _filters() -> dict:
recipe_id = request.args.get("recipe_id", "").strip()
status = request.args.get("status", "").strip()
return {
"date_from": request.args.get("date_from", "").strip(),
"date_to": request.args.get("date_to", "").strip(),
"recipe_id": int(recipe_id) if recipe_id.isdigit() else None,
"status": status if status in {"open", "done", "void"} else "",
"query": request.args.get("q", "").strip(),
"limit": 1000,
}
def _totals(packages: list[dict]) -> str:
groups: dict[str, float] = {}
for package in packages:
if package.get("status") != "done":
continue
unit = package.get("unit") or ""
groups[unit] = groups.get(unit, 0.0) + float(package.get("total_weight") or 0)
return ", ".join(
f"{format_weight(value)} {unit}" for unit, value in sorted(groups.items())
) or "0"
@bp.get("")
def index():
filters = _filters()
packages = list_packages(**filters)
recipe = get_recipe(filters["recipe_id"]) if filters["recipe_id"] else None
return render_template(
"history/list.html",
packages=packages,
filters=filters,
recipes=list_recipes(include_inactive=True),
total=_totals(packages),
selected_recipe=recipe,
)
@bp.get("/export.csv")
def export_csv():
packages = list_packages(**_filters())
buffer = io.StringIO()
writer = csv.writer(buffer, delimiter=";", lineterminator="\r\n")
writer.writerows(to_csv_rows(packages))
return Response(
buffer.getvalue(),
mimetype="text/csv; charset=utf-8",
headers={"Content-Disposition": 'attachment; filename="produse-finite.csv"'},
)
+217
View File
@@ -0,0 +1,217 @@
"""Administrarea sabloanelor de eticheta (designer vizual)."""
from __future__ import annotations
from typing import Any
from flask import (Blueprint, Response, flash, jsonify, redirect, render_template, request,
url_for)
from .. import designer
from ..db import all_settings
from ..labels import print_payload, sample_package, sample_recipe
from ..repositories import (
ValidationError,
create_template,
delete_template,
get_template,
list_label_fields,
list_templates,
save_template_design,
update_template,
)
bp = Blueprint("label_templates", __name__, url_prefix="/sabloane")
@bp.get("")
def index():
return render_template("templates/list.html", templates=list_templates())
@bp.post("/<int:template_id>/stergere")
def remove(template_id: int):
try:
delete_template(template_id)
flash("Sabloul a fost sters.", "success")
except ValidationError as exc:
flash(str(exc), "error")
return redirect(url_for("label_templates.index"))
@bp.get("/<int:template_id>/previzualizare")
def preview(template_id: int):
template = get_template(template_id)
if template is None:
flash("Sabloul nu exista.", "error")
return redirect(url_for("label_templates.index"))
demo = sample_package()
payload = print_payload(demo, template, all_settings(), sample_recipe())
return render_template("label_print.html", payload=payload, auto=False, preview=True)
# ---------------------------------------------------------------------------
# Designer vizual
# ---------------------------------------------------------------------------
def _designer_context(template: dict) -> dict:
width = float(template.get("width_mm") or 100)
height = float(template.get("height_mm") or 60)
design = designer.load_design(template.get("design_json") or "", width, height)
if design is None:
design = designer.default_design(width, height)
custom_rows = list_label_fields()
demo_fields = designer.apply_custom_fields(designer.demo_fields(), custom_rows)
fields = [
{"key": key, "label": label, "sample": sample}
for key, label, sample in designer.FIELD_CATALOG
]
fields += designer.custom_field_catalog(custom_rows, demo_fields)
return {
"template": template,
"design": design,
"fields": fields,
"demo_fields": demo_fields,
"demo_items": designer.demo_items(),
"block_types": designer.BLOCK_TYPES,
"safe_mm": designer.SAFE_MARGIN_MM,
"custom_count": len(custom_rows),
"has_design": bool(template.get("design_json")),
}
@bp.get("/<int:template_id>/designer")
def designer_page(template_id: int):
template = get_template(template_id)
if template is None:
flash("Sabloul nu exista.", "error")
return redirect(url_for("label_templates.index"))
return render_template("templates/designer.html", **_designer_context(template))
@bp.post("/<int:template_id>/designer")
def designer_save(template_id: int):
template = get_template(template_id)
if template is None:
return jsonify({"ok": False, "error": "Sabloul nu exista."}), 404
data = request.get_json(silent=True) or {}
try:
design = designer.validate_design(
data.get("design"),
float(template.get("width_mm") or 100),
float(template.get("height_mm") or 60),
)
except ValidationError as exc:
return jsonify({"ok": False, "error": str(exc)}), 400
save_template_design(template_id, designer.dump_design(design))
# Dimensiunea din designer devine dimensiunea sablonului.
width = float(design["width_mm"])
height = float(design["height_mm"])
if (width, height) != (float(template["width_mm"]), float(template["height_mm"])):
update_template(template_id, {
"name": template["name"],
"width_mm": width,
"height_mm": height,
"body_html": template["body_html"] or " ",
"css": template["css"],
"design_json": designer.dump_design(design),
"is_default": template["is_default"],
})
html, css = designer.render_preview(design, list_label_fields())
return jsonify({"ok": True, "blocks": len(design["blocks"]), "html": html, "css": css})
@bp.post("/<int:template_id>/designer/stergere")
@bp.post("/<int:template_id>/designer/reset")
def designer_reset(template_id: int):
"""Reseteaza designul la varianta implicita (nu lasă șablonul fără etichetă)."""
template = get_template(template_id)
if template is None:
flash("Sabloul nu exista.", "error")
return redirect(url_for("label_templates.index"))
width = float(template.get("width_mm") or 100)
height = float(template.get("height_mm") or 60)
design = designer.default_design(width, height)
save_template_design(template_id, designer.dump_design(design))
flash("Designul a fost resetat la varianta implicita.", "success")
return redirect(url_for("label_templates.designer_page", template_id=template_id))
@bp.get("/designer/qr.svg")
def designer_qr():
"""Cod QR inline pentru previzualizarea din editor."""
data = (request.args.get("data") or "")[:500]
svg = designer.qr_svg(data)
return Response(svg, mimetype="image/svg+xml",
headers={"Cache-Control": "public, max-age=3600"})
@bp.post("/designer/nou")
def designer_create():
"""Creeaza un sablon nou cu designer, cu numele ales de utilizator."""
width = _size_or_default(request.form.get("width_mm"), 100.0)
height = _size_or_default(request.form.get("height_mm"), 60.0)
design = designer.default_design(width, height)
name = (request.form.get("name") or "").strip()[:80]
existing = {t["name"].strip().lower() for t in list_templates()}
if name:
if name.lower() in existing:
flash(f"Exista deja un sablon numit {name!r}. Alege alt nume.", "error")
return redirect(url_for("label_templates.index"))
else:
# Fara nume: generam unul disponibil, ca sa nu blocam crearea.
name = "Sablon designer"
base = name
counter = 2
while name.lower() in existing:
name = f"{base} {counter}"
counter += 1
template_id = create_template({
"name": name,
"width_mm": design["width_mm"],
"height_mm": design["height_mm"],
"body_html": " ",
"css": designer.DESIGN_CSS,
"design_json": designer.dump_design(design),
"is_default": request.form.get("is_default"),
})
flash(f"Sablonul {name} a fost creat.", "success")
return redirect(url_for("label_templates.designer_page", template_id=template_id))
def _size_or_default(value: Any, default: float) -> float:
try:
number = float(str(value).replace(",", "."))
except (TypeError, ValueError):
return default
return number if 10 <= number <= 500 else default
@bp.post("/<int:template_id>/redenumire")
def rename(template_id: int):
"""Schimba numele sablonului (din pagina de designer sau din lista)."""
template = get_template(template_id)
if template is None:
flash("Sabloul nu exista.", "error")
return redirect(url_for("label_templates.index"))
name = (request.form.get("name") or "").strip()
if not name:
flash("Numele sablonului este obligatoriu.", "error")
elif name == template["name"]:
pass
else:
try:
update_template(template_id, {**template, "name": name})
flash(f"Sabloul a fost redenumit in {name}.", "success")
except ValidationError as exc:
flash(str(exc), "error")
if request.form.get("next") == "designer":
return redirect(url_for("label_templates.designer_page", template_id=template_id))
return redirect(url_for("label_templates.index"))
+135
View File
@@ -0,0 +1,135 @@
"""Pagina de cantarire si setarile aplicatiei."""
from __future__ import annotations
from flask import (Blueprint, current_app, flash, jsonify, redirect, render_template, request,
send_file, url_for)
from .. import maintenance
from ..db import all_settings, get_setting, set_setting
from ..repositories import (
get_open_package,
get_recipe,
get_template,
list_components,
list_label_fields,
list_recipes,
list_templates,
package_state,
package_stats,
recent_packages,
)
from ..scale import get_scale
bp = Blueprint("main", __name__)
@bp.get("/")
def dashboard():
scale = get_scale(current_app)
profiles = scale.profiles()
active_profile = get_setting("active_scale_profile")
if active_profile not in {p["name"] for p in profiles}:
active_profile = profiles[0]["name"] if profiles else ""
open_package = get_open_package()
initial = package_state(open_package["id"]) if open_package else None
settings = all_settings()
template_id = settings.get("default_template_id") or ""
if template_id and get_template(int(template_id)) is None:
template_id = ""
recipes = [get_recipe(item["id"]) for item in list_recipes()]
recipes = [recipe for recipe in recipes if recipe is not None]
return render_template(
"dashboard.html",
recipes=recipes,
components=list_components(),
initial=initial,
recent=recent_packages(6),
stats=package_stats(),
profiles=profiles,
active_profile=active_profile,
scale_ready=bool(profiles),
default_operator=settings.get("default_operator", ""),
default_template_id=template_id,
auto_print=settings.get("auto_print", "1") == "1" and current_app.config["AUTO_PRINT"],
poll_ms=700,
scale_error=scale.error,
setup=maintenance.readiness_summary(),
)
@bp.get("/healthz")
def healthz():
"""Verificare simpla pentru monitorizare / reverse proxy."""
status = maintenance.database_status()
summary = maintenance.readiness_summary()
healthy = status["integrity"] == "ok"
payload = {
"ok": healthy,
"version": "0.1.0",
"database": {
"path": status["path"],
"size": status["size"],
"integrity": status["integrity"],
"schema_version": status["schema_version"],
"counts": status["counts"],
},
"setup": {
"ready": summary["ready"],
"missing": [item["label"] for item in summary["missing"]],
},
}
return jsonify(payload), (200 if healthy else 503)
@bp.get("/setari")
def settings_page():
scale = get_scale(current_app)
return render_template(
"settings.html",
profiles=scale.profiles(),
scale_available=scale.available,
scale_error=scale.error,
templates=list_templates(),
components=list_components(include_inactive=True),
recipes=list_recipes(include_inactive=True),
fields_count=len(list_label_fields(include_inactive=True)),
db_status=maintenance.database_status(),
backups=maintenance.list_backups()[:5],
setup=maintenance.readiness_summary(),
)
@bp.get("/setari/backup")
def settings_backup():
"""Descarca un backup proaspat al bazei (inainte de o actualizare)."""
path = maintenance.backup_database("manual")
maintenance.prune_backups()
return send_file(path, as_attachment=True, download_name=path.name)
@bp.post("/setari")
def settings_save():
scale = get_scale(current_app)
valid_profiles = {p["name"] for p in scale.profiles()}
active = (request.form.get("active_scale_profile") or "").strip()
if active and active not in valid_profiles:
flash("Profilul de cantar selectat nu exista.", "error")
return redirect(url_for("main.settings_page"))
template_id = (request.form.get("default_template_id") or "").strip()
if template_id and (not template_id.isdigit() or get_template(int(template_id)) is None):
flash("Sabloul implicit selectat nu exista.", "error")
return redirect(url_for("main.settings_page"))
for key in ("company_name", "company_address", "company_phone", "default_operator"):
set_setting(key, (request.form.get(key) or "").strip()[:200])
set_setting("active_scale_profile", active)
set_setting("default_template_id", template_id)
set_setting("auto_print", "1" if request.form.get("auto_print") else "0")
flash("Setarile au fost salvate.", "success")
return redirect(url_for("main.settings_page"))
+85
View File
@@ -0,0 +1,85 @@
"""Monitor de port serial: identifica formatul unui cantar necunoscut."""
from __future__ import annotations
import math
from flask import Blueprint, current_app, flash, redirect, render_template, request, url_for
from ..probe import BAUD_RATES, FRAMINGS, ProbeError, list_ports, save_capture, sweep
from ..scale import get_scale
bp = Blueprint("monitor", __name__, url_prefix="/monitor")
def _form_values() -> dict:
def number(name: str, default: float) -> float:
raw = (request.form.get(name) or "").strip().replace(",", ".")
try:
value = float(raw)
except ValueError:
return default
return value if math.isfinite(value) else default
return {
"port": (request.form.get("port") or "").strip(),
"baud": int(number("baud", 9600)),
"framing": (request.form.get("framing") or "8N1").strip().upper(),
"seconds": min(max(number("seconds", 5), 0.5), 30),
"save": bool(request.form.get("save")),
"name": (request.form.get("name") or "capture").strip()[:60],
"scan": bool(request.form.get("scan")),
}
@bp.route("", methods=["GET", "POST"])
def index():
scale = get_scale(current_app)
values = _form_values() if request.method == "POST" else {
"port": "", "baud": 9600, "framing": "8N1", "seconds": 5.0,
"save": False, "name": "capture", "scan": False,
}
result = None
scan_hits = None
saved_path = None
error = None
if request.method == "POST":
if not values["port"]:
error = "Alege portul serial."
elif values["scan"]:
try:
rows = sweep(values["port"], seconds=min(values["seconds"], 2.0),
framings=(values["framing"],), bauds=BAUD_RATES)
scan_hits = [r for r in rows if r.get("bytes")]
if not scan_hits:
error = "Niciun byte primit la vitezele incercate. Verifica cablul si setarile cantarului."
except ProbeError as exc:
error = str(exc)
else:
result = scale.probe(values["port"], values["baud"], values["framing"], values["seconds"])
if not result.get("ok"):
error = result.get("error") or "Portul nu a putut fi deschis."
elif values["save"] and result.get("frames"):
try:
saved_path = save_capture(
current_app.config["INSTANCE_DIR"] / "captures",
values["name"],
result["frames"],
)
flash(f"Captura a fost salvata in {saved_path}.", "success")
except OSError as exc:
error = f"Captura nu a putut fi scrisa: {exc}"
return render_template(
"monitor.html",
values=values,
ports=list_ports(),
profiles=scale.profiles(),
result=result,
scan_hits=scan_hits,
saved_path=saved_path,
error=error,
baud_rates=BAUD_RATES,
framings=FRAMINGS,
)
+46
View File
@@ -0,0 +1,46 @@
"""Pagina de printare a unei etichete de produs finit."""
from __future__ import annotations
from flask import Blueprint, abort, current_app, render_template, request
from ..db import all_settings, get_setting
from ..labels import LabelTemplateError, print_payload
from ..repositories import get_default_template, get_package, get_recipe, get_template
bp = Blueprint("printing", __name__)
@bp.get("/labels/<int:package_id>/print")
def print_label(package_id: int):
package = get_package(package_id)
if package is None:
abort(404)
template_id = request.args.get("template_id") or package.get("template_id")
configured = get_setting("default_template_id", "")
template = None
if template_id:
try:
template = get_template(int(template_id))
except (TypeError, ValueError):
template = None
if template is None and configured.isdigit():
template = get_template(int(configured))
template = template or get_default_template()
if template is None:
abort(409, "Nu exista niciun sablon de eticheta configurat.")
recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None
try:
payload = print_payload(package, template, all_settings(), recipe)
except LabelTemplateError as exc:
abort(500, str(exc))
settings = all_settings()
auto = request.args.get("auto")
if auto is None:
auto = settings.get("auto_print", "1") == "1" and current_app.config["AUTO_PRINT"]
else:
auto = auto not in {"0", "false", "no"}
return render_template("label_print.html", payload=payload, auto=bool(auto), preview=False)
+85
View File
@@ -0,0 +1,85 @@
"""Administrarea produselor finite (retetelor)."""
from __future__ import annotations
from flask import Blueprint, flash, redirect, render_template, request, url_for
from ..repositories import (
MAX_RECIPE_COMPONENTS,
ValidationError,
create_recipe,
delete_recipe,
get_recipe,
list_components,
list_recipes,
list_templates,
update_recipe,
)
bp = Blueprint("recipes", __name__, url_prefix="/retete")
def _slot_rows(source: dict | None) -> list[dict]:
source = source or {}
components = source.get("components") or []
rows = []
for slot in range(1, MAX_RECIPE_COMPONENTS + 1):
existing = next((c for c in components if c.get("position") == slot), None)
rows.append({
"slot": slot,
"component_id": existing.get("component_id") if existing else source.get(f"component_{slot}_id", ""),
"target": existing.get("target_weight") if existing else source.get(f"component_{slot}_target", ""),
})
return rows
def _render(template, is_new: bool, recipe: dict | None = None):
return render_template(
template,
recipe=recipe or {},
is_new=is_new,
components=list_components(),
templates=list_templates(),
slot_rows=_slot_rows(recipe),
)
@bp.get("")
def index():
return render_template("recipes/list.html", recipes=list_recipes(include_inactive=True))
@bp.route("/nou", methods=["GET", "POST"])
def create():
if request.method == "POST":
try:
create_recipe(request.form)
flash("Produsul finit a fost adaugat.", "success")
return redirect(url_for("recipes.index"))
except ValidationError as exc:
flash(str(exc), "error")
return _render("recipes/form.html", True, dict(request.form) if request.method == "POST" else {})
@bp.route("/<int:recipe_id>/editare", methods=["GET", "POST"])
def edit(recipe_id: int):
recipe = get_recipe(recipe_id)
if recipe is None:
flash("Produsul finit nu exista.", "error")
return redirect(url_for("recipes.index"))
if request.method == "POST":
try:
update_recipe(recipe_id, request.form)
flash("Produsul finit a fost actualizat.", "success")
return redirect(url_for("recipes.index"))
except ValidationError as exc:
flash(str(exc), "error")
recipe = {**recipe, **dict(request.form)}
return _render("recipes/form.html", False, recipe)
@bp.post("/<int:recipe_id>/stergere")
def remove(recipe_id: int):
delete_recipe(recipe_id)
flash("Produsul finit a fost sters sau dezactivat (daca are pachete in istoric).", "success")
return redirect(url_for("recipes.index"))