Files
Vorrania/backend/app/services/etiketten.py
T
ScarriffleandClaude Opus 5 dd57d3356a Eigene Felder: Spalten in der Artikelliste und Export nach Feldwerten
Eigene Felder waren bisher nur im Artikelformular zu sehen. Zwei Luecken
daraus: In den Listen tauchten sie gar nicht auf, und es gab keinen Weg,
gezielt nach einem Feldwert zu exportieren.

SPALTEN. Je eigenem Feld eine Spalte in der Artikelliste, Schluessel
feld:<id> wie im Etikettenexport (die ID und nicht key, der ist nur je
Kategorie eindeutig). Reine Oberflaechenarbeit: GET /products liefert die
Werte laengst mit, Products.jsx warf sie nur weg. Filtern, Sortieren,
Ein-/Ausblenden und das Merken erbt die Spalte von DataTable.

Ja/Nein erscheint als Haken, liefert zum Filtern aber "Ja"/"Nein" - und
"Nein" auch ohne gespeicherten Wert: Wer den Haken wegnimmt, loescht die
Zeile, "nein" und "nie gesetzt" sind in den Daten dasselbe. Zahlen sortieren
als Zahl, sonst stuende "10" vor "9".

DataTable lernt dafuer defaultHidden: Alle Feldspalten starten verborgen und
sind erst auf Wunsch da - sonst hinge die Liste je nach Haushalt voller
Spalten, die fuer die meisten Zeilen gar nicht gelten.

Dabei einen vorhandenen Fehler gefunden: urlState las die Sortierung mit
split(":") und nahm das erste Stueck als Spalte. Bei "feld:12:asc" kam
dadurch "feld" heraus, und die Sortierung fiel beim Neuladen still weg. Jetzt
am LETZTEN Doppelpunkt getrennt.

EXPORT. Die Etikettenvorlagen bekommen einen Wertefilter: feldfilter in der
Konfiguration, mehrere Bedingungen UND-verknuepft, genau verglichen ohne
Ruecksicht auf Gross-/Kleinschreibung, leerer Wert heisst "ohne Wert".
Gefiltert wird in Python ueber die ohnehin geladenen Feldwerte - auf value
liegt kein Index, und "fehlende Zeile zaehlt als leer" braeuchte in SQL je
Bedingung ein NOT EXISTS.

Der Filter gilt auch fuer Einzelstuecke; der Wert haengt am Artikel des
Stuecks. Ein geloeschtes Feld laesst seine Bedingung wegfallen, genau wie
eine Spalte zu einem geloeschten Feld still verschwindet.

Die Sicherung schreibt den Filter wie die Spalten auf Kategorie::Label um -
ohne das zeigte er nach dem Zurueckspielen auf ein fremdes Feld, und der
Export gaebe mehr Zeilen aus als das Original. Dafuer ist _konfiguration aus
dem Router als konfiguration_schreiben in den Dienst gezogen: Sonst haetten
DREI Stellen denselben JSON-Text gebaut.

Zehn neue Tests, 540 gruen. Keine Schemaaenderung - konfiguration ist freies
JSON, alte Vorlagen lesen sich als leerer Filter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:02:53 +02:00

443 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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,
FieldType,
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/<id>). Einzelstuecke tragen vorrania://i/<UID> 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 = ","
# Kaputte Eintraege fallen einzeln weg, nicht der ganze Filter - sonst
# entschiede ein Tippfehler darueber, ob ein Export alles ausgibt.
feldfilter = []
for eintrag in roh.get("feldfilter", []) if isinstance(roh.get("feldfilter"), list) else []:
if not isinstance(eintrag, dict):
continue
fid = eintrag.get("feld_id")
if not str(fid).lstrip("-").isdigit():
continue
wert = eintrag.get("wert", "")
feldfilter.append({"feld_id": int(fid), "wert": str(wert) if wert is not None else ""})
return {
"spalten": spalten,
"kategorie_ids": kategorie_ids,
"trennzeichen": trenn,
"feldfilter": feldfilter,
}
def konfiguration_schreiben(
spalten: list[str],
kategorie_ids: list[int],
trennzeichen: str,
feldfilter: list[dict] | None = None,
) -> str:
"""Gegenstueck zu konfiguration_lesen.
Lag frueher als _konfiguration im Router. Hierher gezogen, weil sonst DREI
Stellen denselben JSON-Text bauen (POST, PATCH und der Sicherungs-Import)
und ein neuer Schluessel an jeder einzeln nachgetragen werden muesste.
"""
return json.dumps(
{
"spalten": spalten,
"kategorie_ids": kategorie_ids,
"trennzeichen": trennzeichen if trennzeichen in (",", ";", "\t") else ",",
"feldfilter": feldfilter or [],
},
ensure_ascii=False,
)
def _feld_passt(fd: FieldDefinition, bedingung: str, ist: str | None) -> bool:
"""Trifft der gespeicherte Wert die Bedingung?
Ja/Nein ist der heikle Fall: Nimmt man den Haken weg, loescht
apply_field_values die Zeile (services/fields.py). „nein" und „nie gesetzt"
sind in den Daten also dasselbe - beide muessen treffen, sonst faende
„alle mit Nein" so gut wie nichts.
coerce_value wird NUR fuer Ja/Nein benutzt: Bei Auswahl, Zahl und Datum
wirft es bei unpassendem Text, und ein vertippter Filterwert soll keinen
Serverfehler beim Herunterladen ausloesen.
"""
if fd.field_type == FieldType.boolean.value:
from .fields import coerce_value
return (coerce_value(fd, ist) or "false") == (coerce_value(fd, bedingung) or "false")
return (ist or "").casefold() == (bedingung or "").strip().casefold()
def _nach_feldwerten(db: Session, art: str, zeilen: list, feldfilter: list[dict]) -> list:
"""Zeilen auf die reduzieren, die ALLE Bedingungen erfuellen.
In Python und nicht in SQL: Auf ProductFieldValue.value liegt kein Index,
„fehlende Zeile zaehlt als leer" braeuchte je Bedingung ein NOT EXISTS, und
die Mengen sind haushaltsgross. _feldwerte holt ohnehin alles in einem Zug.
Ein geloeschtes Feld laesst seine Bedingung wegfallen - genau wie eine
Spalte zu einem geloeschten Feld still verschwindet.
"""
bedingungen = []
for eintrag in feldfilter:
fd = db.get(FieldDefinition, eintrag["feld_id"])
if fd is not None:
bedingungen.append((fd, eintrag["wert"]))
if not bedingungen:
return zeilen
werte = _feldwerte(db, art, zeilen)
passend = []
for zeile in zeilen:
produkt_id = zeile.product_id if art == "items" else getattr(zeile, "id", None)
eigene = werte.get(produkt_id, {})
if all(_feld_passt(fd, wert, eigene.get(fd.id)) for fd, wert in bedingungen):
passend.append(zeile)
return passend
def _zeilen(
db: Session, art: str, kategorie_ids: list[int], feldfilter: list[dict] | None = None
) -> 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}))
zeilen = frage.order_by(Product.name).all()
else:
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})
)
zeilen = frage.order_by(Item.id).all()
return _nach_feldwerten(db, art, zeilen, feldfilter) if feldfilter else zeilen
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"], konf["feldfilter"])
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()