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,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})
|
||||
Reference in New Issue
Block a user