Files
ske087 bff6d70b17 Initial commit: aplicatie de cantarire si etichetare
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea
componentelor unui produs finit si tiparirea etichetei prin QZ Tray.

- produse finite (retete) formate din componente cantarite
- designer vizual de etichete (mm/px, zona sigura, campuri variabile,
  cod QR cu continut din mai multe bucati)
- legare produs finit -> sablon de eticheta
- navigare pe doua taburi: Cantarire / Setari
- administrare: componente, produse finite, sabloane, campuri, istoric
- backup, resetare baza de date, checklist de pregatire, /healthz
- CLI de administrare si serviciu systemd pentru productie
- vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
2026-10-18 23:38:45 +03:00

278 lines
9.0 KiB
Python

"""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})