diff --git a/backend/app/main.py b/backend/app/main.py index 077d17f..964e817 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from .routers import ( branding, categories, dashboard, + etiketten, field_definitions, groups, items, @@ -31,6 +32,7 @@ from .routers import ( ) from .seed import ( ensure_builtin_categories, + ensure_builtin_etiketten_vorlagen, ensure_builtin_package_types, ensure_builtin_units, ensure_example_object_categories, @@ -255,6 +257,7 @@ async def lifespan(app: FastAPI): try: ensure_builtin_units(db) ensure_builtin_package_types(db) + ensure_builtin_etiketten_vorlagen(db) ensure_builtin_categories(db) ensure_example_object_categories(db) ensure_first_admin(db) @@ -291,6 +294,7 @@ def health() -> dict: app.include_router(auth.router) +app.include_router(etiketten.router) app.include_router(oidc.router) app.include_router(users.router) app.include_router(products.router) diff --git a/backend/app/models.py b/backend/app/models.py index 5fb4d58..49c8aea 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -637,6 +637,43 @@ class DashboardLayout(Base): ) +class EtikettenVorlage(Base): + """Eine gespeicherte Zusammenstellung fuer den Etikettenexport. + + Benannt, damit man sie wieder aufrufen kann („Kabelexport"), und mit frei + gewaehlten Spalten - erst dadurch kommen eigene Felder wie eine Kabellaenge + oder die Kapazitaet einer Powerbank ueberhaupt aufs Etikett. + + Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt ``Setting`` + (dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je Schluessel), + Nutzlast als JSON-Text. Anders als dort ohne ``user_id`` - eine Vorlage + gehoert allen, nicht einem Konto. + """ + + __tablename__ = "etiketten_vorlagen" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(80), unique=True, nullable=False) + # Worauf sich die Vorlage bezieht: items | products | locations. + # Als kurzer String und nicht als DB-Enum, wie DatePrecision und + # CategoryTracking - so laesst sich eine weitere Art spaeter ohne neuen + # Postgres-Typ nachziehen. + art: Mapped[str] = mapped_column(String(16), nullable=False, default="items") + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + # Spaltenwahl, Reihenfolge, Filter und Trennzeichen als JSON-Text. + konfiguration: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + # Hinterlegte P-touch-Vorlage (.lbx). Hoechstens eine je Eintrag, deshalb + # Spalten derselben Zeile statt einer zweiten Tabelle. + datei_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + # deferred: Sonst zoege jede Liste der Vorlagen die Binaerdaten mit - genau + # das vermeidet ItemDocument.data aus demselben Grund. + datei: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), default=_now, onupdate=_now + ) + + class Setting(Base): __tablename__ = "settings" diff --git a/backend/app/routers/etiketten.py b/backend/app/routers/etiketten.py new file mode 100644 index 0000000..81faba2 --- /dev/null +++ b/backend/app/routers/etiketten.py @@ -0,0 +1,300 @@ +"""Etiketten-Vorlagen: gespeicherte Zusammenstellungen fuer den Export. + +Eine Vorlage haelt fest, worauf sie sich bezieht (Einzelstuecke, Artikel oder +Lagerorte), welche Spalten in welcher Reihenfolge exportiert werden und wie +gefiltert wird. Dazu kann eine P-touch-Datei (.lbx) hinterlegt werden, damit +man sie auf einem fremden Rechner nicht suchen muss. + +Vorlagen gehoeren allen: Anlegen und Aendern bleibt Administratoren +vorbehalten, Exportieren darf jeder Angemeldete – es ist ein Lesevorgang. +""" + +from __future__ import annotations + +import io +import json +import re +import zipfile + +from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import EtikettenVorlage, User +from ..schemas import ( + EtikettenVorlageCreate, + EtikettenVorlageOut, + EtikettenVorlageUpdate, + SpalteOut, +) +from ..services.etiketten import ( + ARTEN, + csv_erzeugen, + konfiguration_lesen, + verfuegbare_spalten, +) + +router = APIRouter(prefix="/etiketten", tags=["etiketten"]) + +# Eine .lbx ist ein ZIP von wenigen Kilobyte. Die Grenze ist grosszuegig und +# bleibt weit unter der nginx-Grenze (client_max_body_size 12m). +LBX_MAX_BYTES = 2 * 1024 * 1024 + + +def _vorlage_oder_404(db: Session, vorlage_id: int) -> EtikettenVorlage: + vorlage = db.get(EtikettenVorlage, vorlage_id) + if vorlage is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Vorlage nicht gefunden") + return vorlage + + +def _safe_filename(name: str | None) -> str: + """Steuerzeichen und Anfuehrungszeichen raus – sonst Header-Injektion.""" + cleaned = re.sub(r'[\r\n"]', "", (name or "").strip()) + return cleaned[:255] or "vorlage.lbx" + + +def _pruefe_lbx(dateiname: str, daten: bytes) -> None: + """Ist das plausibel eine P-touch-Vorlage? + + Eine .lbx ist ein ZIP mit label.xml (und prop.xml) darin. Geprueft wird + genau das – nicht der vom Browser gemeldete Content-Type, denn fuer .lbx + meldet der meist application/octet-stream oder gar nichts. + """ + if not daten: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.") + if len(daten) > LBX_MAX_BYTES: + raise HTTPException( + status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + f"Datei zu groß (max. {LBX_MAX_BYTES // (1024 * 1024)} MB).", + ) + if not dateiname.lower().endswith(".lbx"): + raise HTTPException( + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + "Nur .lbx-Dateien aus dem P-touch Editor.", + ) + try: + with zipfile.ZipFile(io.BytesIO(daten)) as archiv: + namen = {n.lower() for n in archiv.namelist()} + except zipfile.BadZipFile: + raise HTTPException( + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + "Das ist keine P-touch-Vorlage (die Datei ist kein ZIP-Archiv).", + ) from None + if "label.xml" not in namen: + raise HTTPException( + status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, + "Das ist keine P-touch-Vorlage (label.xml fehlt).", + ) + + +def _als_out(vorlage: EtikettenVorlage) -> EtikettenVorlageOut: + konf = konfiguration_lesen(vorlage) + return EtikettenVorlageOut( + id=vorlage.id, + name=vorlage.name, + art=vorlage.art, + position=vorlage.position, + spalten=konf["spalten"], + kategorie_ids=konf["kategorie_ids"], + trennzeichen=konf["trennzeichen"], + datei_name=vorlage.datei_name, + updated_at=vorlage.updated_at, + ) + + +def _konfiguration(spalten: list[str], kategorie_ids: list[int], trennzeichen: str) -> str: + return json.dumps( + { + "spalten": spalten, + "kategorie_ids": kategorie_ids, + "trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",", + }, + ensure_ascii=False, + ) + + +@router.get("/spalten", response_model=list[SpalteOut]) +def spalten( + art: str = "items", + kategorie_ids: str | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[SpalteOut]: + """Welche Spalten fuer diese Bezugsart und Kategorien zur Wahl stehen. + + Die eigenen Felder haengen an den Kategorien – ohne diesen Endpunkt + wuesste die Oberflaeche nicht, dass es „Kapazität (mAh)" ueberhaupt gibt. + """ + if art not in ARTEN: + raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unbekannte Bezugsart „{art}“") + ids = [int(t) for t in (kategorie_ids or "").split(",") if t.strip().isdigit()] + return [SpalteOut(**s) for s in verfuegbare_spalten(db, art, ids)] + + +@router.get("/vorlagen", response_model=list[EtikettenVorlageOut]) +def vorlagen_auflisten( + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[EtikettenVorlageOut]: + rows = ( + db.query(EtikettenVorlage) + .order_by(EtikettenVorlage.position, EtikettenVorlage.id) + .all() + ) + return [_als_out(v) for v in rows] + + +@router.post( + "/vorlagen", response_model=EtikettenVorlageOut, status_code=status.HTTP_201_CREATED +) +def vorlage_anlegen( + payload: EtikettenVorlageCreate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> EtikettenVorlageOut: + name = payload.name.strip() + if db.query(EtikettenVorlage).filter(EtikettenVorlage.name == name).first(): + raise HTTPException(status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen") + hoechste = ( + db.query(EtikettenVorlage).order_by(EtikettenVorlage.position.desc()).first() + ) + vorlage = EtikettenVorlage( + name=name, + art=payload.art, + position=(hoechste.position + 1) if hoechste else 0, + konfiguration=_konfiguration( + payload.spalten, payload.kategorie_ids, payload.trennzeichen + ), + ) + db.add(vorlage) + db.commit() + db.refresh(vorlage) + return _als_out(vorlage) + + +@router.patch("/vorlagen/{vorlage_id}", response_model=EtikettenVorlageOut) +def vorlage_aendern( + vorlage_id: int, + payload: EtikettenVorlageUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> EtikettenVorlageOut: + vorlage = _vorlage_oder_404(db, vorlage_id) + data = payload.model_dump(exclude_unset=True) + + if "name" in data: + neuer = data["name"].strip() + doppelt = ( + db.query(EtikettenVorlage) + .filter(EtikettenVorlage.name == neuer, EtikettenVorlage.id != vorlage.id) + .first() + ) + if doppelt: + raise HTTPException( + status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen" + ) + vorlage.name = neuer + if "art" in data: + vorlage.art = data["art"] + if "position" in data: + vorlage.position = data["position"] + + # Die drei Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur + # einen schickt, darf die anderen nicht verlieren. + if {"spalten", "kategorie_ids", "trennzeichen"} & data.keys(): + alt = konfiguration_lesen(vorlage) + vorlage.konfiguration = _konfiguration( + data.get("spalten", alt["spalten"]), + data.get("kategorie_ids", alt["kategorie_ids"]), + data.get("trennzeichen", alt["trennzeichen"]), + ) + + db.commit() + db.refresh(vorlage) + return _als_out(vorlage) + + +@router.delete("/vorlagen/{vorlage_id}") +def vorlage_loeschen( + vorlage_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Response: + db.delete(_vorlage_oder_404(db, vorlage_id)) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) + + +@router.get("/vorlagen/{vorlage_id}/csv") +def vorlage_csv( + vorlage_id: int, + basis_url: str = "", + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Response: + """Die CSV zu einer Vorlage. + + ``basis_url`` braucht nur der Lagerort-QR (…/l/); Einzelstuecke tragen + vorrania://i/ ohne jede Adresse. Die Oberflaeche reicht ihre eigene + Herkunft durch, weil nur sie weiss, unter welcher Adresse sie erreichbar ist. + """ + vorlage = _vorlage_oder_404(db, vorlage_id) + inhalt = csv_erzeugen(db, vorlage, basis_url=basis_url) + dateiname = _safe_filename(f"{vorlage.name}.csv".replace("/", "-")) + return Response( + content=inhalt.encode("utf-8"), + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{dateiname}"'}, + ) + + +@router.post("/vorlagen/{vorlage_id}/datei", response_model=EtikettenVorlageOut) +async def datei_hochladen( + vorlage_id: int, + file: UploadFile = File(...), + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> EtikettenVorlageOut: + """Die P-touch-Vorlage (.lbx) hinterlegen.""" + vorlage = _vorlage_oder_404(db, vorlage_id) + daten = await file.read() + name = _safe_filename(file.filename) + _pruefe_lbx(name, daten) + vorlage.datei_name = name + vorlage.datei = daten + db.commit() + db.refresh(vorlage) + return _als_out(vorlage) + + +@router.get("/vorlagen/{vorlage_id}/datei") +def datei_herunterladen( + vorlage_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Response: + vorlage = _vorlage_oder_404(db, vorlage_id) + if vorlage.datei is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Zu dieser Vorlage ist keine Datei hinterlegt") + return Response( + content=vorlage.datei, + # attachment und nicht inline: Die Datei soll gespeichert werden, der + # Browser kann mit ihr ohnehin nichts anfangen. + media_type="application/octet-stream", + headers={"Content-Disposition": f'attachment; filename="{vorlage.datei_name}"'}, + ) + + +@router.delete("/vorlagen/{vorlage_id}/datei") +def datei_entfernen( + vorlage_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Response: + vorlage = _vorlage_oder_404(db, vorlage_id) + vorlage.datei = None + vorlage.datei_name = None + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 3c0ab37..e2ae800 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -6,6 +6,7 @@ werden ergänzt – es wird nie etwas gelöscht oder überschrieben. from __future__ import annotations +import base64 import csv import io import json @@ -22,11 +23,13 @@ from ..services.dates import MONTH, clean_precision, normalize_best_before from ..services import gruppen as gruppen_graph from ..services.group_codes import sync as sync_group_code from ..services.min_stock import lies_ueberall, schreibe_ueberall +from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen from ..models import ( BaseUnit, Category, CategoryTracking, DatePrecision, + EtikettenVorlage, FieldDefinition, Group, GroupLocationMinStock, @@ -166,10 +169,11 @@ def export_backup_json( loc_name = {loc.id: loc.name for loc in locations} data = { + # v4: Etiketten-Vorlagen samt hinterlegter P-touch-Datei. # v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort - # (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben + # (Ort null = „Ueberall") in Basiseinheiten. Aeltere Sicherungen bleiben # lesbar – ihnen fehlen diese Listen einfach. - "version": 3, + "version": 4, "exported_at": datetime.now(timezone.utc).isoformat(), "exported_at_local": datetime.now().isoformat(timespec="seconds"), "units": [ @@ -262,6 +266,8 @@ def export_backup_json( } ) + data["etiketten_vorlagen"] = _vorlagen_exportieren(db) + return Response( content=json.dumps(data, ensure_ascii=False, indent=2), media_type="application/json; charset=utf-8", @@ -435,6 +441,106 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None: return loc +def _vorlagen_exportieren(db: Session) -> list[dict]: + """Etiketten-Vorlagen fuer die Sicherung. + + Kategorien als PFAD und eigene Felder als LABEL – IDs sind zwischen zwei + Instanzen nicht dieselben, genau wie bei Gruppen und Artikeln. + + Die hinterlegte .lbx kommt als Base64 mit. Das ist das erste Binaerformat in + dieser Sicherung; vertretbar, weil eine .lbx wenige Kilobyte gross ist und + eine Vorlage ohne ihre Datei nur eine halbe Vorlage waere. + """ + zeilen: list[dict] = [] + for v in db.query(EtikettenVorlage).order_by(EtikettenVorlage.position).all(): + konf = konfiguration_lesen(v) + spalten: list[str] = [] + for schluessel in konf["spalten"]: + if schluessel.startswith(FELD_PRAEFIX): + rest = schluessel[len(FELD_PRAEFIX):] + fd = db.get(FieldDefinition, int(rest)) if rest.isdigit() else None + # Kategoriepfad mit dazu: Zwei Kategorien duerfen dasselbe Label + # fuehren, erst beides zusammen ist eindeutig. + spalten.append( + f"{FELD_PRAEFIX}{_category_path(db, db.get(Category, fd.category_id))}" + f"::{fd.label}" if fd else schluessel + ) + else: + spalten.append(schluessel) + zeilen.append({ + "name": v.name, + "art": v.art, + "position": v.position, + "spalten": spalten, + "kategorien": [ + _category_path(db, db.get(Category, kid)) for kid in konf["kategorie_ids"] + ], + "trennzeichen": konf["trennzeichen"], + "datei_name": v.datei_name, + "datei_base64": base64.b64encode(v.datei).decode() if v.datei else None, + }) + return zeilen + + +def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int: + """Vorlagen additiv einspielen – Bestehendes bleibt unberuehrt.""" + angelegt = 0 + pfad_zu_id: dict[str, int] = {} + for cat in db.query(Category).all(): + pfad_zu_id[_category_path(db, cat)] = cat.id + + for entry in zeilen: + try: + name = (entry.get("name") or "").strip() + if not name or db.query(EtikettenVorlage).filter( + EtikettenVorlage.name == name + ).first(): + continue + + spalten: list[str] = [] + for roh in entry.get("spalten", []): + if not str(roh).startswith(FELD_PRAEFIX): + spalten.append(str(roh)) + continue + rest = str(roh)[len(FELD_PRAEFIX):] + pfad, _, label = rest.rpartition("::") + kat_id = pfad_zu_id.get(pfad) + fd = ( + db.query(FieldDefinition) + .filter( + FieldDefinition.category_id == kat_id, + FieldDefinition.label == label, + ).first() + if kat_id else None + ) + # Gibt es das Feld hier nicht, faellt die Spalte weg statt die + # ganze Vorlage scheitern zu lassen. + if fd is not None: + spalten.append(f"{FELD_PRAEFIX}{fd.id}") + + kategorie_ids = [ + pfad_zu_id[p] for p in entry.get("kategorien", []) if p in pfad_zu_id + ] + roh_datei = entry.get("datei_base64") + db.add(EtikettenVorlage( + name=name, + art=entry.get("art") or "items", + position=int(entry.get("position") or 0), + konfiguration=json.dumps({ + "spalten": spalten, + "kategorie_ids": kategorie_ids, + "trennzeichen": entry.get("trennzeichen") or ",", + }, ensure_ascii=False), + datei_name=entry.get("datei_name"), + datei=base64.b64decode(roh_datei) if roh_datei else None, + )) + angelegt += 1 + except Exception as exc: + errors.append(f"Etiketten-Vorlage {entry.get('name')!r}: {exc}") + db.commit() + return angelegt + + def _category_path(db: Session, category: Category | None) -> str: """Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte. @@ -901,9 +1007,14 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict: except Exception as exc: errors.append(f"Produkt {entry.get('name')!r}: {exc}") + vorlagen_created = _vorlagen_importieren( + db, data.get("etiketten_vorlagen", []), errors + ) + return { "format": "json", "mode": mode, + "etiketten_vorlagen_created": vorlagen_created, "units_created": units_created, "products_created": len(created_products), "products_cleared": len(cleared), diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 554e2e2..535b450 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -878,6 +878,49 @@ class ItemOut(BaseModel): gehoert_zu: ItemKurz | None = None +# ---- Etiketten-Vorlagen ---- +class SpalteOut(BaseModel): + """Eine waehlbare Spalte – fest oder ein eigenes Feld.""" + + schluessel: str + titel: str + + +class EtikettenVorlageOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + art: str + position: int + # Aus dem JSON-Text der Konfiguration geholt, damit die Oberflaeche nicht + # selbst parsen muss. + spalten: list[str] = [] + kategorie_ids: list[int] = [] + trennzeichen: str = "," + # Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt. + datei_name: str | None = None + updated_at: datetime + + +class EtikettenVorlageCreate(BaseModel): + name: str = Field(min_length=1, max_length=80) + art: str = Field(default="items", pattern="^(items|products|locations)$") + spalten: list[str] = [] + kategorie_ids: list[int] = [] + trennzeichen: str = "," + + +class EtikettenVorlageUpdate(BaseModel): + """Nur mitgeschickte Felder zaehlen.""" + + name: str | None = Field(default=None, min_length=1, max_length=80) + art: str | None = Field(default=None, pattern="^(items|products|locations)$") + spalten: list[str] | None = None + kategorie_ids: list[int] | None = None + trennzeichen: str | None = None + position: int | None = None + + # ---- Views ---- class ShoppingNeed(BaseModel): """Fehlmenge als Gebinde-Leitangabe – bei zaehlbaren Packungen (Glas/Dose) diff --git a/backend/app/seed.py b/backend/app/seed.py index c1cb8d0..68762e0 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -5,7 +5,17 @@ import json from sqlalchemy.orm import Session from .config import get_settings -from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User +from .models import ( + Category, + CategoryTracking, + EtikettenVorlage, + FieldDefinition, + PackageType, + Role, + Unit, + UnitKind, + User, +) from .security import hash_password from .services.fields import slugify @@ -197,3 +207,33 @@ def ensure_first_admin(db: Session) -> None: ) db.add(admin) db.commit() + + +# Die zwei Vorlagen, die den frueheren festen Etikettenexport nachbilden: Wer +# nichts einstellt, bekommt genau dieselben Spalten wie bisher. +BUILTIN_ETIKETTEN_VORLAGEN = [ + ("Einzelstücke", "items", + ["qr", "uid", "produkt", "marke", "kategorie", "lagerort"]), + ("Lagerorte", "locations", ["qr", "id", "name", "pfad"]), +] + + +def ensure_builtin_etiketten_vorlagen(db: Session) -> None: + """Legt die mitgelieferten Etiketten-Vorlagen an – einmalig. + + Nur wenn noch GAR KEINE Vorlage existiert: Wer seine eigenen gebaut und die + mitgelieferten geloescht hat, soll sie nicht bei jedem Start zurueckbekommen. + """ + if db.query(EtikettenVorlage).first() is not None: + return + for position, (name, art, spalten) in enumerate(BUILTIN_ETIKETTEN_VORLAGEN): + db.add(EtikettenVorlage( + name=name, + art=art, + position=position, + konfiguration=json.dumps( + {"spalten": spalten, "kategorie_ids": [], "trennzeichen": ","}, + ensure_ascii=False, + ), + )) + db.commit() diff --git a/backend/app/services/etiketten.py b/backend/app/services/etiketten.py new file mode 100644 index 0000000..917b2f8 --- /dev/null +++ b/backend/app/services/etiketten.py @@ -0,0 +1,351 @@ +"""Etikettenexport: welche Spalten es gibt und wie daraus eine CSV wird. + +Frueher entstand die CSV in der Weboberflaeche, mit sechs festen Spalten. Mit +frei waehlbaren Spalten, eigenen Feldern und drei Bezugsarten gehoert das an +eine Stelle – und zwar hierher, wo es sich pruefen laesst: Fuer die Oberflaeche +gibt es keine Tests, fuer das Backend ueber dreihundert. + +Alles hier ist bewusst frei von HTTP: Eine Vorlage, eine Datenbank-Sitzung und +eine Basis-Adresse hinein, fertige CSV heraus. +""" + +from __future__ import annotations + +import csv +import io +import json +import re +from dataclasses import dataclass +from typing import Any, Callable + +from sqlalchemy.orm import Session + +from ..models import ( + Category, + EtikettenVorlage, + FieldDefinition, + Item, + Location, + Product, + ProductFieldValue, +) + +# Bezugsarten einer Vorlage. +ARTEN = ("items", "products", "locations") + +# Zellen, die damit beginnen, deutet Excel als Formel. Ein vorangestelltes +# Apostroph macht sie zu reinem Text. Uebernommen aus der fruaeheren +# Browser-Fassung (web/src/pages/Transfer.jsx). +_FORMEL_START = re.compile(r"^[=+\-@\t\r]") + +# Praefix eines selbst definierten Feldes. Die ID und nicht der key: FieldDefinition.key +# ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren. +FELD_PRAEFIX = "feld:" + + +@dataclass +class Kontext: + """Was die Spaltenfunktionen ausser der Zeile selbst brauchen.""" + + db: Session + # Oeffentliche Adresse der Oberflaeche – nur fuer Lagerort-QR noetig + # (…/l/). Einzelstuecke tragen vorrania://i/ ohne jede Adresse. + basis_url: str = "" + # Werte der eigenen Felder, vorab gesammelt: {product_id: {fd_id: wert}}. + feldwerte: dict[int, dict[int, str | None]] | None = None + + +def _kategorie_pfad(db: Session, category: Category | None) -> str: + """„Elektronik > Powerbanks" in einer Spalte.""" + if category is None: + return "" + teile = [category.name] + aktuell, tiefe = category, 0 + while aktuell.parent_id is not None and tiefe < 10: # Schutz vor einem Ring + aktuell = db.get(Category, aktuell.parent_id) + if aktuell is None: + break + teile.append(aktuell.name) + tiefe += 1 + return " > ".join(reversed(teile)) + + +def _lagerort_pfad(db: Session, ort: Location | None) -> str: + if ort is None: + return "" + teile = [ort.name] + aktuell, tiefe = ort, 0 + while aktuell.parent_id is not None and tiefe < 10: + aktuell = db.get(Location, aktuell.parent_id) + if aktuell is None: + break + teile.append(aktuell.name) + tiefe += 1 + return " > ".join(reversed(teile)) + + +# Signatur wie alle Spaltenfunktionen (Zeile, Kontext) – auch wenn der +# Kontext hier nicht gebraucht wird. +def _preis(item: Item, _kontext: "Kontext") -> str: + if item.price_cents is None: + return "" + return f"{item.price_cents / 100:.2f}" + + +def _offener_verleih(item: Item): + for loan in item.loans: + if loan.returned_on is None: + return loan + return None + + +# -------------------------------------------------------------------------- +# Spalten-Registry je Bezugsart: Schluessel -> (Titel, Funktion) +# -------------------------------------------------------------------------- +SPALTEN: dict[str, dict[str, tuple[str, Callable[[Any, Kontext], str]]]] = { + "items": { + "qr": ("QR-Inhalt", lambda i, k: f"vorrania://i/{i.uid}"), + "uid": ("UID", lambda i, k: i.uid), + "produkt": ("Produkt", lambda i, k: i.product.name if i.product else ""), + "marke": ("Marke", lambda i, k: (i.product.brand if i.product else "") or ""), + "kategorie": ( + "Kategorie", + lambda i, k: _kategorie_pfad(k.db, i.product.category if i.product else None), + ), + "lagerort": ("Lagerort", lambda i, k: i.location.name if i.location else ""), + "lagerort_pfad": ("Lagerort (Pfad)", lambda i, k: _lagerort_pfad(k.db, i.location)), + "gekauft_am": ("Gekauft am", lambda i, k: str(i.acquired_on or "")), + "garantie_bis": ("Garantie bis", lambda i, k: str(i.warranty_until or "")), + "gekauft_bei": ("Gekauft bei", lambda i, k: i.shop.name if i.shop else ""), + "kaufpreis": ("Kaufpreis", _preis), + "waehrung": ("Währung", lambda i, k: i.currency or ""), + "notiz": ("Notiz", lambda i, k: i.note or ""), + "verliehen_an": ( + "Verliehen an", + lambda i, k: (_offener_verleih(i).borrower if _offener_verleih(i) else ""), + ), + "gehoert_zu": ( + "Gehört zu", + lambda i, k: ( + f"{i.gehoert_zu.product.name if i.gehoert_zu.product else ''} " + f"({i.gehoert_zu.uid})".strip() + if i.gehoert_zu else "" + ), + ), + }, + "products": { + "name": ("Name", lambda p, k: p.name), + "marke": ("Marke", lambda p, k: p.brand or ""), + "kategorie": ("Kategorie", lambda p, k: _kategorie_pfad(k.db, p.category)), + "barcode": ("Barcode", lambda p, k: p.barcode or ""), + "gebinde": ("Gebinde", lambda p, k: p.package_label or ""), + "gebindegroesse": ( + "Gebindegröße", + lambda p, k: "" if p.package_size is None else _zahl(p.package_size), + ), + "mindestbestand": ( + "Mindestbestand", + lambda p, k: "" if p.min_stock is None else _zahl(p.min_stock), + ), + "gekauft_bei": ("Gekauft bei", lambda p, k: p.shop.name if p.shop else ""), + "produktlink": ("Produktlink", lambda p, k: p.product_url or ""), + }, + "locations": { + "qr": ("QR-Inhalt", lambda o, k: f"{k.basis_url.rstrip('/')}/l/{o.id}"), + "id": ("ID", lambda o, k: o.id), + "name": ("Lagerort", lambda o, k: o.name), + "pfad": ("Pfad", lambda o, k: _lagerort_pfad(k.db, o)), + }, +} + + +def _zahl(wert: float) -> str: + """Ganze Zahlen ohne „.0" – auf einem Etikett stoert das.""" + return str(int(wert)) if float(wert).is_integer() else str(wert) + + +def feld_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]: + """Die eigenen Felder, die als Spalten zur Wahl stehen. + + Eigene Felder haengen an Kategorien und damit am ARTIKEL – nicht am + Einzelstueck. Alle Stuecke eines Artikels teilen sich denselben Wert; fuer + ein Etikett ist genau das gewollt. + """ + if art == "locations": + return [] # Lagerorte haben keine eigenen Felder + frage = db.query(FieldDefinition) + if kategorie_ids: + # Ein Feld gilt auch fuer Unterkategorien; umgekehrt heisst das: alle + # Definitionen entlang der Vorfahren der gewaehlten Kategorien. + from ..routers.categories import descendant_ids + + erlaubt: set[int] = set() + for kid in kategorie_ids: + erlaubt |= descendant_ids(db, kid) + erlaubt |= {c.id for c in _vorfahren(db, kid)} + frage = frage.filter(FieldDefinition.category_id.in_(erlaubt or {-1})) + gesehen: set[int] = set() + spalten: list[dict] = [] + for fd in frage.order_by(FieldDefinition.position, FieldDefinition.id).all(): + if fd.id in gesehen: + continue + gesehen.add(fd.id) + titel = f"{fd.label} ({fd.unit})" if fd.unit else fd.label + spalten.append({"schluessel": f"{FELD_PRAEFIX}{fd.id}", "titel": titel}) + return spalten + + +def _vorfahren(db: Session, category_id: int) -> list[Category]: + kette: list[Category] = [] + aktuell = db.get(Category, category_id) + gesehen: set[int] = set() + while aktuell is not None and aktuell.id not in gesehen: + gesehen.add(aktuell.id) + kette.append(aktuell) + aktuell = db.get(Category, aktuell.parent_id) if aktuell.parent_id else None + return kette + + +def verfuegbare_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]: + """Feste Spalten der Bezugsart plus die passenden eigenen Felder.""" + fest = [ + {"schluessel": s, "titel": titel} + for s, (titel, _) in SPALTEN.get(art, {}).items() + ] + return fest + feld_spalten(db, art, kategorie_ids) + + +# -------------------------------------------------------------------------- +# Konfiguration +# -------------------------------------------------------------------------- +def konfiguration_lesen(vorlage: EtikettenVorlage) -> dict: + """JSON-Text zu einem Woerterbuch – kaputter Inhalt ergibt Vorgaben.""" + try: + roh = json.loads(vorlage.konfiguration or "{}") + except (ValueError, TypeError): + roh = {} + if not isinstance(roh, dict): + roh = {} + spalten = [str(s) for s in roh.get("spalten", []) if isinstance(s, (str, int))] + kategorie_ids = [int(k) for k in roh.get("kategorie_ids", []) if str(k).lstrip("-").isdigit()] + trenn = roh.get("trennzeichen") or "," + if trenn not in (",", ";", "\t"): + trenn = "," + return {"spalten": spalten, "kategorie_ids": kategorie_ids, "trennzeichen": trenn} + + +def _zeilen(db: Session, art: str, kategorie_ids: list[int]) -> list: + if art == "locations": + return db.query(Location).order_by(Location.name).all() + + from ..routers.categories import descendant_ids + + ids: set[int] | None = None + if kategorie_ids: + ids = set() + for kid in kategorie_ids: + ids |= descendant_ids(db, kid) + + if art == "products": + frage = db.query(Product) + if ids is not None: + frage = frage.filter(Product.category_id.in_(ids or {-1})) + return frage.order_by(Product.name).all() + + frage = db.query(Item) + if ids is not None: + frage = frage.join(Product, Item.product_id == Product.id).filter( + Product.category_id.in_(ids or {-1}) + ) + return frage.order_by(Item.id).all() + + +def _feldwerte(db: Session, art: str, zeilen: list) -> dict[int, dict[int, str | None]]: + """Alle Feldwerte der beteiligten Artikel in EINER Abfrage. + + Ohne das fragt jede Zeile einzeln nach – bei ein paar hundert Etiketten + ist das der Unterschied zwischen einer Abfrage und Hunderten. + """ + if art == "locations": + return {} + produkt_ids = { + (z.product_id if art == "items" else z.id) for z in zeilen + } + if not produkt_ids: + return {} + werte: dict[int, dict[int, str | None]] = {} + for pfv in ( + db.query(ProductFieldValue) + .filter(ProductFieldValue.product_id.in_(produkt_ids)) + .all() + ): + werte.setdefault(pfv.product_id, {})[pfv.field_definition_id] = pfv.value + return werte + + +def _zelle(schluessel: str, zeile, art: str, kontext: Kontext) -> str | None: + """Wert einer Spalte – None, wenn es die Spalte nicht (mehr) gibt.""" + if schluessel.startswith(FELD_PRAEFIX): + rest = schluessel[len(FELD_PRAEFIX):] + if not rest.isdigit(): + return None + fd_id = int(rest) + if kontext.db.get(FieldDefinition, fd_id) is None: + return None # Feld wurde geloescht – Spalte faellt still weg + produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None) + return (kontext.feldwerte or {}).get(produkt_id, {}).get(fd_id) or "" + eintrag = SPALTEN.get(art, {}).get(schluessel) + if eintrag is None: + return None + return eintrag[1](zeile, kontext) + + +def _titel(schluessel: str, art: str, db: Session) -> str | None: + if schluessel.startswith(FELD_PRAEFIX): + rest = schluessel[len(FELD_PRAEFIX):] + if not rest.isdigit(): + return None + fd = db.get(FieldDefinition, int(rest)) + if fd is None: + return None + return f"{fd.label} ({fd.unit})" if fd.unit else fd.label + eintrag = SPALTEN.get(art, {}).get(schluessel) + return eintrag[0] if eintrag else None + + +def _schuetzen(wert: str) -> str: + """Formel-Injektion verhindern (=, +, -, @ am Zellenanfang).""" + text = "" if wert is None else str(wert) + return f"'{text}" if _FORMEL_START.match(text) else text + + +def csv_erzeugen(db: Session, vorlage: EtikettenVorlage, basis_url: str = "") -> str: + """Die fertige CSV zu einer Vorlage. + + Mit BOM und CRLF, damit Excel und P-touch die Umlaute richtig lesen. + Unbekannte Spalten (geloeschtes Feld, geaenderte Bezugsart) fallen still + weg, statt den Export zu zerreissen – so wie die Tabellen in der + Oberflaeche verschwundene Spalten aussortieren. + """ + art = vorlage.art if vorlage.art in ARTEN else "items" + konf = konfiguration_lesen(vorlage) + + gueltig = [s for s in konf["spalten"] if _titel(s, art, db) is not None] + if not gueltig: + # Lieber die festen Spalten der Bezugsart als eine leere Datei. + gueltig = list(SPALTEN.get(art, {}).keys()) + + zeilen = _zeilen(db, art, konf["kategorie_ids"]) + kontext = Kontext(db=db, basis_url=basis_url, feldwerte=_feldwerte(db, art, zeilen)) + + puffer = io.StringIO() + schreiber = csv.writer( + puffer, delimiter=konf["trennzeichen"], lineterminator="\r\n", + quoting=csv.QUOTE_MINIMAL, + ) + schreiber.writerow([_schuetzen(_titel(s, art, db) or s) for s in gueltig]) + for zeile in zeilen: + schreiber.writerow( + [_schuetzen(_zelle(s, zeile, art, kontext) or "") for s in gueltig] + ) + return "" + puffer.getvalue() diff --git a/backend/tests/test_backup_gruppen.py b/backend/tests/test_backup_gruppen.py index 241fc75..055c430 100644 --- a/backend/tests/test_backup_gruppen.py +++ b/backend/tests/test_backup_gruppen.py @@ -60,7 +60,8 @@ def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch): db.commit() daten = json.loads(_sicherung(db, user)) - assert daten["version"] == 3 + # v4 seit den Etiketten-Vorlagen; aeltere Sicherungen bleiben lesbar. + assert daten["version"] == 4 nach_name = {g["name"]: g for g in daten["groups"]} assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"] assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"] diff --git a/backend/tests/test_etiketten.py b/backend/tests/test_etiketten.py new file mode 100644 index 0000000..7309abe --- /dev/null +++ b/backend/tests/test_etiketten.py @@ -0,0 +1,353 @@ +"""Etiketten-Vorlagen: Spaltenwahl, eigene Felder, CSV-Form und .lbx-Prüfung. + +Der Export ist eine reine Funktion über einer Datenbank-Sitzung – geprüft wird +deshalb ohne HTTP, direkt am Ergebnis. +""" + +import io +import json +import zipfile + +import pytest +from fastapi import HTTPException + +from app.models import ( + Category, + CategoryTracking, + EtikettenVorlage, + FieldDefinition, + Item, + Location, + Product, + ProductFieldValue, + Role, + User, +) +from app.routers.etiketten import _pruefe_lbx, spalten +from app.services.etiketten import csv_erzeugen, konfiguration_lesen + + +@pytest.fixture() +def admin(db): + person = User(username="admin", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@pytest.fixture() +def kabel(db): + """Kategorie „Kabel" mit eigenem Feld „Länge" in Metern.""" + cat = Category(name="Kabel", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + fd = FieldDefinition( + category_id=cat.id, label="Länge", key="laenge", field_type="number", unit="m" + ) + db.add(fd) + db.commit() + db.refresh(cat) + db.refresh(fd) + return cat, fd + + +def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=","): + v = EtikettenVorlage( + name=f"Test {len(db.query(EtikettenVorlage).all())}", + art=art, + konfiguration=json.dumps({ + "spalten": spalten, + "kategorie_ids": kategorie_ids or [], + "trennzeichen": trenn, + }), + ) + db.add(v) + db.commit() + db.refresh(v) + return v + + +def _stueck(db, cat, name="HDMI-Kabel", laenge=None, fd=None, ort=None): + product = Product(name=name, category_id=cat.id, individual=True, package_size=1) + db.add(product) + db.flush() + if laenge is not None and fd is not None: + db.add(ProductFieldValue( + product_id=product.id, field_definition_id=fd.id, value=laenge + )) + item = Item(uid=f"UID{product.id:05d}", product_id=product.id, + location_id=(ort.id if ort else None)) + db.add(item) + db.commit() + db.refresh(item) + return item + + +def _zeilen(csv_text): + """Ohne BOM, als Liste von Zeilen.""" + assert csv_text.startswith(""), "BOM fehlt – Excel zeigt sonst kaputte Umlaute" + return csv_text[1:].rstrip("\r\n").split("\r\n") + + +# -------------------------------------------------------------------------- +# Spalten und Reihenfolge +# -------------------------------------------------------------------------- +def test_spalten_kommen_in_gespeicherter_reihenfolge(db, kabel): + cat, _ = kabel + _stueck(db, cat) + v = _vorlage(db, ["produkt", "uid", "qr"]) + + kopf = _zeilen(csv_erzeugen(db, v))[0] + assert kopf == "Produkt,UID,QR-Inhalt" + + +def test_eigenes_feld_landet_in_der_csv(db, kabel): + cat, fd = kabel + _stueck(db, cat, laenge="3", fd=fd) + v = _vorlage(db, ["uid", f"feld:{fd.id}"]) + + kopf, zeile = _zeilen(csv_erzeugen(db, v)) + assert kopf == "UID,Länge (m)" + assert zeile.endswith(",3") + + +def test_fehlender_feldwert_gibt_eine_leere_zelle(db, kabel): + """Nicht etwa eine Luecke – sonst verrutschen alle folgenden Spalten.""" + cat, fd = kabel + _stueck(db, cat) # ohne Wert + v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"]) + + zeile = _zeilen(csv_erzeugen(db, v))[1] + assert zeile.count(",") == 2 + assert ",," in zeile + + +def test_geloeschtes_feld_zerreisst_den_export_nicht(db, kabel): + cat, fd = kabel + _stueck(db, cat) + v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"]) + db.delete(fd) + db.commit() + + kopf = _zeilen(csv_erzeugen(db, v))[0] + assert kopf == "UID,Produkt" # die tote Spalte faellt still weg + + +def test_ohne_gueltige_spalte_greifen_die_festen(db, kabel): + cat, _ = kabel + _stueck(db, cat) + v = _vorlage(db, ["gibtsnicht"]) + + kopf = _zeilen(csv_erzeugen(db, v))[0] + assert kopf.startswith("QR-Inhalt,UID,Produkt") + + +# -------------------------------------------------------------------------- +# CSV-Form +# -------------------------------------------------------------------------- +def test_trennzeichen_semikolon_und_tab(db, kabel): + cat, _ = kabel + _stueck(db, cat) + assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn=";")))[0] \ + == "UID;Produkt" + assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn="\t")))[0] \ + == "UID\tProdukt" + + +def test_zelle_mit_trennzeichen_wird_eingefasst(db, kabel): + cat, _ = kabel + _stueck(db, cat, name="Kabel, lang") + v = _vorlage(db, ["produkt"]) + + assert _zeilen(csv_erzeugen(db, v))[1] == '"Kabel, lang"' + + +def test_formel_wird_entschaerft(db, kabel): + """Ohne das fuehrt Excel den Zellinhalt als Formel aus.""" + cat, _ = kabel + _stueck(db, cat, name="=1+1") + v = _vorlage(db, ["produkt"]) + + assert _zeilen(csv_erzeugen(db, v))[1] == "'=1+1" + + +def test_zeilenende_ist_crlf(db, kabel): + cat, _ = kabel + _stueck(db, cat) + assert "\r\n" in csv_erzeugen(db, _vorlage(db, ["uid"])) + + +# -------------------------------------------------------------------------- +# Bezugsarten und Filter +# -------------------------------------------------------------------------- +def test_kategoriefilter_greift_samt_unterkategorien(db, kabel): + cat, _ = kabel + unter = Category(name="HDMI", tracking=CategoryTracking.object.value, parent_id=cat.id) + andere = Category(name="Werkzeug", tracking=CategoryTracking.object.value) + db.add_all([unter, andere]) + db.commit() + _stueck(db, unter, name="HDMI 2.1") + _stueck(db, andere, name="Hammer") + + v = _vorlage(db, ["produkt"], kategorie_ids=[cat.id]) + zeilen = _zeilen(csv_erzeugen(db, v)) + assert zeilen[1:] == ["HDMI 2.1"] + + +def test_lagerorte_brauchen_die_basis_adresse_fuer_den_qr(db): + ort = Location(name="Keller") + db.add(ort) + db.commit() + v = _vorlage(db, ["qr", "name"], art="locations") + + zeile = _zeilen(csv_erzeugen(db, v, basis_url="https://haus.example/"))[1] + assert zeile.startswith(f"https://haus.example/l/{ort.id},") + + +def test_artikel_als_bezugsart(db, kabel): + cat, fd = kabel + _stueck(db, cat, name="USB-C", laenge="1", fd=fd) + v = _vorlage(db, ["name", f"feld:{fd.id}"], art="products") + + kopf, zeile = _zeilen(csv_erzeugen(db, v)) + assert kopf == "Name,Länge (m)" + assert zeile == "USB-C,1" + + +# -------------------------------------------------------------------------- +# Konfiguration lesen +# -------------------------------------------------------------------------- +def test_kaputte_konfiguration_ergibt_vorgaben(db): + v = EtikettenVorlage(name="Kaputt", art="items", konfiguration="{nicht json") + db.add(v) + db.commit() + + konf = konfiguration_lesen(v) + assert konf == {"spalten": [], "kategorie_ids": [], "trennzeichen": ","} + + +def test_unerlaubtes_trennzeichen_faellt_auf_komma_zurueck(db): + v = _vorlage(db, ["uid"], trenn="|") + assert konfiguration_lesen(v)["trennzeichen"] == "," + + +# -------------------------------------------------------------------------- +# Spaltenliste für die Oberfläche +# -------------------------------------------------------------------------- +def test_spaltenliste_nennt_eigene_felder_der_kategorie(db, admin, kabel): + cat, fd = kabel + liste = spalten(art="items", kategorie_ids=str(cat.id), db=db, _=admin) + schluessel = {s.schluessel for s in liste} + + assert "qr" in schluessel and "uid" in schluessel + assert f"feld:{fd.id}" in schluessel + assert next(s.titel for s in liste if s.schluessel == f"feld:{fd.id}") == "Länge (m)" + + +def test_lagerorte_haben_keine_eigenen_felder(db, admin, kabel): + liste = spalten(art="locations", kategorie_ids=None, db=db, _=admin) + assert not any(s.schluessel.startswith("feld:") for s in liste) + + +def test_unbekannte_bezugsart_wird_abgelehnt(db, admin): + with pytest.raises(HTTPException) as fehler: + spalten(art="quatsch", kategorie_ids=None, db=db, _=admin) + assert fehler.value.status_code == 400 + + +# -------------------------------------------------------------------------- +# .lbx-Prüfung +# -------------------------------------------------------------------------- +def _zip(namen): + puffer = io.BytesIO() + with zipfile.ZipFile(puffer, "w") as z: + for n in namen: + z.writestr(n, "") + return puffer.getvalue() + + +def test_echte_lbx_wird_angenommen(db): + _pruefe_lbx("etikett.lbx", _zip(["label.xml", "prop.xml"])) # wirft nicht + + +def test_zip_ohne_label_xml_wird_abgelehnt(db): + with pytest.raises(HTTPException) as fehler: + _pruefe_lbx("etikett.lbx", _zip(["irgendwas.txt"])) + assert fehler.value.status_code == 415 + + +def test_nicht_zip_wird_abgelehnt(db): + with pytest.raises(HTTPException) as fehler: + _pruefe_lbx("etikett.lbx", b"\x89PNG\r\n\x1a\n und noch was") + assert fehler.value.status_code == 415 + + +def test_falsche_endung_wird_abgelehnt(db): + with pytest.raises(HTTPException) as fehler: + _pruefe_lbx("bild.png", _zip(["label.xml"])) + assert fehler.value.status_code == 415 + + +def test_leere_datei_wird_abgelehnt(db): + with pytest.raises(HTTPException) as fehler: + _pruefe_lbx("etikett.lbx", b"") + assert fehler.value.status_code == 400 + + +# -------------------------------------------------------------------------- +# Backup-Umlauf +# -------------------------------------------------------------------------- +def test_vorlage_uebersteht_die_sicherung(db, admin, kabel): + """Kategorien als Pfad, eigene Felder als Label – IDs sind zwischen zwei + Instanzen nicht dieselben.""" + import json as _json + + from app.routers.transfer import _import_json, export_backup_json + from app.seed import ensure_builtin_units + + cat, fd = kabel + _stueck(db, cat, laenge="5", fd=fd) + v = _vorlage(db, ["uid", f"feld:{fd.id}"], kategorie_ids=[cat.id], trenn=";") + v.name = "Kabelexport" + v.datei_name = "kabel.lbx" + v.datei = _zip(["label.xml"]) + db.commit() + + inhalt = export_backup_json(db=db, _=admin).body + daten = _json.loads(inhalt) + gesichert = daten["etiketten_vorlagen"] + assert [e["name"] for e in gesichert] == ["Kabelexport"] + # Das Feld steht als Pfad::Label drin, nicht als ID. + assert gesichert[0]["spalten"][1] == "feld:Kabel::Länge" + + # In eine zweite, leere Instanz einspielen. + from app.database import Base + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + from sqlalchemy.pool import StaticPool + + zweiter_motor = create_engine( + "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool + ) + Base.metadata.create_all(bind=zweiter_motor) + zweite = sessionmaker(bind=zweiter_motor)() + ensure_builtin_units(zweite) + person = User(username="admin", password_hash="x", role=Role.admin) + zweite.add(person) + zweite.commit() + + _import_json(zweite, inhalt, person, "add") + + neu = zweite.query(EtikettenVorlage).filter( + EtikettenVorlage.name == "Kabelexport" + ).one() + konf = konfiguration_lesen(neu) + assert konf["trennzeichen"] == ";" + assert neu.datei_name == "kabel.lbx" + assert neu.datei == _zip(["label.xml"]) + # Das Feld wurde ueber Pfad und Label wiedergefunden – mit NEUER ID. + feld_spalte = [s for s in konf["spalten"] if s.startswith("feld:")][0] + neue_fd = zweite.query(FieldDefinition).filter(FieldDefinition.label == "Länge").one() + assert feld_spalte == f"feld:{neue_fd.id}" + zweite.close() diff --git a/web/src/api.js b/web/src/api.js index 1dbbf58..dbe7e96 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -359,6 +359,36 @@ export const api = { exportJson: () => downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`), // Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu. + // ---- Etiketten-Vorlagen ---- + etikettenVorlagen: () => request("/etiketten/vorlagen"), + etikettenSpalten: (art, categoryIds) => { + const p = new URLSearchParams({ art }); + if (categoryIds && categoryIds.length) p.set("kategorie_ids", categoryIds.join(",")); + return request(`/etiketten/spalten?${p}`); + }, + etikettenVorlageAnlegen: (body) => + request("/etiketten/vorlagen", { method: "POST", body }), + etikettenVorlageAendern: (id, body) => + request(`/etiketten/vorlagen/${id}`, { method: "PATCH", body }), + etikettenVorlageLoeschen: (id) => + request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }), + // Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/); + // nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist. + etikettenCsv: (id, name) => + downloadFile( + `/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`, + `${name || "etiketten"}.csv`, + ), + etikettenDateiHochladen: (id, file) => { + const fd = new FormData(); + fd.append("file", file); + return request(`/etiketten/vorlagen/${id}/datei`, { method: "POST", formData: fd }); + }, + etikettenDatei: (id, name) => + downloadFile(`/etiketten/vorlagen/${id}/datei`, name || "vorlage.lbx"), + etikettenDateiEntfernen: (id) => + request(`/etiketten/vorlagen/${id}/datei`, { method: "DELETE" }), + labelRows: (categoryIds) => request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`), importStock: (file, mode = "add") => { diff --git a/web/src/components/EtikettenVorlagen.jsx b/web/src/components/EtikettenVorlagen.jsx new file mode 100644 index 0000000..79bf645 --- /dev/null +++ b/web/src/components/EtikettenVorlagen.jsx @@ -0,0 +1,325 @@ +import { useCallback, useEffect, useState } from "react"; +import { api } from "../api"; +import Icon from "./Icon"; +import { useConfirm } from "../confirm"; +import { useToast } from "../toast"; +import { asTree } from "../categoryTree"; + +/** + * Gespeicherte Zusammenstellungen für den Etikettenexport. + * + * Ersetzt die beiden früheren Abschnitte mit festen Spalten. Der Gewinn sind + * nicht die Vorlagen an sich, sondern die frei wählbaren Spalten: Erst dadurch + * kommen eigene Felder wie eine Kabellänge oder die Kapazität einer Powerbank + * überhaupt aufs Etikett. + * + * Die CSV baut der Server – dort liegen die Feldwerte, und dort lässt sich das + * Ergebnis prüfen. + */ + +const ARTEN = [ + { wert: "items", label: "Einzelstücke", hinweis: "Ein Etikett je Stück, mit UID und QR." }, + { wert: "products", label: "Artikel", hinweis: "Ein Etikett je Artikel statt je Stück." }, + { wert: "locations", label: "Lagerorte", hinweis: "Ein Etikett je Lagerort, mit dessen QR." }, +]; + +const TRENNZEICHEN = [ + { wert: ",", label: "Komma" }, + { wert: ";", label: "Semikolon" }, + { wert: "\t", label: "Tabulator" }, +]; + +export default function EtikettenVorlagen({ onError }) { + const confirm = useConfirm(); + const toast = useToast(); + const [vorlagen, setVorlagen] = useState(null); + const [offen, setOffen] = useState(null); // id der bearbeiteten Vorlage + const [busy, setBusy] = useState(false); + + const laden = useCallback(async () => { + try { setVorlagen(await api.etikettenVorlagen()); } + catch (err) { onError(err.message); setVorlagen([]); } + }, [onError]); + + useEffect(() => { laden(); }, [laden]); + + async function anlegen() { + const name = await confirm({ + title: "Neue Vorlage", + message: "Wie soll sie heißen? Zum Beispiel „Kabelexport“.", + confirmLabel: "Anlegen", + prompt: true, + }); + if (!name || typeof name !== "string") return; + setBusy(true); + try { + const neu = await api.etikettenVorlageAnlegen({ + name: name.trim(), + art: "items", + spalten: ["qr", "uid", "produkt"], + kategorie_ids: [], + trennzeichen: ",", + }); + await laden(); + setOffen(neu.id); + toast(`Vorlage „${neu.name}“ angelegt.`); + } catch (err) { onError(err.message); } finally { setBusy(false); } + } + + async function loeschen(v) { + const ok = await confirm({ + title: `Vorlage „${v.name}“ löschen?`, + message: "Die hinterlegte P-touch-Datei geht mit verloren.", + confirmLabel: "Löschen", + danger: true, + }); + if (!ok) return; + try { + await api.etikettenVorlageLoeschen(v.id); + if (offen === v.id) setOffen(null); + await laden(); + } catch (err) { onError(err.message); } + } + + async function csv(v) { + setBusy(true); + try { await api.etikettenCsv(v.id, v.name); } + catch (err) { onError(err.message); } finally { setBusy(false); } + } + + return ( +
+
+

Etiketten-Vorlagen

+ +
+

+ Eine Vorlage hält fest, worauf sie sich bezieht, welche Spalten in welcher + Reihenfolge exportiert werden und wie gefiltert wird – samt eigener Felder. + Dazu lässt sich die P-touch-Datei (.lbx) hinterlegen. +

+ + {vorlagen == null &&
Lädt…
} + {vorlagen && vorlagen.length === 0 && ( +

Noch keine Vorlage.

+ )} + +
    + {(vorlagen || []).map((v) => ( +
  • +
    + + {v.name}{" "} + + {ARTEN.find((a) => a.wert === v.art)?.label || v.art} + {" · "}{v.spalten.length} Spalten + {v.datei_name ? " · mit Vorlage" : ""} + + + + {v.datei_name && ( + + )} + + +
    + {offen === v.id && ( + + )} +
  • + ))} +
+
+ ); +} + +/** Bearbeiten einer einzelnen Vorlage. */ +function VorlageEditor({ vorlage, onGespeichert, onError }) { + const toast = useToast(); + const [art, setArt] = useState(vorlage.art); + const [spalten, setSpalten] = useState(vorlage.spalten); + const [katIds, setKatIds] = useState(vorlage.kategorie_ids); + const [trenn, setTrenn] = useState(vorlage.trennzeichen); + const [moeglich, setMoeglich] = useState([]); + const [kategorien, setKategorien] = useState([]); + const [busy, setBusy] = useState(false); + + useEffect(() => { + api.listCategories().then(setKategorien).catch(() => {}); + }, []); + + // Welche Spalten es gibt, hängt von Bezugsart UND Kategorien ab – die eigenen + // Felder kommen schließlich von dort. + useEffect(() => { + let aktiv = true; + api.etikettenSpalten(art, katIds) + .then((s) => { if (aktiv) setMoeglich(s); }) + .catch((err) => onError(err.message)); + return () => { aktiv = false; }; + }, [art, katIds, onError]); + + const titelVon = (schluessel) => + moeglich.find((m) => m.schluessel === schluessel)?.titel || schluessel; + const offen = moeglich.filter((m) => !spalten.includes(m.schluessel)); + + function verschieben(index, richtung) { + const ziel = index + richtung; + if (ziel < 0 || ziel >= spalten.length) return; + const kopie = [...spalten]; + [kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]]; + setSpalten(kopie); + } + + async function speichern() { + setBusy(true); + try { + await api.etikettenVorlageAendern(vorlage.id, { + art, spalten, kategorie_ids: katIds, trennzeichen: trenn, + }); + await onGespeichert(); + toast("Vorlage gespeichert."); + } catch (err) { onError(err.message); } finally { setBusy(false); } + } + + async function dateiWaehlen(e) { + const datei = e.target.files?.[0]; + e.target.value = ""; // damit dieselbe Datei erneut gewählt werden kann + if (!datei) return; + setBusy(true); + try { + await api.etikettenDateiHochladen(vorlage.id, datei); + await onGespeichert(); + toast("P-touch-Vorlage hinterlegt."); + } catch (err) { onError(err.message); } finally { setBusy(false); } + } + + // Einzelstuecke gibt es nur bei Gegenstaenden; Artikel kann es in beiden + // Welten geben, dort also nicht einschraenken. + const baum = asTree(kategorien).filter( + (c) => art === "products" || c.tracking === "object", + ); + + return ( +
+
+ + +
+

{ARTEN.find((a) => a.wert === art)?.hinweis}

+ + {art !== "locations" && ( + <> + Kategorien (leer = alle) +
+ {baum.map((c) => ( + + ))} +
+

+ Eigene Felder hängen am Artikel, nicht am einzelnen Stück – alle Stücke + eines Artikels tragen denselben Wert. +

+ + )} + +
+
+ Spalten (in dieser Reihenfolge) + {spalten.length === 0 && ( +

Noch keine gewählt – es greifen dann die Standardspalten.

+ )} +
    + {spalten.map((s, i) => ( +
  • + {titelVon(s)} + + + +
  • + ))} +
+
+
+ Verfügbar + {offen.length === 0 &&

Alle Spalten sind gewählt.

} +
    + {offen.map((m) => ( +
  • + +
  • + ))} +
+
+
+ +
+ + + {vorlage.datei_name && ( + + )} +
+

+ Eine .lbx merkt sich den Pfad zu ihrer Datenquelle. Auf einem fremden + Rechner muss die CSV in P-touch einmal neu verknüpft werden – die Datei + dabeizuhaben erspart trotzdem das Suchen. +

+
+ ); +} diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index 67f347d..463dacf 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -3,6 +3,7 @@ import { api } from "../api"; import { useConfirm } from "../confirm"; import Icon from "../components/Icon"; import { asTree } from "../categoryTree"; +import EtikettenVorlagen from "../components/EtikettenVorlagen"; import { itemDeepLink } from "../qr"; // Baut die Etiketten-CSV mit wählbarem Trennzeichen (BOM für Excel-Umlaute). @@ -362,83 +363,7 @@ export default function Transfer() { {mdInfo &&

{mdInfo}

} -
-

QR-Etiketten-Export (P-touch & Co.)

-

- Je Einzelstück eine Zeile: QR-Inhalt, UID, Produkt, Marke, Kategorie, - Lagerort. In der Etiketten-Software (z.B. P-touch) als Datenbank{" "} - verknüpfen – sie erzeugt QR-Code und Text automatisch. Ohne Auswahl kommen alle - Einzelstücke. Wählst du eine Kategorie, kommen ihre Unterkategorien - automatisch mit – sie erscheinen unten als „enthalten". -

- -
- {objTree.map((c) => { - const enthalten = abgedecktVon(c.id); - const direkt = selCats.has(c.id); - return ( - - ); - })} - {objTree.length === 0 && ( - Noch keine Gegenstands-Kategorien. - )} -
-
- - {selCats.size > 0 && ( - - )} -
- {labelInfo &&

{labelInfo}

} -
- -
-

Lagerort-Etiketten-Export (P-touch & Co.)

-

- Je Lagerort eine Zeile: QR-Inhalt, ID, Lagerort und der{" "} - Pfad (z.B. „Hedingen → Keller"). In der Etiketten-Software als{" "} - Datenbank verknüpfen. Verwendet dasselbe Trennzeichen wie oben. - Ein gescannter Lagerort-QR öffnet den Ort in der App/Weboberfläche. -

-
- - - {locations.length} Lagerort{locations.length === 1 ? "" : "e"} - -
- {locInfo &&

{locInfo}

} -
+

CSV-Aufbau