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
+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)