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:
2026-10-18 23:38:45 +03:00
commit bff6d70b17
107 changed files with 21428 additions and 0 deletions
+60
View File
@@ -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
+40
View File
@@ -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
View File
@@ -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))
+681
View File
@@ -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 = " &middot; " 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")
+175
View File
@@ -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",
}
+57
View File
@@ -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)
+407
View File
@@ -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)
+184
View File
@@ -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
+871
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Blueprint-urile aplicatiei sunt importate explicit in `create_app`."""
+277
View File
@@ -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})
+58
View File
@@ -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"))
+113
View File
@@ -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)})
+65
View File
@@ -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"'},
)
+217
View File
@@ -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"))
+135
View File
@@ -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"))
+85
View File
@@ -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,
)
+46
View File
@@ -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)
+85
View File
@@ -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"))
+229
View File
@@ -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
+117
View File
@@ -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
);
+726
View File
@@ -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; }
+467
View File
@@ -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
+489
View File
@@ -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 &amp; 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();
}
})();
+16
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+577
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
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();
}
})();
+77
View File
@@ -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 &amp; 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 &amp; 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>
+47
View File
@@ -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 %}
+47
View File
@@ -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 %}
+159
View File
@@ -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 %}
+120
View File
@@ -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 %}
+75
View File
@@ -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 %}
+100
View File
@@ -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 %}
+89
View File
@@ -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>
+170
View File
@@ -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 %}
+141
View File
@@ -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 %}
+51
View File
@@ -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 %}
+194
View File
@@ -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 %}
+154
View File
@@ -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 %}
+59
View File
@@ -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 %}
+1
View File
@@ -0,0 +1 @@
"""Utilitare de linie de comanda."""
+122
View File
@@ -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())
+89
View File
@@ -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())
+58
View File
@@ -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"