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
278 lines
9.0 KiB
Python
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})
|