Files
cantarire-etichetare/cantarire/repositories.py
T
ske087 bff6d70b17 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
2026-10-18 23:38:45 +03:00

872 lines
31 KiB
Python

"""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()