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,60 @@
|
||||
"""Aplicatia Flask: fabrica, blueprint-uri, filtre Jinja."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask
|
||||
|
||||
from . import db
|
||||
from .config import Config
|
||||
from .labels import _format_datetime, _format_weight
|
||||
from .logging_setup import configure_logging
|
||||
from .scale import create_scale
|
||||
|
||||
|
||||
def create_app(test_config: dict | None = None) -> Flask:
|
||||
app = Flask(__name__, instance_relative_config=False)
|
||||
app.config.from_object(Config)
|
||||
if test_config:
|
||||
app.config.update(test_config)
|
||||
|
||||
instance_dir = Path(app.config["INSTANCE_DIR"])
|
||||
instance_dir.mkdir(parents=True, exist_ok=True)
|
||||
Path(app.config["DATABASE"]).parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
db.register(app)
|
||||
create_scale(app)
|
||||
if not app.config.get("TESTING"):
|
||||
configure_logging()
|
||||
|
||||
with app.app_context():
|
||||
db.init_db()
|
||||
|
||||
from .routes import (api, components, fields, history, label_templates, main, monitor,
|
||||
printing, recipes)
|
||||
|
||||
app.register_blueprint(main.bp)
|
||||
app.register_blueprint(components.bp)
|
||||
app.register_blueprint(recipes.bp)
|
||||
app.register_blueprint(label_templates.bp)
|
||||
app.register_blueprint(fields.bp)
|
||||
app.register_blueprint(printing.bp)
|
||||
app.register_blueprint(history.bp)
|
||||
app.register_blueprint(monitor.bp)
|
||||
app.register_blueprint(api.bp)
|
||||
|
||||
app.jinja_env.filters["weight"] = _format_weight
|
||||
app.jinja_env.filters["dt"] = _format_datetime
|
||||
|
||||
@app.context_processor
|
||||
def inject_globals():
|
||||
from .db import all_settings
|
||||
|
||||
return {
|
||||
"app_settings": all_settings(),
|
||||
"current_year": datetime.now().year,
|
||||
"app_version": "0.1.0",
|
||||
}
|
||||
|
||||
return app
|
||||
@@ -0,0 +1,40 @@
|
||||
"""Configurarea aplicatiei (variabile de mediu, fara secrete in cod)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
# Cheia folosita cand nu exista CANTARIRE_SECRET_KEY; NU se foloseste in productie.
|
||||
DEV_SECRET_KEY = "dev-only-change-me"
|
||||
|
||||
|
||||
def _env_flag(name: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(name)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
class Config:
|
||||
# Flask
|
||||
SECRET_KEY = os.environ.get("CANTARIRE_SECRET_KEY", DEV_SECRET_KEY)
|
||||
MAX_CONTENT_LENGTH = 2 * 1024 * 1024
|
||||
|
||||
# Date de rulare: baza SQLite, bridge.json, jurnale de etichete
|
||||
INSTANCE_DIR = Path(os.environ.get("CANTARIRE_INSTANCE", BASE_DIR / "instance"))
|
||||
DATABASE = INSTANCE_DIR / "cantarire.db"
|
||||
BRIDGE_CONFIG = Path(os.environ.get("CANTARIRE_BRIDGE_CONFIG", INSTANCE_DIR / "bridge.json"))
|
||||
|
||||
# CANTARIRE_SIMULATE="mahak_mds,toledo_cont:4800:7E1" porneste cantoare simulate
|
||||
SIMULATE = os.environ.get("CANTARIRE_SIMULATE", "").strip()
|
||||
|
||||
# Cat de proaspata / cat de mult asteptam o citire de la cantar
|
||||
WEIGHT_MAX_AGE_MS = int(os.environ.get("CANTARIRE_MAX_AGE_MS", "1500"))
|
||||
WEIGHT_WAIT_MS = int(os.environ.get("CANTARIRE_WAIT_MS", "2500"))
|
||||
|
||||
# Printarea automata din browser (plugin extern); se poate dezactiva global
|
||||
AUTO_PRINT = _env_flag("CANTARIRE_AUTO_PRINT", True)
|
||||
|
||||
JSON_AS_ASCII = False
|
||||
+172
@@ -0,0 +1,172 @@
|
||||
"""Conexiunea SQLite, migrarea de la modelul vechi si setarile aplicatiei."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import click
|
||||
from flask import current_app, g
|
||||
|
||||
SCHEMA_PATH = Path(__file__).with_name("schema.sql")
|
||||
SCHEMA_VERSION = 5
|
||||
|
||||
DEFAULT_SETTINGS: dict[str, str] = {
|
||||
"company_name": "Firma SRL",
|
||||
"company_address": "",
|
||||
"company_phone": "",
|
||||
"active_scale_profile": "",
|
||||
"default_template_id": "",
|
||||
"auto_print": "1",
|
||||
"default_operator": "",
|
||||
}
|
||||
|
||||
DEFAULT_TEMPLATE_NAME = "Eticheta produs finit"
|
||||
|
||||
|
||||
def _default_design_json(width: float = 100.0, height: float = 60.0) -> str:
|
||||
"""Designul implicit (import lazy: `designer` depinde de `repositories`)."""
|
||||
from . import designer
|
||||
|
||||
return designer.dump_design(designer.default_design(width, height))
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
if "db" not in g:
|
||||
path = Path(current_app.config["DATABASE"])
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
db = sqlite3.connect(path, detect_types=sqlite3.PARSE_DECLTYPES)
|
||||
db.row_factory = sqlite3.Row
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
db.execute("PRAGMA journal_mode = WAL")
|
||||
g.db = db
|
||||
return g.db
|
||||
|
||||
|
||||
def close_db(_exc: Optional[BaseException] = None) -> None:
|
||||
db = g.pop("db", None)
|
||||
if db is not None:
|
||||
db.close()
|
||||
|
||||
|
||||
def _table_names(db: sqlite3.Connection) -> set[str]:
|
||||
return {row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type = 'table'")}
|
||||
|
||||
|
||||
def _migrate_legacy(db: sqlite3.Connection) -> None:
|
||||
"""Modelul vechi (`products` + `weighings`) devine `components` + istoric arhivat."""
|
||||
tables = _table_names(db)
|
||||
if "components" not in tables and "products" in tables:
|
||||
db.execute("ALTER TABLE products RENAME TO components")
|
||||
if "packages" not in tables and "weighings" in tables:
|
||||
db.execute("ALTER TABLE weighings RENAME TO legacy_weighings")
|
||||
|
||||
|
||||
def _schema_version(db: sqlite3.Connection) -> int:
|
||||
"""Versiunea schemei salvata in setari (0 daca lipseste)."""
|
||||
if "settings" not in _table_names(db):
|
||||
return 0
|
||||
row = db.execute("SELECT value FROM settings WHERE key = 'schema_version'").fetchone()
|
||||
try:
|
||||
return int(row["value"]) if row is not None else 0
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _seed_settings(db: sqlite3.Connection) -> None:
|
||||
for key, value in DEFAULT_SETTINGS.items():
|
||||
db.execute("INSERT OR IGNORE INTO settings(key, value) VALUES (?, ?)", (key, value))
|
||||
|
||||
|
||||
def _seed_template(db: sqlite3.Connection) -> None:
|
||||
row = db.execute(
|
||||
"SELECT id FROM label_templates ORDER BY is_default DESC, id LIMIT 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
cur = db.execute(
|
||||
"INSERT INTO label_templates(name, width_mm, height_mm, body_html, css,"
|
||||
" design_json, is_default) VALUES (?, ?, ?, ' ', '', ?, 1)",
|
||||
(DEFAULT_TEMPLATE_NAME, 100.0, 60.0, _default_design_json(100.0, 60.0)),
|
||||
)
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value) VALUES ('default_template_id', ?)",
|
||||
(str(cur.lastrowid),),
|
||||
)
|
||||
return
|
||||
|
||||
# Sabloanele vechi (doar HTML/CSS) primesc designul implicit, ca eticheta sa iasa mereu.
|
||||
legacy = db.execute(
|
||||
"SELECT id, width_mm, height_mm FROM label_templates"
|
||||
" WHERE design_json IS NULL OR TRIM(design_json) = ''"
|
||||
).fetchall()
|
||||
for item in legacy:
|
||||
width = float(item["width_mm"] or 100.0)
|
||||
height = float(item["height_mm"] or 60.0)
|
||||
db.execute(
|
||||
"UPDATE label_templates SET design_json = ?, updated_at = datetime('now')"
|
||||
" WHERE id = ?",
|
||||
(_default_design_json(width, height), item["id"]),
|
||||
)
|
||||
|
||||
|
||||
def _add_missing_columns(db: sqlite3.Connection) -> None:
|
||||
"""Coloane adaugate dupa prima versiune a schemei."""
|
||||
tables = _table_names(db)
|
||||
if "label_templates" in tables:
|
||||
columns = {row[1] for row in db.execute("PRAGMA table_info(label_templates)")}
|
||||
if "design_json" not in columns:
|
||||
db.execute(
|
||||
"ALTER TABLE label_templates ADD COLUMN design_json TEXT NOT NULL DEFAULT ''"
|
||||
)
|
||||
if "recipes" in tables:
|
||||
columns = {row[1] for row in db.execute("PRAGMA table_info(recipes)")}
|
||||
# Sablonul de eticheta al fiecarui produs finit (v5).
|
||||
if "template_id" not in columns:
|
||||
db.execute(
|
||||
"ALTER TABLE recipes ADD COLUMN template_id INTEGER"
|
||||
" REFERENCES label_templates(id) ON DELETE SET NULL"
|
||||
)
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""Creeaza/migreaza tabelele, setarile si sablonul implicit."""
|
||||
db = get_db()
|
||||
_migrate_legacy(db)
|
||||
db.executescript(SCHEMA_PATH.read_text(encoding="utf-8"))
|
||||
_add_missing_columns(db)
|
||||
_seed_settings(db)
|
||||
_seed_template(db)
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO settings(key, value) VALUES ('schema_version', ?)",
|
||||
(str(SCHEMA_VERSION),),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def get_setting(key: str, default: str = "") -> str:
|
||||
row = get_db().execute("SELECT value FROM settings WHERE key = ?", (key,)).fetchone()
|
||||
return row["value"] if row is not None else default
|
||||
|
||||
|
||||
def set_setting(key: str, value: Any) -> None:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO settings(key, value) VALUES (?, ?)"
|
||||
" ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
(key, str(value)),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def all_settings() -> dict[str, str]:
|
||||
return {row["key"]: row["value"] for row in get_db().execute("SELECT key, value FROM settings")}
|
||||
|
||||
|
||||
def init_db_command() -> None:
|
||||
init_db()
|
||||
click.echo("Baza de date a fost initializata.")
|
||||
|
||||
|
||||
def register(app) -> None:
|
||||
app.teardown_appcontext(close_db)
|
||||
app.cli.add_command(click.command("init-db")(init_db_command))
|
||||
@@ -0,0 +1,681 @@
|
||||
"""Designer de etichete: model, validare si randare HTML.
|
||||
|
||||
Un sablon este descris prin `design_json` — blocuri pozitionate in milimetri,
|
||||
editate vizual in browser. Randarea se face integral pe server.
|
||||
|
||||
Modelul de design (v1):
|
||||
|
||||
{
|
||||
"version": 1,
|
||||
"width_mm": 100, "height_mm": 60,
|
||||
"blocks": [
|
||||
{"id": "b1", "type": "text", "x": 4, "y": 4, "w": 60, "h": 6,
|
||||
"text": "PRODUS FINIT", "style": {"size": 3.2, "bold": true}},
|
||||
{"id": "b2", "type": "field", "x": 4, "y": 10, "w": 60, "h": 12,
|
||||
"field": "package.total_weight", "style": {"size": 14, "bold": true}},
|
||||
{"id": "b3", "type": "items", "x": 4, "y": 26, "w": 60, "h": 20,
|
||||
"style": {"size": 3.4}},
|
||||
{"id": "b4", "type": "qr", "x": 72, "y": 30, "w": 22, "h": 22},
|
||||
{"id": "b5", "type": "line", "x": 4, "y": 50, "w": 92, "h": 0.4}
|
||||
]
|
||||
}
|
||||
|
||||
Randarea este facuta integral pe server, in HTML cu pozitionare absoluta in
|
||||
mm, ca eticheta sa fie identica in previzualizare si la tiparirea QZ Tray.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import html
|
||||
import io
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from .repositories import ValidationError
|
||||
from .units import format_weight
|
||||
|
||||
DESIGN_VERSION = 1
|
||||
MAX_BLOCKS = 60
|
||||
MIN_SIZE_MM = 0.3
|
||||
# Marja pe care imprimantele termice nu o pot acoperi, afisata in editor ca
|
||||
# contur rosu. Nu este impusa la validare, doar recomandata.
|
||||
SAFE_MARGIN_MM = 3.0
|
||||
MAX_COLOR = re.compile(r"^#[0-9A-Fa-f]{3,8}$")
|
||||
|
||||
# Campuri disponibile in designer. `sample` este folosit pentru previzualizare.
|
||||
FIELD_CATALOG: list[tuple[str, str, str]] = [
|
||||
("recipe.name", "Produs finit — denumire", "Fasole cu carne"),
|
||||
("recipe.code", "Produs finit — cod", "PF-001"),
|
||||
("package.total_weight", "Greutate totală", "450"),
|
||||
("package.weight_unit", "Greutate cu unitate", "450 g"),
|
||||
("package.unit", "Unitate de măsură", "g"),
|
||||
("package.lot", "Lot", "LOT-2610-A"),
|
||||
("package.operator", "Operator", "Maria"),
|
||||
("package.completed_at", "Data finalizării", "18.10.2026 20:15"),
|
||||
("package.id", "Număr pachet", "124"),
|
||||
("company.name", "Firmă — denumire", "Moto Adv SRL"),
|
||||
("company.address", "Firmă — adresă", "Str. Exemplu 1"),
|
||||
("company.phone", "Firmă — telefon", "0700 000 000"),
|
||||
]
|
||||
|
||||
FIELD_KEYS = {key for key, _label, _sample in FIELD_CATALOG}
|
||||
|
||||
# Campuri proprii (definite din pagina "Campuri"): `custom.<slug>`.
|
||||
CUSTOM_PREFIX = "custom."
|
||||
CUSTOM_KEY = re.compile(r"^custom\.[a-z0-9_]{1,48}$")
|
||||
PLACEHOLDER = re.compile(r"\{\{\s*([A-Za-z0-9_.]+)\s*\}\}")
|
||||
MAX_CUSTOM_TEMPLATE = 300
|
||||
MAX_CUSTOM_LABEL = 60
|
||||
|
||||
BLOCK_TYPES = ("text", "field", "items", "qr", "line", "image")
|
||||
|
||||
# Continutul codului QR: lista ordonata de campuri si/sau text liber.
|
||||
MAX_QR_PARTS = 12
|
||||
MAX_QR_TEXT = 60
|
||||
QR_SEPARATORS: dict[str, str] = {
|
||||
"pipe": " | ",
|
||||
"dash": " - ",
|
||||
"slash": "/",
|
||||
"space": " ",
|
||||
"newline": "\n",
|
||||
"none": "",
|
||||
}
|
||||
# Chei acceptate doar in QR (nu sunt campuri de afisat ca text).
|
||||
QR_EXTRA_KEYS = {"package.qr"}
|
||||
|
||||
DEFAULT_STYLES: dict[str, dict[str, Any]] = {
|
||||
"text": {"size": 3.2, "bold": False, "italic": False, "align": "left",
|
||||
"uppercase": False, "color": "#000000"},
|
||||
"field": {"size": 4.0, "bold": False, "italic": False, "align": "left",
|
||||
"uppercase": False, "color": "#000000"},
|
||||
"items": {"size": 3.4, "bold": False, "italic": False, "align": "left",
|
||||
"uppercase": False, "color": "#000000", "separator": "stack"},
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _number(value: Any, *, default: float, low: float, high: float) -> float:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not math.isfinite(number):
|
||||
return default
|
||||
return round(min(max(number, low), high), 3)
|
||||
|
||||
|
||||
def _style(raw: Any, kind: str) -> dict[str, Any]:
|
||||
base = dict(DEFAULT_STYLES.get(kind, DEFAULT_STYLES["text"]))
|
||||
if isinstance(raw, dict):
|
||||
base["size"] = _number(raw.get("size"), default=base["size"], low=1.5, high=40)
|
||||
for flag in ("bold", "italic", "uppercase"):
|
||||
if flag in raw:
|
||||
base[flag] = bool(raw[flag])
|
||||
align = str(raw.get("align") or base["align"]).lower()
|
||||
base["align"] = align if align in {"left", "center", "right"} else "left"
|
||||
color = str(raw.get("color") or base["color"])
|
||||
base["color"] = color if MAX_COLOR.match(color) else "#000000"
|
||||
if kind == "items":
|
||||
separator = str(raw.get("separator") or base.get("separator", "stack"))
|
||||
base["separator"] = separator if separator in {"stack", "inline"} else "stack"
|
||||
return base
|
||||
|
||||
|
||||
def _clean_block(raw: Any, index: int, width: float, height: float) -> Optional[dict[str, Any]]:
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
kind = str(raw.get("type") or "").lower()
|
||||
if kind not in BLOCK_TYPES:
|
||||
return None
|
||||
|
||||
block = {
|
||||
"id": str(raw.get("id") or f"b{index + 1}")[:40],
|
||||
"type": kind,
|
||||
"x": _number(raw.get("x"), default=0, low=0, high=width),
|
||||
"y": _number(raw.get("y"), default=0, low=0, high=height),
|
||||
"w": _number(raw.get("w"), default=max(10.0, width / 2), low=1, high=width),
|
||||
"h": _number(raw.get("h"), default=6, low=MIN_SIZE_MM, high=height),
|
||||
"style": _style(raw.get("style"), kind),
|
||||
}
|
||||
# Sa nu iasa din eticheta.
|
||||
block["w"] = min(block["w"], max(1.0, width - block["x"]))
|
||||
block["h"] = min(block["h"], max(MIN_SIZE_MM, height - block["y"]))
|
||||
|
||||
if kind == "text":
|
||||
block["text"] = str(raw.get("text") or "")[:200]
|
||||
elif kind == "field":
|
||||
field = str(raw.get("field") or "recipe.name")[:60]
|
||||
if field in FIELD_KEYS or CUSTOM_KEY.match(field):
|
||||
block["field"] = field
|
||||
else:
|
||||
block["field"] = "recipe.name"
|
||||
block["prefix"] = str(raw.get("prefix") or "")[:40]
|
||||
block["suffix"] = str(raw.get("suffix") or "")[:40]
|
||||
elif kind == "qr":
|
||||
block["separator"], block["parts"] = _clean_qr_parts(raw)
|
||||
elif kind == "image":
|
||||
src = str(raw.get("src") or "")[:2000]
|
||||
if not _image_src_ok(src):
|
||||
src = ""
|
||||
block["src"] = src
|
||||
return block
|
||||
|
||||
|
||||
def _image_src_ok(src: str) -> bool:
|
||||
if not src:
|
||||
return False
|
||||
if src.startswith("data:image/"):
|
||||
return len(src) <= 400_000
|
||||
return src.startswith(("http://", "https://", "/static/"))
|
||||
|
||||
|
||||
def qr_key_ok(key: str) -> bool:
|
||||
return key in FIELD_KEYS or key in QR_EXTRA_KEYS or bool(CUSTOM_KEY.match(key))
|
||||
|
||||
|
||||
def _clean_qr_parts(raw: dict[str, Any]) -> tuple[str, list[dict[str, str]]]:
|
||||
"""Continutul QR: separator + lista de `{field}` / `{text}`."""
|
||||
separator = str(raw.get("separator") or "pipe")
|
||||
if separator not in QR_SEPARATORS:
|
||||
separator = "pipe"
|
||||
|
||||
parts: list[dict[str, str]] = []
|
||||
raw_parts = raw.get("parts")
|
||||
if isinstance(raw_parts, list):
|
||||
for item in raw_parts:
|
||||
if len(parts) >= MAX_QR_PARTS:
|
||||
break
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
if item.get("field"):
|
||||
key = str(item.get("field"))[:60]
|
||||
if qr_key_ok(key):
|
||||
parts.append({"field": key})
|
||||
elif "text" in item:
|
||||
text = str(item.get("text") or "")[:MAX_QR_TEXT]
|
||||
if text:
|
||||
parts.append({"text": text})
|
||||
|
||||
# Designuri vechi: doar `field`; il pastram ca o singura parte.
|
||||
if not parts:
|
||||
legacy = str(raw.get("field") or "package.qr")[:60]
|
||||
parts = [{"field": legacy if qr_key_ok(legacy) else "package.qr"}]
|
||||
return separator, parts
|
||||
|
||||
|
||||
def qr_payload(block: dict[str, Any], fields: dict[str, Any]) -> str:
|
||||
"""Textul codat in QR, din partile configurate (goale sunt sarite)."""
|
||||
parts = block.get("parts") or []
|
||||
if not parts:
|
||||
return str(fields.get(block.get("field") or "package.qr") or "")
|
||||
separator = QR_SEPARATORS.get(str(block.get("separator") or "pipe"), " | ")
|
||||
pieces: list[str] = []
|
||||
for part in parts:
|
||||
if "text" in part:
|
||||
piece = str(part.get("text") or "")
|
||||
else:
|
||||
piece = str(fields.get(part.get("field") or "") or "")
|
||||
if piece:
|
||||
pieces.append(piece)
|
||||
return separator.join(pieces)
|
||||
|
||||
|
||||
def validate_design(raw: Any, width_mm: float, height_mm: float) -> dict[str, Any]:
|
||||
"""Curata si limiteaza un design primit din browser."""
|
||||
if isinstance(raw, str):
|
||||
try:
|
||||
raw = json.loads(raw)
|
||||
except ValueError as exc:
|
||||
raise ValidationError("Designul nu este un JSON valid.") from exc
|
||||
if not isinstance(raw, dict):
|
||||
raise ValidationError("Designul trebuie sa fie un obiect JSON.")
|
||||
|
||||
width = _number(raw.get("width_mm"), default=width_mm, low=10, high=500)
|
||||
height = _number(raw.get("height_mm"), default=height_mm, low=10, high=500)
|
||||
|
||||
blocks_raw = raw.get("blocks")
|
||||
if not isinstance(blocks_raw, list):
|
||||
blocks_raw = []
|
||||
if len(blocks_raw) > MAX_BLOCKS:
|
||||
raise ValidationError(f"Maxim {MAX_BLOCKS} elemente pe eticheta.")
|
||||
|
||||
blocks = []
|
||||
for index, item in enumerate(blocks_raw):
|
||||
block = _clean_block(item, index, width, height)
|
||||
if block is not None:
|
||||
blocks.append(block)
|
||||
|
||||
return {
|
||||
"version": DESIGN_VERSION,
|
||||
"width_mm": width,
|
||||
"height_mm": height,
|
||||
"blocks": blocks,
|
||||
}
|
||||
|
||||
|
||||
def dump_design(design: dict[str, Any]) -> str:
|
||||
return json.dumps(design, ensure_ascii=False, separators=(",", ":"))
|
||||
|
||||
|
||||
def load_design(text: str, width_mm: float, height_mm: float) -> Optional[dict[str, Any]]:
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return validate_design(json.loads(text), width_mm, height_mm)
|
||||
except (ValueError, ValidationError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Valori
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _date(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(str(value)).strftime(fmt)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
def resolve_fields(
|
||||
package: dict[str, Any],
|
||||
items: Iterable[dict[str, Any]],
|
||||
recipe: Optional[dict[str, Any]],
|
||||
settings: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Valorile afisate pe eticheta (deja formatate)."""
|
||||
recipe = recipe or {}
|
||||
item_list = list(items)
|
||||
unit = package.get("unit") or recipe.get("unit") or ""
|
||||
return {
|
||||
"recipe.name": str(recipe.get("name") or package.get("recipe_name") or ""),
|
||||
"recipe.code": str(recipe.get("code") or package.get("recipe_code") or ""),
|
||||
"package.total_weight": format_weight(package.get("total_weight")),
|
||||
"package.weight_unit": (
|
||||
f"{format_weight(package.get('total_weight'))} {unit}".strip()
|
||||
),
|
||||
"package.unit": str(unit),
|
||||
"package.lot": str(package.get("lot") or ""),
|
||||
"package.operator": str(package.get("operator") or ""),
|
||||
"package.completed_at": _date(package.get("completed_at") or package.get("created_at")),
|
||||
"package.id": str(package.get("id") or ""),
|
||||
"company.name": str(settings.get("company_name") or ""),
|
||||
"company.address": str(settings.get("company_address") or ""),
|
||||
"company.phone": str(settings.get("company_phone") or ""),
|
||||
# Text folosit in codul QR: produs, lot, greutate, data.
|
||||
"package.qr": " | ".join(part for part in (
|
||||
str(recipe.get("code") or package.get("recipe_code") or ""),
|
||||
str(package.get("lot") or ""),
|
||||
f"{format_weight(package.get('total_weight'))} {unit}".strip(),
|
||||
_date(package.get("completed_at") or package.get("created_at"), "%Y-%m-%d"),
|
||||
) if part),
|
||||
"_items": item_list, # type: ignore[dict-item]
|
||||
}
|
||||
|
||||
|
||||
def demo_fields() -> dict[str, Any]:
|
||||
"""Aceleasi valori ca in designer, ca previzualizarea sa fie fidela."""
|
||||
demo_package = {
|
||||
"id": 124,
|
||||
"recipe_code": "PF-001",
|
||||
"recipe_name": "Fasole cu carne",
|
||||
"unit": "g",
|
||||
"total_weight": 450,
|
||||
"lot": "LOT-2610-A",
|
||||
"operator": "Maria",
|
||||
"completed_at": "2026-10-18T20:15:00+03:00",
|
||||
}
|
||||
demo_items = [
|
||||
{"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"},
|
||||
{"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"},
|
||||
]
|
||||
demo_recipe = {"code": "PF-001", "name": "Fasole cu carne", "unit": "g"}
|
||||
demo_settings = {
|
||||
"company_name": "Moto Adv SRL",
|
||||
"company_address": "Str. Exemplu 1",
|
||||
"company_phone": "0700 000 000",
|
||||
}
|
||||
return resolve_fields(demo_package, demo_items, demo_recipe, demo_settings)
|
||||
|
||||
|
||||
def demo_items() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"},
|
||||
{"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Campuri proprii (custom.*)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def custom_placeholders(text: str) -> list[str]:
|
||||
"""Numele de campuri referite prin {{...}} intr-un sablon de camp propriu."""
|
||||
return PLACEHOLDER.findall(str(text or ""))
|
||||
|
||||
|
||||
def custom_template_errors(text: str) -> list[str]:
|
||||
"""Mesaje pentru sablonul unui camp propriu (gol, prea lung, campuri necunoscute)."""
|
||||
value = str(text or "").strip()
|
||||
if not value:
|
||||
return ["Textul campului este obligatoriu."]
|
||||
if len(value) > MAX_CUSTOM_TEMPLATE:
|
||||
return [f"Textul campului poate avea maxim {MAX_CUSTOM_TEMPLATE} caractere."]
|
||||
unknown = sorted({name for name in custom_placeholders(value) if name not in FIELD_KEYS})
|
||||
if unknown:
|
||||
return [
|
||||
"Campuri necunoscute: "
|
||||
+ ", ".join(f"{{{{{name}}}}}" for name in unknown)
|
||||
+ ". Sunt acceptate doar campurile fixe din lista."
|
||||
]
|
||||
return []
|
||||
|
||||
|
||||
def validate_custom_field(data: dict[str, Any]) -> None:
|
||||
"""Verifica un camp propriu inainte de salvare."""
|
||||
label = str(data.get("label") or "").strip()
|
||||
if not label:
|
||||
raise ValidationError("Denumirea campului este obligatorie.")
|
||||
if len(label) > MAX_CUSTOM_LABEL:
|
||||
raise ValidationError(f"Denumirea poate avea maxim {MAX_CUSTOM_LABEL} caractere.")
|
||||
errors = custom_template_errors(str(data.get("template") or ""))
|
||||
if errors:
|
||||
raise ValidationError(errors[0])
|
||||
|
||||
|
||||
def expand_custom(text: str, values: dict[str, Any]) -> str:
|
||||
"""Inlocuieste {{camp}} cu valoarea deja calculata; necunoscutele devin gol."""
|
||||
def replace(match: "re.Match[str]") -> str:
|
||||
value = values.get(match.group(1))
|
||||
if value is None or isinstance(value, (list, dict)):
|
||||
return ""
|
||||
return str(value)
|
||||
|
||||
return PLACEHOLDER.sub(replace, str(text or ""))
|
||||
|
||||
|
||||
def apply_custom_fields(
|
||||
values: dict[str, Any],
|
||||
custom: Optional[Iterable[dict[str, Any]]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Adauga valorile campurilor proprii peste cele fixe.
|
||||
|
||||
Campurile proprii se pot referi doar la campuri fixe, deci nu pot intra in bucla.
|
||||
"""
|
||||
result = dict(values)
|
||||
for row in custom or []:
|
||||
key = str(row.get("key") or "")
|
||||
if not CUSTOM_KEY.match(key):
|
||||
continue
|
||||
result[key] = expand_custom(row.get("template") or "", result)
|
||||
return result
|
||||
|
||||
|
||||
def custom_field_catalog(
|
||||
custom: Optional[Iterable[dict[str, Any]]] = None,
|
||||
demo: Optional[dict[str, Any]] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Intrarile pentru popup-ul de alegere a campului, inclusiv cele proprii."""
|
||||
samples = demo if demo is not None else demo_fields()
|
||||
entries: list[dict[str, Any]] = []
|
||||
for row in custom or []:
|
||||
key = str(row.get("key") or "")
|
||||
if not CUSTOM_KEY.match(key):
|
||||
continue
|
||||
entries.append({
|
||||
"key": key,
|
||||
"label": row.get("label") or key,
|
||||
"sample": str(samples.get(key) or ""),
|
||||
"group": "Câmpuri proprii",
|
||||
"custom": True,
|
||||
})
|
||||
return entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Randare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@lru_cache(maxsize=64)
|
||||
def qr_svg(payload: str) -> str:
|
||||
"""Cod QR inline (SVG), fara dependinte de imagini externe."""
|
||||
import qrcode
|
||||
import qrcode.image.svg
|
||||
|
||||
image = qrcode.make(
|
||||
payload or " ",
|
||||
image_factory=qrcode.image.svg.SvgPathImage,
|
||||
box_size=10,
|
||||
border=2,
|
||||
)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer)
|
||||
svg = buffer.getvalue().decode("utf-8")
|
||||
# Dimensiunea o da CSS-ul; pastram doar viewBox-ul.
|
||||
return re.sub(r'<svg([^>]*?)\swidth="[^"]*"\sheight="[^"]*"',
|
||||
r"<svg\1", svg, count=1)
|
||||
|
||||
|
||||
def _font_size(style: dict[str, Any]) -> float:
|
||||
return float(style.get("size") or 3.2)
|
||||
|
||||
|
||||
def _text_style(style: dict[str, Any], *, height: float) -> str:
|
||||
parts = [
|
||||
f"font-size:{_font_size(style):.2f}mm",
|
||||
f"font-weight:{'700' if style.get('bold') else '400'}",
|
||||
f"font-style:{'italic' if style.get('italic') else 'normal'}",
|
||||
f"text-align:{style.get('align', 'left')}",
|
||||
f"color:{style.get('color', '#000000')}",
|
||||
"line-height:1.15",
|
||||
f"height:{height:.2f}mm",
|
||||
"overflow:hidden",
|
||||
"white-space:pre-wrap",
|
||||
"word-break:break-word",
|
||||
]
|
||||
if style.get("uppercase"):
|
||||
parts.append("text-transform:uppercase")
|
||||
return ";".join(parts)
|
||||
|
||||
|
||||
def _block_position(block: dict[str, Any]) -> str:
|
||||
return (
|
||||
f"left:{block['x']:.2f}mm;top:{block['y']:.2f}mm;"
|
||||
f"width:{block['w']:.2f}mm;height:{block['h']:.2f}mm"
|
||||
)
|
||||
|
||||
|
||||
def render_block(
|
||||
block: dict[str, Any],
|
||||
fields: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
) -> str:
|
||||
kind = block["type"]
|
||||
style = block.get("style") or {}
|
||||
position = _block_position(block)
|
||||
|
||||
if kind == "line":
|
||||
return (
|
||||
f'<div class="d-block d-line" style="{position};background:'
|
||||
f'{style.get("color", "#000000")}"></div>'
|
||||
)
|
||||
|
||||
if kind == "text":
|
||||
text = html.escape(block.get("text") or "")
|
||||
if style.get("uppercase"):
|
||||
text = text.upper()
|
||||
return (f'<div class="d-block d-text" style="{position};'
|
||||
f'{_text_style(style, height=block["h"])}">{text}</div>')
|
||||
|
||||
if kind == "field":
|
||||
value = str(fields.get(block.get("field") or "") or "")
|
||||
prefix = block.get("prefix") or ""
|
||||
suffix = block.get("suffix") or ""
|
||||
text = html.escape(f"{prefix}{value}{suffix}")
|
||||
if style.get("uppercase"):
|
||||
text = text.upper()
|
||||
return (f'<div class="d-block d-field" style="{position};'
|
||||
f'{_text_style(style, height=block["h"])}">{text}</div>')
|
||||
|
||||
if kind == "items":
|
||||
if not items:
|
||||
return f'<div class="d-block d-items" style="{position}"></div>'
|
||||
lines = []
|
||||
for item in items:
|
||||
weight = format_weight(item.get("weight"))
|
||||
unit = html.escape(str(item.get("unit") or ""))
|
||||
name = html.escape(str(item.get("component_name") or ""))
|
||||
if style.get("separator") == "inline":
|
||||
lines.append(
|
||||
f'<span class="d-item"><span class="d-item-w">{weight} {unit}</span> {name}</span>'
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f'<div class="d-item"><span class="d-item-w">{weight} {unit}</span> {name}</div>'
|
||||
)
|
||||
separator = " · " if style.get("separator") == "inline" else ""
|
||||
body = separator.join(lines)
|
||||
return (f'<div class="d-block d-items" style="{position};'
|
||||
f'{_text_style(style, height=block["h"])}">{body}</div>')
|
||||
|
||||
if kind == "qr":
|
||||
value = qr_payload(block, fields)
|
||||
svg = qr_svg(value)
|
||||
return (f'<div class="d-block d-qr" style="{position}">'
|
||||
f'<div class="d-qr-inner">{svg}</div></div>')
|
||||
|
||||
if kind == "image":
|
||||
src = block.get("src") or ""
|
||||
if not src:
|
||||
return (f'<div class="d-block d-image d-image-empty" style="{position}"></div>')
|
||||
return (f'<div class="d-block d-image" style="{position}">'
|
||||
f'<img src="{html.escape(src, quote=True)}" alt=""></div>')
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def render_design(
|
||||
design: dict[str, Any],
|
||||
fields: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
) -> str:
|
||||
width = float(design.get("width_mm") or 100)
|
||||
height = float(design.get("height_mm") or 60)
|
||||
blocks = "".join(render_block(block, fields, items)
|
||||
for block in design.get("blocks") or [])
|
||||
return (
|
||||
f'<div class="design-label" style="position:relative;width:{width:.2f}mm;'
|
||||
f'height:{height:.2f}mm;overflow:hidden;background:#fff;color:#000">'
|
||||
f"{blocks}</div>"
|
||||
)
|
||||
|
||||
|
||||
DESIGN_CSS = """\
|
||||
.design-label { font-family: Arial, Helvetica, sans-serif; }
|
||||
.d-block { position: absolute; box-sizing: border-box; }
|
||||
.d-text, .d-field, .d-items { overflow: hidden; }
|
||||
.d-item { white-space: nowrap; }
|
||||
.d-item-w { font-weight: 700; }
|
||||
.d-qr-inner { width: 100%; height: 100%; }
|
||||
.d-qr-inner svg { width: 100%; height: 100%; display: block; }
|
||||
.d-image img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.d-image-empty { border: 0.3mm dashed #94a3b8; }
|
||||
"""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sablon nou
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def default_design(width_mm: float = 100.0, height_mm: float = 60.0) -> dict[str, Any]:
|
||||
"""Un design de pornire, cu elementele deja asezate in zona sigura.
|
||||
|
||||
Proportiile se adapteaza la inaltimea etichetei, ca elementele sa nu iasa
|
||||
din margini nici pe formate mici (ex. 60 x 40 mm).
|
||||
"""
|
||||
width = float(width_mm)
|
||||
height = float(height_mm)
|
||||
margin = SAFE_MARGIN_MM
|
||||
k = min(1.0, max(0.55, height / 60.0))
|
||||
|
||||
def r(value: float) -> float:
|
||||
return round(value, 2)
|
||||
|
||||
qr = min(22.0, width * 0.28, max(8.0, height - 2 * margin - 2))
|
||||
qr_x = r(width - margin - qr)
|
||||
column = r(max(6.0, qr_x - 2 - margin))
|
||||
|
||||
company_h = 4 * k
|
||||
name_h = 8 * k
|
||||
weight_h = 15 * k
|
||||
meta_h = 4 * k
|
||||
meta_step = 4.5 * k
|
||||
|
||||
company_y = margin
|
||||
line_y = company_y + company_h
|
||||
name_y = line_y + 1
|
||||
weight_y = name_y + name_h + 1
|
||||
items_y = weight_y + weight_h + 1
|
||||
operator_y = height - margin - meta_h
|
||||
lot_y = operator_y - meta_step
|
||||
items_h = max(3 * k, lot_y - 1 - items_y)
|
||||
|
||||
return {
|
||||
"version": DESIGN_VERSION,
|
||||
"width_mm": width,
|
||||
"height_mm": height,
|
||||
"blocks": [
|
||||
{"id": "b1", "type": "field", "x": r(margin), "y": r(company_y),
|
||||
"w": column, "h": r(company_h),
|
||||
"field": "company.name",
|
||||
"style": _style({"size": 3.0 * k, "bold": True, "uppercase": True}, "field")},
|
||||
{"id": "b2", "type": "field", "x": r(margin), "y": r(name_y),
|
||||
"w": column, "h": r(name_h),
|
||||
"field": "recipe.name", "style": _style({"size": 5.0 * k, "bold": True}, "field")},
|
||||
{"id": "b3", "type": "field", "x": r(margin), "y": r(weight_y),
|
||||
"w": column, "h": r(weight_h),
|
||||
"field": "package.weight_unit", "suffix": "",
|
||||
"style": _style({"size": 14.0 * k, "bold": True}, "field")},
|
||||
{"id": "b4", "type": "items", "x": r(margin), "y": r(items_y),
|
||||
"w": column, "h": r(items_h), "style": _style({"size": 3.4 * k}, "items")},
|
||||
{"id": "b5", "type": "field", "x": r(margin), "y": r(lot_y),
|
||||
"w": column, "h": r(meta_h),
|
||||
"field": "package.lot", "prefix": "Lot: ",
|
||||
"style": _style({"size": 3.0 * k}, "field")},
|
||||
{"id": "b6", "type": "field", "x": r(margin), "y": r(operator_y),
|
||||
"w": column, "h": r(meta_h),
|
||||
"field": "package.operator", "prefix": "Operator: ",
|
||||
"style": _style({"size": 3.0 * k}, "field")},
|
||||
{"id": "b7", "type": "qr", "x": qr_x, "y": r(margin + 1),
|
||||
"w": r(qr), "h": r(qr),
|
||||
"field": "package.qr", "separator": "pipe",
|
||||
"parts": [{"field": "package.qr"}],
|
||||
"style": _style({}, "qr")},
|
||||
{"id": "b8", "type": "line", "x": r(margin), "y": r(line_y),
|
||||
"w": column, "h": 0.3,
|
||||
"style": _style({"color": "#000000"}, "line")},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def render_preview(
|
||||
design: dict[str, Any],
|
||||
custom: Optional[Iterable[dict[str, Any]]] = None,
|
||||
) -> tuple[str, str]:
|
||||
"""(html, css) randate cu date demo — pentru previzualizarea din editor."""
|
||||
fields = apply_custom_fields(demo_fields(), custom)
|
||||
items = demo_items()
|
||||
return render_design(design, fields, items), DESIGN_CSS
|
||||
|
||||
|
||||
def design_to_base64(design: dict[str, Any]) -> str:
|
||||
return base64.b64encode(dump_design(design).encode("utf-8")).decode("ascii")
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Randarea etichetelor de produs finit si contractul de printare.
|
||||
|
||||
Eticheta este generata din designul vizual (blocuri in milimetri) si contine:
|
||||
- denumirea produsului finit si greutatea totala
|
||||
- defalcarea pe componente (ex. 250 g Fasole, 200 g Carne)
|
||||
- lot, data, operator
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import designer
|
||||
from .repositories import ValidationError, list_label_fields
|
||||
from .units import format_weight as _format_weight
|
||||
|
||||
|
||||
def _format_datetime(value: Any, fmt: str = "%d.%m.%Y %H:%M") -> str:
|
||||
if not value:
|
||||
return ""
|
||||
try:
|
||||
return datetime.fromisoformat(str(value)).strftime(fmt)
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
|
||||
|
||||
class LabelTemplateError(ValidationError):
|
||||
pass
|
||||
|
||||
|
||||
def sample_package() -> dict[str, Any]:
|
||||
now = datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
return {
|
||||
"id": 0,
|
||||
"created_at": now,
|
||||
"completed_at": now,
|
||||
"status": "done",
|
||||
"recipe_code": "PF-DEMO",
|
||||
"recipe_name": "Fasole cu carne",
|
||||
"unit": "g",
|
||||
"target_weight": 450,
|
||||
"total_weight": 450,
|
||||
"lot": "LOT-DEMO",
|
||||
"operator": "Operator",
|
||||
"notes": "",
|
||||
"print_count": 0,
|
||||
"items": [
|
||||
{"position": 1, "component_name": "Fasole", "weight": 250, "unit": "g"},
|
||||
{"position": 2, "component_name": "Carne", "weight": 200, "unit": "g"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def sample_recipe() -> dict[str, Any]:
|
||||
return {
|
||||
"id": 0,
|
||||
"code": "PF-DEMO",
|
||||
"name": "Fasole cu carne",
|
||||
"unit": "g",
|
||||
"target_weight": 450,
|
||||
"note": "",
|
||||
"components": [
|
||||
{"position": 1, "name": "Fasole", "code": "FAS", "target_weight": 250},
|
||||
{"position": 2, "name": "Carne", "code": "CAR", "target_weight": 200},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_context(
|
||||
package: dict[str, Any],
|
||||
items: list[dict[str, Any]],
|
||||
recipe: Optional[dict[str, Any]],
|
||||
settings: dict[str, str],
|
||||
template: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Contextul vechi Jinja, pastrat pentru compatibilitate cu integrarile existente."""
|
||||
return {
|
||||
"package": package,
|
||||
"items": items,
|
||||
"recipe": recipe or {
|
||||
"id": None,
|
||||
"code": package.get("recipe_code", ""),
|
||||
"name": package.get("recipe_name", ""),
|
||||
"unit": package.get("unit", ""),
|
||||
"components": [],
|
||||
},
|
||||
"company": {
|
||||
"name": settings.get("company_name", ""),
|
||||
"address": settings.get("company_address", ""),
|
||||
"phone": settings.get("company_phone", ""),
|
||||
},
|
||||
"label": {
|
||||
"width_mm": float((template or {}).get("width_mm", 100)),
|
||||
"height_mm": float((template or {}).get("height_mm", 60)),
|
||||
},
|
||||
"settings": settings,
|
||||
}
|
||||
|
||||
|
||||
def design_for(template: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Designul sablonului; daca lipseste (sablon vechi), se foloseste cel implicit."""
|
||||
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:
|
||||
return designer.default_design(width, height)
|
||||
return design
|
||||
|
||||
|
||||
def render_body(
|
||||
template: dict[str, Any],
|
||||
package: dict[str, Any],
|
||||
items: Optional[list[dict[str, Any]]] = None,
|
||||
recipe: Optional[dict[str, Any]] = None,
|
||||
settings: Optional[dict[str, str]] = None,
|
||||
) -> str:
|
||||
item_list = items if items is not None else package.get("items") or []
|
||||
design = design_for(template)
|
||||
fields = designer.resolve_fields(package, item_list, recipe, settings or {})
|
||||
fields = designer.apply_custom_fields(fields, list_label_fields())
|
||||
return designer.render_design(design, fields, item_list)
|
||||
|
||||
|
||||
def template_css(template: dict[str, Any]) -> str:
|
||||
css = template.get("css") or ""
|
||||
return f"{designer.DESIGN_CSS}\n{css}".strip()
|
||||
|
||||
|
||||
def validate_template(template: dict[str, Any], settings: Optional[dict[str, str]] = None) -> None:
|
||||
"""Revalideaza designul si il randeaza cu date demo, ca sa prindem erorile."""
|
||||
design = designer.validate_design(
|
||||
design_for(template),
|
||||
float(template.get("width_mm") or 100),
|
||||
float(template.get("height_mm") or 60),
|
||||
)
|
||||
demo = designer.apply_custom_fields(designer.demo_fields(), list_label_fields())
|
||||
designer.render_design(design, demo, designer.demo_items())
|
||||
|
||||
|
||||
def print_payload(
|
||||
package: dict[str, Any],
|
||||
template: dict[str, Any],
|
||||
settings: dict[str, str],
|
||||
recipe: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
items = package.get("items") or []
|
||||
html = render_body(template, package, items, recipe, settings)
|
||||
breakdown = ", ".join(
|
||||
f"{_format_weight(item['weight'])} {item['unit']} {item['component_name']}"
|
||||
for item in items
|
||||
)
|
||||
return {
|
||||
"job_id": f"p{package['id']}-{int(package.get('print_count') or 0) + 1}",
|
||||
"package_id": package["id"],
|
||||
"template": {
|
||||
"id": template["id"],
|
||||
"name": template["name"],
|
||||
"width_mm": float(template["width_mm"]),
|
||||
"height_mm": float(template["height_mm"]),
|
||||
},
|
||||
"html": html,
|
||||
"css": template_css(template),
|
||||
"fields": {
|
||||
"recipe_code": package.get("recipe_code", ""),
|
||||
"recipe_name": package.get("recipe_name", ""),
|
||||
"lot": package.get("lot", ""),
|
||||
"operator": package.get("operator", ""),
|
||||
"total_weight": _format_weight(package.get("total_weight")),
|
||||
"unit": package.get("unit", ""),
|
||||
"completed_at": package.get("completed_at", ""),
|
||||
"breakdown": breakdown,
|
||||
},
|
||||
"print_url": f"/labels/{package['id']}/print",
|
||||
"ack_url": f"/api/packages/{package['id']}/printed",
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Logging: jurnal de acces lizibil, fara zgomotul polling-ului.
|
||||
|
||||
Flask/werkzeug scrie un rand pentru fiecare cerere. Pagina de cantarire
|
||||
interogheaza greutatea de mai multe ori pe secunda, deci jurnalul devine
|
||||
inutilizabil. Aici:
|
||||
- cererile de polling (/api/weight) si fisierele statice nu sunt logate;
|
||||
- erorile (4xx/5xx) sunt pastrate intotdeauna;
|
||||
- jurnalul aplicatiei merge la stderr, cu timestamp.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
|
||||
QUIET_PREFIXES = (
|
||||
"GET /api/weight",
|
||||
"GET /static/",
|
||||
"GET /favicon.ico",
|
||||
)
|
||||
|
||||
# Werkzeug access line: ... "GET /path HTTP/1.1" 200 -
|
||||
_STATUS_RE = re.compile(r'"\s(\d{3})\s')
|
||||
|
||||
|
||||
class _QuietPolling(logging.Filter):
|
||||
"""Drop access-log records for polling and static assets, keep errors."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
try:
|
||||
message = record.getMessage()
|
||||
except Exception: # noqa: BLE001 - never let logging break a request
|
||||
return True
|
||||
if not any(prefix in message for prefix in QUIET_PREFIXES):
|
||||
return True
|
||||
match = _STATUS_RE.search(message)
|
||||
if match is None:
|
||||
return True
|
||||
# Keep only failures (4xx / 5xx); 2xx and 304 are noise.
|
||||
return not match.group(1).startswith(("2", "3"))
|
||||
|
||||
|
||||
def configure_logging(level: int = logging.INFO) -> None:
|
||||
handler = logging.StreamHandler(sys.stderr)
|
||||
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-7s %(name)s: %(message)s",
|
||||
"%H:%M:%S"))
|
||||
root = logging.getLogger()
|
||||
if not root.handlers:
|
||||
root.addHandler(handler)
|
||||
root.setLevel(level)
|
||||
|
||||
werkzeug = logging.getLogger("werkzeug")
|
||||
werkzeug.setLevel(logging.INFO)
|
||||
if not any(isinstance(f, _QuietPolling) for f in werkzeug.filters):
|
||||
werkzeug.addFilter(_QuietPolling())
|
||||
|
||||
logging.getLogger("scale_bridge").setLevel(logging.WARNING)
|
||||
@@ -0,0 +1,407 @@
|
||||
"""Backup, reset si verificarea starii pentru punerea in productie.
|
||||
|
||||
Toate functiile se apeleaza in contextul aplicatiei Flask, deoarece folosesc
|
||||
conexiunea SQLite tinuta de `g` (vezi `db.get_db`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from flask import current_app
|
||||
|
||||
from .config import DEV_SECRET_KEY
|
||||
from .db import DEFAULT_SETTINGS, SCHEMA_VERSION, _table_names, get_db, init_db
|
||||
|
||||
BACKUP_PREFIX = "cantarire-"
|
||||
BACKUP_SUFFIX = ".db"
|
||||
KEEP_BACKUPS = 20
|
||||
|
||||
# Tabele golite la reset, in ordinea impusa de cheile straine.
|
||||
DATA_TABLES = (
|
||||
"package_items",
|
||||
"packages",
|
||||
"recipe_components",
|
||||
"recipes",
|
||||
"components",
|
||||
"label_fields",
|
||||
"label_templates",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def backup_dir() -> Path:
|
||||
path = Path(current_app.config["INSTANCE_DIR"]) / "backups"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def backup_database(reason: str = "") -> Path:
|
||||
"""Copiaza baza intr-un fisier consistent (API-ul de backup SQLite, WAL-safe)."""
|
||||
source = Path(current_app.config["DATABASE"])
|
||||
if not source.exists():
|
||||
raise FileNotFoundError(f"Baza de date nu exista: {source}")
|
||||
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
label = f"-{reason}" if reason else ""
|
||||
target = backup_dir() / f"{BACKUP_PREFIX}{stamp}{label}{BACKUP_SUFFIX}"
|
||||
counter = 2
|
||||
while target.exists():
|
||||
target = backup_dir() / f"{BACKUP_PREFIX}{stamp}-{counter}{label}{BACKUP_SUFFIX}"
|
||||
counter += 1
|
||||
|
||||
destination = sqlite3.connect(target)
|
||||
try:
|
||||
get_db().backup(destination)
|
||||
destination.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||
finally:
|
||||
destination.close()
|
||||
return target
|
||||
|
||||
|
||||
def list_backups() -> list[dict[str, Any]]:
|
||||
"""Backup-urile existente, cele mai noi primele."""
|
||||
items: list[dict[str, Any]] = []
|
||||
for path in backup_dir().glob(f"{BACKUP_PREFIX}*{BACKUP_SUFFIX}"):
|
||||
stat = path.stat()
|
||||
items.append({
|
||||
"name": path.name,
|
||||
"path": str(path),
|
||||
"size": stat.st_size,
|
||||
"size_human": _human_size(stat.st_size),
|
||||
"created_at": datetime.fromtimestamp(stat.st_mtime),
|
||||
})
|
||||
return sorted(items, key=lambda item: item["created_at"], reverse=True)
|
||||
|
||||
|
||||
def prune_backups(keep: int = KEEP_BACKUPS) -> int:
|
||||
"""Sterge backup-urile peste limita; intoarce cate au fost sterse."""
|
||||
backups = list_backups()
|
||||
removed = 0
|
||||
for item in backups[keep:]:
|
||||
try:
|
||||
Path(item["path"]).unlink()
|
||||
removed += 1
|
||||
except OSError:
|
||||
pass
|
||||
return removed
|
||||
|
||||
|
||||
def _human_size(size: int) -> str:
|
||||
value = float(size)
|
||||
for unit in ("B", "kB", "MB", "GB"):
|
||||
if value < 1024 or unit == "GB":
|
||||
return f"{value:.0f} {unit}" if unit == "B" else f"{value:.1f} {unit}"
|
||||
value /= 1024
|
||||
return f"{value:.1f} GB"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Starea bazei
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _safe_count(table: str) -> int:
|
||||
db = get_db()
|
||||
if table not in _table_names(db):
|
||||
return 0
|
||||
return int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||
|
||||
|
||||
def database_status() -> dict[str, Any]:
|
||||
path = Path(current_app.config["DATABASE"])
|
||||
db = get_db()
|
||||
try:
|
||||
integrity = db.execute("PRAGMA quick_check").fetchone()[0]
|
||||
except sqlite3.DatabaseError as exc: # pragma: no cover - doar la fisiere corupte
|
||||
integrity = f"eroare: {exc}"
|
||||
size = path.stat().st_size if path.exists() else 0
|
||||
return {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"size": size,
|
||||
"size_human": _human_size(size),
|
||||
"integrity": integrity,
|
||||
"schema_version": SCHEMA_VERSION,
|
||||
"counts": {
|
||||
"components": _safe_count("components"),
|
||||
"recipes": _safe_count("recipes"),
|
||||
"packages": _safe_count("packages"),
|
||||
"package_items": _safe_count("package_items"),
|
||||
"templates": _safe_count("label_templates"),
|
||||
"fields": _safe_count("label_fields"),
|
||||
},
|
||||
"backups": len(list_backups()),
|
||||
"backup_dir": str(backup_dir()),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Verificare de pregatire pentru productie
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def readiness() -> list[dict[str, Any]]:
|
||||
"""Checklist pentru prima punere in functiune.
|
||||
|
||||
Fiecare element are `ok`, un mesaj scurt si (optional) link-ul unde se rezolva.
|
||||
"""
|
||||
from .repositories import list_components, list_recipes, list_templates
|
||||
from .db import all_settings
|
||||
from .scale import get_scale
|
||||
|
||||
settings = all_settings()
|
||||
scale = get_scale(current_app)
|
||||
profiles = scale.profiles()
|
||||
components = list_components()
|
||||
recipes = list_recipes()
|
||||
templates = list_templates()
|
||||
|
||||
company = (settings.get("company_name") or "").strip()
|
||||
company_ok = bool(company) and company != DEFAULT_SETTINGS["company_name"]
|
||||
|
||||
active_profile = (settings.get("active_scale_profile") or "").strip()
|
||||
profile_ok = bool(profiles) and active_profile in {p["name"] for p in profiles}
|
||||
|
||||
secret = str(current_app.config.get("SECRET_KEY") or "")
|
||||
secret_ok = bool(secret) and secret != DEV_SECRET_KEY
|
||||
|
||||
ready_recipes = [r for r in recipes if (r.get("component_count") or 0) > 0]
|
||||
|
||||
return [
|
||||
{
|
||||
"key": "database",
|
||||
"label": "Baza de date",
|
||||
"ok": True,
|
||||
"message": database_status()["path"],
|
||||
},
|
||||
{
|
||||
"key": "company",
|
||||
"label": "Datele firmei",
|
||||
"ok": company_ok,
|
||||
"message": company if company_ok else "Completează denumirea firmei pe etichetă.",
|
||||
"url": "/setari",
|
||||
"action": "Deschide Setări",
|
||||
},
|
||||
{
|
||||
"key": "components",
|
||||
"label": "Componente",
|
||||
"ok": bool(components),
|
||||
"message": (f"{len(components)} active" if components
|
||||
else "Adaugă ce se cântărește (ex. fasole, carne)."),
|
||||
"url": "/componente/nou",
|
||||
"action": "Adaugă component",
|
||||
},
|
||||
{
|
||||
"key": "recipes",
|
||||
"label": "Produse finite",
|
||||
"ok": bool(ready_recipes),
|
||||
"message": (f"{len(ready_recipes)} cu componente" if ready_recipes
|
||||
else "Creează un produs finit cu 1–2 componente."),
|
||||
"url": "/retete/nou",
|
||||
"action": "Adaugă produs finit",
|
||||
},
|
||||
{
|
||||
"key": "template",
|
||||
"label": "Șablon de etichetă",
|
||||
"ok": bool(templates),
|
||||
"message": (templates[0]["name"] if templates
|
||||
else "Fără șablon, eticheta nu poate fi tipărită."),
|
||||
"url": "/sabloane",
|
||||
"action": "Deschide șabloane",
|
||||
},
|
||||
{
|
||||
"key": "company_contact",
|
||||
"label": "Contact pe etichetă",
|
||||
"ok": bool((settings.get("company_phone") or "").strip()),
|
||||
"message": (settings.get("company_phone") or "Opțional: telefonul firmei."),
|
||||
"url": "/setari",
|
||||
"action": "Completează",
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"key": "scale",
|
||||
"label": "Cântar",
|
||||
"ok": profile_ok,
|
||||
"message": (active_profile if profile_ok
|
||||
else "Fără profil activ se cântărește manual."),
|
||||
"url": "/setari",
|
||||
"action": "Alege profilul",
|
||||
"optional": True,
|
||||
},
|
||||
{
|
||||
"key": "secret",
|
||||
"label": "Cheie de sesiune",
|
||||
"ok": secret_ok,
|
||||
"message": ("Generată" if secret_ok
|
||||
else "Setează CANTARIRE_SECRET_KEY înainte de producție."),
|
||||
"optional": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def readiness_summary() -> dict[str, Any]:
|
||||
items = readiness()
|
||||
required = [item for item in items if not item.get("optional")]
|
||||
missing = [item for item in required if not item["ok"]]
|
||||
return {
|
||||
"checks": items,
|
||||
"missing": missing,
|
||||
"ready": not missing,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reset_database(*, keep_backup: bool = True) -> dict[str, Any]:
|
||||
"""Goleste datele de productie si readuce setarile/etalonul la valorile initiale.
|
||||
|
||||
Pastreaza un backup inainte, ca operatia sa poata fi anulata manual.
|
||||
"""
|
||||
backup_path = backup_database("reset") if keep_backup else None
|
||||
|
||||
db = get_db()
|
||||
db.execute("PRAGMA foreign_keys = OFF")
|
||||
removed: dict[str, int] = {}
|
||||
try:
|
||||
for table in DATA_TABLES:
|
||||
if table in _table_names(db):
|
||||
removed[table] = int(db.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||
db.execute(f"DELETE FROM {table}")
|
||||
db.execute("DELETE FROM settings")
|
||||
db.commit()
|
||||
finally:
|
||||
db.execute("PRAGMA foreign_keys = ON")
|
||||
|
||||
# `init_db` reseaza setarile implicite si sablonul de pornire.
|
||||
init_db()
|
||||
# Recupereaza spatiul fisierului dupa stergere.
|
||||
get_db().execute("VACUUM")
|
||||
|
||||
return {
|
||||
"removed": removed,
|
||||
"backup": str(backup_path) if backup_path else None,
|
||||
"status": database_status(),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Date demo (pentru instruire / testare)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
DEMO_COMPONENTS = [
|
||||
("FAS", "Fasole", "g"),
|
||||
("CAR", "Carne", "g"),
|
||||
("GAR", "Garnitură", "g"),
|
||||
]
|
||||
|
||||
DEMO_RECIPES = [
|
||||
{
|
||||
"code": "PF-001",
|
||||
"name": "Fasole cu carne",
|
||||
"target_weight": 450.0,
|
||||
"components": [("FAS", 250.0), ("CAR", 200.0)],
|
||||
},
|
||||
{
|
||||
"code": "PF-002",
|
||||
"name": "Ciorbă de legume",
|
||||
"target_weight": 350.0,
|
||||
"components": [("GAR", 350.0)],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def seed_demo() -> dict[str, Any]:
|
||||
"""Recreaza datele demo (componente, produse finite, firma, operator)."""
|
||||
from . import designer
|
||||
from .db import all_settings, set_setting
|
||||
from .repositories import (
|
||||
create_component,
|
||||
create_recipe,
|
||||
get_component_by_code,
|
||||
list_components,
|
||||
list_recipes,
|
||||
list_templates,
|
||||
save_template_design,
|
||||
)
|
||||
|
||||
created = {"components": 0, "recipes": 0}
|
||||
for code, name, unit in DEMO_COMPONENTS:
|
||||
if get_component_by_code(code) is None:
|
||||
create_component({"code": code, "name": name, "unit": unit, "active": "on"})
|
||||
created["components"] += 1
|
||||
|
||||
existing_codes = {recipe["code"] for recipe in list_recipes()}
|
||||
for spec in DEMO_RECIPES:
|
||||
if spec["code"] in existing_codes:
|
||||
continue
|
||||
data: dict[str, Any] = {
|
||||
"code": spec["code"],
|
||||
"name": spec["name"],
|
||||
"unit": "g",
|
||||
"target_weight": spec["target_weight"],
|
||||
"active": "on",
|
||||
}
|
||||
for slot, (code, target) in enumerate(spec["components"], start=1):
|
||||
component = get_component_by_code(code)
|
||||
if component is None:
|
||||
continue
|
||||
data[f"component_{slot}_id"] = str(component["id"])
|
||||
data[f"component_{slot}_target"] = str(target)
|
||||
create_recipe(data)
|
||||
created["recipes"] += 1
|
||||
|
||||
settings = all_settings()
|
||||
if not (settings.get("company_name") or "").strip() or \
|
||||
settings.get("company_name") == DEFAULT_SETTINGS["company_name"]:
|
||||
set_setting("company_name", "Moto Adv SRL")
|
||||
if not (settings.get("company_phone") or "").strip():
|
||||
set_setting("company_phone", "0700 000 000")
|
||||
if not (settings.get("default_operator") or "").strip():
|
||||
set_setting("default_operator", "Maria")
|
||||
|
||||
templates = list_templates()
|
||||
if templates and not (templates[0].get("design_json") or "").strip():
|
||||
template = templates[0]
|
||||
save_template_design(
|
||||
template["id"],
|
||||
designer.dump_design(designer.default_design(template["width_mm"], template["height_mm"])),
|
||||
)
|
||||
|
||||
return {
|
||||
"created": created,
|
||||
"recipes": [recipe["code"] for recipe in list_recipes()],
|
||||
"components": [row["code"] for row in list_components(include_inactive=True)],
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Utilitare pentru afisare
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def reset_report(result: dict[str, Any]) -> str:
|
||||
"""Rezumat lizibil pentru CLI."""
|
||||
lines = ["Date sterse:"]
|
||||
for table, count in result["removed"].items():
|
||||
lines.append(f" {table:<20} {count}")
|
||||
if result["backup"]:
|
||||
lines.append(f"Backup: {result['backup']}")
|
||||
else:
|
||||
lines.append("Backup: (nu s-a facut)")
|
||||
status = result["status"]
|
||||
lines.append(
|
||||
f"Baza: {status['path']} · {status['size_human']} · schema v{status['schema_version']}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def ensure_instance_dirs() -> None:
|
||||
"""Creeaza folderele de rulare daca lipsesc (util la prima pornire)."""
|
||||
root = Path(current_app.config["INSTANCE_DIR"])
|
||||
(root / "backups").mkdir(parents=True, exist_ok=True)
|
||||
(root / "captures").mkdir(parents=True, exist_ok=True)
|
||||
@@ -0,0 +1,184 @@
|
||||
"""Monitor de port serial: ce trimite de fapt cantarul.
|
||||
|
||||
Instrument pentru un model nou (ex. Bizerba BC800): deschide portul in mod
|
||||
ascultare (nu scrie nimic catre cantar), strange cadrele brute si le trece
|
||||
prin toate protocoalele cunoscute. Rezultatul spune daca formatul este deja
|
||||
suportat sau daca trebuie adaugat un protocol nou.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
VENDOR_DIR = Path(__file__).resolve().parent.parent / "vendor" / "syncpulse-scale-bridge"
|
||||
|
||||
BAUD_RATES = (9600, 4800, 19200, 2400, 38400, 1200, 57600, 115200)
|
||||
FRAMINGS = ("8N1", "7E1", "7O1", "8E1", "8O1")
|
||||
MAX_FRAMES = 60
|
||||
|
||||
|
||||
class ProbeError(RuntimeError):
|
||||
"""Portul nu poate fi deschis sau setarile sunt invalide."""
|
||||
|
||||
|
||||
def _bridge():
|
||||
path = str(VENDOR_DIR)
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
from scale_bridge import serialio # noqa: PLC0415
|
||||
|
||||
return serialio
|
||||
|
||||
|
||||
def valid_framing(text: str) -> str:
|
||||
value = (text or "").strip().upper()
|
||||
if not re.fullmatch(r"[5678][NEOMS][12]", value):
|
||||
raise ProbeError(f"framing invalid: {text!r} (ex. 8N1, 7E1)")
|
||||
return value
|
||||
|
||||
|
||||
def list_ports() -> list[dict[str, Any]]:
|
||||
serialio = _bridge()
|
||||
return serialio.list_serial_ports()
|
||||
|
||||
|
||||
def decode(data: bytes) -> list[dict[str, Any]]:
|
||||
"""Ce protocoale din biblioteca recunosc datele primite."""
|
||||
_bridge()
|
||||
from scale_bridge.protocols import PROTOCOLS # noqa: PLC0415
|
||||
|
||||
guesses: list[dict[str, Any]] = []
|
||||
if not data:
|
||||
return guesses
|
||||
for pid, proto in PROTOCOLS.items():
|
||||
if pid.startswith("custom"):
|
||||
continue
|
||||
try:
|
||||
readings = [r for r in proto.readings(data) if r.value is not None]
|
||||
except Exception: # noqa: BLE001 - un parser nu trebuie sa rupa monitorul
|
||||
continue
|
||||
if not readings:
|
||||
continue
|
||||
last = readings[-1]
|
||||
guesses.append({
|
||||
"protocol": pid,
|
||||
"name": proto.name,
|
||||
"kind": proto.kind,
|
||||
"count": len(readings),
|
||||
"value": last.value,
|
||||
"unit": last.unit,
|
||||
"stable": last.stable,
|
||||
"specificity": proto.specificity,
|
||||
})
|
||||
guesses.sort(key=lambda g: (-g["specificity"], -g["count"]))
|
||||
return guesses
|
||||
|
||||
|
||||
def probe_port(
|
||||
port: str,
|
||||
baud: int = 9600,
|
||||
framing: str = "8N1",
|
||||
seconds: float = 5.0,
|
||||
opener: Optional[Any] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Asculta portul `seconds` secunde si intoarce cadrele brute."""
|
||||
serialio = _bridge()
|
||||
framing = valid_framing(framing)
|
||||
if not port:
|
||||
raise ProbeError("Alege portul serial.")
|
||||
if not 50 <= int(baud) <= 921600:
|
||||
raise ProbeError(f"baud invalid: {baud}")
|
||||
seconds = min(max(float(seconds), 0.5), 60.0)
|
||||
|
||||
serial_cfg = {
|
||||
"port": port,
|
||||
"baudrate": int(baud),
|
||||
"bytesize": int(framing[0]),
|
||||
"parity": framing[1],
|
||||
"stopbits": int(framing[2]),
|
||||
}
|
||||
try:
|
||||
settings = serialio.LineSettings.from_dicts(serial_cfg, {})
|
||||
except ValueError as exc:
|
||||
raise ProbeError(str(exc)) from exc
|
||||
|
||||
worker = serialio.PortWorker(settings, opener or serialio.open_real)
|
||||
worker.start()
|
||||
deadline = time.monotonic() + seconds
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
if worker.last_error and not worker.is_open:
|
||||
break
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
worker.stop()
|
||||
worker.join(timeout=3)
|
||||
|
||||
chunks = worker.recent(MAX_FRAMES)
|
||||
joined = b"".join(data for _, data in chunks)
|
||||
now = serialio.now_ms()
|
||||
return {
|
||||
"ok": bool(chunks) or bool(worker.is_open),
|
||||
"port": settings.port,
|
||||
"settings": settings.describe(),
|
||||
"baud": int(baud),
|
||||
"framing": framing,
|
||||
"seconds": seconds,
|
||||
"error": None if (chunks or worker.is_open) else worker.last_error,
|
||||
"bytes": len(joined),
|
||||
"frames": [
|
||||
{"age_ms": max(0, now - t), "hex": serialio.format_hex(data),
|
||||
"text": serialio.printable(data), "size": len(data)}
|
||||
for t, data in chunks
|
||||
],
|
||||
"guesses": decode(joined),
|
||||
"raw": joined,
|
||||
}
|
||||
|
||||
|
||||
def sweep(
|
||||
port: str,
|
||||
seconds: float = 2.0,
|
||||
framings: tuple[str, ...] = ("8N1", "7E1"),
|
||||
bauds: tuple[int, ...] = BAUD_RATES,
|
||||
opener: Optional[Any] = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Incearca mai multe setari de linie si pastreaza doar cele cu date."""
|
||||
results = []
|
||||
for framing in framings:
|
||||
for baud in bauds:
|
||||
try:
|
||||
result = probe_port(port, baud, framing, seconds, opener)
|
||||
except ProbeError as exc:
|
||||
results.append({"baud": baud, "framing": framing, "ok": False,
|
||||
"error": str(exc), "bytes": 0, "guesses": []})
|
||||
continue
|
||||
results.append({
|
||||
"baud": baud,
|
||||
"framing": framing,
|
||||
"ok": result["ok"],
|
||||
"error": result["error"],
|
||||
"bytes": result["bytes"],
|
||||
"frames": len(result["frames"]),
|
||||
"guesses": result["guesses"],
|
||||
"raw": result["raw"],
|
||||
})
|
||||
if result["bytes"] and result["guesses"]:
|
||||
# Un protocol recunoscut este un rezultat bun: nu mai insistam.
|
||||
pass
|
||||
return results
|
||||
|
||||
|
||||
def save_capture(folder: str | Path, name: str, frames: list[dict[str, Any]]) -> Path:
|
||||
"""Scrie cadrele ca hex, un cadru pe linie (formatul din tests/captures)."""
|
||||
safe = re.sub(r"[^A-Za-z0-9_-]+", "-", str(name or "capture")).strip("-") or "capture"
|
||||
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
target = Path(folder) / f"{safe}-{stamp}.hex"
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [f["hex"] for f in frames if f.get("hex")]
|
||||
target.write_text("\n".join(lines) + "\n", encoding="ascii")
|
||||
return target
|
||||
@@ -0,0 +1,871 @@
|
||||
"""Accesul la date: componente, retete (produse finite) si pachete cantarite."""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Any, Iterable, Optional
|
||||
|
||||
from .db import get_db
|
||||
from .units import UNITS, convert, format_weight, normalize_unit
|
||||
|
||||
MAX_RECIPE_COMPONENTS = 4
|
||||
|
||||
|
||||
class ValidationError(ValueError):
|
||||
"""Date introduse de utilizator invalide."""
|
||||
|
||||
|
||||
def _now_iso() -> str:
|
||||
return datetime.now().astimezone().isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _text(value: Any, limit: int) -> str:
|
||||
return str(value or "").strip()[:limit]
|
||||
|
||||
|
||||
def _number(
|
||||
value: Any,
|
||||
*,
|
||||
label: str,
|
||||
required: bool = False,
|
||||
minimum: float = 0.0,
|
||||
) -> Optional[float]:
|
||||
if value in (None, ""):
|
||||
if required:
|
||||
raise ValidationError(f"{label} este obligatorie.")
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
value = value.strip().replace(",", ".")
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(f"{label} trebuie sa fie un numar.") from exc
|
||||
if not math.isfinite(number) or number < minimum:
|
||||
raise ValidationError(f"{label} trebuie sa fie un numar pozitiv.")
|
||||
return round(number, 3)
|
||||
|
||||
|
||||
def _flag(value: Any) -> int:
|
||||
return 1 if value in (True, 1, "1", "on", "true", "yes") else 0
|
||||
|
||||
|
||||
def _optional_id(value: Any) -> Optional[int]:
|
||||
"""Id numeric optional: '', None si 0 devin None; altfel int sau eroare."""
|
||||
if value in (None, "", 0, "0"):
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
raise ValidationError("Identificator invalid.")
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("Identificator invalid.") from exc
|
||||
return number if number > 0 else None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Componente (ce se pune pe cantar)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_components(include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
sql = "SELECT * FROM components"
|
||||
if not include_inactive:
|
||||
sql += " WHERE active = 1"
|
||||
sql += " ORDER BY name COLLATE NOCASE"
|
||||
return [dict(r) for r in get_db().execute(sql)]
|
||||
|
||||
|
||||
def get_component(component_id: int) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute("SELECT * FROM components WHERE id = ?", (component_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_component_by_code(code: str) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT * FROM components WHERE code = ? COLLATE NOCASE", (_text(code, 40),)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _clean_component(data: dict[str, Any], component_id: Optional[int] = None) -> dict[str, Any]:
|
||||
code = _text(data.get("code"), 40)
|
||||
name = _text(data.get("name"), 120)
|
||||
unit = normalize_unit(data.get("unit"), "g")
|
||||
if not code:
|
||||
raise ValidationError("Codul componentului este obligatoriu.")
|
||||
if not name:
|
||||
raise ValidationError("Denumirea componentului este obligatorie.")
|
||||
if unit not in UNITS:
|
||||
raise ValidationError(f"Unitate de masura necunoscuta: {unit}.")
|
||||
|
||||
db = get_db()
|
||||
sql = "SELECT id FROM components WHERE code = ? COLLATE NOCASE"
|
||||
params: list[Any] = [code]
|
||||
if component_id is not None:
|
||||
sql += " AND id <> ?"
|
||||
params.append(component_id)
|
||||
if db.execute(sql, params).fetchone():
|
||||
raise ValidationError(f"Exista deja un component cu codul {code!r}.")
|
||||
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"unit": unit,
|
||||
"description": _text(data.get("description"), 500),
|
||||
"active": _flag(data.get("active", True)),
|
||||
}
|
||||
|
||||
|
||||
def create_component(data: dict[str, Any]) -> int:
|
||||
item = _clean_component(data)
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO components(code, name, unit, description, active)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(item["code"], item["name"], item["unit"], item["description"], item["active"]),
|
||||
)
|
||||
db.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_component(component_id: int, data: dict[str, Any]) -> None:
|
||||
if get_component(component_id) is None:
|
||||
raise ValidationError("Componentul nu exista.")
|
||||
item = _clean_component(data, component_id)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE components SET code = ?, name = ?, unit = ?, description = ?, active = ?,"
|
||||
" updated_at = datetime('now') WHERE id = ?",
|
||||
(item["code"], item["name"], item["unit"], item["description"], item["active"],
|
||||
component_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_component(component_id: int) -> None:
|
||||
"""Sterge componentul daca nu este folosit; altfel il dezactiveaza."""
|
||||
db = get_db()
|
||||
used = db.execute(
|
||||
"SELECT COUNT(*) FROM recipe_components WHERE component_id = ?", (component_id,)
|
||||
).fetchone()[0]
|
||||
if used:
|
||||
db.execute(
|
||||
"UPDATE components SET active = 0, updated_at = datetime('now') WHERE id = ?",
|
||||
(component_id,),
|
||||
)
|
||||
else:
|
||||
db.execute("DELETE FROM components WHERE id = ?", (component_id,))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retete (produsul finit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _recipe_components(recipe_id: int) -> list[dict[str, Any]]:
|
||||
rows = get_db().execute(
|
||||
"SELECT rc.*, c.code AS component_code, c.unit AS component_unit"
|
||||
" FROM recipe_components rc LEFT JOIN components c ON c.id = rc.component_id"
|
||||
" WHERE rc.recipe_id = ? ORDER BY rc.position",
|
||||
(recipe_id,),
|
||||
)
|
||||
return [
|
||||
{
|
||||
"position": row["position"],
|
||||
"component_id": row["component_id"],
|
||||
"code": row["component_code"] or "",
|
||||
"name": row["name"] or (row["component_code"] or ""),
|
||||
"target_weight": row["target_weight"],
|
||||
"unit": row["component_unit"] or "",
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def list_recipes(include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
sql = (
|
||||
"SELECT r.*, t.name AS template_name,"
|
||||
" (SELECT COUNT(*) FROM recipe_components rc WHERE rc.recipe_id = r.id) AS component_count"
|
||||
" FROM recipes r LEFT JOIN label_templates t ON t.id = r.template_id"
|
||||
)
|
||||
if not include_inactive:
|
||||
sql += " WHERE r.active = 1"
|
||||
sql += " ORDER BY r.name COLLATE NOCASE"
|
||||
return [dict(r) for r in get_db().execute(sql)]
|
||||
|
||||
|
||||
def get_recipe(recipe_id: int) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT r.*, t.name AS template_name FROM recipes r"
|
||||
" LEFT JOIN label_templates t ON t.id = r.template_id WHERE r.id = ?",
|
||||
(recipe_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
recipe = dict(row)
|
||||
recipe["components"] = _recipe_components(recipe_id)
|
||||
recipe["component_count"] = len(recipe["components"])
|
||||
return recipe
|
||||
|
||||
|
||||
def _parse_recipe_components(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
seen: set[int] = set()
|
||||
for slot in range(1, MAX_RECIPE_COMPONENTS + 1):
|
||||
raw_id = data.get(f"component_{slot}_id")
|
||||
if raw_id in (None, "", "0", 0):
|
||||
continue
|
||||
try:
|
||||
component_id = int(raw_id)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError(f"Componentul {slot} nu este valid.") from exc
|
||||
component = get_component(component_id)
|
||||
if component is None or not component["active"]:
|
||||
raise ValidationError(f"Componentul {slot} nu exista sau este inactiv.")
|
||||
if component_id in seen:
|
||||
raise ValidationError(f"Componentul {component['name']!r} apare de doua ori.")
|
||||
seen.add(component_id)
|
||||
items.append({
|
||||
"component_id": component_id,
|
||||
"code": component["code"],
|
||||
"name": component["name"],
|
||||
"target_weight": _number(
|
||||
data.get(f"component_{slot}_target"),
|
||||
label=f"Greutatea tinta pentru {component['name']}",
|
||||
),
|
||||
})
|
||||
|
||||
if not items:
|
||||
raise ValidationError("O reteta are nevoie de cel putin un component.")
|
||||
for position, item in enumerate(items, start=1):
|
||||
item["position"] = position
|
||||
return items
|
||||
|
||||
|
||||
def _clean_recipe(data: dict[str, Any], recipe_id: Optional[int] = None) -> dict[str, Any]:
|
||||
code = _text(data.get("code"), 40)
|
||||
name = _text(data.get("name"), 120)
|
||||
unit = normalize_unit(data.get("unit"), "g")
|
||||
if not code:
|
||||
raise ValidationError("Codul produsului finit este obligatoriu.")
|
||||
if not name:
|
||||
raise ValidationError("Denumirea produsului finit este obligatorie.")
|
||||
if unit not in UNITS:
|
||||
raise ValidationError(f"Unitate de masura necunoscuta: {unit}.")
|
||||
|
||||
db = get_db()
|
||||
sql = "SELECT id FROM recipes WHERE code = ? COLLATE NOCASE"
|
||||
params: list[Any] = [code]
|
||||
if recipe_id is not None:
|
||||
sql += " AND id <> ?"
|
||||
params.append(recipe_id)
|
||||
if db.execute(sql, params).fetchone():
|
||||
raise ValidationError(f"Exista deja un produs finit cu codul {code!r}.")
|
||||
|
||||
template_id = _optional_id(data.get("template_id"))
|
||||
if template_id is not None and get_template(template_id) is None:
|
||||
raise ValidationError("Sablonul de eticheta ales nu exista.")
|
||||
|
||||
return {
|
||||
"code": code,
|
||||
"name": name,
|
||||
"unit": unit,
|
||||
"target_weight": _number(data.get("target_weight"), label="Greutatea totala tinta"),
|
||||
"note": _text(data.get("note"), 500),
|
||||
"active": _flag(data.get("active", True)),
|
||||
"template_id": template_id,
|
||||
"components": _parse_recipe_components(data),
|
||||
}
|
||||
|
||||
|
||||
def create_recipe(data: dict[str, Any]) -> int:
|
||||
recipe = _clean_recipe(data)
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO recipes(code, name, unit, target_weight, note, active, template_id)"
|
||||
" VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(recipe["code"], recipe["name"], recipe["unit"], recipe["target_weight"],
|
||||
recipe["note"], recipe["active"], recipe["template_id"]),
|
||||
)
|
||||
recipe_id = int(cur.lastrowid)
|
||||
_write_recipe_components(recipe_id, recipe["components"])
|
||||
db.commit()
|
||||
return recipe_id
|
||||
|
||||
|
||||
def update_recipe(recipe_id: int, data: dict[str, Any]) -> None:
|
||||
if get_recipe(recipe_id) is None:
|
||||
raise ValidationError("Produsul finit nu exista.")
|
||||
recipe = _clean_recipe(data, recipe_id)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE recipes SET code = ?, name = ?, unit = ?, target_weight = ?, note = ?,"
|
||||
" active = ?, template_id = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(recipe["code"], recipe["name"], recipe["unit"], recipe["target_weight"],
|
||||
recipe["note"], recipe["active"], recipe["template_id"], recipe_id),
|
||||
)
|
||||
db.execute("DELETE FROM recipe_components WHERE recipe_id = ?", (recipe_id,))
|
||||
_write_recipe_components(recipe_id, recipe["components"])
|
||||
db.commit()
|
||||
|
||||
|
||||
def _write_recipe_components(recipe_id: int, items: list[dict[str, Any]]) -> None:
|
||||
db = get_db()
|
||||
for item in items:
|
||||
db.execute(
|
||||
"INSERT INTO recipe_components(recipe_id, position, component_id, name, target_weight)"
|
||||
" VALUES (?, ?, ?, ?, ?)",
|
||||
(recipe_id, item["position"], item["component_id"], item["name"],
|
||||
item["target_weight"]),
|
||||
)
|
||||
|
||||
|
||||
def delete_recipe(recipe_id: int) -> None:
|
||||
"""Sterge reteta daca nu are pachete; altfel o dezactiveaza."""
|
||||
db = get_db()
|
||||
used = db.execute(
|
||||
"SELECT COUNT(*) FROM packages WHERE recipe_id = ?", (recipe_id,)
|
||||
).fetchone()[0]
|
||||
if used:
|
||||
db.execute("UPDATE recipes SET active = 0, updated_at = datetime('now') WHERE id = ?",
|
||||
(recipe_id,))
|
||||
else:
|
||||
db.execute("DELETE FROM recipes WHERE id = ?", (recipe_id,))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pachete (o eticheta de produs finit)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _package_row(package_id: int) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute("SELECT * FROM packages WHERE id = ?", (package_id,)).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_package(package_id: int) -> Optional[dict[str, Any]]:
|
||||
package = _package_row(package_id)
|
||||
if package is None:
|
||||
return None
|
||||
package["items"] = [
|
||||
dict(row) for row in get_db().execute(
|
||||
"SELECT * FROM package_items WHERE package_id = ? ORDER BY position",
|
||||
(package_id,),
|
||||
)
|
||||
]
|
||||
return package
|
||||
|
||||
|
||||
def get_open_package() -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT id FROM packages WHERE status = 'open' ORDER BY id DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return get_package(row["id"]) if row else None
|
||||
|
||||
|
||||
def start_package(
|
||||
recipe_id: int,
|
||||
*,
|
||||
operator: str = "",
|
||||
lot: str = "",
|
||||
notes: str = "",
|
||||
template_id: Optional[int] = None,
|
||||
) -> int:
|
||||
recipe = get_recipe(recipe_id)
|
||||
if recipe is None or not recipe["active"]:
|
||||
raise ValidationError("Produsul finit nu exista sau este inactiv.")
|
||||
if not recipe["components"]:
|
||||
raise ValidationError("Produsul finit nu are componente configurate.")
|
||||
if get_open_package() is not None:
|
||||
raise ValidationError("Exista deja un pachet inceput. Finalizeaza-l sau renunta la el.")
|
||||
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO packages(created_at, status, recipe_id, recipe_code, recipe_name, unit,"
|
||||
" target_weight, operator, lot, notes, template_id)"
|
||||
" VALUES (?, 'open', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(_now_iso(), recipe["id"], recipe["code"], recipe["name"], recipe["unit"],
|
||||
recipe["target_weight"], _text(operator, 64), _text(lot, 64), _text(notes, 500),
|
||||
template_id),
|
||||
)
|
||||
db.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def add_package_item(
|
||||
package_id: int,
|
||||
*,
|
||||
weight: float,
|
||||
unit: str,
|
||||
source: str = "manual",
|
||||
stable: bool = True,
|
||||
scale_profile: str = "",
|
||||
raw_hex: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""Adauga urmatoarea cantarire din reteta si intoarce pachetul actualizat."""
|
||||
package = get_package(package_id)
|
||||
if package is None:
|
||||
raise ValidationError("Pachetul nu exista.")
|
||||
if package["status"] != "open":
|
||||
raise ValidationError("Pachetul este deja finalizat.")
|
||||
|
||||
recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None
|
||||
plan = recipe["components"] if recipe else []
|
||||
position = len(package["items"]) + 1
|
||||
if position > len(plan):
|
||||
raise ValidationError("Toate componentele au fost cantarite.")
|
||||
planned = plan[position - 1]
|
||||
|
||||
normalized = normalize_unit(unit, package["unit"])
|
||||
converted = convert(float(weight), normalized, package["unit"])
|
||||
if converted is None:
|
||||
raise ValidationError(f"Unitatea {normalized!r} nu poate fi convertita in {package['unit']!r}.")
|
||||
value = round(converted, 3)
|
||||
if value <= 0:
|
||||
raise ValidationError("Greutatea trebuie sa fie mai mare decat zero.")
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO package_items(package_id, position, component_id, component_code,"
|
||||
" component_name, weight, unit, target_weight, stable, source, scale_profile, raw_hex,"
|
||||
" created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
(package_id, position, planned.get("component_id"), planned.get("code") or "",
|
||||
planned.get("name") or "", value, package["unit"], planned.get("target_weight"),
|
||||
1 if stable else 0, "scale" if source == "scale" else "manual",
|
||||
_text(scale_profile, 64), _text(raw_hex, 500), _now_iso()),
|
||||
)
|
||||
db.commit()
|
||||
return get_package(package_id) or package
|
||||
|
||||
|
||||
def undo_package_item(package_id: int) -> dict[str, Any]:
|
||||
package = get_package(package_id)
|
||||
if package is None:
|
||||
raise ValidationError("Pachetul nu exista.")
|
||||
if package["status"] != "open":
|
||||
raise ValidationError("Pachetul este finalizat; nu se mai poate reveni.")
|
||||
if not package["items"]:
|
||||
raise ValidationError("Nu exista nicio cantarire de anulat.")
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"DELETE FROM package_items WHERE package_id = ? AND position = ?",
|
||||
(package_id, max(item["position"] for item in package["items"])),
|
||||
)
|
||||
db.commit()
|
||||
return get_package(package_id) or package
|
||||
|
||||
|
||||
def complete_package(package_id: int) -> dict[str, Any]:
|
||||
package = get_package(package_id)
|
||||
if package is None:
|
||||
raise ValidationError("Pachetul nu exista.")
|
||||
if package["status"] == "done":
|
||||
return package
|
||||
if package["status"] != "open":
|
||||
raise ValidationError("Pachetul nu mai poate fi finalizat.")
|
||||
recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None
|
||||
planned = len(recipe["components"]) if recipe else 0
|
||||
if len(package["items"]) != planned or planned == 0:
|
||||
raise ValidationError("Nu toate componentele au fost cantarite.")
|
||||
|
||||
total = round(sum(float(item["weight"]) for item in package["items"]), 3)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE packages SET status = 'done', completed_at = ?, total_weight = ? WHERE id = ?",
|
||||
(_now_iso(), total, package_id),
|
||||
)
|
||||
db.commit()
|
||||
return get_package(package_id) or package
|
||||
|
||||
|
||||
def void_package(package_id: int) -> None:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE packages SET status = 'void', completed_at = COALESCE(completed_at, ?)"
|
||||
" WHERE id = ? AND status = 'open'",
|
||||
(_now_iso(), package_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def mark_printed(package_id: int) -> Optional[dict[str, Any]]:
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE packages SET printed_at = ?, print_count = print_count + 1 WHERE id = ?",
|
||||
(_now_iso(), package_id),
|
||||
)
|
||||
db.commit()
|
||||
return get_package(package_id)
|
||||
|
||||
|
||||
def package_state(package_id: int) -> Optional[dict[str, Any]]:
|
||||
"""Starea fluxului: pachet, reteta, pasul curent si totalul."""
|
||||
package = get_package(package_id)
|
||||
if package is None:
|
||||
return None
|
||||
recipe = get_recipe(package["recipe_id"]) if package["recipe_id"] else None
|
||||
plan = recipe["components"] if recipe else []
|
||||
done = len(package["items"])
|
||||
step = None
|
||||
if package["status"] == "open" and done < len(plan):
|
||||
planned = plan[done]
|
||||
step = {
|
||||
"position": done + 1,
|
||||
"total": len(plan),
|
||||
"component_id": planned.get("component_id"),
|
||||
"component_code": planned.get("code") or "",
|
||||
"component_name": planned.get("name") or "",
|
||||
"target_weight": planned.get("target_weight"),
|
||||
"unit": package["unit"],
|
||||
}
|
||||
return {
|
||||
"package": package,
|
||||
"recipe": recipe,
|
||||
"items": package["items"],
|
||||
"step": step,
|
||||
"planned_total": len(plan),
|
||||
"total_weight": round(sum(float(item["weight"]) for item in package["items"]), 3),
|
||||
"complete": package["status"] == "done",
|
||||
"open": package["status"] == "open",
|
||||
}
|
||||
|
||||
|
||||
def list_packages(
|
||||
*,
|
||||
date_from: str = "",
|
||||
date_to: str = "",
|
||||
recipe_id: Optional[int] = None,
|
||||
status: str = "",
|
||||
query: str = "",
|
||||
limit: int = 500,
|
||||
) -> list[dict[str, Any]]:
|
||||
sql = (
|
||||
"SELECT p.*,"
|
||||
" (SELECT COUNT(*) FROM package_items i WHERE i.package_id = p.id) AS item_count,"
|
||||
" (SELECT GROUP_CONCAT(i.component_name || ' ' || printf('%g', i.weight) || ' ' || i.unit, '; ')"
|
||||
" FROM package_items i WHERE i.package_id = p.id) AS item_summary"
|
||||
" FROM packages p WHERE 1 = 1"
|
||||
)
|
||||
params: list[Any] = []
|
||||
if date_from:
|
||||
sql += " AND COALESCE(p.completed_at, p.created_at) >= ?"
|
||||
params.append(date_from)
|
||||
if date_to:
|
||||
sql += " AND COALESCE(p.completed_at, p.created_at) <= ?"
|
||||
params.append(date_to + "T23:59:59.999999" if len(date_to) == 10 else date_to)
|
||||
if recipe_id:
|
||||
sql += " AND p.recipe_id = ?"
|
||||
params.append(recipe_id)
|
||||
if status:
|
||||
sql += " AND p.status = ?"
|
||||
params.append(status)
|
||||
if query:
|
||||
sql += (" AND (p.recipe_name LIKE ? OR p.recipe_code LIKE ? OR p.lot LIKE ?"
|
||||
" OR p.operator LIKE ?)")
|
||||
like = f"%{query}%"
|
||||
params.extend([like, like, like, like])
|
||||
sql += " ORDER BY COALESCE(p.completed_at, p.created_at) DESC, p.id DESC LIMIT ?"
|
||||
params.append(max(1, min(int(limit), 5000)))
|
||||
return [dict(row) for row in get_db().execute(sql, params)]
|
||||
|
||||
|
||||
def recent_packages(limit: int = 6) -> list[dict[str, Any]]:
|
||||
return list_packages(limit=limit)
|
||||
|
||||
|
||||
def package_stats() -> dict[str, Any]:
|
||||
db = get_db()
|
||||
today = db.execute(
|
||||
"SELECT COUNT(*) FROM packages WHERE status = 'done'"
|
||||
" AND date(COALESCE(completed_at, created_at)) = date('now', 'localtime')"
|
||||
).fetchone()[0]
|
||||
units = db.execute(
|
||||
"SELECT unit, SUM(total_weight) AS total FROM packages WHERE status = 'done'"
|
||||
" AND date(COALESCE(completed_at, created_at)) = date('now', 'localtime')"
|
||||
" GROUP BY unit ORDER BY unit"
|
||||
).fetchall()
|
||||
recipes = db.execute("SELECT COUNT(*) FROM recipes WHERE active = 1").fetchone()[0]
|
||||
return {
|
||||
"today_count": today,
|
||||
"today_total": ", ".join(
|
||||
f"{format_weight(row['total'] or 0)} {row['unit']}" for row in units
|
||||
) or "0",
|
||||
"recipes": recipes,
|
||||
}
|
||||
|
||||
|
||||
def to_csv_rows(packages: Iterable[dict[str, Any]]) -> list[list[str]]:
|
||||
rows = [[
|
||||
"ID", "Data", "Cod produs finit", "Produs finit", "Lot", "Operator",
|
||||
"Greutate totala", "UM", "Componente", "Status", "Tiparit la", "Numar tipariri",
|
||||
]]
|
||||
for package in packages:
|
||||
rows.append([
|
||||
str(package["id"]),
|
||||
package.get("completed_at") or package.get("created_at") or "",
|
||||
package.get("recipe_code") or "",
|
||||
package.get("recipe_name") or "",
|
||||
package.get("lot") or "",
|
||||
package.get("operator") or "",
|
||||
f"{float(package.get('total_weight') or 0):.3f}",
|
||||
package.get("unit") or "",
|
||||
package.get("item_summary") or "",
|
||||
package.get("status") or "",
|
||||
package.get("printed_at") or "",
|
||||
str(package.get("print_count") or 0),
|
||||
])
|
||||
return rows
|
||||
|
||||
|
||||
def slugify(text: str) -> str:
|
||||
text = re.sub(r"[^A-Za-z0-9_-]+", "-", text.strip().lower())
|
||||
return text.strip("-_") or "eticheta"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sabloane de eticheta
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def list_templates() -> list[dict[str, Any]]:
|
||||
return [dict(row) for row in get_db().execute(
|
||||
"SELECT * FROM label_templates ORDER BY is_default DESC, name COLLATE NOCASE"
|
||||
)]
|
||||
|
||||
|
||||
def get_template(template_id: int) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT * FROM label_templates WHERE id = ?", (template_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_default_template() -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT * FROM label_templates ORDER BY is_default DESC, id LIMIT 1"
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _clean_template(data: dict[str, Any], template_id: Optional[int] = None) -> dict[str, Any]:
|
||||
name = _text(data.get("name"), 80)
|
||||
# `body_html`/`css` sunt coloane vechi (modul HTML/CSS scos); le pastram goale
|
||||
# pentru compatibilitatea schemei, fara sa cerem continut de la utilizator.
|
||||
body = str(data.get("body_html") or "").strip()
|
||||
css = str(data.get("css") or "").strip()
|
||||
design_json = data.get("design_json")
|
||||
design_json = str(design_json) if design_json is not None else None
|
||||
if not name:
|
||||
raise ValidationError("Numele sablonului este obligatoriu.")
|
||||
if not body and not design_json:
|
||||
raise ValidationError("Continutul etichetei este obligatoriu.")
|
||||
|
||||
width = _number(data.get("width_mm"), label="Latimea etichetei") or 100.0
|
||||
height = _number(data.get("height_mm"), label="Inaltimea etichetei") or 60.0
|
||||
if not (10 <= width <= 500 and 10 <= height <= 500):
|
||||
raise ValidationError("Dimensiunile etichetei trebuie sa fie intre 10 si 500 mm.")
|
||||
|
||||
db = get_db()
|
||||
sql = "SELECT id FROM label_templates WHERE name = ? COLLATE NOCASE"
|
||||
params: list[Any] = [name]
|
||||
if template_id is not None:
|
||||
sql += " AND id <> ?"
|
||||
params.append(template_id)
|
||||
if db.execute(sql, params).fetchone():
|
||||
raise ValidationError(f"Exista deja un sablon numit {name!r}.")
|
||||
|
||||
return {
|
||||
"name": name,
|
||||
"width_mm": width,
|
||||
"height_mm": height,
|
||||
"body_html": body,
|
||||
"css": css,
|
||||
"design_json": design_json,
|
||||
"is_default": _flag(data.get("is_default")),
|
||||
}
|
||||
|
||||
|
||||
def create_template(data: dict[str, Any]) -> int:
|
||||
template = _clean_template(data)
|
||||
db = get_db()
|
||||
if template["is_default"]:
|
||||
db.execute("UPDATE label_templates SET is_default = 0")
|
||||
cur = db.execute(
|
||||
"INSERT INTO label_templates(name, width_mm, height_mm, body_html, css, design_json,"
|
||||
" is_default) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(template["name"], template["width_mm"], template["height_mm"], template["body_html"],
|
||||
template["css"], template["design_json"] or "", template["is_default"]),
|
||||
)
|
||||
db.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_template(template_id: int, data: dict[str, Any]) -> None:
|
||||
existing = get_template(template_id)
|
||||
if existing is None:
|
||||
raise ValidationError("Sabloul nu exista.")
|
||||
payload = dict(data)
|
||||
# Formularul HTML nu trimite design; pastram designul existent.
|
||||
if "design_json" not in payload:
|
||||
payload["design_json"] = existing.get("design_json") or ""
|
||||
template = _clean_template(payload, template_id)
|
||||
db = get_db()
|
||||
if template["is_default"]:
|
||||
db.execute("UPDATE label_templates SET is_default = 0")
|
||||
db.execute(
|
||||
"UPDATE label_templates SET name = ?, width_mm = ?, height_mm = ?, body_html = ?,"
|
||||
" css = ?, design_json = ?, is_default = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(template["name"], template["width_mm"], template["height_mm"], template["body_html"],
|
||||
template["css"], template["design_json"] or "", template["is_default"], template_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def save_template_design(template_id: int, design_json: str) -> None:
|
||||
"""Salveaza designul vizual (JSON) pentru un sablon."""
|
||||
if get_template(template_id) is None:
|
||||
raise ValidationError("Sabloul nu exista.")
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE label_templates SET design_json = ?, updated_at = datetime('now') WHERE id = ?",
|
||||
(design_json, template_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def delete_template(template_id: int) -> None:
|
||||
db = get_db()
|
||||
template = get_template(template_id)
|
||||
if template is None:
|
||||
return
|
||||
used = db.execute(
|
||||
"SELECT COUNT(*) FROM packages WHERE template_id = ?", (template_id,)
|
||||
).fetchone()[0]
|
||||
if used:
|
||||
raise ValidationError("Sabloul este folosit de pachete existente si nu poate fi sters.")
|
||||
bound = db.execute(
|
||||
"SELECT COUNT(*) FROM recipes WHERE template_id = ? AND active = 1", (template_id,)
|
||||
).fetchone()[0]
|
||||
if bound:
|
||||
raise ValidationError(
|
||||
"Sabloul este atribuit unor produse finite active. Schimba sablonul acestora"
|
||||
" inainte de stergere."
|
||||
)
|
||||
db.execute("DELETE FROM label_templates WHERE id = ?", (template_id,))
|
||||
if template["is_default"]:
|
||||
row = db.execute("SELECT id FROM label_templates ORDER BY id LIMIT 1").fetchone()
|
||||
if row:
|
||||
db.execute("UPDATE label_templates SET is_default = 1 WHERE id = ?", (row["id"],))
|
||||
db.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Campuri variabile proprii (custom.*)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CUSTOM_KEY_PREFIX = "custom."
|
||||
|
||||
# Diacriticele romanesti nu se descompun in NFKD (s/ț au virgula dedesubt),
|
||||
# deci le transliteram explicit inainte de a construi cheia.
|
||||
_KEY_TRANSLIT = str.maketrans({
|
||||
"ă": "a", "â": "a", "á": "a", "à": "a", "ä": "a", "å": "a",
|
||||
"î": "i", "í": "i", "ì": "i", "ï": "i",
|
||||
"ș": "s", "ş": "s", "ś": "s",
|
||||
"ț": "t", "ţ": "t",
|
||||
"é": "e", "è": "e", "ë": "e",
|
||||
"ó": "o", "ò": "o", "ö": "o",
|
||||
"ú": "u", "ù": "u", "ü": "u",
|
||||
"ç": "c", "ñ": "n",
|
||||
})
|
||||
|
||||
|
||||
def _key_slug(text: str) -> str:
|
||||
lowered = str(text or "").strip().lower().translate(_KEY_TRANSLIT)
|
||||
return re.sub(r"[^a-z0-9]+", "_", lowered).strip("_")[:40]
|
||||
|
||||
|
||||
|
||||
def list_label_fields(include_inactive: bool = False) -> list[dict[str, Any]]:
|
||||
sql = "SELECT * FROM label_fields"
|
||||
if not include_inactive:
|
||||
sql += " WHERE active = 1"
|
||||
sql += " ORDER BY label COLLATE NOCASE"
|
||||
return [dict(row) for row in get_db().execute(sql)]
|
||||
|
||||
|
||||
def get_label_field(field_id: int) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT * FROM label_fields WHERE id = ?", (field_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def get_label_field_by_key(key: str) -> Optional[dict[str, Any]]:
|
||||
row = get_db().execute(
|
||||
"SELECT * FROM label_fields WHERE key = ?", (key,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def _new_field_key(label: str) -> str:
|
||||
"""`Contact firmă` -> `custom.contact_firma`, cu sufix numeric daca exista deja."""
|
||||
base = _key_slug(label) or "camp"
|
||||
key = f"{CUSTOM_KEY_PREFIX}{base}"
|
||||
db = get_db()
|
||||
counter = 2
|
||||
while db.execute("SELECT id FROM label_fields WHERE key = ?", (key,)).fetchone():
|
||||
key = f"{CUSTOM_KEY_PREFIX}{base}_{counter}"
|
||||
counter += 1
|
||||
return key
|
||||
|
||||
|
||||
def _clean_label_field(data: dict[str, Any]) -> dict[str, Any]:
|
||||
label = _text(data.get("label"), 60)
|
||||
template = str(data.get("template") or "").strip()
|
||||
if not label:
|
||||
raise ValidationError("Denumirea campului este obligatorie.")
|
||||
if not template:
|
||||
raise ValidationError("Textul campului este obligatoriu.")
|
||||
if len(template) > 300:
|
||||
raise ValidationError("Textul campului poate avea maxim 300 de caractere.")
|
||||
return {"label": label, "template": template, "active": _flag(data.get("active"))}
|
||||
|
||||
|
||||
def create_label_field(data: dict[str, Any]) -> int:
|
||||
field = _clean_label_field(data)
|
||||
key = _new_field_key(field["label"])
|
||||
db = get_db()
|
||||
cur = db.execute(
|
||||
"INSERT INTO label_fields(key, label, template, active) VALUES (?, ?, ?, ?)",
|
||||
(key, field["label"], field["template"], field["active"]),
|
||||
)
|
||||
db.commit()
|
||||
return int(cur.lastrowid)
|
||||
|
||||
|
||||
def update_label_field(field_id: int, data: dict[str, Any]) -> None:
|
||||
if get_label_field(field_id) is None:
|
||||
raise ValidationError("Campul nu exista.")
|
||||
field = _clean_label_field(data)
|
||||
db = get_db()
|
||||
# Cheia ramane neschimbata: designurile o refera deja.
|
||||
db.execute(
|
||||
"UPDATE label_fields SET label = ?, template = ?, active = ?,"
|
||||
" updated_at = datetime('now') WHERE id = ?",
|
||||
(field["label"], field["template"], field["active"], field_id),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
def count_label_field_usage(key: str) -> int:
|
||||
"""In cate sabloane (design) apare campul dat."""
|
||||
needle = f'"{key}"'
|
||||
row = get_db().execute(
|
||||
"SELECT COUNT(*) FROM label_templates WHERE design_json LIKE ?", (f"%{needle}%",)
|
||||
).fetchone()
|
||||
return int(row[0]) if row else 0
|
||||
|
||||
|
||||
def delete_label_field(field_id: int) -> None:
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM label_fields WHERE id = ?", (field_id,))
|
||||
db.commit()
|
||||
@@ -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"))
|
||||
@@ -0,0 +1,229 @@
|
||||
"""Adaptor pentru pachetul vendorizat `scale_bridge`.
|
||||
|
||||
Aplicatia importa direct biblioteca SyncPulse Scale Bridge (nu trece prin HTTP):
|
||||
bridge-ul tine portul serial deschis si ne da citirea deja parsata, cu unitate,
|
||||
stare de stabilitate si valoarea bruta.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import sys
|
||||
import threading
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .config import BASE_DIR
|
||||
|
||||
VENDOR_DIR = BASE_DIR / "vendor" / "syncpulse-scale-bridge"
|
||||
|
||||
|
||||
class ScaleError(RuntimeError):
|
||||
"""Cantarul nu poate fi citit (port ocupat, profile lipsa, etc.)."""
|
||||
|
||||
|
||||
def _vendor_on_path() -> None:
|
||||
path = str(VENDOR_DIR)
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
|
||||
class ScaleService:
|
||||
"""O singura instanta per proces; porturile rămân deschise intre cereri."""
|
||||
|
||||
def __init__(self, bridge_config: str | Path, simulate: str = ""):
|
||||
self.bridge_config = str(bridge_config)
|
||||
self.simulate = simulate
|
||||
self._lock = threading.Lock()
|
||||
self._loaded = False
|
||||
self._config: Any = None
|
||||
self._registry: Any = None
|
||||
self._simulator: Any = None
|
||||
self._opener: Any = None
|
||||
self._error: Optional[str] = None
|
||||
self._closed = False
|
||||
|
||||
# -- initializare ------------------------------------------------------
|
||||
def _load(self) -> None:
|
||||
if self._loaded:
|
||||
return
|
||||
with self._lock:
|
||||
if self._loaded:
|
||||
return
|
||||
try:
|
||||
_vendor_on_path()
|
||||
from scale_bridge.config import Config as BridgeConfig
|
||||
from scale_bridge.serialio import Registry, open_real
|
||||
from scale_bridge.simulator import SimulatorBank
|
||||
|
||||
Path(self.bridge_config).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._config = BridgeConfig(self.bridge_config)
|
||||
if self.simulate:
|
||||
self._simulator = SimulatorBank(self.simulate)
|
||||
self._opener = self._simulator.opener(open_real)
|
||||
self._registry = Registry(self._opener)
|
||||
else:
|
||||
self._opener = open_real
|
||||
self._registry = Registry(self._opener)
|
||||
except Exception as exc: # noqa: BLE001 - afisam motivul in UI
|
||||
self._error = f"Scale bridge indisponibil: {exc}"
|
||||
finally:
|
||||
self._loaded = True
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
self._load()
|
||||
return self._registry is not None and self._config is not None
|
||||
|
||||
@property
|
||||
def error(self) -> Optional[str]:
|
||||
self._load()
|
||||
return self._error
|
||||
|
||||
# -- profile -----------------------------------------------------------
|
||||
def profiles(self) -> list[dict[str, Any]]:
|
||||
self._load()
|
||||
if self._config is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"name": p["name"],
|
||||
"label": p.get("label") or p["name"],
|
||||
"port": p.get("port", ""),
|
||||
"protocol": p.get("protocol", ""),
|
||||
}
|
||||
for p in self._config.profiles()
|
||||
]
|
||||
|
||||
def resolve_profile(self, name: str = "") -> Optional[dict[str, Any]]:
|
||||
self._load()
|
||||
if self._config is None:
|
||||
return None
|
||||
if name:
|
||||
return self._config.profile(name)
|
||||
profiles = self._config.profiles()
|
||||
return profiles[0] if profiles else None
|
||||
|
||||
# -- citire ------------------------------------------------------------
|
||||
def read(
|
||||
self,
|
||||
profile_name: str = "",
|
||||
*,
|
||||
max_age_ms: int = 1500,
|
||||
wait_ms: int = 2500,
|
||||
) -> dict[str, Any]:
|
||||
"""Citire normalizata, gata de trimis catre UI/API."""
|
||||
self._load()
|
||||
if self._config is None or self._registry is None:
|
||||
return self._failure(self._error or "Scale bridge indisponibil.")
|
||||
|
||||
try:
|
||||
from scale_bridge import profiles as bridge_profiles
|
||||
from scale_bridge.serialio import PortBusy, now_ms
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self._failure(f"Scale bridge indisponibil: {exc}")
|
||||
|
||||
profile = self.resolve_profile(profile_name)
|
||||
if profile is None:
|
||||
if profile_name:
|
||||
return self._failure(f"Profilul de cantar {profile_name!r} nu exista.")
|
||||
return self._failure("Nu exista niciun profil de cantar configurat.")
|
||||
|
||||
try:
|
||||
worker = self._registry.worker_for(bridge_profiles.line_settings(profile))
|
||||
result = bridge_profiles.wait_for_reading(
|
||||
profile, worker, int(max_age_ms), int(wait_ms), now_ms
|
||||
)
|
||||
worker.touch()
|
||||
except PortBusy as exc:
|
||||
return self._failure(str(exc), profile=profile["name"])
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return self._failure(f"Eroare la citirea cantarului: {exc}", profile=profile["name"])
|
||||
|
||||
return {
|
||||
"ok": bool(result.get("ok")),
|
||||
"error": result.get("error"),
|
||||
"profile": result.get("profile") or profile["name"],
|
||||
"port": result.get("port") or profile.get("port", ""),
|
||||
"protocol": result.get("protocol") or profile.get("protocol", ""),
|
||||
"open": result.get("open"),
|
||||
"value": result.get("value"),
|
||||
"unit": result.get("unit") or profile.get("unit") or "",
|
||||
"stable": bool(result.get("stable")),
|
||||
"stale": bool(result.get("stale")),
|
||||
"overload": bool(result.get("overload")),
|
||||
"age_ms": result.get("age_ms"),
|
||||
"measured_at": result.get("measured_at"),
|
||||
"raw_hex": result.get("raw_hex") or "",
|
||||
"net": result.get("net"),
|
||||
"fields": result.get("fields") or {},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _failure(message: str, profile: str = "") -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": message,
|
||||
"profile": profile,
|
||||
"value": None,
|
||||
"unit": "",
|
||||
"stable": False,
|
||||
"stale": False,
|
||||
"overload": False,
|
||||
"age_ms": None,
|
||||
"measured_at": None,
|
||||
"raw_hex": "",
|
||||
"fields": {},
|
||||
}
|
||||
|
||||
# -- diagnostic --------------------------------------------------------
|
||||
def probe(
|
||||
self,
|
||||
port: str,
|
||||
baud: int = 9600,
|
||||
framing: str = "8N1",
|
||||
seconds: float = 5.0,
|
||||
) -> dict[str, Any]:
|
||||
"""Asculta un port (fara sa scrie nimic) si raporteaza ce sosește.
|
||||
|
||||
Citirea normala a cantarului tine portul deschis; pentru monitor il
|
||||
eliberam intai, apoi registrul il redeschide la urmatoarea citire.
|
||||
"""
|
||||
self._load()
|
||||
if self._registry is None:
|
||||
return {"ok": False, "port": port, "error": self._error or
|
||||
"Scale bridge indisponibil.", "frames": [], "guesses": [], "bytes": 0}
|
||||
try:
|
||||
from .probe import probe_port
|
||||
|
||||
self._registry.release(port)
|
||||
return probe_port(port, baud, framing, seconds, opener=self._opener)
|
||||
except Exception as exc: # noqa: BLE001 - eroarea ajunge in pagina de monitor
|
||||
return {"ok": False, "port": port, "error": str(exc),
|
||||
"frames": [], "guesses": [], "bytes": 0}
|
||||
|
||||
# -- oprire ------------------------------------------------------------
|
||||
def close(self) -> None:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
registry = self._registry
|
||||
if registry is not None:
|
||||
try:
|
||||
registry.stop_all()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
def get_scale(app) -> ScaleService:
|
||||
service = app.extensions.get("scale")
|
||||
if service is None: # pragma: no cover - create_app il creeaza mereu
|
||||
service = ScaleService(app.config["BRIDGE_CONFIG"], app.config["SIMULATE"])
|
||||
app.extensions["scale"] = service
|
||||
return service
|
||||
|
||||
|
||||
def create_scale(app) -> ScaleService:
|
||||
service = ScaleService(app.config["BRIDGE_CONFIG"], app.config["SIMULATE"])
|
||||
app.extensions["scale"] = service
|
||||
atexit.register(service.close)
|
||||
return service
|
||||
@@ -0,0 +1,117 @@
|
||||
-- Schema SQLite: produse finite din 1..N componente cantarite.
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- Componentele cantarite (materii prime / semifabricate).
|
||||
CREATE TABLE IF NOT EXISTS components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'g',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Produsul finit: reteta si ordinea componentelor.
|
||||
CREATE TABLE IF NOT EXISTS recipes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
unit TEXT NOT NULL DEFAULT 'g',
|
||||
target_weight REAL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
-- Sablonul de eticheta folosit pentru acest produs finit.
|
||||
template_id INTEGER REFERENCES label_templates(id) ON DELETE SET NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS recipe_components (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
recipe_id INTEGER NOT NULL REFERENCES recipes(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
component_id INTEGER REFERENCES components(id) ON DELETE SET NULL,
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
target_weight REAL,
|
||||
UNIQUE(recipe_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_recipe_components_recipe ON recipe_components(recipe_id, position);
|
||||
|
||||
-- Un pachet = o eticheta de produs finit.
|
||||
CREATE TABLE IF NOT EXISTS packages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at TEXT NOT NULL,
|
||||
completed_at TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'open', -- open | done | void
|
||||
recipe_id INTEGER REFERENCES recipes(id) ON DELETE SET NULL,
|
||||
recipe_code TEXT NOT NULL DEFAULT '',
|
||||
recipe_name TEXT NOT NULL DEFAULT '',
|
||||
unit TEXT NOT NULL DEFAULT 'g',
|
||||
target_weight REAL,
|
||||
total_weight REAL,
|
||||
operator TEXT NOT NULL DEFAULT '',
|
||||
lot TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
template_id INTEGER REFERENCES label_templates(id) ON DELETE SET NULL,
|
||||
printed_at TEXT,
|
||||
print_count INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_status ON packages(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_packages_completed ON packages(completed_at DESC);
|
||||
|
||||
-- Cantaririle pachetului, in ordinea retetei.
|
||||
CREATE TABLE IF NOT EXISTS package_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
package_id INTEGER NOT NULL REFERENCES packages(id) ON DELETE CASCADE,
|
||||
position INTEGER NOT NULL,
|
||||
component_id INTEGER,
|
||||
component_code TEXT NOT NULL DEFAULT '',
|
||||
component_name TEXT NOT NULL,
|
||||
weight REAL NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
target_weight REAL,
|
||||
stable INTEGER NOT NULL DEFAULT 1,
|
||||
source TEXT NOT NULL DEFAULT 'manual', -- manual | scale
|
||||
scale_profile TEXT NOT NULL DEFAULT '',
|
||||
raw_hex TEXT NOT NULL DEFAULT '',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(package_id, position)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_package_items_package ON package_items(package_id, position);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS label_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
width_mm REAL NOT NULL DEFAULT 100,
|
||||
height_mm REAL NOT NULL DEFAULT 60,
|
||||
body_html TEXT NOT NULL,
|
||||
css TEXT NOT NULL DEFAULT '',
|
||||
design_json TEXT NOT NULL DEFAULT '',
|
||||
is_default INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Campuri variabile proprii, disponibile in designer langa cele fixe.
|
||||
-- `template` este text liber cu referinte {{campiexistent}} la campurile fixe.
|
||||
CREATE TABLE IF NOT EXISTS label_fields (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
label TEXT NOT NULL,
|
||||
template TEXT NOT NULL DEFAULT '',
|
||||
active INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_label_fields_active ON label_fields(active, label);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,726 @@
|
||||
:root {
|
||||
--bg: #eef2f6;
|
||||
--panel: #ffffff;
|
||||
--panel-soft: #f7f9fb;
|
||||
--ink: #0f172a;
|
||||
--muted: #64748b;
|
||||
--line: #dbe3ec;
|
||||
--brand: #0f766e;
|
||||
--brand-dark: #115e59;
|
||||
--brand-soft: #ccfbf1;
|
||||
--ok: #15803d;
|
||||
--ok-soft: #dcfce7;
|
||||
--warn: #b45309;
|
||||
--warn-soft: #fef3c7;
|
||||
--err: #b91c1c;
|
||||
--err-soft: #fee2e2;
|
||||
--radius: 12px;
|
||||
--shadow: 0 1px 2px rgba(15, 23, 42, .05), 0 8px 24px rgba(15, 23, 42, .06);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
a { color: var(--brand-dark); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
code, .code, pre, .code-block {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
/* -- layout -------------------------------------------------------------- */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
padding: 12px 24px;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
letter-spacing: .2px;
|
||||
}
|
||||
.brand:hover { text-decoration: none; }
|
||||
|
||||
.brand-mark {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #14b8a6, #0f766e);
|
||||
color: #04211d;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.nav { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.nav a {
|
||||
color: #cbd5e1;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.nav a:hover { background: #1e293b; color: #fff; text-decoration: none; }
|
||||
.nav a.active { background: #14532d; color: #d1fae5; }
|
||||
|
||||
/* -- subnavigatie pentru sectiunea Setari -------------------------------- */
|
||||
.subnav {
|
||||
position: sticky;
|
||||
top: 62px; /* inaltimea topbar-ului */
|
||||
z-index: 19;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.subnav-inner {
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 24px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.subnav a {
|
||||
flex: none;
|
||||
padding: 7px 14px;
|
||||
border-radius: 999px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.subnav a:hover { background: #e2e8f0; color: var(--ink); text-decoration: none; }
|
||||
.subnav a.active { background: var(--brand); color: #fff; }
|
||||
|
||||
/* -- carduri de administrare (hub in Setari) ----------------------------- */
|
||||
.hub-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(190px, 1fr));
|
||||
gap: 12px;
|
||||
margin: 0 0 18px;
|
||||
}
|
||||
.hub-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
background: var(--panel);
|
||||
box-shadow: var(--shadow);
|
||||
color: inherit;
|
||||
}
|
||||
.hub-card:hover { border-color: var(--brand); background: #fff; text-decoration: none; }
|
||||
.hub-title { font-weight: 700; font-size: 15px; }
|
||||
.hub-meta { font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* -- QZ Tray status ------------------------------------------------------ */
|
||||
.qz-pill {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 13px;
|
||||
border: 1px solid #334155;
|
||||
border-radius: 999px;
|
||||
background: #111c2e;
|
||||
color: #cbd5e1;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.qz-pill:hover { background: #1e293b; }
|
||||
.qz-pill:disabled { cursor: default; opacity: 1; }
|
||||
.qz-pill[data-qz-state="ready"] { border-color: #15803d; color: #bbf7d0; }
|
||||
.qz-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #94a3b8;
|
||||
flex: none;
|
||||
}
|
||||
.qz-pill[data-qz-state="ready"] .qz-dot { background: #22c55e; box-shadow: 0 0 0 3px rgba(34, 197, 94, .2); }
|
||||
.qz-pill[data-qz-state="connecting"] .qz-dot { background: #f59e0b; }
|
||||
.qz-pill[data-qz-state="offline"] .qz-dot,
|
||||
.qz-pill[data-qz-state="missing"] .qz-dot { background: #ef4444; }
|
||||
|
||||
.qz-hint {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 12px;
|
||||
margin-top: 10px;
|
||||
border-top: 1px dashed var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
.qz-state-line { display: flex; align-items: center; gap: 10px; margin: 0 0 14px; }
|
||||
|
||||
.container {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 24px 56px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
max-width: 1240px;
|
||||
margin: 0 auto;
|
||||
padding: 20px 24px 32px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.page-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.page-head h1 { margin: 0 0 4px; font-size: 26px; letter-spacing: -.4px; }
|
||||
.subtitle { margin: 0; color: var(--muted); }
|
||||
|
||||
.stats { display: flex; gap: 10px; }
|
||||
.stat {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 16px;
|
||||
min-width: 110px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.stat-value { display: block; font-size: 22px; font-weight: 800; line-height: 1.1; }
|
||||
.stat-label { display: block; color: var(--muted); font-size: 12px; }
|
||||
|
||||
/* -- cards --------------------------------------------------------------- */
|
||||
.card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
box-shadow: var(--shadow);
|
||||
min-width: 0;
|
||||
}
|
||||
.card h2 { margin: 0 0 14px; font-size: 17px; }
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.card-head h2 { margin: 0; }
|
||||
.card.narrow { max-width: 620px; }
|
||||
|
||||
.grid { display: grid; gap: 18px; }
|
||||
.weigh-grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, .95fr); margin-bottom: 18px; }
|
||||
.template-grid { grid-template-columns: minmax(0, 1.4fr) minmax(0, .8fr); align-items: start; }
|
||||
.settings-grid { grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr); align-items: start; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.weigh-grid, .template-grid, .settings-grid { grid-template-columns: 1fr; }
|
||||
/* cele doua taburi principale raman vizibile si pe ecrane mici */
|
||||
.topbar { gap: 12px; padding: 10px 14px; flex-wrap: wrap; }
|
||||
.brand-text { display: none; }
|
||||
.nav a { padding: 7px 10px; font-size: 14px; }
|
||||
.subnav { top: 54px; }
|
||||
.subnav-inner { padding: 6px 14px; }
|
||||
.subnav a { padding: 6px 11px; font-size: 12.5px; }
|
||||
}
|
||||
|
||||
/* -- forms --------------------------------------------------------------- */
|
||||
.form, .filters { display: flex; flex-direction: column; gap: 14px; }
|
||||
.filters { flex-direction: row; flex-wrap: wrap; align-items: flex-end; }
|
||||
|
||||
.field { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
|
||||
.field > label { font-weight: 600; font-size: 13px; color: #334155; }
|
||||
.field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.field.grow { flex: 1 1 220px; }
|
||||
.actions-field { flex-direction: row; gap: 8px; }
|
||||
|
||||
input[type="text"], input[type="search"], input[type="date"], input[type="number"],
|
||||
select, textarea {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--brand);
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, .16);
|
||||
}
|
||||
textarea { resize: vertical; }
|
||||
textarea.code { font-family: ui-monospace, Menlo, Consolas, monospace; font-size: 13px; line-height: 1.45; }
|
||||
|
||||
.check { display: flex; align-items: center; gap: 8px; font-size: 14px; color: #334155; }
|
||||
.req { color: var(--err); }
|
||||
.inline-select { width: auto; padding: 6px 10px; font-size: 13px; }
|
||||
.inline { display: inline; }
|
||||
|
||||
.actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
|
||||
|
||||
/* Formularul de creare sablon: nume + buton, pe un singur rand. */
|
||||
.new-template-form { display: flex; align-items: center; gap: 8px; }
|
||||
.new-template-form input[type="text"] { width: 190px; padding: 9px 11px; }
|
||||
.new-template-form .btn { flex: none; }
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: var(--ink);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: var(--panel-soft); text-decoration: none; }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn.primary { background: var(--brand); border-color: var(--brand); color: #fff; }
|
||||
.btn.primary:hover { background: var(--brand-dark); }
|
||||
.btn.ghost { background: transparent; }
|
||||
.btn.danger { color: var(--err); }
|
||||
.btn.warn {
|
||||
background: #fffbeb;
|
||||
border-color: #f59e0b;
|
||||
color: #92400e;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn.warn:hover { background: #fef3c7; border-color: #d97706; }
|
||||
|
||||
.link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0 6px;
|
||||
color: var(--brand-dark);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link:hover { text-decoration: underline; }
|
||||
.link.danger { color: var(--err); }
|
||||
|
||||
/* -- scale display ------------------------------------------------------- */
|
||||
.scale-card { display: flex; flex-direction: column; }
|
||||
.scale-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: #94a3b8; }
|
||||
.dot.ok { background: #22c55e; box-shadow: 0 0 0 4px rgba(34, 197, 94, .18); }
|
||||
.dot.off { background: #ef4444; box-shadow: 0 0 0 4px rgba(239, 68, 68, .15); }
|
||||
.chip {
|
||||
margin-left: auto;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 999px;
|
||||
padding: 3px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.weight-display {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
padding: 26px 12px 18px;
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(20, 184, 166, .12), transparent 62%),
|
||||
var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
#weight-value {
|
||||
font-size: clamp(48px, 9vw, 88px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -2px;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
#weight-value.unstable { color: #b45309; }
|
||||
#weight-value.error { color: #b91c1c; }
|
||||
.weight-unit { font-size: 24px; font-weight: 700; color: var(--muted); }
|
||||
|
||||
.weight-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin: 12px 0 4px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
background: #e2e8f0;
|
||||
color: #334155;
|
||||
}
|
||||
.badge.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.err { background: var(--err-soft); color: var(--err); }
|
||||
.badge.off { background: #e2e8f0; color: var(--muted); }
|
||||
.raw { margin-left: auto; color: var(--muted); font-family: ui-monospace, Menlo, monospace; font-size: 11px; }
|
||||
|
||||
.scale-hint {
|
||||
margin-top: auto;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--line);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* -- messages ------------------------------------------------------------ */
|
||||
.message { margin: 12px 0 0; min-height: 20px; font-weight: 500; }
|
||||
.message.error { color: var(--err); }
|
||||
.message.success { color: var(--ok); }
|
||||
.message.info { color: var(--muted); }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12.5px; }
|
||||
|
||||
.notice {
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--line);
|
||||
margin-bottom: 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
.notice.warning { background: var(--warn-soft); border-color: #fde68a; color: #78350f; }
|
||||
.notice a { color: inherit; text-decoration: underline; }
|
||||
|
||||
.flash-stack { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; }
|
||||
.flash {
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
font-weight: 500;
|
||||
}
|
||||
.flash-success { background: var(--ok-soft); border-color: #bbf7d0; color: #14532d; }
|
||||
.flash-error { background: var(--err-soft); border-color: #fecaca; color: #7f1d1d; }
|
||||
|
||||
/* -- tables -------------------------------------------------------------- */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
text-align: left;
|
||||
padding: 11px 12px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.table th {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .4px;
|
||||
color: var(--muted);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.table tbody tr:hover { background: #f8fafc; }
|
||||
.table.compact th, .table.compact td { padding: 8px 10px; }
|
||||
.table .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
||||
.table .right { text-align: right; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.row-muted td { color: var(--muted); }
|
||||
|
||||
/* -- help / settings ----------------------------------------------------- */
|
||||
.help-card { background: var(--panel-soft); }
|
||||
.help-card h2:not(:first-child) { margin-top: 22px; }
|
||||
.var-list { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 6px; font-size: 13.5px; }
|
||||
.var-list code { background: #e2e8f0; padding: 1px 5px; border-radius: 4px; }
|
||||
.code-block {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
background: #0b1220;
|
||||
color: #d1fae5;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.profile-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
|
||||
.profile-list li { padding: 10px 12px; background: #fff; border: 1px solid var(--line); border-radius: 8px; }
|
||||
|
||||
/* -- monitor serial ------------------------------------------------------ */
|
||||
.monitor-options {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px dashed var(--line);
|
||||
}
|
||||
.monitor-options .check { font-size: 13px; }
|
||||
.card h3 { margin: 20px 0 10px; font-size: 15px; color: #1e293b; }
|
||||
code.hex {
|
||||
display: inline-block;
|
||||
max-width: 420px;
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 11.5px;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
/* -- fluxul de cântărire ------------------------------------------------ */
|
||||
.flow-card { display: flex; flex-direction: column; }
|
||||
.flow-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.flow-head h2 { margin: 0; }
|
||||
.flow-body { flex: 1; display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.step {
|
||||
padding: 18px;
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
radial-gradient(circle at 50% 0%, rgba(20, 184, 166, .14), transparent 65%),
|
||||
var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
text-align: center;
|
||||
}
|
||||
.step-label {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
letter-spacing: .6px;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
.step-component {
|
||||
font-size: clamp(28px, 4.4vw, 44px);
|
||||
font-weight: 800;
|
||||
letter-spacing: -.6px;
|
||||
line-height: 1.1;
|
||||
margin: 4px 0 2px;
|
||||
}
|
||||
.step-target { color: var(--brand-dark); font-weight: 700; }
|
||||
|
||||
.plan {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 14px;
|
||||
color: #334155;
|
||||
}
|
||||
.plan li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.plan .w {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 700;
|
||||
min-width: 74px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.plan.accepted li { background: #f0fdf4; border-color: #bbf7d0; }
|
||||
|
||||
.done { text-align: center; padding: 8px 0 2px; }
|
||||
.done-mark {
|
||||
width: 52px;
|
||||
height: 52px;
|
||||
margin: 0 auto 8px;
|
||||
border-radius: 50%;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: var(--ok-soft);
|
||||
color: var(--ok);
|
||||
font-size: 26px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.done-total { display: flex; align-items: baseline; justify-content: center; gap: 8px; }
|
||||
.done-total .value { font-size: 40px; font-weight: 800; letter-spacing: -1px; }
|
||||
.done-total .unit { font-size: 18px; font-weight: 700; color: var(--muted); }
|
||||
|
||||
.component-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.component-index {
|
||||
flex: none;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 50%;
|
||||
background: var(--brand);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.component-target { flex: none; width: 110px; }
|
||||
|
||||
.hint { margin: 0; }
|
||||
|
||||
/* -- print / toast ------------------------------------------------------- */
|
||||
.print-frame {
|
||||
position: fixed;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
left: -9999px;
|
||||
top: -9999px;
|
||||
border: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
right: 24px;
|
||||
bottom: 24px;
|
||||
max-width: 420px;
|
||||
padding: 12px 16px;
|
||||
border-radius: 10px;
|
||||
background: #0b1220;
|
||||
color: #e2e8f0;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, .3);
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
transition: opacity .18s ease, transform .18s ease;
|
||||
pointer-events: none;
|
||||
font-weight: 500;
|
||||
z-index: 50;
|
||||
}
|
||||
.toast.show { opacity: 1; transform: translateY(0); }
|
||||
.toast.error { background: #7f1d1d; color: #fee2e2; }
|
||||
.toast.warn { background: #78350f; color: #fef3c7; }
|
||||
.toast.success { background: #14532d; color: #dcfce7; }
|
||||
|
||||
/* -- campuri variabile --------------------------------------------------- */code.field-template {
|
||||
display: inline-block;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
vertical-align: bottom;
|
||||
padding: 2px 6px;
|
||||
border-radius: 5px;
|
||||
background: var(--panel-soft);
|
||||
font-size: 12px;
|
||||
}
|
||||
.field-preview {
|
||||
padding: 10px 12px;
|
||||
border: 1px dashed #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
font-size: 15px;
|
||||
min-height: 42px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
.field-preview.error { border-color: #fca5a5; background: #fef2f2; color: #b91c1c; font-size: 13px; }
|
||||
.field-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.field-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 7px 11px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.field-chip:hover { border-color: var(--brand); background: var(--brand-soft); }
|
||||
.chip-label { font-size: 12.5px; font-weight: 600; }
|
||||
.chip-sample { font-size: 11px; color: var(--muted); }
|
||||
|
||||
/* -- checklist de pornire ------------------------------------------------ */
|
||||
.setup-card { margin-bottom: 18px; border-left: 4px solid #f59e0b; }
|
||||
.setup-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.setup-head h2 { margin: 0 0 2px; }
|
||||
.setup-head p { margin: 0; }
|
||||
.setup-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 8px; }
|
||||
.setup-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.setup-mark {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #fde68a;
|
||||
color: #92400e;
|
||||
font-weight: 800;
|
||||
font-size: 13px;
|
||||
}
|
||||
.setup-body { display: flex; flex-direction: column; gap: 2px; flex: 1 1 auto; min-width: 0; }
|
||||
.setup-item .btn { flex: none; padding: 7px 12px; font-size: 13px; }
|
||||
|
||||
.system-list { list-style: none; margin: 6px 0 12px; padding: 0; display: grid; gap: 4px; }
|
||||
.system-list code { word-break: break-all; font-size: 11.5px; }
|
||||
@@ -0,0 +1,467 @@
|
||||
/* Designer de etichete: layout, canvas, panouri. */
|
||||
|
||||
.designer { display: flex; flex-direction: column; gap: 14px; }
|
||||
|
||||
.designer-head {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.designer-head h1 { margin: 0 0 4px; font-size: 24px; letter-spacing: -.3px; }
|
||||
.designer-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* Numele sablonului se editeaza direct din antet. */
|
||||
.designer-head-main { min-width: 0; flex: 1 1 320px; }
|
||||
.rename-form { display: flex; align-items: center; gap: 8px; margin: 0 0 4px; }
|
||||
.rename-form .btn { padding: 7px 12px; font-size: 13px; flex: none; }
|
||||
.title-input {
|
||||
width: min(420px, 100%);
|
||||
padding: 6px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font: inherit;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -.3px;
|
||||
color: var(--ink);
|
||||
}
|
||||
.title-input:hover { border-color: var(--line); background: #fff; }
|
||||
.title-input:focus {
|
||||
border-color: var(--brand);
|
||||
background: #fff;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(15, 118, 110, .16);
|
||||
}
|
||||
|
||||
.designer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 200px minmax(0, 1fr) 260px;
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
/* -- bara de sus: adaugare, dimensiuni, zoom, zona sigura ---------------- */
|
||||
.designer-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 22px;
|
||||
padding: 12px 16px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.designer-toolbar h2 {
|
||||
margin: 0 0 7px;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.tool-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-grow { flex: 1 1 320px; }
|
||||
.tool-group + .tool-group {
|
||||
padding-left: 22px;
|
||||
border-left: 1px solid var(--line);
|
||||
}
|
||||
.tool-fields { display: flex; gap: 10px; }
|
||||
.tool-fields .field { gap: 4px; }
|
||||
.tool-fields .field > label { font-size: 11.5px; }
|
||||
.tool-fields input { width: 82px; padding: 7px 9px; }
|
||||
.zoom-slider { width: 130px; margin: 6px 0 0; }
|
||||
.tool-safe { min-width: 168px; }
|
||||
.tool-safe .check { font-size: 13px; }
|
||||
.tool-safe .safe-hint { max-width: 210px; margin-top: 6px; }
|
||||
.tool-safe #safe-arrange {
|
||||
margin-top: 8px;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 7px 10px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.tool-group + .tool-group { padding-left: 0; border-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.designer-grid { grid-template-columns: 1fr; }
|
||||
.designer-canvas-wrap { order: 1; }
|
||||
.designer-elements { order: 2; position: static; }
|
||||
#props { order: 3; position: static; }
|
||||
.designer-elements .block-list { max-height: 260px; }
|
||||
}
|
||||
|
||||
.designer-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
position: sticky;
|
||||
top: 72px;
|
||||
}
|
||||
.designer-panel h2 {
|
||||
margin: 0 0 10px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.designer-panel h2 .count {
|
||||
display: inline-block;
|
||||
min-width: 20px;
|
||||
margin-left: 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel-soft);
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.designer-panel h2:not(:first-child) { margin-top: 18px; }
|
||||
.designer-panel h3 {
|
||||
margin: 16px 0 8px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.designer-panel .field { margin-bottom: 8px; }
|
||||
.designer-panel .field > label { font-size: 12px; }
|
||||
.designer-panel .btn { padding: 8px 12px; font-size: 13px; }
|
||||
|
||||
.palette { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.palette-btn {
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #c8d3e0;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.palette-btn:hover { background: var(--brand-soft); border-color: var(--brand); }
|
||||
|
||||
.designer-elements .block-list {
|
||||
max-height: calc(100vh - 330px);
|
||||
overflow-y: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.block-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 4px; }
|
||||
.block-item {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: var(--panel-soft);
|
||||
font: inherit;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.block-item:hover { background: #eef2f6; }
|
||||
.block-item.active { border-color: var(--brand); background: var(--brand-soft); font-weight: 700; }
|
||||
|
||||
.designer-canvas-wrap { min-width: 0; }
|
||||
.canvas-scroll {
|
||||
overflow: auto;
|
||||
padding: 18px;
|
||||
background:
|
||||
linear-gradient(#f1f5f9, #f1f5f9) padding-box,
|
||||
var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.canvas-stage { display: flex; justify-content: center; }
|
||||
.design-canvas {
|
||||
position: relative;
|
||||
background: #fff;
|
||||
box-shadow: 0 4px 18px rgba(15, 23, 42, .16);
|
||||
outline: 1px solid #cbd5e1;
|
||||
flex: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.canvas-grid {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, rgba(15, 23, 42, .07) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgba(15, 23, 42, .07) 1px, transparent 1px);
|
||||
background-size: var(--grid, 60px) var(--grid, 60px);
|
||||
}
|
||||
.canvas-blocks { position: absolute; inset: 0; }
|
||||
|
||||
/* Conturul zonei sigure: 3 mm de la marginea etichetei. Doar vizual. */
|
||||
.canvas-safe {
|
||||
position: absolute;
|
||||
border: 1px dashed #dc2626;
|
||||
pointer-events: none;
|
||||
z-index: 3;
|
||||
}
|
||||
.canvas-safe span {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 3px;
|
||||
font-size: 8px;
|
||||
line-height: 1;
|
||||
letter-spacing: .3px;
|
||||
text-transform: uppercase;
|
||||
color: #dc2626;
|
||||
opacity: .65;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.safe-hint { margin: 4px 0 0; font-size: 12px; }
|
||||
.safe-hint.ok { color: #15803d; }
|
||||
.safe-hint.warn { color: #b91c1c; font-weight: 600; }
|
||||
|
||||
.c-block {
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
cursor: move;
|
||||
user-select: none;
|
||||
outline: 1px solid transparent;
|
||||
}
|
||||
.c-block:hover { outline-color: rgba(15, 118, 110, .45); }
|
||||
.c-block.selected { outline: 2px solid var(--brand); }
|
||||
.c-block.outside-safe { outline: 1px dashed #dc2626; }
|
||||
.c-block.outside-safe.selected { outline: 2px solid var(--brand); }
|
||||
.c-block.outside-safe::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(220, 38, 38, .08);
|
||||
pointer-events: none;
|
||||
}
|
||||
.c-block.c-line { min-height: 0; }
|
||||
.c-block.c-media { overflow: hidden; }
|
||||
.c-block.c-media img { width: 100%; height: 100%; object-fit: contain; display: block; }
|
||||
.c-block.c-empty { background: repeating-linear-gradient(45deg, #f8fafc, #f8fafc 4px, #e2e8f0 4px, #e2e8f0 8px); }
|
||||
.c-block .d-item { white-space: nowrap; }
|
||||
.c-block .d-item-w { font-weight: 700; }
|
||||
|
||||
/* Manerul sta in interiorul blocului: blocurile media au overflow:hidden si
|
||||
* ar taia un maner pozitionat in afara (nu ar mai putea fi apucat). */
|
||||
.c-resize {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-radius: 2px;
|
||||
background: var(--brand);
|
||||
border: 1px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(15, 23, 42, .15);
|
||||
cursor: nwse-resize;
|
||||
display: none;
|
||||
z-index: 2;
|
||||
}
|
||||
.c-block.selected .c-resize { display: block; }
|
||||
|
||||
.props-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.props-row { display: flex; gap: 8px; }
|
||||
.props-checks { display: flex; flex-direction: column; gap: 4px; margin: 8px 0; }
|
||||
.props-checks .check { font-size: 12.5px; }
|
||||
.designer-panel input[type="color"] { width: 100%; height: 34px; padding: 2px; }
|
||||
.designer-clear { margin-top: 14px; padding-top: 12px; border-top: 1px dashed var(--line); }
|
||||
|
||||
/* -- popup: alegerea campului variabil ----------------------------------- */
|
||||
.picker {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.picker-backdrop { position: absolute; inset: 0; background: rgba(15, 23, 42, .45); }
|
||||
.picker-card {
|
||||
position: relative;
|
||||
width: min(520px, 100%);
|
||||
max-height: min(640px, calc(100vh - 40px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 18px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 24px 60px rgba(15, 23, 42, .28);
|
||||
}
|
||||
.picker-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.picker-head h2 { margin: 0; font-size: 16px; }
|
||||
.picker-close {
|
||||
padding: 2px 8px;
|
||||
border: 0;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker-close:hover { background: var(--panel-soft); color: var(--ink); }
|
||||
.picker-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 120px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
.picker-group {
|
||||
padding: 10px 4px 3px;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.picker-item {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: var(--panel-soft);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.picker-item:hover, .picker-item:focus-visible {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
outline: none;
|
||||
}
|
||||
.picker-label { font-size: 13.5px; font-weight: 600; }
|
||||
.picker-item.recent .picker-label::after {
|
||||
content: ' · ultimul';
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
color: var(--brand);
|
||||
}
|
||||
.picker-sample { font-size: 11.5px; color: var(--muted); }
|
||||
.picker-empty { padding: 18px 6px; text-align: center; font-size: 13px; color: var(--muted); }
|
||||
.picker-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
.picker-foot-actions { display: flex; align-items: center; gap: 12px; }
|
||||
.picker-item.custom .picker-label::before {
|
||||
content: '★ ';
|
||||
color: var(--brand);
|
||||
}
|
||||
.picker-item.picked {
|
||||
border-color: var(--brand);
|
||||
background: var(--brand-soft);
|
||||
}
|
||||
.picker-check {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-right: 6px;
|
||||
border: 1px solid #94a3b8;
|
||||
border-radius: 3px;
|
||||
background: #fff;
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
color: var(--brand);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.picker-item.picked .picker-check { border-color: var(--brand); }
|
||||
|
||||
/* -- continutul codului QR ------------------------------------------------ */
|
||||
.qr-parts { margin-top: 6px; }
|
||||
.qr-parts-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
}
|
||||
.qr-part-list { list-style: none; margin: 0 0 8px; padding: 0; display: grid; gap: 4px; }
|
||||
.qr-part {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 5px 6px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 7px;
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
.qr-part-num {
|
||||
flex: none;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
color: #475569;
|
||||
}
|
||||
.qr-part-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; }
|
||||
.qr-part-body strong { font-size: 12px; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qr-part-sample { font-size: 10.5px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.qr-part input[type="text"] { flex: 1 1 auto; min-width: 0; padding: 5px 7px; font-size: 12px; }
|
||||
.qr-part-tools { display: flex; gap: 2px; flex: none; }
|
||||
.qr-part-btn {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 5px;
|
||||
background: #fff;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
line-height: 1;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
}
|
||||
.qr-part-btn:hover:not(:disabled) { border-color: var(--brand); color: var(--brand); }
|
||||
.qr-part-btn:disabled { opacity: .35; cursor: default; }
|
||||
.qr-part-btn.danger:hover { border-color: #dc2626; color: #dc2626; }
|
||||
.qr-part-actions { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||
.qr-part-actions .btn { padding: 6px 10px; font-size: 12.5px; }
|
||||
.qr-preview-line {
|
||||
margin: 0 0 8px;
|
||||
padding: 5px 7px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
word-break: break-all;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.hidden { display: none !important; }
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* QZ Tray printing for Cantarire & Etichetare.
|
||||
*
|
||||
* Mirrors the working integration from `quality_app-v2`:
|
||||
* - the patched `qz-tray.js` (2.2.4) that skips the certificate exchange and
|
||||
* talks to a custom QZ Tray service using pairing-key auth;
|
||||
* - `qz.printers.find()` + auto-selection of the thermal/label printer;
|
||||
* - `qz.configs.create(printer, { scaleContent:false, rasterize:.., size, units:'mm' })`;
|
||||
* - `qz.print(config, [{ type:'pdf'|'html', format:'base64'|'plain', data }])`.
|
||||
*
|
||||
* Public API (kept simple so the rest of the app does not care about QZ):
|
||||
* window.CantarirePrint.print(payload) -> Promise<{ok, method, printer?}>
|
||||
* window.CantarireQZ -> status / connect / printers / test
|
||||
*
|
||||
* `payload` is produced by `cantarire/labels.py::print_payload`:
|
||||
* { job_id, weighing_id, template:{width_mm,height_mm}, html, css,
|
||||
* fields, print_url, ack_url, pdf_url? }
|
||||
*
|
||||
* If `pdf_url` is present the label is fetched as PDF and sent with
|
||||
* `type:'pdf'` (the exact flow used by quality_app-v2). Otherwise the HTML
|
||||
* label is rasterized by QZ Tray at the physical size from the template.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var STORAGE_KEY = 'cantarire.qz.printer';
|
||||
|
||||
var config = Object.assign({
|
||||
autoConnect: true,
|
||||
fallbackToBrowser: true,
|
||||
connectTimeoutMs: 6000,
|
||||
storageKey: STORAGE_KEY,
|
||||
thermalHints: ['thermal', 'label', 'zebra', 'godex', 'argox', 'tsc', 'citizen',
|
||||
'xprinter', 'bixolon', 'epson', 'datamax', 'sato'],
|
||||
// Optional: standard QZ Tray (with certificate + signature) instead of the
|
||||
// patched pairing-key service. Set via window.CANTARIRE_QZ before this file.
|
||||
certificateUrl: '',
|
||||
signatureUrl: ''
|
||||
}, window.CANTARIRE_QZ || {});
|
||||
|
||||
var state = {
|
||||
status: 'idle', // idle | connecting | ready | offline | missing
|
||||
connected: false,
|
||||
printers: [],
|
||||
printer: '',
|
||||
version: '',
|
||||
error: null,
|
||||
lastFailureAt: 0
|
||||
};
|
||||
|
||||
var connectPromise = null;
|
||||
var lastRendered = '';
|
||||
|
||||
// -- helpers -------------------------------------------------------------
|
||||
function lib() { return window.qz; }
|
||||
|
||||
function snapshot() {
|
||||
return {
|
||||
status: state.status,
|
||||
connected: state.connected,
|
||||
printers: state.printers.slice(),
|
||||
printer: state.printer,
|
||||
version: state.version,
|
||||
error: state.error
|
||||
};
|
||||
}
|
||||
|
||||
function messageOf(error) {
|
||||
if (!error) return 'Eroare necunoscuta';
|
||||
return error.message || String(error);
|
||||
}
|
||||
|
||||
function setState(patch) {
|
||||
Object.assign(state, patch);
|
||||
emit();
|
||||
}
|
||||
|
||||
function emit() {
|
||||
var detail = snapshot();
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent('cantarire:qz', { detail: detail }));
|
||||
} catch (error) { /* browsere foarte vechi */ }
|
||||
syncUi();
|
||||
}
|
||||
|
||||
function savedPrinter() {
|
||||
try { return localStorage.getItem(config.storageKey) || ''; } catch (error) { return ''; }
|
||||
}
|
||||
|
||||
function savePrinter(name) {
|
||||
try {
|
||||
if (name) localStorage.setItem(config.storageKey, name);
|
||||
else localStorage.removeItem(config.storageKey);
|
||||
} catch (error) { /* mod privat */ }
|
||||
}
|
||||
|
||||
function choosePrinter(printers) {
|
||||
var saved = savedPrinter();
|
||||
if (saved && printers.indexOf(saved) >= 0) return saved;
|
||||
for (var i = 0; i < printers.length; i++) {
|
||||
var name = printers[i].toLowerCase();
|
||||
for (var j = 0; j < config.thermalHints.length; j++) {
|
||||
if (name.indexOf(config.thermalHints[j]) >= 0) return printers[i];
|
||||
}
|
||||
}
|
||||
return printers.length ? printers[0] : '';
|
||||
}
|
||||
|
||||
function setupSecurity(qz) {
|
||||
if (config.certificateUrl && qz.security && qz.security.setCertificatePromise) {
|
||||
qz.security.setCertificatePromise(function (resolve, reject) {
|
||||
fetch(config.certificateUrl, { cache: 'no-store' }).then(function (response) {
|
||||
if (!response.ok) throw new Error('certificat: HTTP ' + response.status);
|
||||
return response.text();
|
||||
}).then(resolve, reject);
|
||||
}, { rejectOnFailure: true });
|
||||
}
|
||||
if (config.signatureUrl && qz.security && qz.security.setSignaturePromise) {
|
||||
qz.security.setSignaturePromise(function (toSign) {
|
||||
return function (resolve, reject) {
|
||||
fetch(config.signatureUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ request: toSign })
|
||||
}).then(function (response) {
|
||||
if (!response.ok) throw new Error('semnatura: HTTP ' + response.status);
|
||||
return response.text();
|
||||
}).then(resolve, reject);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// -- connection ----------------------------------------------------------
|
||||
function connect() {
|
||||
if (state.connected) return Promise.resolve(true);
|
||||
if (connectPromise) return connectPromise;
|
||||
|
||||
var qz = lib();
|
||||
if (!qz) {
|
||||
setState({
|
||||
status: 'missing',
|
||||
connected: false,
|
||||
error: 'Biblioteca qz-tray.js nu este incarcata.',
|
||||
version: ''
|
||||
});
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
setState({ status: 'connecting', error: null });
|
||||
|
||||
// The QZ client sweeps several hosts/ports before giving up; reflect a
|
||||
// failure in the UI early. A connection that succeeds later still wins.
|
||||
var timeout = Number(config.connectTimeoutMs) || 6000;
|
||||
var watchdog = setTimeout(function () {
|
||||
if (!state.connected && state.status === 'connecting') {
|
||||
setState({
|
||||
status: 'offline',
|
||||
error: 'QZ Tray nu a raspuns in ' + timeout + ' ms (agentul este pornit?).'
|
||||
});
|
||||
}
|
||||
}, timeout);
|
||||
|
||||
connectPromise = Promise.resolve()
|
||||
.then(function () {
|
||||
try { setupSecurity(qz); } catch (error) { console.warn('[QZ] security:', error); }
|
||||
return qz.websocket.isActive() ? null : qz.websocket.connect();
|
||||
})
|
||||
.then(function () { return qz.api.getVersion(); })
|
||||
.then(function (version) {
|
||||
setState({ status: 'ready', connected: true, version: version, error: null });
|
||||
return loadPrinters();
|
||||
})
|
||||
.then(function () { return true; })
|
||||
.catch(function (error) {
|
||||
setState({
|
||||
status: 'offline',
|
||||
connected: false,
|
||||
version: '',
|
||||
error: messageOf(error),
|
||||
lastFailureAt: Date.now()
|
||||
});
|
||||
return false;
|
||||
})
|
||||
.finally(function () {
|
||||
clearTimeout(watchdog);
|
||||
connectPromise = null;
|
||||
});
|
||||
|
||||
return connectPromise;
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
var qz = lib();
|
||||
var done = qz && qz.websocket.isActive() ? qz.websocket.disconnect() : Promise.resolve();
|
||||
return Promise.resolve(done).catch(function () { /* ignoram */ }).then(function () {
|
||||
setState({ status: 'idle', connected: false, version: '', printers: [] });
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function loadPrinters() {
|
||||
var qz = lib();
|
||||
if (!qz || !state.connected) return Promise.resolve([]);
|
||||
|
||||
return qz.printers.find().then(function (found) {
|
||||
var list = Array.isArray(found) ? found.map(String) : (found ? [String(found)] : []);
|
||||
var chosen = choosePrinter(list);
|
||||
state.printers = list;
|
||||
state.printer = chosen;
|
||||
if (chosen) savePrinter(chosen);
|
||||
emit();
|
||||
return list;
|
||||
}).catch(function (error) {
|
||||
setState({ error: messageOf(error) });
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
function setPrinter(name) {
|
||||
state.printer = name || '';
|
||||
savePrinter(state.printer);
|
||||
emit();
|
||||
}
|
||||
|
||||
// -- payload -------------------------------------------------------------
|
||||
function dimensions(payload) {
|
||||
var template = payload.template || {};
|
||||
var width = Number(template.width_mm);
|
||||
var height = Number(template.height_mm);
|
||||
return {
|
||||
width: isFinite(width) && width > 0 ? width : 100,
|
||||
height: isFinite(height) && height > 0 ? height : 60
|
||||
};
|
||||
}
|
||||
|
||||
function labelDocument(payload) {
|
||||
var size = dimensions(payload);
|
||||
return '<!doctype html><html><head><meta charset="utf-8"><style>'
|
||||
+ '@page{size:' + size.width + 'mm ' + size.height + 'mm;margin:0}'
|
||||
+ 'html,body{margin:0;padding:0;width:' + size.width + 'mm;height:' + size.height
|
||||
+ 'mm;overflow:hidden;background:#fff}'
|
||||
+ '*,*::before,*::after{box-sizing:border-box}'
|
||||
+ (payload.css || '')
|
||||
+ '</style></head><body><div class="label-sheet" style="width:' + size.width
|
||||
+ 'mm;height:' + size.height + 'mm;overflow:hidden">'
|
||||
+ (payload.html || '')
|
||||
+ '</div></body></html>';
|
||||
}
|
||||
|
||||
function fetchBase64(url) {
|
||||
return fetch(url, { cache: 'no-store' }).then(function (response) {
|
||||
if (!response.ok) throw new Error('PDF: HTTP ' + response.status);
|
||||
return response.blob();
|
||||
}).then(function (blob) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onloadend = function () {
|
||||
resolve(String(reader.result).split(',')[1]);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printPdf(qz, payload) {
|
||||
var size = dimensions(payload);
|
||||
var printerConfig = qz.configs.create(state.printer || '', {
|
||||
scaleContent: false,
|
||||
rasterize: false,
|
||||
size: { width: size.width, height: size.height },
|
||||
units: 'mm',
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 }
|
||||
});
|
||||
return fetchBase64(payload.pdf_url).then(function (base64) {
|
||||
return qz.print(printerConfig, [{ type: 'pdf', format: 'base64', data: base64 }]);
|
||||
});
|
||||
}
|
||||
|
||||
function printHtml(qz, payload) {
|
||||
var size = dimensions(payload);
|
||||
var printerConfig = qz.configs.create(state.printer || '', {
|
||||
scaleContent: false,
|
||||
rasterize: true,
|
||||
size: { width: size.width, height: size.height },
|
||||
units: 'mm',
|
||||
margins: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
interpolation: 'optimizeQuality'
|
||||
});
|
||||
return qz.print(printerConfig, [{
|
||||
type: 'html',
|
||||
format: 'plain',
|
||||
data: labelDocument(payload)
|
||||
}]);
|
||||
}
|
||||
|
||||
function browserPrint(payload) {
|
||||
// On the label page itself, the browser dialog is exactly what we want.
|
||||
if (document.getElementById('label-sheet')) {
|
||||
window.print();
|
||||
return { ok: true, method: 'browser' };
|
||||
}
|
||||
|
||||
// Anywhere else (dashboard), print the label page, not the current page.
|
||||
var url = payload && payload.print_url;
|
||||
if (url) {
|
||||
var frame = document.getElementById('print-frame');
|
||||
if (frame) {
|
||||
frame.src = url + '?auto=1';
|
||||
return { ok: true, method: 'browser', framed: true };
|
||||
}
|
||||
window.open(url + '?auto=1', '_blank', 'noopener');
|
||||
return { ok: true, method: 'browser' };
|
||||
}
|
||||
|
||||
window.print();
|
||||
return { ok: true, method: 'browser' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Do not hold the operator for the full host/port sweep of the QZ client:
|
||||
* wait at most `connectTimeoutMs`, then let the caller fall back.
|
||||
* A connection that succeeds later is still used by the next print.
|
||||
*/
|
||||
function ensureConnected() {
|
||||
if (state.connected) return Promise.resolve(true);
|
||||
|
||||
// QZ was not running a moment ago: do not make every label wait again.
|
||||
if (state.status === 'offline' && state.lastFailureAt &&
|
||||
Date.now() - state.lastFailureAt < 30000) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
var timeout = Number(config.connectTimeoutMs) || 6000;
|
||||
return Promise.race([
|
||||
connect(),
|
||||
new Promise(function (resolve) { setTimeout(function () { resolve(false); }, timeout); })
|
||||
]);
|
||||
}
|
||||
|
||||
// -- public print --------------------------------------------------------
|
||||
function print(payload) {
|
||||
payload = payload || {};
|
||||
|
||||
return ensureConnected().then(function (connected) {
|
||||
if (!connected) {
|
||||
if (!config.fallbackToBrowser) {
|
||||
throw new Error(state.error || 'QZ Tray nu este disponibil.');
|
||||
}
|
||||
console.warn('[QZ] fallback la tiparirea din browser:', state.error);
|
||||
return browserPrint(payload);
|
||||
}
|
||||
|
||||
var qz = lib();
|
||||
var job = payload.pdf_url
|
||||
? printPdf(qz, payload).catch(function (error) {
|
||||
// PDF-ul poate lipsi (endpoint nedisponibil): incercam HTML-ul.
|
||||
console.warn('[QZ] PDF indisponibil, folosesc eticheta HTML:', error);
|
||||
return printHtml(qz, payload);
|
||||
})
|
||||
: printHtml(qz, payload);
|
||||
|
||||
return job.then(function () {
|
||||
if (state.error) setState({ error: null });
|
||||
return { ok: true, method: 'qz', printer: state.printer };
|
||||
}).catch(function (error) {
|
||||
setState({ error: messageOf(error), lastFailureAt: Date.now() });
|
||||
if (!config.fallbackToBrowser) throw error;
|
||||
console.warn('[QZ] tiparirea a esuat, folosesc browserul:', error);
|
||||
return browserPrint(payload);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function printTest() {
|
||||
var stamp = new Date().toLocaleString('ro-RO');
|
||||
var payload = {
|
||||
template: { width_mm: 60, height_mm: 40 },
|
||||
css: '.t{font-family:Arial,sans-serif;text-align:center;padding:4mm;color:#000}'
|
||||
+ '.t h1{font-size:5mm;margin:0 0 2mm}.t p{font-size:3.5mm;margin:1mm 0}',
|
||||
html: '<div class="t"><h1>TEST IMPRIMANTA</h1>'
|
||||
+ '<p>' + (state.printer || 'imprimanta implicita') + '</p>'
|
||||
+ '<p>' + stamp + '</p>'
|
||||
+ '<p>Cantarire & Etichetare</p></div>'
|
||||
};
|
||||
return print(payload);
|
||||
}
|
||||
|
||||
// -- UI binding ----------------------------------------------------------
|
||||
function statusText() {
|
||||
if (state.status === 'ready') return 'QZ Tray: pregatit';
|
||||
if (state.status === 'connecting') return 'QZ Tray: conectare…';
|
||||
if (state.status === 'missing') return 'QZ Tray: biblioteca lipsa';
|
||||
if (state.status === 'offline') return 'QZ Tray: deconectat';
|
||||
return 'QZ Tray: neinitializat';
|
||||
}
|
||||
|
||||
function renderPrinterSelect(select) {
|
||||
var signature = state.printers.join('|') + '#' + state.printer;
|
||||
if (select.getAttribute('data-qz-rendered') === signature) return;
|
||||
select.setAttribute('data-qz-rendered', signature);
|
||||
|
||||
var current = state.printer || '';
|
||||
select.innerHTML = '';
|
||||
var auto = document.createElement('option');
|
||||
auto.value = '';
|
||||
auto.textContent = state.printers.length ? 'Implicit (sistem)' : 'Nicio imprimanta detectata';
|
||||
select.appendChild(auto);
|
||||
|
||||
state.printers.forEach(function (name) {
|
||||
var option = document.createElement('option');
|
||||
option.value = name;
|
||||
option.textContent = name;
|
||||
if (name === current) option.selected = true;
|
||||
select.appendChild(option);
|
||||
});
|
||||
select.value = current;
|
||||
select.disabled = state.printers.length === 0;
|
||||
}
|
||||
|
||||
function syncUi() {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.querySelectorAll('[data-qz-status]').forEach(function (node) {
|
||||
node.textContent = statusText();
|
||||
var pill = node.closest('[data-qz-pill]');
|
||||
if (pill) pill.setAttribute('data-qz-state', state.status);
|
||||
if (state.error) node.title = state.error;
|
||||
else node.removeAttribute('title');
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-qz-printer]').forEach(renderPrinterSelect);
|
||||
|
||||
document.querySelectorAll('[data-qz-version]').forEach(function (node) {
|
||||
node.textContent = state.version ? 'v' + state.version : '';
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-qz-connect]').forEach(function (node) {
|
||||
node.disabled = state.connected || state.status === 'connecting';
|
||||
});
|
||||
}
|
||||
|
||||
function bindUi() {
|
||||
document.addEventListener('change', function (event) {
|
||||
var select = event.target.closest ? event.target.closest('[data-qz-printer]') : null;
|
||||
if (select) setPrinter(select.value);
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target.closest ? event.target.closest('[data-qz-connect],[data-qz-test],[data-qz-refresh]') : null;
|
||||
if (!target) return;
|
||||
if (target.hasAttribute('data-qz-connect')) {
|
||||
event.preventDefault();
|
||||
connect();
|
||||
} else if (target.hasAttribute('data-qz-test')) {
|
||||
event.preventDefault();
|
||||
printTest().catch(function (error) { alert('Test esuat: ' + messageOf(error)); });
|
||||
} else if (target.hasAttribute('data-qz-refresh')) {
|
||||
event.preventDefault();
|
||||
if (!state.connected) connect();
|
||||
else loadPrinters();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -- boot ----------------------------------------------------------------
|
||||
window.CantarireQZ = {
|
||||
getState: snapshot,
|
||||
connect: connect,
|
||||
disconnect: disconnect,
|
||||
refreshPrinters: loadPrinters,
|
||||
setPrinter: setPrinter,
|
||||
printTest: printTest
|
||||
};
|
||||
window.CantarirePrint = { print: print };
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
bindUi();
|
||||
syncUi();
|
||||
if (config.autoConnect) connect();
|
||||
});
|
||||
} else {
|
||||
bindUi();
|
||||
syncUi();
|
||||
if (config.autoConnect) connect();
|
||||
}
|
||||
})();
|
||||
Vendored
+16
@@ -0,0 +1,16 @@
|
||||
# QZ Tray connector (`qz-tray.js`)
|
||||
|
||||
- **Version:** 2.2.4
|
||||
- **License:** LGPL-2.1-only (see the header of `qz-tray.js`)
|
||||
- **Source:** copied unmodified from
|
||||
`quality_app-v2/app/static/js/qz-tray.js` (Gitea: `ske087/quality_app-v2`).
|
||||
- **Local patch:** it **skips the certificate exchange** and connects to the
|
||||
custom QZ Tray service that authenticates with a **pairing key**
|
||||
(`PATCHED` markers around the original `_qz.websocket.connection.sendData({ certificate … })`).
|
||||
|
||||
Because the file is a modified LGPL-2.1 library, keep it under
|
||||
`static/js/vendor/` and ship this notice plus the license header with it.
|
||||
If you later use stock QZ Tray (certificate + signature), set
|
||||
`window.CANTARIRE_QZ = { certificateUrl, signatureUrl }` before loading
|
||||
`qz-print.js`; `qz-print.js` wires `qz.security.setCertificatePromise` and
|
||||
`setSignaturePromise` when those URLs are provided.
|
||||
+2871
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
||||
/* Fluxul de cântărire a unui produs finit din 1..N componente.
|
||||
*
|
||||
* State machine:
|
||||
* fără pachet -> alegi produsul finit și începi
|
||||
* pas curent -> cântărești componenta (cântar sau manual), Enter acceptă
|
||||
* ultimul pas -> serverul finalizează pachetul și întoarce eticheta
|
||||
* finalizat -> eticheta se tipărește; Enter începe următorul pachet
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var cfg = window.CANTARIRE || {};
|
||||
var $ = function (id) { return document.getElementById(id); };
|
||||
|
||||
// Polling: rapid cat timp se cantareste, rar cand pagina doar asteapta.
|
||||
var POLL_ACTIVE_MS = Number(cfg.pollMs) || 700;
|
||||
var POLL_IDLE_MS = 2500;
|
||||
var pollTimer = null;
|
||||
var el = {
|
||||
flow: $('flow-body'),
|
||||
title: $('flow-title'),
|
||||
progress: $('flow-progress'),
|
||||
message: $('flow-message'),
|
||||
weightValue: $('weight-value'),
|
||||
weightUnit: $('weight-unit'),
|
||||
stability: $('stability'),
|
||||
age: $('age'),
|
||||
raw: $('raw'),
|
||||
scaleDot: $('scale-dot'),
|
||||
scaleState: $('scale-state'),
|
||||
scaleProfile: $('scale-profile'),
|
||||
scaleError: $('scale-error'),
|
||||
profileSelect: $('profile-select'),
|
||||
toast: $('toast'),
|
||||
frame: $('print-frame')
|
||||
};
|
||||
|
||||
var state = cfg.initial || null;
|
||||
var recipes = cfg.recipes || [];
|
||||
var activeProfile = cfg.activeProfile || '';
|
||||
var scaleReady = Boolean(cfg.scaleReady);
|
||||
var manualMode = !scaleReady;
|
||||
var busy = false;
|
||||
var polling = false;
|
||||
var reading = null;
|
||||
var forceArmedAt = 0;
|
||||
var lastLabel = null;
|
||||
var lastRecipeId = null;
|
||||
var lastLot = '';
|
||||
var lastOperator = cfg.defaultOperator || '';
|
||||
var toastTimer = null;
|
||||
|
||||
// -- utilitare -----------------------------------------------------------
|
||||
function esc(value) {
|
||||
return String(value === null || value === undefined ? '' : value)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function fmt(value) {
|
||||
var number = Number(value);
|
||||
if (!isFinite(number)) return '--';
|
||||
var text = number.toFixed(3);
|
||||
return text.replace(/\.?0+$/, '') || '0';
|
||||
}
|
||||
|
||||
function message(text, kind) {
|
||||
if (!el.message) return;
|
||||
el.message.textContent = text || '';
|
||||
el.message.className = 'message ' + (kind || '');
|
||||
}
|
||||
|
||||
function toast(text, kind) {
|
||||
if (!el.toast) return;
|
||||
el.toast.textContent = text;
|
||||
el.toast.className = 'toast show ' + (kind || '');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(function () { el.toast.className = 'toast ' + (kind || ''); }, 3200);
|
||||
}
|
||||
|
||||
function setProgress(text) {
|
||||
if (el.progress) el.progress.textContent = text || '';
|
||||
}
|
||||
|
||||
function getJSON(url) {
|
||||
return fetch(url, { headers: { Accept: 'application/json' }, cache: 'no-store' })
|
||||
.then(function (response) { return response.json(); });
|
||||
}
|
||||
|
||||
function postJSON(url, body) {
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify(body || {})
|
||||
}).then(function (response) {
|
||||
return response.json().then(function (data) {
|
||||
return { status: response.status, data: data };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setBusy(value) {
|
||||
busy = value;
|
||||
document.querySelectorAll('#flow-body button, #flow-body input, #flow-body select')
|
||||
.forEach(function (node) { node.disabled = value; });
|
||||
}
|
||||
|
||||
// -- stare ---------------------------------------------------------------
|
||||
function adopt(data) {
|
||||
if (!data) return;
|
||||
if (data.state === null) {
|
||||
state = null;
|
||||
} else if (data.package) {
|
||||
state = {
|
||||
package: data.package,
|
||||
recipe: data.recipe,
|
||||
items: data.items || [],
|
||||
step: data.step,
|
||||
planned_total: data.planned_total,
|
||||
total_weight: data.total_weight,
|
||||
complete: Boolean(data.complete),
|
||||
open: Boolean(data.open)
|
||||
};
|
||||
if (data.recipe) lastRecipeId = data.recipe.id;
|
||||
if (data.package) {
|
||||
lastLot = data.package.lot || lastLot;
|
||||
lastOperator = data.package.operator || lastOperator;
|
||||
}
|
||||
}
|
||||
render();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
try {
|
||||
var data = await getJSON('/api/package');
|
||||
adopt(data);
|
||||
} catch (error) {
|
||||
message('Nu pot citi starea pachetului: ' + error, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// -- randare -------------------------------------------------------------
|
||||
function render() {
|
||||
if (!el.flow) return;
|
||||
if (!state) {
|
||||
renderStart();
|
||||
} else if (state.complete) {
|
||||
renderDone();
|
||||
} else {
|
||||
renderStep();
|
||||
}
|
||||
}
|
||||
|
||||
function recipeOptions(selected) {
|
||||
return recipes.map(function (recipe) {
|
||||
var suffix = ' · ' + recipe.components.length + ' comp.';
|
||||
return '<option value="' + recipe.id + '"' + (recipe.id === selected ? ' selected' : '') +
|
||||
'>' + esc(recipe.code + ' · ' + recipe.name) + suffix + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function breakdown(recipe) {
|
||||
return (recipe.components || []).map(function (part) {
|
||||
return '<li><span class="w">' + (part.target_weight ? fmt(part.target_weight) + ' ' + esc(recipe.unit) : '—')
|
||||
+ '</span> ' + esc(part.name) + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderStart() {
|
||||
if (el.title) el.title.textContent = 'Pachet nou';
|
||||
setProgress('');
|
||||
|
||||
if (!recipes.length) {
|
||||
el.flow.innerHTML = '<p class="muted">Nu există produse finite active. ' +
|
||||
'<a class="link" href="/retete/nou">Creează unul</a>.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
var selected = lastRecipeId || (recipes[0] && recipes[0].id) || null;
|
||||
var recipe = recipes.filter(function (r) { return r.id === selected; })[0] || recipes[0];
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="field">' +
|
||||
' <label for="start-recipe">Produs finit</label>' +
|
||||
' <select id="start-recipe">' + recipeOptions(recipe.id) + '</select>' +
|
||||
'</div>' +
|
||||
'<ul class="plan" id="start-plan">' + breakdown(recipe) + '</ul>' +
|
||||
'<div class="field-row">' +
|
||||
' <div class="field"><label for="start-lot">Lot</label>' +
|
||||
' <input id="start-lot" type="text" maxlength="64" value="' + esc(lastLot) + '" placeholder="ex. LOT-2610-A"></div>' +
|
||||
' <div class="field"><label for="start-operator">Operator</label>' +
|
||||
' <input id="start-operator" type="text" maxlength="64" value="' + esc(lastOperator) + '"></div>' +
|
||||
'</div>' +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-start">Începe pachetul</button>' +
|
||||
'</div>' +
|
||||
'<p class="hint muted small">Enter pornește pachetul. Apoi cântărești fiecare componentă în ordinea de mai sus.</p>';
|
||||
|
||||
var select = $('start-recipe');
|
||||
if (select) {
|
||||
select.addEventListener('change', function () {
|
||||
var chosen = recipes.filter(function (r) { return String(r.id) === select.value; })[0];
|
||||
if (chosen) {
|
||||
lastRecipeId = chosen.id;
|
||||
var plan = $('start-plan');
|
||||
if (plan) plan.innerHTML = breakdown(chosen);
|
||||
}
|
||||
});
|
||||
}
|
||||
var button = $('btn-start');
|
||||
if (button) button.addEventListener('click', startPackage);
|
||||
}
|
||||
|
||||
function renderStep() {
|
||||
var step = state.step;
|
||||
if (!step) { refresh(); return; }
|
||||
if (el.title) el.title.textContent = state.recipe ? state.recipe.name : 'Cântărire';
|
||||
setProgress('Pas ' + step.position + ' din ' + step.total);
|
||||
|
||||
var accepted = (state.items || []).map(function (item) {
|
||||
return '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
|
||||
esc(item.component_name) + '</li>';
|
||||
}).join('');
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="step">' +
|
||||
' <div class="step-label">Pune pe cântar</div>' +
|
||||
' <div class="step-component">' + esc(step.component_name) + '</div>' +
|
||||
(step.target_weight
|
||||
? '<div class="step-target">Țintă: ' + fmt(step.target_weight) + ' ' + esc(step.unit) + '</div>'
|
||||
: '') +
|
||||
'</div>' +
|
||||
(manualMode
|
||||
? '<div class="field"><label for="manual-weight">Greutate (' + esc(step.unit) + ')</label>' +
|
||||
'<input id="manual-weight" type="number" step="0.001" min="0" inputmode="decimal" placeholder="0">' +
|
||||
'</div>'
|
||||
: '<div class="actions"><button class="btn ghost" type="button" id="btn-manual-toggle">Introducere manuală</button></div>') +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-accept">Acceptă' + (manualMode ? '' : ' (Enter)') + '</button>' +
|
||||
(accepted ? ' <button class="btn" type="button" id="btn-undo">Anulează ultima</button>' : '') +
|
||||
' <button class="btn ghost" type="button" id="btn-void">Renunță la pachet</button>' +
|
||||
'</div>' +
|
||||
(accepted ? '<h3>Acceptate</h3><ul class="plan accepted">' + accepted + '</ul>' : '');
|
||||
|
||||
if (manualMode) {
|
||||
var input = $('manual-weight');
|
||||
if (input) { input.focus(); input.select(); }
|
||||
}
|
||||
var accept = $('btn-accept');
|
||||
if (accept) accept.addEventListener('click', acceptStep);
|
||||
var undo = $('btn-undo');
|
||||
if (undo) undo.addEventListener('click', undoStep);
|
||||
var voidButton = $('btn-void');
|
||||
if (voidButton) voidButton.addEventListener('click', voidPackage);
|
||||
var toggle = $('btn-manual-toggle');
|
||||
if (toggle) {
|
||||
toggle.addEventListener('click', function () {
|
||||
manualMode = true;
|
||||
renderStep();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderDone() {
|
||||
var pkg = state.package || {};
|
||||
if (el.title) el.title.textContent = pkg.recipe_name || 'Pachet finalizat';
|
||||
setProgress('Finalizat');
|
||||
|
||||
var accepted = (state.items || []).map(function (item) {
|
||||
return '<li><span class="w">' + fmt(item.weight) + ' ' + esc(item.unit) + '</span> ' +
|
||||
esc(item.component_name) + '</li>';
|
||||
}).join('');
|
||||
|
||||
el.flow.innerHTML =
|
||||
'<div class="done">' +
|
||||
' <div class="done-mark">✓</div>' +
|
||||
' <div class="done-total"><span class="value">' + fmt(pkg.total_weight) + '</span>' +
|
||||
' <span class="unit">' + esc(pkg.unit) + '</span></div>' +
|
||||
' <div class="muted">Pachet #' + pkg.id + (pkg.lot ? ' · Lot ' + esc(pkg.lot) : '') + '</div>' +
|
||||
'</div>' +
|
||||
'<ul class="plan accepted">' + accepted + '</ul>' +
|
||||
'<div class="actions">' +
|
||||
' <button class="btn primary" type="button" id="btn-next">Pachet nou (Enter)</button>' +
|
||||
(lastLabel ? ' <button class="btn" type="button" id="btn-reprint">Tipărește eticheta</button>' : '') +
|
||||
' <a class="btn ghost" target="_blank" rel="noopener" href="/labels/' + pkg.id + '/print?auto=0">Vezi eticheta</a>' +
|
||||
'</div>';
|
||||
|
||||
var next = $('btn-next');
|
||||
if (next) next.addEventListener('click', startPackage);
|
||||
var reprint = $('btn-reprint');
|
||||
if (reprint) {
|
||||
reprint.addEventListener('click', function () {
|
||||
if (lastLabel) printLabel(lastLabel);
|
||||
});
|
||||
}
|
||||
if (next) next.focus();
|
||||
}
|
||||
|
||||
// -- acțiuni -------------------------------------------------------------
|
||||
async function startPackage() {
|
||||
if (busy) return;
|
||||
var select = $('start-recipe');
|
||||
var recipeId = select ? Number(select.value) : lastRecipeId;
|
||||
if (!recipeId) {
|
||||
toast('Alege produsul finit.', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
message('Se pregătește pachetul…', 'info');
|
||||
try {
|
||||
var result = await postJSON('/api/packages', {
|
||||
recipe_id: recipeId,
|
||||
operator: ($('start-operator') ? $('start-operator').value.trim() : '') || lastOperator,
|
||||
lot: ($('start-lot') ? $('start-lot').value.trim() : '') || lastLot,
|
||||
// Serverul alege sablonul: produsul finit -> setarea implicita -> primul sablon.
|
||||
template_id: null
|
||||
});
|
||||
if (!result.data.ok) {
|
||||
message(result.data.error || 'Pachetul nu a putut fi început.', 'error');
|
||||
toast(result.data.error || 'Eroare.', 'error');
|
||||
return;
|
||||
}
|
||||
lastLabel = null;
|
||||
adopt(result.data);
|
||||
message('Pachet început. Cântărește prima componentă.', 'info');
|
||||
} catch (error) {
|
||||
message('Eroare de rețea: ' + error, 'error');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function manualWeight() {
|
||||
var input = $('manual-weight');
|
||||
if (!input) return null;
|
||||
var raw = String(input.value || '').trim().replace(',', '.');
|
||||
return raw === '' ? null : raw;
|
||||
}
|
||||
|
||||
async function acceptStep() {
|
||||
if (busy || !state || !state.package) return;
|
||||
var force = forceArmedAt && Date.now() - forceArmedAt < 5000;
|
||||
var body = {};
|
||||
if (force) body.force = true;
|
||||
if (manualMode) {
|
||||
var value = manualWeight();
|
||||
if (value === null) {
|
||||
toast('Introdu greutatea sau conectează cântarul.', 'error');
|
||||
return;
|
||||
}
|
||||
body.weight = value;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
message('Se înregistrează…', 'info');
|
||||
try {
|
||||
var result = await postJSON('/api/packages/' + state.package.id + '/items', body);
|
||||
var data = result.data;
|
||||
|
||||
if (!result.status || result.status >= 400 || !data.ok) {
|
||||
if (data.reading) applyReading(data.reading);
|
||||
var unstable = /stabil/i.test(data.error || '');
|
||||
if (unstable) {
|
||||
forceArmedAt = Date.now();
|
||||
message((data.error || 'Greutate instabilă.') +
|
||||
' Apasă din nou pe Acceptă pentru a folosi valoarea curentă.', 'error');
|
||||
} else {
|
||||
message(data.error || 'Cântărirea nu a putut fi salvată.', 'error');
|
||||
toast(data.error || 'Eroare la salvare.', 'error');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
forceArmedAt = 0;
|
||||
adopt(data);
|
||||
if (data.label) {
|
||||
lastLabel = data.label;
|
||||
if (cfg.autoPrint !== false) {
|
||||
await printLabel(data.label);
|
||||
message('Etichetă tipărită. Apasă Enter pentru următorul pachet.', 'success');
|
||||
} else {
|
||||
message('Pachet finalizat. Apasă „Tipărește eticheta” sau Enter pentru următorul pachet.', 'success');
|
||||
}
|
||||
updateStats();
|
||||
} else {
|
||||
message('Acceptat: ' + fmt(state.total_weight) + ' ' + (state.package.unit || ''), 'success');
|
||||
}
|
||||
} catch (error) {
|
||||
message('Eroare de rețea la salvare.', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function undoStep() {
|
||||
if (busy || !state || !state.package) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
var result = await postJSON('/api/packages/' + state.package.id + '/undo', {});
|
||||
if (result.data.ok) {
|
||||
adopt(result.data);
|
||||
message('Ultima cântărire a fost anulată.', 'info');
|
||||
} else {
|
||||
message(result.data.error || 'Nu se poate anula.', 'error');
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
async function voidPackage() {
|
||||
if (busy || !state || !state.package) return;
|
||||
if (!confirm('Renunți la pachetul început? Cântăririle lui nu vor apărea în istoric.')) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await postJSON('/api/packages/' + state.package.id + '/void', {});
|
||||
state = null;
|
||||
lastLabel = null;
|
||||
message('Pachet anulat.', 'info');
|
||||
} finally {
|
||||
setBusy(false);
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// -- tipărire ------------------------------------------------------------
|
||||
function acknowledge(payload) {
|
||||
if (!payload || !payload.ack_url) return;
|
||||
fetch(payload.ack_url, { method: 'POST', headers: { Accept: 'application/json' } })
|
||||
.catch(function () { /* confirmarea este auxiliară */ });
|
||||
}
|
||||
|
||||
function printLabel(payload) {
|
||||
if (!payload) return Promise.resolve();
|
||||
|
||||
if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') {
|
||||
return Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) {
|
||||
if (result && result.method === 'qz') acknowledge(payload);
|
||||
return result;
|
||||
}).catch(function (error) {
|
||||
console.error('Tipărirea a eșuat:', error);
|
||||
toast('Imprimanta nu a răspuns.', 'error');
|
||||
});
|
||||
}
|
||||
|
||||
if (el.frame) {
|
||||
el.frame.src = payload.print_url + '?auto=1';
|
||||
return Promise.resolve();
|
||||
}
|
||||
window.open(payload.print_url, '_blank', 'noopener');
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
var node = $('stat-today-count');
|
||||
if (!node) return;
|
||||
var value = Number(node.textContent) || 0;
|
||||
node.textContent = String(value + 1);
|
||||
}
|
||||
|
||||
// -- citirea cântarului --------------------------------------------------
|
||||
function applyReading(data) {
|
||||
reading = data;
|
||||
if (!data) return;
|
||||
var ok = Boolean(data.ok);
|
||||
var stale = Boolean(data.stale);
|
||||
var stable = Boolean(data.stable);
|
||||
var hasValue = data.value !== null && data.value !== undefined;
|
||||
|
||||
if (el.weightValue) {
|
||||
el.weightValue.textContent = hasValue ? fmt(data.value) : '--';
|
||||
el.weightValue.className = !ok ? 'error' : (stable && !stale ? '' : 'unstable');
|
||||
}
|
||||
if (el.weightUnit) el.weightUnit.textContent = data.unit || '';
|
||||
|
||||
if (!ok) {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot off';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Cântar indisponibil';
|
||||
if (el.stability) { el.stability.textContent = 'fără date'; el.stability.className = 'badge off'; }
|
||||
if (el.scaleError) el.scaleError.textContent = data.error || 'Cântarul nu poate fi citit.';
|
||||
} else if (stale) {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot off';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Citire veche';
|
||||
if (el.stability) { el.stability.textContent = 'citire veche'; el.stability.className = 'badge warn'; }
|
||||
if (el.scaleError) el.scaleError.textContent = '';
|
||||
} else {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot ok';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Cântar conectat';
|
||||
if (el.stability) {
|
||||
el.stability.textContent = stable ? 'stabil' : 'instabil';
|
||||
el.stability.className = 'badge ' + (stable ? 'ok' : 'warn');
|
||||
}
|
||||
if (el.scaleError) el.scaleError.textContent = '';
|
||||
}
|
||||
|
||||
if (el.scaleProfile) el.scaleProfile.textContent = (data.profile || activeProfile || 'fără profil') +
|
||||
(data.port ? ' · ' + data.port : '');
|
||||
if (el.age) {
|
||||
el.age.textContent = typeof data.age_ms === 'number'
|
||||
? (data.age_ms < 1000 ? '<1 s' : (data.age_ms / 1000).toFixed(1) + ' s')
|
||||
: '';
|
||||
}
|
||||
if (el.raw) el.raw.textContent = data.raw_hex ? 'raw: ' + data.raw_hex.slice(0, 40) : '';
|
||||
}
|
||||
|
||||
function poll() {
|
||||
if (!scaleReady || polling || document.hidden) return;
|
||||
polling = true;
|
||||
var controller = new AbortController();
|
||||
var timer = setTimeout(function () { controller.abort(); }, 6000);
|
||||
var params = new URLSearchParams();
|
||||
if (activeProfile) params.set('profile', activeProfile);
|
||||
|
||||
fetch('/api/weight?' + params.toString(), {
|
||||
headers: { Accept: 'application/json' }, cache: 'no-store', signal: controller.signal
|
||||
})
|
||||
.then(function (response) { return response.json(); })
|
||||
.then(applyReading)
|
||||
.catch(function (error) {
|
||||
if (error && error.name !== 'AbortError') {
|
||||
if (el.scaleDot) el.scaleDot.className = 'dot off';
|
||||
if (el.scaleState) el.scaleState.textContent = 'Conexiune întreruptă';
|
||||
}
|
||||
})
|
||||
.finally(function () { clearTimeout(timer); polling = false; });
|
||||
}
|
||||
|
||||
// -- tastatură -----------------------------------------------------------
|
||||
function onKeyDown(event) {
|
||||
if (event.key !== 'Enter' || event.ctrlKey || event.metaKey) return;
|
||||
var tag = (event.target && event.target.tagName || '').toLowerCase();
|
||||
if (tag === 'textarea' || tag === 'button' || tag === 'a') return;
|
||||
|
||||
if (!state) {
|
||||
event.preventDefault();
|
||||
startPackage();
|
||||
} else if (state.complete) {
|
||||
event.preventDefault();
|
||||
startPackage();
|
||||
} else if (state.step) {
|
||||
event.preventDefault();
|
||||
acceptStep();
|
||||
}
|
||||
}
|
||||
|
||||
// -- start ---------------------------------------------------------------
|
||||
function schedulePoll() {
|
||||
clearTimeout(pollTimer);
|
||||
var delay = (state && state.open && state.step) ? POLL_ACTIVE_MS : POLL_IDLE_MS;
|
||||
pollTimer = setTimeout(function () {
|
||||
poll();
|
||||
schedulePoll();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
if (el.profileSelect) {
|
||||
el.profileSelect.addEventListener('change', function () {
|
||||
activeProfile = el.profileSelect.value;
|
||||
poll();
|
||||
});
|
||||
}
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (!document.hidden) poll();
|
||||
});
|
||||
|
||||
render();
|
||||
if (scaleReady) {
|
||||
poll();
|
||||
schedulePoll();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,77 @@
|
||||
<!doctype html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}Cântărire și etichetare{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
|
||||
{% block head %}{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% set admin_blueprints = ['history', 'recipes', 'components', 'label_templates', 'fields',
|
||||
'monitor'] %}
|
||||
{% set in_settings = request.blueprint in admin_blueprints
|
||||
or request.endpoint == 'main.settings_page' %}
|
||||
<header class="topbar">
|
||||
<a class="brand" href="{{ url_for('main.dashboard') }}">
|
||||
<span class="brand-mark">CE</span>
|
||||
<span class="brand-text">Cântărire & Etichetare</span>
|
||||
</a>
|
||||
<nav class="nav">
|
||||
<a href="{{ url_for('main.dashboard') }}"
|
||||
class="{{ 'active' if request.endpoint == 'main.dashboard' }}">Cântărire</a>
|
||||
<a href="{{ url_for('main.settings_page') }}"
|
||||
class="{{ 'active' if in_settings }}">Setări</a>
|
||||
</nav>
|
||||
<button class="qz-pill" type="button" data-qz-pill data-qz-connect
|
||||
title="Starea conexiunii QZ Tray">
|
||||
<span class="qz-dot"></span>
|
||||
<span data-qz-status>QZ Tray: se verifică…</span>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{% if in_settings %}
|
||||
<nav class="subnav" aria-label="Secțiuni de administrare">
|
||||
<div class="subnav-inner">
|
||||
<a href="{{ url_for('main.settings_page') }}"
|
||||
class="{{ 'active' if request.endpoint == 'main.settings_page' }}">Generale</a>
|
||||
<a href="{{ url_for('components.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'components' }}">Componente</a>
|
||||
<a href="{{ url_for('recipes.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'recipes' }}">Produse finite</a>
|
||||
<a href="{{ url_for('label_templates.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'label_templates' }}">Șabloane</a>
|
||||
<a href="{{ url_for('fields.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'fields' }}">Câmpuri</a>
|
||||
<a href="{{ url_for('history.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'history' }}">Istoric</a>
|
||||
<a href="{{ url_for('monitor.index') }}"
|
||||
class="{{ 'active' if request.blueprint == 'monitor' }}">Monitor serial</a>
|
||||
</div>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
<main class="container">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="flash-stack">
|
||||
{% for category, message in messages %}
|
||||
<div class="flash flash-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<span>Cântărire & Etichetare v{{ app_version }}</span>
|
||||
<span>Produse finite · SyncPulse Scale Bridge · QZ Tray</span>
|
||||
</footer>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
<script src="{{ url_for('static', filename='js/vendor/qz-tray.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/qz-print.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ "Component nou" if is_new else "Editare component" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>{{ "Component nou" if is_new else "Editare component" }}</h1>
|
||||
<p class="subtitle">Numele apare pe etichetă, lângă greutate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card narrow">
|
||||
<form method="post" class="form">
|
||||
<div class="field">
|
||||
<label for="code">Cod <span class="req">*</span></label>
|
||||
<input id="code" name="code" type="text" maxlength="40" required
|
||||
value="{{ component.code or '' }}" autofocus>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name">Denumire <span class="req">*</span></label>
|
||||
<input id="name" name="name" type="text" maxlength="120" required
|
||||
value="{{ component.name or '' }}" placeholder="ex. Fasole">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="unit">Unitate de măsură</label>
|
||||
<select id="unit" name="unit">
|
||||
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
|
||||
<option value="{{ u }}" {{ 'selected' if (component.unit or 'g') == u }}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="description">Descriere</label>
|
||||
<textarea id="description" name="description" rows="3" maxlength="500">{{ component.description or '' }}</textarea>
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="active" {{ 'checked' if component.active in [True, 1, '1', 'on', 'true'] or is_new }}>
|
||||
Component activ
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează</button>
|
||||
<a class="btn ghost" href="{{ url_for('components.index') }}">Renunță</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Componente{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Componente</h1>
|
||||
<p class="subtitle">Ce se cântărește: fasole, carne, garnitură etc.</p>
|
||||
</div>
|
||||
<a class="btn primary" href="{{ url_for('components.create') }}">+ Component nou</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if components %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Cod</th><th>Denumire</th><th>UM</th><th>Descriere</th><th>Stare</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for c in components %}
|
||||
<tr class="{{ 'row-muted' if not c.active }}">
|
||||
<td><code>{{ c.code }}</code></td>
|
||||
<td>{{ c.name }}</td>
|
||||
<td>{{ c.unit }}</td>
|
||||
<td class="muted">{{ c.description or "-" }}</td>
|
||||
<td>
|
||||
{% if c.active %}<span class="badge ok">activ</span>
|
||||
{% else %}<span class="badge off">inactiv</span>{% endif %}
|
||||
</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('components.edit', component_id=c.id) }}">Editează</a>
|
||||
<form class="inline" method="post" action="{{ url_for('components.remove', component_id=c.id) }}"
|
||||
onsubmit="return confirm('Ștergi componentul {{ c.code }}? Dacă e folosit într-o rețetă, va fi doar dezactivat.');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nu există componente. <a href="{{ url_for('components.create') }}">Adaugă primul component</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Cântărire produs finit{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Cântărire produs finit</h1>
|
||||
<p class="subtitle">Alege produsul, cântărește componentele în ordine și tipărește eticheta.</p>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<div class="stat"><span class="stat-value" id="stat-today-count">{{ stats.today_count }}</span><span class="stat-label">pachete azi</span></div>
|
||||
<div class="stat"><span class="stat-value">{{ stats.today_total }}</span><span class="stat-label">total azi</span></div>
|
||||
<div class="stat"><span class="stat-value">{{ stats.recipes }}</span><span class="stat-label">produse finite</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{% if not setup.ready %}
|
||||
<section class="card setup-card">
|
||||
<div class="setup-head">
|
||||
<div>
|
||||
<h2>Pregătire pentru prima cântărire</h2>
|
||||
<p class="muted small">Completează pe rând; poți cântări după ce lista e goală.</p>
|
||||
</div>
|
||||
<span class="badge warn">{{ setup.missing|length }} de completat</span>
|
||||
</div>
|
||||
<ul class="setup-list">
|
||||
{% for item in setup.checks %}
|
||||
{% if not item.ok and not item.optional %}
|
||||
<li class="setup-item">
|
||||
<span class="setup-mark" aria-hidden="true">!</span>
|
||||
<div class="setup-body">
|
||||
<strong>{{ item.label }}</strong>
|
||||
<span class="muted small">{{ item.message }}</span>
|
||||
</div>
|
||||
{% if item.url %}
|
||||
<a class="btn ghost" href="{{ item.url }}">{{ item.action or 'Deschide' }}</a>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<div class="grid weigh-grid">
|
||||
<section class="card flow-card">
|
||||
<div class="flow-head">
|
||||
<h2 id="flow-title">Pachet nou</h2>
|
||||
<span id="flow-progress" class="chip"></span>
|
||||
</div>
|
||||
<div id="flow-body" class="flow-body"></div>
|
||||
<p id="flow-message" class="message" role="status" aria-live="polite"></p>
|
||||
</section>
|
||||
|
||||
<section class="card scale-card">
|
||||
<div class="scale-head">
|
||||
<span id="scale-dot" class="dot off"></span>
|
||||
<span id="scale-state">{{ "Se conectează…" if scale_ready else "Introducere manuală" }}</span>
|
||||
<span id="scale-profile" class="chip">{{ active_profile or "fără profil" }}</span>
|
||||
</div>
|
||||
|
||||
<div class="weight-display">
|
||||
<span id="weight-value">--</span>
|
||||
<span id="weight-unit" class="weight-unit"></span>
|
||||
</div>
|
||||
|
||||
<div class="weight-meta">
|
||||
<span id="stability" class="badge">—</span>
|
||||
<span id="age" class="muted"></span>
|
||||
<span id="raw" class="raw"></span>
|
||||
</div>
|
||||
|
||||
<p id="scale-error" class="message error">{% if scale_error %}{{ scale_error }}{% endif %}</p>
|
||||
|
||||
{% if profiles %}
|
||||
<div class="scale-hint">
|
||||
<span>Profil:</span>
|
||||
<select id="profile-select" class="inline-select">
|
||||
{% for p in profiles %}
|
||||
<option value="{{ p.name }}" {{ 'selected' if p.name == active_profile }}>
|
||||
{{ p.label }} · {{ p.port }} · {{ p.protocol }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="scale-hint">
|
||||
<span>Cântarul nu este configurat — cântărește manual.</span>
|
||||
<a class="link" href="{{ url_for('monitor.index') }}">Monitor</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="qz-hint">
|
||||
<span>Imprimantă:</span>
|
||||
<select class="inline-select" data-qz-printer>
|
||||
<option value="">Implicit (sistem)</option>
|
||||
</select>
|
||||
<button class="link" type="button" data-qz-connect>Conectează</button>
|
||||
<button class="link" type="button" data-qz-test>Test</button>
|
||||
<span class="muted small" data-qz-version></span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Ultimele pachete</h2>
|
||||
<a class="link" href="{{ url_for('history.index') }}">Vezi tot istoricul →</a>
|
||||
</div>
|
||||
{% if recent %}
|
||||
<div class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead>
|
||||
<tr><th>Finalizat</th><th>Produs finit</th><th>Componente</th><th class="num">Total</th><th>Lot</th><th>Tipărit</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in recent %}
|
||||
<tr>
|
||||
<td class="nowrap">{{ (p.completed_at or p.created_at)|dt }}</td>
|
||||
<td>{{ p.recipe_code }} · {{ p.recipe_name }}</td>
|
||||
<td class="muted">{{ p.item_summary or "-" }}</td>
|
||||
<td class="num">{{ (p.total_weight or 0)|weight }} {{ p.unit }}</td>
|
||||
<td>{{ p.lot or "-" }}</td>
|
||||
<td>{{ "da" if p.printed_at else "nu" }}</td>
|
||||
<td class="right">
|
||||
{% if p.status == "done" %}
|
||||
<a class="link" target="_blank" rel="noopener"
|
||||
href="{{ url_for('printing.print_label', package_id=p.id, auto='0') }}">Etichetă</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Niciun pachet finalizat încă.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<iframe id="print-frame" class="print-frame" title="Tipărire etichetă" aria-hidden="true"></iframe>
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.CANTARIRE = {
|
||||
pollMs: {{ poll_ms|int }},
|
||||
scaleReady: {{ 'true' if scale_ready else 'false' }},
|
||||
activeProfile: {{ active_profile|tojson }},
|
||||
defaultOperator: {{ default_operator|tojson }},
|
||||
defaultTemplateId: {{ default_template_id|tojson }},
|
||||
autoPrint: {{ 'true' if auto_print else 'false' }},
|
||||
initial: {{ initial|tojson }},
|
||||
recipes: {{ recipes|tojson }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/weigh.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,120 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ "Câmp nou" if is_new else "Editare câmp" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>{{ "Câmp nou" if is_new else "Editare câmp" }}</h1>
|
||||
<p class="subtitle">
|
||||
Scrie textul etichetei; între acolade poți referi un câmp existent.
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn ghost" href="{{ url_for('fields.index') }}">Înapoi la listă</a>
|
||||
</section>
|
||||
|
||||
<section class="card narrow">
|
||||
<form method="post" class="form">
|
||||
<div class="field">
|
||||
<label for="label">Denumire <span class="req">*</span></label>
|
||||
<input id="label" name="label" type="text" maxlength="{{ max_label }}" required
|
||||
value="{{ field.label or '' }}" placeholder="ex. Contact firmă" autofocus>
|
||||
<p class="muted small">Apare în popup-ul din designer.</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="template">Text <span class="req">*</span></label>
|
||||
<textarea id="template" name="template" rows="3" maxlength="{{ max_template }}"
|
||||
placeholder="ex. Tel. {{ '{{company.phone}}' }}">{{ field.template or '' }}</textarea>
|
||||
<p class="muted small">
|
||||
Poți folosi orice câmp din tabelul de mai jos, scris între <code>{{ '{{' }}</code> și
|
||||
<code>{{ '}}' }}</code>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>Previzualizare cu date demo</label>
|
||||
<div id="field-preview" class="field-preview muted">—</div>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" name="active"
|
||||
{{ 'checked' if field.active in [True, 1, '1', 'on', 'true'] or is_new }}>
|
||||
Câmp activ (apare în designer)
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează</button>
|
||||
<a class="btn ghost" href="{{ url_for('fields.index') }}">Renunță</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card narrow">
|
||||
<h2>Câmpuri disponibile</h2>
|
||||
<p class="muted small">Click pe un câmp pentru a-l insera în text.</p>
|
||||
<div class="field-chips">
|
||||
{% for c in catalog %}
|
||||
<button class="field-chip" type="button" data-insert="{{ c.key }}"
|
||||
title="{{ c.label }} · exemplu: {{ c.sample }}">
|
||||
<span class="chip-label">{{ c.label }}</span>
|
||||
<span class="chip-sample">{{ c.sample }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function () {
|
||||
var textarea = document.getElementById('template');
|
||||
var preview = document.getElementById('field-preview');
|
||||
var timer = null;
|
||||
var url = {{ url_for('fields.preview')|tojson }};
|
||||
|
||||
function refresh() {
|
||||
var text = textarea.value.trim();
|
||||
if (!text) {
|
||||
preview.textContent = '—';
|
||||
preview.className = 'field-preview muted';
|
||||
return;
|
||||
}
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(function () {
|
||||
fetch(url + '?template=' + encodeURIComponent(text), {
|
||||
headers: { Accept: 'application/json' }
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.ok) {
|
||||
preview.textContent = data.error;
|
||||
preview.className = 'field-preview error';
|
||||
return;
|
||||
}
|
||||
preview.textContent = data.value || '(gol)';
|
||||
preview.className = 'field-preview';
|
||||
})
|
||||
.catch(function () {
|
||||
preview.textContent = 'Previzualizarea nu a putut fi calculată.';
|
||||
preview.className = 'field-preview error';
|
||||
});
|
||||
}, 250);
|
||||
}
|
||||
|
||||
textarea.addEventListener('input', refresh);
|
||||
refresh();
|
||||
|
||||
document.querySelectorAll('[data-insert]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var token = '{{ "{{" }}' + button.dataset.insert + '{{ "}}" }}';
|
||||
var start = textarea.selectionStart || 0;
|
||||
var end = textarea.selectionEnd || 0;
|
||||
textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end);
|
||||
textarea.focus();
|
||||
textarea.selectionStart = textarea.selectionEnd = start + token.length;
|
||||
refresh();
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,75 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Câmpuri variabile{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Câmpuri variabile</h1>
|
||||
<p class="subtitle">
|
||||
Text gata de pus pe etichetă, care poate prelua date din pachet, produs sau firmă.
|
||||
</p>
|
||||
</div>
|
||||
<a class="btn primary" href="{{ url_for('fields.create') }}">+ Câmp nou</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if fields %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Denumire</th>
|
||||
<th>Text</th>
|
||||
<th>Rezultat (demo)</th>
|
||||
<th>Cheie</th>
|
||||
<th>Șabloane</th>
|
||||
<th>Stare</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for f in fields %}
|
||||
<tr class="{{ 'row-muted' if not f.active }}">
|
||||
<td><strong>{{ f.label }}</strong></td>
|
||||
<td><code class="field-template">{{ f.template }}</code></td>
|
||||
<td>{{ f.preview or "-" }}</td>
|
||||
<td><code class="muted">{{ f.key }}</code></td>
|
||||
<td>
|
||||
{% if f.usage %}<a class="link" href="{{ url_for('label_templates.index') }}">{{ f.usage }}</a>
|
||||
{% else %}<span class="muted">-</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if f.active %}<span class="badge ok">activ</span>
|
||||
{% else %}<span class="badge off">inactiv</span>{% endif %}
|
||||
</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('fields.edit', field_id=f.id) }}">Editează</a>
|
||||
<form class="inline" method="post" action="{{ url_for('fields.remove', field_id=f.id) }}"
|
||||
onsubmit="return confirm('Ștergi câmpul {{ f.label }}?{% if f.usage %} Este folosit în {{ f.usage }} șabloane și va apărea gol.{% endif %}');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">
|
||||
Nu există câmpuri proprii. <a href="{{ url_for('fields.create') }}">Adaugă primul câmp</a>
|
||||
sau folosește câmpurile fixe din designer.
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Cum funcționează</h2>
|
||||
<p class="muted small">
|
||||
În textul câmpului poți scrie orice, iar între acolade poți pune un câmp existent:
|
||||
<code>Tel. {{ '{{company.phone}}' }}</code> sau
|
||||
<code>{{ '{{recipe.name}}' }} · lot {{ '{{package.lot}}' }}</code>.
|
||||
La tipărire, acestea sunt înlocuite cu datele reale. Câmpurile proprii apar în designer
|
||||
în grupul <strong>Câmpuri proprii</strong>.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,100 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Istoric produse finite{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Istoric produse finite</h1>
|
||||
<p class="subtitle">Filtrează, verifică și exportă pachetele finalizate.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<form method="get" class="filters">
|
||||
<div class="field">
|
||||
<label for="date_from">De la</label>
|
||||
<input id="date_from" name="date_from" type="date" value="{{ filters.date_from }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="date_to">Până la</label>
|
||||
<input id="date_to" name="date_to" type="date" value="{{ filters.date_to }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="recipe_id">Produs finit</label>
|
||||
<select id="recipe_id" name="recipe_id">
|
||||
<option value="">— toate —</option>
|
||||
{% for r in recipes %}
|
||||
<option value="{{ r.id }}" {{ 'selected' if filters.recipe_id == r.id }}>{{ r.code }} · {{ r.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="status">Stare</label>
|
||||
<select id="status" name="status">
|
||||
<option value="">— toate —</option>
|
||||
<option value="done" {{ 'selected' if filters.status == 'done' }}>finalizat</option>
|
||||
<option value="open" {{ 'selected' if filters.status == 'open' }}>în lucru</option>
|
||||
<option value="void" {{ 'selected' if filters.status == 'void' }}>anulat</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="field grow">
|
||||
<label for="q">Căutare</label>
|
||||
<input id="q" name="q" type="search" value="{{ filters.query }}" placeholder="produs, lot, operator…">
|
||||
</div>
|
||||
<div class="field actions-field">
|
||||
<button class="btn primary" type="submit">Filtrează</button>
|
||||
<a class="btn" href="{{ url_for('history.index') }}">Resetează</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>{{ packages|length }} pachete · total {{ total }}</h2>
|
||||
<a class="btn ghost"
|
||||
href="{{ url_for('history.export_csv', date_from=filters.date_from, date_to=filters.date_to, recipe_id=filters.recipe_id or '', status=filters.status, q=filters.query) }}">Export CSV</a>
|
||||
</div>
|
||||
|
||||
{% if packages %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Data</th><th>Produs finit</th><th>Componente</th><th class="num">Total</th>
|
||||
<th>Lot</th><th>Operator</th><th>Stare</th><th>Tipărit</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for p in packages %}
|
||||
<tr class="{{ 'row-muted' if p.status != 'done' }}">
|
||||
<td class="nowrap">{{ (p.completed_at or p.created_at)|dt }}</td>
|
||||
<td>{{ p.recipe_code }} · {{ p.recipe_name }}</td>
|
||||
<td class="muted small">{{ p.item_summary or "-" }}</td>
|
||||
<td class="num">{{ (p.total_weight or 0)|weight }} {{ p.unit }}</td>
|
||||
<td>{{ p.lot or "-" }}</td>
|
||||
<td>{{ p.operator or "-" }}</td>
|
||||
<td>
|
||||
{% if p.status == "done" %}<span class="badge ok">finalizat</span>
|
||||
{% elif p.status == "open" %}<span class="badge warn">în lucru</span>
|
||||
{% else %}<span class="badge err">anulat</span>{% endif %}
|
||||
</td>
|
||||
<td class="nowrap">
|
||||
{% if p.printed_at %}<span class="badge ok">{{ p.print_count }}×</span>
|
||||
{% else %}<span class="muted">—</span>{% endif %}
|
||||
</td>
|
||||
<td class="right">
|
||||
{% if p.status == "done" %}
|
||||
<a class="link" target="_blank" rel="noopener"
|
||||
href="{{ url_for('printing.print_label', package_id=p.id, auto='0') }}">Etichetă</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nicio înregistrare pentru filtrele selectate.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,89 @@
|
||||
<!doctype html>
|
||||
<html lang="ro">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Etichetă {{ payload.fields.recipe_code }} · {{ payload.fields.total_weight }} {{ payload.fields.unit }}</title>
|
||||
<style>
|
||||
@page { size: {{ payload.template.width_mm }}mm {{ payload.template.height_mm }}mm; margin: 0; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; background: #e9edf1; }
|
||||
.preview-bar {
|
||||
position: sticky; top: 0; z-index: 2;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 10px 16px; background: #0f172a; color: #fff;
|
||||
font: 14px/1.4 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
}
|
||||
.preview-bar button {
|
||||
margin-left: auto; padding: 8px 16px; border: 0; border-radius: 6px;
|
||||
background: #14b8a6; color: #04201d; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.preview-stage { padding: 24px; display: flex; justify-content: center; }
|
||||
.label-sheet {
|
||||
width: {{ payload.template.width_mm }}mm;
|
||||
height: {{ payload.template.height_mm }}mm;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 24px rgba(15, 23, 42, .18);
|
||||
}
|
||||
{{ payload.css|safe }}
|
||||
@media print {
|
||||
html, body { background: #fff; }
|
||||
.preview-bar, .preview-stage { padding: 0; }
|
||||
.preview-bar { display: none !important; }
|
||||
.label-sheet { box-shadow: none; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if preview %}
|
||||
<div class="preview-bar">
|
||||
<strong>Previzualizare șablon</strong>
|
||||
<span>{{ payload.template.name }} · {{ payload.template.width_mm|weight }} × {{ payload.template.height_mm|weight }} mm</span>
|
||||
<button type="button" onclick="window.print()">Tipărește previzualizarea</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="preview-stage">
|
||||
<div class="label-sheet" id="label-sheet">
|
||||
{{ payload.html|safe }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script id="label-payload" type="application/json">{{ payload|tojson }}</script>
|
||||
<script src="{{ url_for('static', filename='js/vendor/qz-tray.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/qz-print.js') }}"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var payload = JSON.parse(document.getElementById('label-payload').textContent);
|
||||
var auto = {{ 'true' if auto else 'false' }};
|
||||
var preview = {{ 'true' if preview else 'false' }};
|
||||
|
||||
function acknowledge() {
|
||||
if (preview) return;
|
||||
try {
|
||||
fetch(payload.ack_url, { method: 'POST', headers: { 'Accept': 'application/json' } });
|
||||
} catch (e) { /* tipărirea a reușit; confirmarea este auxiliară */ }
|
||||
}
|
||||
|
||||
function doPrint() {
|
||||
if (window.CantarirePrint && typeof window.CantarirePrint.print === 'function') {
|
||||
Promise.resolve(window.CantarirePrint.print(payload)).then(function (result) {
|
||||
if (result && result.method === 'qz') acknowledge();
|
||||
}, function (err) {
|
||||
console.error('Tipărirea QZ Tray a eșuat:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
window.print();
|
||||
acknowledge();
|
||||
}
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
if (!auto) return;
|
||||
setTimeout(doPrint, 120);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,170 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Monitor serial{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Monitor serial</h1>
|
||||
<p class="subtitle">Ascultă portul cântarului și află ce format trimite. Nu se scrie nimic către cântar.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="notice">
|
||||
<strong>Folosire:</strong> conectează cântarul, pornește o cântărire pe el (sau apasă Print) și rulează
|
||||
monitorul. Dacă nu apar bytes, încearcă <em>Scanează vitezele</em> sau altă setare de linie.
|
||||
O captură salvată aici este exact ce trebuie ca să adăugăm un protocol nou.
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<form method="post" class="filters">
|
||||
<div class="field">
|
||||
<label for="port">Port</label>
|
||||
<input id="port" name="port" type="text" list="port-list" required
|
||||
placeholder="/dev/ttyUSB0 sau COM3" value="{{ values.port }}">
|
||||
<datalist id="port-list">
|
||||
{% for p in ports %}
|
||||
<option value="{{ p.device }}">{{ p.description or p.device }}{% if p.chip %} · {{ p.chip }}{% endif %}</option>
|
||||
{% endfor %}
|
||||
</datalist>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="baud">Baud</label>
|
||||
<input id="baud" name="baud" type="number" min="50" max="921600" value="{{ values.baud }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="framing">Framing</label>
|
||||
<select id="framing" name="framing">
|
||||
{% for f in framings %}
|
||||
<option value="{{ f }}" {{ 'selected' if values.framing == f }}>{{ f }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="seconds">Secunde</label>
|
||||
<input id="seconds" name="seconds" type="number" min="0.5" max="30" step="0.5" value="{{ values.seconds }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="name">Nume captură</label>
|
||||
<input id="name" name="name" type="text" maxlength="60" value="{{ values.name }}">
|
||||
</div>
|
||||
<div class="field actions-field">
|
||||
<button class="btn primary" type="submit">Ascultă</button>
|
||||
</div>
|
||||
<div class="monitor-options">
|
||||
<label class="check"><input type="checkbox" name="scan" value="1" {{ 'checked' if values.scan }}> Scanează vitezele</label>
|
||||
<label class="check"><input type="checkbox" name="save" value="1" {{ 'checked' if values.save }}> Salvează captura</label>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{% if ports %}
|
||||
<p class="muted small">
|
||||
Porturi detectate:
|
||||
{% for p in ports %}<code>{{ p.device }}</code>{% if p.chip %} ({{ p.chip }}){% endif %}{% if not loop.last %}, {% endif %}{% endfor %}
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="muted small">Nu a fost detectat niciun port. Verifică adaptorul USB-RJ11 (driver, cablu).</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
{% if error %}
|
||||
<div class="notice warning">{{ error }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if saved_path %}
|
||||
<div class="notice"><strong>Cauptră salvată:</strong> <code>{{ saved_path }}</code></div>
|
||||
{% endif %}
|
||||
|
||||
{% if scan_hits %}
|
||||
<section class="card">
|
||||
<h2>Viteze cu date</h2>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>Baud</th><th>Framing</th><th class="num">Bytes</th><th>Protocoale recunoscute</th></tr></thead>
|
||||
<tbody>
|
||||
{% for hit in scan_hits %}
|
||||
<tr>
|
||||
<td>{{ hit.baud }}</td>
|
||||
<td>{{ hit.framing }}</td>
|
||||
<td class="num">{{ hit.bytes }}</td>
|
||||
<td>
|
||||
{% if hit.guesses %}
|
||||
{% for g in hit.guesses %}<span class="badge ok">{{ g.protocol }}</span> {% endfor %}
|
||||
{% else %}<span class="muted">necunoscut</span>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if result %}
|
||||
<section class="card">
|
||||
<div class="card-head">
|
||||
<h2>Rezultat: {{ result.port }} · {{ result.baud }} {{ result.framing }}</h2>
|
||||
<span class="badge {{ 'ok' if result.ok else 'err' }}">{{ result.bytes }} bytes</span>
|
||||
</div>
|
||||
|
||||
{% if result.guesses %}
|
||||
<h3>Protocoale recunoscute</h3>
|
||||
<div class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead><tr><th>ID</th><th>Nume</th><th>Tip</th><th class="num">Valoare</th><th class="num">Cadre</th></tr></thead>
|
||||
<tbody>
|
||||
{% for g in result.guesses %}
|
||||
<tr>
|
||||
<td><code>{{ g.protocol }}</code></td>
|
||||
<td>{{ g.name }}</td>
|
||||
<td>{{ g.kind }}</td>
|
||||
<td class="num">{{ g.value }} {{ g.unit }}</td>
|
||||
<td class="num">{{ g.count }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Niciun protocol cunoscut nu recunoaște datele. Salvează captura și o folosim ca să scriem parserul.</p>
|
||||
{% endif %}
|
||||
|
||||
<h3>Cadre brute ({{ result.frames|length }})</h3>
|
||||
{% if result.frames %}
|
||||
<div class="table-wrap">
|
||||
<table class="table compact">
|
||||
<thead><tr><th class="num">Acum</th><th class="num">Bytes</th><th>Hex</th><th>Text</th></tr></thead>
|
||||
<tbody>
|
||||
{% for f in result.frames %}
|
||||
<tr>
|
||||
<td class="num">{{ f.age_ms }} ms</td>
|
||||
<td class="num">{{ f.size }}</td>
|
||||
<td><code class="hex">{{ f.hex }}</code></td>
|
||||
<td><code>{{ f.text }}</code></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Niciun byte primit. Cântarul poate fi setat să nu trimită nimic pe această interfață.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="card help-card">
|
||||
<h2>Setări de încercat</h2>
|
||||
<ul class="var-list">
|
||||
<li><code>9600 8N1</code> — cel mai comun pentru cântare de magazin.</li>
|
||||
<li><code>9600 7E1</code> / <code>4800 7E1</code> — frecvent la indicatoarele vechi.</li>
|
||||
<li><code>9600 7O1</code> — dacă textul apare cu bitul 8 schimbat.</li>
|
||||
<li>Unele cântare trimit doar la apăsarea <em>Print</em>: ține monitorul pornit și apasă.</li>
|
||||
</ul>
|
||||
<h2>Cablaj modular (RJ11 / 10P4C)</h2>
|
||||
<p class="muted small">
|
||||
Un adaptor USB–RJ11 funcționează doar dacă cei patru ași ai prizei cântarului sunt
|
||||
<strong>TX, RX, GND</strong> la nivel RS-232 și adaptorul are aceeași ordine. Verifică pinoutul în manualul
|
||||
BC800 înainte de a conecta; unele prize modulare de cântar sunt TTL sau current-loop și nu se leagă
|
||||
direct la un adaptor RS-232.
|
||||
</p>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ "Produs finit nou" if is_new else "Editare produs finit" }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>{{ "Produs finit nou" if is_new else "Editare produs finit" }}</h1>
|
||||
<p class="subtitle">Ordinea componentelor este ordinea în care se cântăresc.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid template-grid">
|
||||
<section class="card">
|
||||
<form method="post" class="form">
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="code">Cod <span class="req">*</span></label>
|
||||
<input id="code" name="code" type="text" maxlength="40" required value="{{ recipe.code or '' }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="unit">Unitate</label>
|
||||
<select id="unit" name="unit">
|
||||
{% for u in ["g", "kg", "mg", "t", "lb", "oz"] %}
|
||||
<option value="{{ u }}" {{ 'selected' if (recipe.unit or 'g') == u }}>{{ u }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="name">Denumire pe etichetă <span class="req">*</span></label>
|
||||
<input id="name" name="name" type="text" maxlength="120" required
|
||||
value="{{ recipe.name or '' }}" placeholder="ex. Fasole cu carne">
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="target_weight">Greutate totală țintă (opțional)</label>
|
||||
<input id="target_weight" name="target_weight" type="number" step="0.001" min="0"
|
||||
value="{{ recipe.target_weight|weight if recipe.target_weight else '' }}" placeholder="ex. 450">
|
||||
<p class="muted small">Doar orientativă; eticheta afișează greutatea reală cântărită.</p>
|
||||
</div>
|
||||
|
||||
<h2>Etichetă</h2>
|
||||
<div class="field">
|
||||
<label for="template_id">Șablon de etichetă</label>
|
||||
<select id="template_id" name="template_id">
|
||||
<option value="">— șablonul implicit din Setări —</option>
|
||||
{% for t in templates %}
|
||||
<option value="{{ t.id }}" {{ 'selected' if recipe.template_id|string == t.id|string }}>
|
||||
{{ t.name }} · {{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="muted small">
|
||||
{% if templates %}
|
||||
Eticheta acestui produs finit se randează cu șablonul ales aici.
|
||||
<a href="{{ url_for('label_templates.index') }}">Vezi șabloanele</a> ·
|
||||
<a href="{{ url_for('label_templates.index') }}">creează unul nou</a>
|
||||
{% else %}
|
||||
Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează întâi un șablon</a>,
|
||||
altfel eticheta nu poate fi tipărită.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h2>Componente</h2>
|
||||
<p class="muted small">Completează cel puțin primul rând. Ordinea rândurilor = ordinea cântăririi.</p>
|
||||
|
||||
{% for row in slot_rows %}
|
||||
<div class="component-row">
|
||||
<span class="component-index">{{ row.slot }}</span>
|
||||
<div class="field grow">
|
||||
<label for="component_{{ row.slot }}_id">
|
||||
Component {{ row.slot }}{% if row.slot == 1 %} <span class="req">*</span>{% endif %}
|
||||
</label>
|
||||
<select id="component_{{ row.slot }}_id" name="component_{{ row.slot }}_id">
|
||||
<option value="">— {{ "obligatoriu" if row.slot == 1 else "opțional" }} —</option>
|
||||
{% for c in components %}
|
||||
<option value="{{ c.id }}"
|
||||
{{ 'selected' if row.component_id|string == c.id|string }}>
|
||||
{{ c.code }} · {{ c.name }} ({{ c.unit }})
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field component-target">
|
||||
<label for="component_{{ row.slot }}_target">Țintă</label>
|
||||
<input id="component_{{ row.slot }}_target" name="component_{{ row.slot }}_target"
|
||||
type="number" step="0.001" min="0" value="{{ row.target|weight if row.target else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
<div class="field">
|
||||
<label for="note">Observații interne</label>
|
||||
<textarea id="note" name="note" rows="2" maxlength="500">{{ recipe.note or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<label class="check">
|
||||
<input type="checkbox" name="active" {{ 'checked' if recipe.active in [True, 1, '1', 'on', 'true'] or is_new }}>
|
||||
Produs finit activ
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează</button>
|
||||
<a class="btn ghost" href="{{ url_for('recipes.index') }}">Renunță</a>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="card help-card">
|
||||
<h2>Cum funcționează</h2>
|
||||
<p class="small">
|
||||
La cântărire alegi produsul finit. Aplicația cere pe rând fiecare componentă, în ordinea de aici.
|
||||
După ultima componentă eticheta se tipărește automat.
|
||||
</p>
|
||||
<h2>Eticheta produsului</h2>
|
||||
<p class="small">
|
||||
Șablonul ales aici este folosit automat la tipărirea etichetelor pentru acest produs finit;
|
||||
dacă lași gol, se folosește șablonul implicit din Setări.
|
||||
</p>
|
||||
<h2>Exemplu</h2>
|
||||
<p class="small">
|
||||
<strong>Fasole cu carne</strong>, unitate <code>g</code>:
|
||||
</p>
|
||||
<ul class="var-list">
|
||||
<li>1 · Fasole — țintă 250</li>
|
||||
<li>2 · Carne — țintă 200</li>
|
||||
</ul>
|
||||
<p class="small muted">
|
||||
Eticheta va arăta totalul real cântărit și defalcarea:
|
||||
<code>250 g Fasole</code>, <code>200 g Carne</code>.
|
||||
</p>
|
||||
<h2>Fără componentă nouă?</h2>
|
||||
<p class="small muted">
|
||||
Dacă lipsește o componentă din listă, o adaugi întâi în
|
||||
<a href="{{ url_for('components.create') }}">Componente</a>, apoi revii aici.
|
||||
</p>
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,51 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Produse finite{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Produse finite</h1>
|
||||
<p class="subtitle">Rețetele care se cântăresc și se etichetează.</p>
|
||||
</div>
|
||||
<a class="btn primary" href="{{ url_for('recipes.create') }}">+ Produs finit</a>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if recipes %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Cod</th><th>Denumire</th><th class="num">Componente</th><th class="num">Țintă</th><th>Etichetă</th><th>Stare</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for r in recipes %}
|
||||
<tr class="{{ 'row-muted' if not r.active }}">
|
||||
<td><code>{{ r.code }}</code></td>
|
||||
<td>{{ r.name }}</td>
|
||||
<td class="num">{{ r.component_count }}</td>
|
||||
<td class="num">{{ (r.target_weight|weight ~ ' ' ~ r.unit) if r.target_weight else "—" }}</td>
|
||||
<td>
|
||||
{% if r.template_name %}{{ r.template_name }}
|
||||
{% else %}<span class="muted">implicit</span>{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if r.active %}<span class="badge ok">activ</span>
|
||||
{% else %}<span class="badge off">inactiv</span>{% endif %}
|
||||
</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('recipes.edit', recipe_id=r.id) }}">Editează</a>
|
||||
<form class="inline" method="post" action="{{ url_for('recipes.remove', recipe_id=r.id) }}"
|
||||
onsubmit="return confirm('Ștergi produsul finit {{ r.code }}? Dacă are pachete în istoric, va fi doar dezactivat.');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nu există produse finite. <a href="{{ url_for('recipes.create') }}">Creează primul</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Setări{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Setări</h1>
|
||||
<p class="subtitle">
|
||||
Datele firmei, cântarul și imprimanta; de aici ajungi la evidențele aplicației.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<nav class="hub-grid" aria-label="Administrare">
|
||||
<a class="hub-card" href="{{ url_for('components.index') }}">
|
||||
<span class="hub-title">Componente</span>
|
||||
<span class="hub-meta">{{ components|length }} definite · ce se cântărește</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('recipes.index') }}">
|
||||
<span class="hub-title">Produse finite</span>
|
||||
<span class="hub-meta">{{ recipes|length }} rețete · combinații de componente</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('label_templates.index') }}">
|
||||
<span class="hub-title">Șabloane</span>
|
||||
<span class="hub-meta">{{ templates|length }} șabloane · design de etichetă</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('fields.index') }}">
|
||||
<span class="hub-title">Câmpuri</span>
|
||||
<span class="hub-meta">{{ fields_count }}
|
||||
{{ 'variabilă' if fields_count == 1 else 'variabile' }} · text pentru etichete</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('history.index') }}">
|
||||
<span class="hub-title">Istoric</span>
|
||||
<span class="hub-meta">Pachete finalizate, filtre și export CSV</span>
|
||||
</a>
|
||||
<a class="hub-card" href="{{ url_for('monitor.index') }}">
|
||||
<span class="hub-title">Monitor serial</span>
|
||||
<span class="hub-meta">Identifică un cântar nou</span>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="grid settings-grid">
|
||||
<section class="card">
|
||||
<form method="post" class="form">
|
||||
<h2>Date firmă</h2>
|
||||
<div class="field">
|
||||
<label for="company_name">Denumire firmă</label>
|
||||
<input id="company_name" name="company_name" type="text" maxlength="200"
|
||||
value="{{ app_settings.company_name }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="company_address">Adresă</label>
|
||||
<input id="company_address" name="company_address" type="text" maxlength="200"
|
||||
value="{{ app_settings.company_address }}">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="company_phone">Telefon</label>
|
||||
<input id="company_phone" name="company_phone" type="text" maxlength="200"
|
||||
value="{{ app_settings.company_phone }}">
|
||||
</div>
|
||||
|
||||
<h2>Cântar</h2>
|
||||
<div class="field">
|
||||
<label for="active_scale_profile">Profil cântar activ</label>
|
||||
<select id="active_scale_profile" name="active_scale_profile">
|
||||
<option value="">— introducere manuală —</option>
|
||||
{% for p in profiles %}
|
||||
<option value="{{ p.name }}" {{ 'selected' if app_settings.active_scale_profile == p.name }}>
|
||||
{{ p.label }} · {{ p.port }} · {{ p.protocol }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% if not profiles %}
|
||||
<p class="muted small">
|
||||
Nu există profile. Configurează cântarul în SyncPulse Scale Bridge sau folosește
|
||||
<a href="{{ url_for('monitor.index') }}">monitorul serial</a>.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<h2>Etichete</h2>
|
||||
<div class="field">
|
||||
<label for="default_template_id">Șablon implicit</label>
|
||||
<select id="default_template_id" name="default_template_id">
|
||||
<option value="">— primul disponibil —</option>
|
||||
{% for t in templates %}
|
||||
<option value="{{ t.id }}" {{ 'selected' if app_settings.default_template_id == t.id|string }}>
|
||||
{{ t.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="default_operator">Operator implicit</label>
|
||||
<input id="default_operator" name="default_operator" type="text" maxlength="64"
|
||||
value="{{ app_settings.default_operator }}">
|
||||
</div>
|
||||
<label class="check">
|
||||
<input type="checkbox" name="auto_print" {{ 'checked' if app_settings.auto_print == '1' }}>
|
||||
Tipărește automat eticheta după ultima componentă
|
||||
</label>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn primary" type="submit">Salvează setările</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<aside class="card help-card">
|
||||
<h2>Starea cântarului</h2>
|
||||
<p>
|
||||
{% if scale_available %}
|
||||
<span class="badge ok">bibliotecă încărcată</span>
|
||||
{% else %}
|
||||
<span class="badge err">indisponibil</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if scale_error %}<p class="message error">{{ scale_error }}</p>{% endif %}
|
||||
|
||||
<h2>Profile detectate</h2>
|
||||
{% if profiles %}
|
||||
<ul class="profile-list">
|
||||
{% for p in profiles %}
|
||||
<li>
|
||||
<strong>{{ p.label }}</strong><br>
|
||||
<span class="muted">{{ p.name }} · {{ p.port }} · {{ p.protocol }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="muted">Niciun profil salvat în <code>instance/bridge.json</code>.</p>
|
||||
{% endif %}
|
||||
|
||||
<h2>Imprimantă (QZ Tray)</h2>
|
||||
<p class="muted small">
|
||||
Eticheta este generată de aplicație și tipărită pe imprimanta termică prin QZ Tray, separat de
|
||||
cântar. Biblioteca din <code>static/js/vendor/qz-tray.js</code> este patch-uită pentru
|
||||
autentificare cu pairing key.
|
||||
</p>
|
||||
<p class="qz-state-line">
|
||||
<span class="badge" data-qz-status>QZ Tray: se verifică…</span>
|
||||
<span class="muted small" data-qz-version></span>
|
||||
</p>
|
||||
<div class="field">
|
||||
<label for="qz-printer">Imprimantă implicită</label>
|
||||
<select id="qz-printer" data-qz-printer>
|
||||
<option value="">Implicit (sistem)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button class="btn" type="button" data-qz-connect>Conectează</button>
|
||||
<button class="btn" type="button" data-qz-refresh>Reîncarcă lista</button>
|
||||
<button class="btn ghost" type="button" data-qz-test>Tipărește test</button>
|
||||
</div>
|
||||
|
||||
<h2>Integrare</h2>
|
||||
<p class="muted small">
|
||||
Aplicația importă direct pachetul <code>vendor/syncpulse-scale-bridge</code>; profilul serial
|
||||
este citit din <code>instance/bridge.json</code>.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Pentru teste fără hardware: <code>CANTARIRE_SIMULATE=mahak_mds</code>.
|
||||
</p>
|
||||
|
||||
<h2>Sistem</h2>
|
||||
<p class="small">
|
||||
{% if setup.ready %}<span class="badge ok">gata de cântărit</span>
|
||||
{% else %}<span class="badge warn">{{ setup.missing|length }} de completat</span>{% endif %}
|
||||
<span class="badge">schema v{{ db_status.schema_version }}</span>
|
||||
</p>
|
||||
<ul class="system-list small">
|
||||
<li><span class="muted">Bază de date</span> <code>{{ db_status.path }}</code></li>
|
||||
<li><span class="muted">Mărime</span> {{ db_status.size_human }}</li>
|
||||
<li><span class="muted">Integritate</span> {{ db_status.integrity }}</li>
|
||||
<li><span class="muted">Înregistrări</span>
|
||||
{{ db_status.counts.components }} componente ·
|
||||
{{ db_status.counts.recipes }} produse finite ·
|
||||
{{ db_status.counts.packages }} pachete</li>
|
||||
</ul>
|
||||
<div class="actions">
|
||||
<a class="btn" href="{{ url_for('main.settings_backup') }}">Descarcă backup</a>
|
||||
<a class="btn ghost" href="{{ url_for('main.healthz') }}" target="_blank" rel="noopener">Stare (/healthz)</a>
|
||||
</div>
|
||||
{% if backups %}
|
||||
<p class="muted small">Ultimele backup-uri:</p>
|
||||
<ul class="system-list small">
|
||||
{% for b in backups %}
|
||||
<li>{{ b.created_at.strftime('%d.%m.%Y %H:%M') }} · {{ b.size_human }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,154 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Designer etichetă · {{ template.name }}{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/designer.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="designer" id="designer"
|
||||
data-save-url="{{ url_for('label_templates.designer_save', template_id=template.id) }}"
|
||||
data-qr-url="{{ url_for('label_templates.designer_qr') }}">
|
||||
|
||||
<header class="designer-head">
|
||||
<div class="designer-head-main">
|
||||
<form method="post" class="rename-form"
|
||||
action="{{ url_for('label_templates.rename', template_id=template.id) }}">
|
||||
<input type="hidden" name="next" value="designer">
|
||||
<input id="template-name" name="name" type="text" maxlength="80" required
|
||||
value="{{ template.name }}" class="title-input"
|
||||
aria-label="Nume șablon" title="Numele șablonului">
|
||||
<button class="btn ghost" type="submit">Redenumește</button>
|
||||
</form>
|
||||
<p class="subtitle">
|
||||
Trage elementele pe etichetă. Previzualizarea folosește date demo; la tipărire se folosesc datele reale.
|
||||
</p>
|
||||
</div>
|
||||
<div class="designer-actions">
|
||||
<span id="designer-state" class="badge off">nesalvat</span>
|
||||
<a class="btn ghost" target="_blank" rel="noopener"
|
||||
href="{{ url_for('label_templates.preview', template_id=template.id) }}">Previzualizare</a>
|
||||
<button class="btn primary" type="button" id="designer-save">Salvează</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="designer-toolbar">
|
||||
<div class="tool-group tool-grow">
|
||||
<h2>Adaugă element</h2>
|
||||
<div class="palette">
|
||||
<button class="palette-btn" type="button" data-add-picker="variable">Câmp variabil</button>
|
||||
<button class="palette-btn" type="button" data-add="text">Text</button>
|
||||
<button class="palette-btn" type="button" data-add="qr">Cod QR</button>
|
||||
<button class="palette-btn" type="button" data-add="line">Linie</button>
|
||||
<button class="palette-btn" type="button" data-add="image">Imagine</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-group">
|
||||
<h2>Etichetă (mm)</h2>
|
||||
<div class="tool-fields">
|
||||
<div class="field">
|
||||
<label for="label-width">Lățime</label>
|
||||
<input id="label-width" type="number" min="10" max="500" step="0.5">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="label-height">Înălțime</label>
|
||||
<input id="label-height" type="number" min="10" max="500" step="0.5">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tool-group">
|
||||
<h2>Zoom</h2>
|
||||
<input id="designer-zoom" class="zoom-slider" type="range" min="3" max="14" step="0.5">
|
||||
</div>
|
||||
|
||||
<div class="tool-group tool-safe">
|
||||
<h2>Zonă sigură</h2>
|
||||
<label class="check"><input id="safe-toggle" type="checkbox" checked>
|
||||
Contur roșu</label>
|
||||
<p id="safe-hint" class="safe-hint muted">Zona sigură: {{ safe_mm }} mm de la fiecare margine.</p>
|
||||
<button id="safe-arrange" class="btn warn hidden" type="button"
|
||||
title="Mută elementele care ies din zona sigură în interiorul conturului roșu">
|
||||
Rearanjează în zona sigură
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="designer-grid">
|
||||
<aside class="designer-panel designer-elements">
|
||||
<h2>Elemente pe etichetă <span id="block-count" class="count">0</span></h2>
|
||||
<ul id="block-list" class="block-list"></ul>
|
||||
|
||||
<form method="post" class="designer-clear"
|
||||
action="{{ url_for('label_templates.designer_reset', template_id=template.id) }}"
|
||||
onsubmit="return confirm('Resetezi designul la varianta implicită? Modificările nesalvate se pierd.');">
|
||||
<button class="link danger" type="submit">Resetează designul</button>
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
<section class="designer-canvas-wrap">
|
||||
<div class="canvas-scroll">
|
||||
<div class="canvas-stage">
|
||||
<div id="canvas" class="design-canvas">
|
||||
<div class="canvas-grid"></div>
|
||||
<div class="canvas-blocks" id="canvas-blocks"></div>
|
||||
<div class="canvas-safe" id="canvas-safe"><span>zonă sigură</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="muted small">
|
||||
Trage de element pentru mutare, de colțul din dreapta-jos pentru redimensionare.
|
||||
Săgețile mută cu 0,5 mm (Shift: 2 mm). Delete șterge elementul.
|
||||
</p>
|
||||
<p class="muted small">
|
||||
Recomandat: păstrează toate elementele în interiorul conturului roșu — imprimantele
|
||||
termice nu pot tipări până la marginea hârtiei.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<aside class="designer-panel" id="props">
|
||||
<h2>Proprietăți</h2>
|
||||
<p class="muted small" id="props-empty">Selectează un element.</p>
|
||||
<div id="props-body" class="hidden"></div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div id="field-picker" class="picker hidden" role="dialog" aria-modal="true"
|
||||
aria-labelledby="field-picker-title">
|
||||
<div class="picker-backdrop" data-picker-close></div>
|
||||
<div class="picker-card">
|
||||
<header class="picker-head">
|
||||
<h2 id="field-picker-title">Alege câmpul variabil</h2>
|
||||
<button class="picker-close" type="button" data-picker-close aria-label="Închide">×</button>
|
||||
</header>
|
||||
<input id="field-picker-search" type="search" autocomplete="off"
|
||||
placeholder="Caută un câmp…">
|
||||
<ul id="field-picker-list" class="picker-list"></ul>
|
||||
<footer class="picker-foot">
|
||||
<span class="muted small" id="field-picker-hint">Click pe un câmp pentru a-l adăuga pe etichetă.</span>
|
||||
<span class="picker-foot-actions">
|
||||
<a class="link" href="{{ url_for('fields.index') }}">Gestionează câmpurile</a>
|
||||
<button class="btn primary hidden" type="button" id="field-picker-confirm">Adaugă</button>
|
||||
<button class="btn ghost" type="button" data-picker-close>Anulează</button>
|
||||
</span>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
window.DESIGNER = {
|
||||
design: {{ design|tojson }},
|
||||
fields: {{ fields|tojson }},
|
||||
demoFields: {{ demo_fields|tojson }},
|
||||
demoItems: {{ demo_items|tojson }},
|
||||
safeMm: {{ safe_mm }}
|
||||
};
|
||||
</script>
|
||||
<script src="{{ url_for('static', filename='js/designer.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Șabloane etichete{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<section class="page-head">
|
||||
<div>
|
||||
<h1>Șabloane etichete</h1>
|
||||
<p class="subtitle">Desenează eticheta în designer; fiecare produs finit poate folosi alt șablon.</p>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<form method="post" action="{{ url_for('label_templates.designer_create') }}"
|
||||
class="inline new-template-form">
|
||||
<input type="text" name="name" maxlength="80" required
|
||||
placeholder="Nume șablon nou" aria-label="Nume șablon nou">
|
||||
<button class="btn primary" type="submit">+ Șablon nou</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
{% if templates %}
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>Nume</th><th>Dimensiune</th><th>Elemente</th><th>Implicit</th><th>Actualizat</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for t in templates %}
|
||||
<tr>
|
||||
<td>{{ t.name }}</td>
|
||||
<td>{{ t.width_mm|weight }} × {{ t.height_mm|weight }} mm</td>
|
||||
<td>
|
||||
{% if t.design_json %}
|
||||
<span class="badge ok">designer</span>
|
||||
{% else %}
|
||||
<span class="badge warn">fără design</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>{% if t.is_default %}<span class="badge ok">implicit</span>{% else %}<span class="muted">—</span>{% endif %}</td>
|
||||
<td class="nowrap muted">{{ t.updated_at|dt }}</td>
|
||||
<td class="right nowrap">
|
||||
<a class="link" href="{{ url_for('label_templates.designer_page', template_id=t.id) }}">Designer</a>
|
||||
<a class="link" target="_blank" rel="noopener"
|
||||
href="{{ url_for('label_templates.preview', template_id=t.id) }}">Previzualizare</a>
|
||||
<form class="inline" method="post" action="{{ url_for('label_templates.remove', template_id=t.id) }}"
|
||||
onsubmit="return confirm('Ștergi șablonul {{ t.name }}?');">
|
||||
<button class="link danger" type="submit">Șterge</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted">Nu există șabloane. <a href="{{ url_for('label_templates.index') }}">Creează unul</a>.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1 @@
|
||||
"""Utilitare de linie de comanda."""
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Utilitar de administrare: stare, backup, reset, verificare, date demo.
|
||||
|
||||
Exemple:
|
||||
|
||||
python -m cantarire.tools.admin status
|
||||
python -m cantarire.tools.admin check
|
||||
python -m cantarire.tools.admin backup --keep 10
|
||||
python -m cantarire.tools.admin reset --yes
|
||||
python -m cantarire.tools.admin seed-demo
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from cantarire import create_app
|
||||
from cantarire import maintenance
|
||||
|
||||
|
||||
def _status() -> int:
|
||||
status = maintenance.database_status()
|
||||
print(f"Baza de date : {status['path']}")
|
||||
print(f"Marime : {status['size_human']}")
|
||||
print(f"Integritate : {status['integrity']}")
|
||||
print(f"Schema : v{status['schema_version']}")
|
||||
print(f"Backup-uri : {status['backups']} in {status['backup_dir']}")
|
||||
print("Inregistrari :")
|
||||
for table, count in status["counts"].items():
|
||||
print(f" {table:<14} {count}")
|
||||
return 0
|
||||
|
||||
|
||||
def _check(as_json: bool = False) -> int:
|
||||
summary = maintenance.readiness_summary()
|
||||
if as_json:
|
||||
import json
|
||||
|
||||
print(json.dumps(summary, indent=2, ensure_ascii=False, default=str))
|
||||
return 0 if summary["ready"] else 1
|
||||
|
||||
print("Verificare pregatire productie:\n")
|
||||
for item in summary["checks"]:
|
||||
mark = "OK " if item["ok"] else ("OPT " if item.get("optional") else "LIPSA")
|
||||
print(f"[{mark}] {item['label']:<22} {item['message']}")
|
||||
print()
|
||||
if summary["ready"]:
|
||||
print("Gata de cantarit: toate elementele obligatorii sunt completate.")
|
||||
return 0
|
||||
missing = ", ".join(item["label"] for item in summary["missing"])
|
||||
print(f"De completat: {missing}")
|
||||
return 1
|
||||
|
||||
|
||||
def _backup(keep: int) -> int:
|
||||
path = maintenance.backup_database("manual")
|
||||
removed = maintenance.prune_backups(keep)
|
||||
print(f"Backup creat: {path}")
|
||||
if removed:
|
||||
print(f"Backup-uri vechi sterse: {removed} (pastrez ultimele {keep})")
|
||||
return 0
|
||||
|
||||
|
||||
def _reset(confirmed: bool, keep_backup: bool) -> int:
|
||||
if not confirmed:
|
||||
print("Operatia goleste toate datele (componente, produse finite, pachete, sabloane).")
|
||||
print("Ruleaz-o din nou cu --yes daca esti sigur.")
|
||||
return 2
|
||||
result = maintenance.reset_database(keep_backup=keep_backup)
|
||||
print(maintenance.reset_report(result))
|
||||
return 0
|
||||
|
||||
|
||||
def _seed_demo() -> int:
|
||||
result = maintenance.seed_demo()
|
||||
created = result["created"]
|
||||
print(f"Componente create: {created['components']} · produse finite create: {created['recipes']}")
|
||||
print(f"Componente: {', '.join(result['components']) or '-'}")
|
||||
print(f"Produse finite: {', '.join(result['recipes']) or '-'}")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None, app=None) -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="cantarire-admin",
|
||||
description="Administrare: stare, backup, reset, verificare.",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
sub.add_parser("status", help="starea bazei si numarul de inregistrari")
|
||||
check = sub.add_parser("check", help="verifica daca aplicatia e gata de cantarit")
|
||||
check.add_argument("--json", action="store_true", help="iesire JSON")
|
||||
|
||||
backup = sub.add_parser("backup", help="creeaza un backup al bazei")
|
||||
backup.add_argument("--keep", type=int, default=maintenance.KEEP_BACKUPS,
|
||||
help="cate backup-uri pastreaza (implicit 20)")
|
||||
|
||||
reset = sub.add_parser("reset", help="goleste datele si readuce setarile initiale")
|
||||
reset.add_argument("--yes", action="store_true", help="confirma stergerea")
|
||||
reset.add_argument("--no-backup", action="store_true", help="nu face backup inainte")
|
||||
|
||||
sub.add_parser("seed-demo", help="adauga datele demo (instruire/testare)")
|
||||
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
application = app or create_app()
|
||||
with application.app_context():
|
||||
maintenance.ensure_instance_dirs()
|
||||
if args.command == "status":
|
||||
return _status()
|
||||
if args.command == "check":
|
||||
return _check(args.json)
|
||||
if args.command == "backup":
|
||||
return _backup(args.keep)
|
||||
if args.command == "reset":
|
||||
return _reset(args.yes, not args.no_backup)
|
||||
if args.command == "seed-demo":
|
||||
return _seed_demo()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Utilitar CLI pentru identificarea unui cantar nou.
|
||||
|
||||
python -m cantarire.tools.monitor --list
|
||||
python -m cantarire.tools.monitor /dev/ttyUSB0 --seconds 8
|
||||
python -m cantarire.tools.monitor COM3 --scan --seconds 2
|
||||
python -m cantarire.tools.monitor COM3 --baud 9600 --framing 7E1 --save bizerba
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
from ..probe import BAUD_RATES, FRAMINGS, ProbeError, list_ports, probe_port, save_capture, sweep
|
||||
|
||||
|
||||
def _print_ports() -> int:
|
||||
ports = list_ports()
|
||||
if not ports:
|
||||
print("Niciun port serial detectat.")
|
||||
return 1
|
||||
for p in ports:
|
||||
chip = f" [{p['chip']}]" if p.get("chip") else ""
|
||||
print(f"{p['device']:<20} {p.get('description') or ''}{chip}")
|
||||
if p.get("note"):
|
||||
print(f"{'':<20} ! {p['note']}")
|
||||
return 0
|
||||
|
||||
|
||||
def _print_result(result: dict) -> None:
|
||||
print(f"\n{result['settings']} ({result['seconds']}s)")
|
||||
print(f"bytes: {result['bytes']} cadre: {len(result['frames'])}")
|
||||
if result.get("error"):
|
||||
print(f"eroare: {result['error']}")
|
||||
for frame in result["frames"]:
|
||||
print(f" {frame['hex']:<56} {frame['text']}")
|
||||
if result["guesses"]:
|
||||
print("\nprotocoale recunoscute:")
|
||||
for g in result["guesses"]:
|
||||
print(f" {g['protocol']:<18} {g['kind']:<10} {g['value']} {g['unit']} "
|
||||
f"({g['count']} cadre)")
|
||||
elif result["bytes"]:
|
||||
print("\nNiciun protocol cunoscut. Salveaza captura si adaugam parserul.")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description="Monitor port serial cantar")
|
||||
parser.add_argument("port", nargs="?", help="ex. /dev/ttyUSB0 sau COM3")
|
||||
parser.add_argument("--list", action="store_true", help="listeaza porturile si iese")
|
||||
parser.add_argument("--baud", type=int, default=9600)
|
||||
parser.add_argument("--framing", default="8N1", choices=FRAMINGS)
|
||||
parser.add_argument("--seconds", type=float, default=5.0)
|
||||
parser.add_argument("--scan", action="store_true",
|
||||
help="incearca toate vitezele la framing-ul dat")
|
||||
parser.add_argument("--save", metavar="NUME", help="scrie captura in instance/captures")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
if args.list or not args.port:
|
||||
return _print_ports()
|
||||
|
||||
try:
|
||||
if args.scan:
|
||||
rows = sweep(args.port, seconds=min(args.seconds, 3.0),
|
||||
framings=(args.framing,), bauds=BAUD_RATES)
|
||||
hits = [r for r in rows if r.get("bytes")]
|
||||
if not hits:
|
||||
print("Niciun byte primit la vitezele incercate.")
|
||||
for row in rows:
|
||||
if row.get("error"):
|
||||
print(f" {row['baud']} {row['framing']}: {row['error']}")
|
||||
return 1
|
||||
for hit in hits:
|
||||
names = ", ".join(g["protocol"] for g in hit["guesses"]) or "necunoscut"
|
||||
print(f"{hit['baud']:>7} {hit['framing']} {hit['bytes']:>6} bytes {names}")
|
||||
return 0
|
||||
|
||||
result = probe_port(args.port, args.baud, args.framing, args.seconds)
|
||||
except ProbeError as exc:
|
||||
print(f"eroare: {exc}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
_print_result(result)
|
||||
if args.save and result["frames"]:
|
||||
target = save_capture("instance/captures", args.save, result["frames"])
|
||||
print(f"\ncaptura: {target}")
|
||||
return 0 if result["ok"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Unitati de masa, normalizare si conversii.
|
||||
|
||||
Etichetele de produs finit sunt exprimate de obicei in grame, iar cantarul
|
||||
poate raporta kilograme. Toate cantaririle sunt convertite in unitatea retetei
|
||||
inainte de a fi salvate.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
# Unitati acceptate, in ordinea afisata in interfata.
|
||||
UNITS: tuple[str, ...] = ("g", "kg", "mg", "t", "lb", "oz")
|
||||
|
||||
GRAMS_PER: dict[str, float] = {
|
||||
"g": 1.0,
|
||||
"kg": 1000.0,
|
||||
"mg": 0.001,
|
||||
"t": 1_000_000.0,
|
||||
"lb": 453.59237,
|
||||
"oz": 28.349523125,
|
||||
}
|
||||
|
||||
ALIASES: dict[str, str] = {
|
||||
"gr": "g", "gram": "g", "grame": "g", "grams": "g",
|
||||
"kg": "kg", "kgs": "kg", "kilogram": "kg", "kilograme": "kg",
|
||||
"mg": "mg", "miligram": "mg", "miligrame": "mg",
|
||||
"t": "t", "tona": "t", "ton": "t", "to": "t",
|
||||
"lb": "lb", "lbs": "lb", "pound": "lb",
|
||||
"oz": "oz", "ounce": "oz",
|
||||
}
|
||||
|
||||
|
||||
def normalize_unit(value: object, default: str = "g") -> str:
|
||||
text = str(value or "").strip().lower()
|
||||
if not text:
|
||||
return default
|
||||
return ALIASES.get(text, text if text in GRAMS_PER else default)
|
||||
|
||||
|
||||
def convert(value: float, from_unit: str, to_unit: str) -> Optional[float]:
|
||||
"""Converteste intre unitati de masa; None daca una nu este unitate de masa."""
|
||||
source = GRAMS_PER.get(from_unit)
|
||||
target = GRAMS_PER.get(to_unit)
|
||||
if source is None or target is None:
|
||||
return None
|
||||
return value * source / target
|
||||
|
||||
|
||||
def format_weight(value: object, decimals: int = 3) -> str:
|
||||
"""Formatare pentru etichete: fara zerouri finale inutile."""
|
||||
try:
|
||||
number = float(value) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return str(value)
|
||||
text = f"{number:.{int(decimals)}f}"
|
||||
if "." in text:
|
||||
text = text.rstrip("0").rstrip(".")
|
||||
return text or "0"
|
||||
Reference in New Issue
Block a user