Files
Vorrania/backend/app/services/etiketten.py
T
ScarriffleandClaude Opus 5 8de222072c Exportbedingungen: Vergleichsart je Feldart statt nacktem Textfeld
Bisher tippte man den Wert immer von Hand ein - auch bei einem Haken, wo man
wissen musste, dass intern "true" steht, und bei einer Auswahl, wo ein
Vertipper stillschweigend nichts fand. Das ist der unangenehmste Fehlschlag:
Die CSV kommt einfach leer heraus, ohne Hinweis warum.

Jetzt bringt jede Bedingung die passende Eingabe mit:

  Ja/Nein    ein Haken
  Auswahl    "ist gleich" / "ist nicht gleich" plus das Dropdown der Optionen
  Zahl       "ist groesser =" / "ist =" / "ist kleiner =" plus Zahlenfeld
  Text       wie bisher freie Eingabe, leer = ohne Wert

Damit die Oberflaeche das kann, traegt /etiketten/spalten jetzt Art und
Auswahlwerte je Feld mit - vorher gab es nur Schluessel und Titel.

Der Filter bekommt dafuer ein op (=, !=, >=, <=), Vorgabe "=", damit
bestehende Vorlagen unveraendert weiterlaufen. Groesser/kleiner vergleicht
als Zahl und nimmt Komma wie Punkt; Zeilen ohne Wert treffen dabei NIE -
"leer" ist nicht kleiner als drei, es ist unvergleichbar. Eine unbekannte
Vergleichsart faellt auf "=" zurueck statt den Export zu zerreissen.

Die Sicherung traegt das op mit, wie schon das Feld selbst.

Sieben neue Tests, 547 gruen. Diesmal zusaetzlich in der laufenden Oberflaeche
durchgespielt: Alle vier Feldarten zeigen die richtige Eingabe, der Filter
wird gespeichert, und die CSV enthaelt bei "Laenge >= 5" genau die Artikel
mit 5 und 12 - nicht den mit 2 und nicht den ohne Wert.

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

479 lines
18 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 .fields import options_list
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
# Art und Auswahlwerte kommen mit: Erst damit kann die Oberflaeche beim
# Wertefilter eine Checkbox, ein Dropdown oder ein Zahlenfeld anbieten
# statt eines nackten Textfelds, in das man sich vertippt.
spalten.append({
"schluessel": f"{FELD_PRAEFIX}{fd.id}",
"titel": titel,
"typ": fd.field_type,
"optionen": options_list(fd),
})
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", "")
op = eintrag.get("op", "=")
if op not in ("=", "!=", ">=", "<="):
op = "="
feldfilter.append({
"feld_id": int(fid),
"op": op,
"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 _als_zahl(text: str | None) -> float | None:
"""Zahl aus dem gespeicherten Text – Komma gilt wie Punkt."""
try:
return float(str(text).replace(",", "."))
except (TypeError, ValueError):
return None
def _feld_passt(fd: FieldDefinition, op: str, 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
gleich = (coerce_value(fd, ist) or "false") == (coerce_value(fd, bedingung) or "false")
return not gleich if op == "!=" else gleich
if op in (">=", "<="):
# Groesser/kleiner gibt es nur auf Zahlen. Ohne Wert oder mit
# unlesbarem Text trifft die Bedingung nie - „leer" ist nicht kleiner
# als drei, es ist unvergleichbar.
links, rechts = _als_zahl(ist), _als_zahl(bedingung)
if links is None or rechts is None:
return False
return links >= rechts if op == ">=" else links <= rechts
gleich = (ist or "").casefold() == (bedingung or "").strip().casefold()
return not gleich if op == "!=" else gleich
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.get("op", "="), 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, op, wert, eigene.get(fd.id)) for fd, op, 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()