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:
@@ -0,0 +1 @@
|
||||
"""Blueprint-urile aplicatiei sunt importate explicit in `create_app`."""
|
||||
@@ -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})
|
||||
@@ -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"))
|
||||
@@ -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)})
|
||||
@@ -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"'},
|
||||
)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user