diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 63bfbbc..19ef529 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -502,7 +502,11 @@ def _vorlagen_exportieren(db: Session) -> list[dict]: if fd is None: continue pfad = _category_path(db, db.get(Category, fd.category_id)) - feldfilter.append({"feld": f"{pfad}::{fd.label}", "wert": bedingung["wert"]}) + feldfilter.append({ + "feld": f"{pfad}::{fd.label}", + "op": bedingung.get("op", "="), + "wert": bedingung["wert"], + }) zeilen.append({ "name": v.name, @@ -573,7 +577,11 @@ def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int: # Wie bei den Spalten: fehlt das Feld hier, faellt die Bedingung # weg statt die ganze Vorlage scheitern zu lassen. if fd is not None: - feldfilter.append({"feld_id": fd.id, "wert": bedingung.get("wert", "")}) + feldfilter.append({ + "feld_id": fd.id, + "op": bedingung.get("op", "="), + "wert": bedingung.get("wert", ""), + }) kategorie_ids = [ pfad_zu_id[p] for p in entry.get("kategorien", []) if p in pfad_zu_id diff --git a/backend/app/schemas.py b/backend/app/schemas.py index d32b2da..c182f01 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -1124,17 +1124,29 @@ class SpalteOut(BaseModel): schluessel: str titel: str + # Nur bei eigenen Feldern gefuellt. Die Oberflaeche braucht beides, um beim + # Wertefilter die passende Eingabe anzubieten (Checkbox, Auswahl, Zahl). + typ: str = "" + optionen: list[str] = [] class FeldFilter(BaseModel): """Eine Bedingung auf einem eigenen Feld. - Mehrere Bedingungen muessen alle zutreffen. Verglichen wird genau, aber - ohne Ruecksicht auf Gross-/Kleinschreibung. Ein leerer ``wert`` trifft die - Zeilen OHNE Wert in diesem Feld. + Mehrere Bedingungen muessen alle zutreffen. + + ``op`` ist die Vergleichsart. Vorgabe ist ``=``, damit aeltere Vorlagen + ohne Angabe unveraendert weiterlaufen: + + * ``=`` / ``!=`` – genau gleich bzw. ungleich, ohne Ruecksicht auf + Gross-/Kleinschreibung. Ein leerer ``wert`` trifft bei ``=`` die Zeilen + OHNE Wert in diesem Feld. + * ``>=`` / ``<=`` – nur bei Zahlenfeldern sinnvoll; Zeilen ohne Wert oder + mit unlesbarer Zahl treffen nie. """ feld_id: int + op: str = Field(default="=", pattern=r"^(=|!=|>=|<=)$") wert: str = "" diff --git a/backend/app/services/etiketten.py b/backend/app/services/etiketten.py index e849bf7..1840df2 100644 --- a/backend/app/services/etiketten.py +++ b/backend/app/services/etiketten.py @@ -20,6 +20,7 @@ from typing import Any, Callable from sqlalchemy.orm import Session +from .fields import options_list from ..models import ( Category, EtikettenVorlage, @@ -192,7 +193,15 @@ def feld_spalten(db: Session, art: str, kategorie_ids: list[int]) -> list[dict]: 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}) + # 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 @@ -242,7 +251,14 @@ def konfiguration_lesen(vorlage: EtikettenVorlage) -> dict: 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 ""}) + 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, @@ -274,7 +290,15 @@ def konfiguration_schreiben( ) -def _feld_passt(fd: FieldDefinition, bedingung: str, ist: str | None) -> bool: +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 @@ -289,8 +313,20 @@ def _feld_passt(fd: FieldDefinition, bedingung: str, ist: str | None) -> bool: 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() + 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: @@ -307,7 +343,7 @@ def _nach_feldwerten(db: Session, art: str, zeilen: list, feldfilter: list[dict] for eintrag in feldfilter: fd = db.get(FieldDefinition, eintrag["feld_id"]) if fd is not None: - bedingungen.append((fd, eintrag["wert"])) + bedingungen.append((fd, eintrag.get("op", "="), eintrag["wert"])) if not bedingungen: return zeilen @@ -316,7 +352,7 @@ def _nach_feldwerten(db: Session, art: str, zeilen: list, feldfilter: list[dict] 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): + if all(_feld_passt(fd, op, wert, eigene.get(fd.id)) for fd, op, wert in bedingungen): passend.append(zeile) return passend diff --git a/backend/tests/test_etiketten.py b/backend/tests/test_etiketten.py index c07e4a5..024d858 100644 --- a/backend/tests/test_etiketten.py +++ b/backend/tests/test_etiketten.py @@ -26,7 +26,7 @@ from app.models import ( User, ) from app.routers.etiketten import _pruefe_lbx, datei_herunterladen, spalten -from app.services.etiketten import csv_erzeugen, konfiguration_lesen +from app.services.etiketten import csv_erzeugen, konfiguration_lesen, verfuegbare_spalten @pytest.fixture() @@ -608,7 +608,9 @@ def test_feldfilter_uebersteht_die_sicherung(db, haken): inhalt = export_backup_json(db=db, _=person).body gesichert = _json.loads(inhalt)["etiketten_vorlagen"][0] # Nach Pfad und Label, nie nach ID. - assert gesichert["feldfilter"] == [{"feld": "Kleidung::Verliehen", "wert": "ja"}] + assert gesichert["feldfilter"] == [ + {"feld": "Kleidung::Verliehen", "op": "=", "wert": "ja"} + ] from app.database import Base from sqlalchemy import create_engine @@ -635,6 +637,99 @@ def test_feldfilter_uebersteht_die_sicherung(db, haken): ).one() konf = konfiguration_lesen(neu) # Auf die ID der ZWEITEN Instanz umgeschrieben, Wert unveraendert. - assert konf["feldfilter"] == [{"feld_id": neues_feld.id, "wert": "ja"}] + assert konf["feldfilter"] == [{"feld_id": neues_feld.id, "op": "=", "wert": "ja"}] assert _namen(zweite, neu) == ["Jacke"] zweite.close() + + +def test_zahlenfeld_groesser_gleich(db, kabel): + cat, fd = kabel + _artikel(db, cat, "Kurz", fd, "2") + _artikel(db, cat, "Mittel", fd, "5") + _artikel(db, cat, "Lang", fd, "10") + v = _vorlage(db, ["name"], art="products", + feldfilter=[{"feld_id": fd.id, "op": ">=", "wert": "5"}]) + + assert sorted(_namen(db, v)) == ["Lang", "Mittel"] + + +def test_zahlenfeld_kleiner_gleich_mit_komma(db, kabel): + """Gespeichert wird der getippte Text – „2,5" muss als Zahl gelten.""" + cat, fd = kabel + _artikel(db, cat, "Kurz", fd, "2,5") + _artikel(db, cat, "Lang", fd, "10") + v = _vorlage(db, ["name"], art="products", + feldfilter=[{"feld_id": fd.id, "op": "<=", "wert": "3"}]) + + assert _namen(db, v) == ["Kurz"] + + +def test_groesser_gleich_trifft_artikel_ohne_wert_nicht(db, kabel): + """„leer" ist nicht kleiner als drei – es ist unvergleichbar.""" + cat, fd = kabel + _artikel(db, cat, "Mit Wert", fd, "9") + _artikel(db, cat, "Ohne Wert") + for op in (">=", "<="): + v = _vorlage(db, ["name"], art="products", + feldfilter=[{"feld_id": fd.id, "op": op, "wert": "3"}]) + assert "Ohne Wert" not in _namen(db, v) + + +def test_ungleich_bei_auswahlfeld(db): + cat = Category(name="Kleidung", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + fd = FieldDefinition( + category_id=cat.id, label="Größe", key="groesse", field_type="select", + options=json.dumps(["S", "M", "L"]), + ) + db.add(fd) + db.commit() + db.refresh(cat) + db.refresh(fd) + + _artikel(db, cat, "Klein", fd, "S") + _artikel(db, cat, "Mittel", fd, "M") + v = _vorlage(db, ["name"], art="products", + feldfilter=[{"feld_id": fd.id, "op": "!=", "wert": "S"}]) + + assert _namen(db, v) == ["Mittel"] + + +def test_ungleich_bei_ja_nein(db, haken): + cat, fd = haken + _artikel(db, cat, "Jacke", fd, "true") + _artikel(db, cat, "Hose", fd, "false") + v = _vorlage(db, ["name"], art="products", + feldfilter=[{"feld_id": fd.id, "op": "!=", "wert": "ja"}]) + + assert _namen(db, v) == ["Hose"] + + +def test_unbekannte_vergleichsart_faellt_auf_gleich_zurueck(db, haken): + cat, fd = haken + v = EtikettenVorlage(name="Krumm", art="products", konfiguration=json.dumps({ + "spalten": ["name"], + "feldfilter": [{"feld_id": fd.id, "op": "~~", "wert": "ja"}], + })) + db.add(v) + db.commit() + + assert konfiguration_lesen(v)["feldfilter"][0]["op"] == "=" + + +def test_auswahlfeld_liefert_seine_optionen_zur_spalte(db): + """Die Oberflaeche kann nur dann ein Dropdown anbieten.""" + cat = Category(name="Kleidung", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + db.add(FieldDefinition( + category_id=cat.id, label="Größe", key="groesse", field_type="select", + options=json.dumps(["S", "M"]), + )) + db.commit() + + spalten = verfuegbare_spalten(db, "products", [cat.id]) + feld = [s for s in spalten if s["schluessel"].startswith("feld:")][0] + assert feld["typ"] == "select" + assert feld["optionen"] == ["S", "M"] diff --git a/web/src/components/EtikettenVorlagen.jsx b/web/src/components/EtikettenVorlagen.jsx index 5c04b16..63ea472 100644 --- a/web/src/components/EtikettenVorlagen.jsx +++ b/web/src/components/EtikettenVorlagen.jsx @@ -155,6 +155,65 @@ export default function EtikettenVorlagen({ onError }) { } /** Bearbeiten einer einzelnen Vorlage. */ +/** + * Die Eingabe einer Filterbedingung, passend zur Art des Feldes. + * + * Ohne das waere jede Bedingung ein nacktes Textfeld: Man muesste wissen, dass + * ein Haken intern „true" heisst und wie eine Auswahl genau geschrieben ist. + * Ein Vertipper faende dann stillschweigend nichts – der unangenehmste + * Fehlschlag, weil die CSV einfach leer herauskommt. + */ +function FilterEingabe({ feld, bedingung, onAendern }) { + const typ = feld?.typ || "text"; + + if (typ === "boolean") { + return ( + + ); + } + + if (typ === "select") { + return ( + <> + + + + ); + } + + if (typ === "number") { + return ( + <> + + onAendern({ wert: e.target.value })} /> + + ); + } + + // Text, mehrzeiliger Text und Datum: freie Eingabe, leer = ohne Wert. + return ( + onAendern({ wert: e.target.value })} /> + ); +} + function VorlageEditor({ vorlage, onGespeichert, onError }) { const toast = useToast(); const [art, setArt] = useState(vorlage.art); @@ -265,18 +324,21 @@ function VorlageEditor({ vorlage, onGespeichert, onError }) { Nur Zeilen mit bestimmten Feldwerten (alle Bedingungen müssen zutreffen)

- Wert genau so eingeben, wie er im Feld steht; bei Ja/Nein-Feldern - „ja" oder „nein". Leer heißt: ohne Wert – also alle, - bei denen das Feld nicht ausgefüllt ist. + Bei Textfeldern genau so eingeben, wie es im Feld steht;{" "} + leer heißt dort: ohne Wert – also alle, bei denen + das Feld nicht ausgefüllt ist. Größer/kleiner vergleicht als Zahl; + Zeilen ohne Wert treffen dabei nie.

)}