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>
This commit is contained in:
Scarriffle
2026-10-16 16:18:48 +02:00
co-authored by Claude Opus 5
parent dd57d3356a
commit 8de222072c
5 changed files with 250 additions and 31 deletions
+10 -2
View File
@@ -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
+15 -3
View File
@@ -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 = ""
+43 -7
View File
@@ -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
+98 -3
View File
@@ -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"]
+84 -16
View File
@@ -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 (
<label className="check-inline" style={{ margin: 0 }}>
<input type="checkbox" checked={bedingung.wert === "true"}
onChange={(e) => onAendern({ op: "=", wert: e.target.checked ? "true" : "false" })} />
<span className="muted small">{bedingung.wert === "true" ? "ja" : "nein"}</span>
</label>
);
}
if (typ === "select") {
return (
<>
<select value={bedingung.op} style={{ width: 110 }}
onChange={(e) => onAendern({ op: e.target.value })}>
<option value="=">ist gleich</option>
<option value="!=">ist nicht gleich</option>
</select>
<select value={bedingung.wert} style={{ width: 120 }}
onChange={(e) => onAendern({ wert: e.target.value })}>
{(feld.optionen || []).map((o) => <option key={o} value={o}>{o}</option>)}
</select>
</>
);
}
if (typ === "number") {
return (
<>
<select value={bedingung.op} style={{ width: 110 }}
onChange={(e) => onAendern({ op: e.target.value })}>
<option value=">=">ist größer =</option>
<option value="=">ist =</option>
<option value="<=">ist kleiner =</option>
</select>
<input value={bedingung.wert} inputMode="decimal" style={{ width: 90 }}
onChange={(e) => onAendern({ wert: e.target.value })} />
</>
);
}
// Text, mehrzeiliger Text und Datum: freie Eingabe, leer = ohne Wert.
return (
<input value={bedingung.wert} placeholder="ohne Wert" style={{ width: 120 }}
onChange={(e) => 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)
</span>
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{feldfilter.map((f, i) => (
<li key={f.feld_id}>
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(`feld:${f.feld_id}`)}</span>
<input value={f.wert} placeholder="ohne Wert" style={{ width: 120 }}
onChange={(e) => setFeldfilter(feldfilter.map((x, j) =>
(j === i ? { ...x, wert: e.target.value } : x)))} />
<button type="button" className="btn-icon danger" title="Bedingung entfernen"
onClick={() => setFeldfilter(feldfilter.filter((_, j) => j !== i))}>
<Icon name="close" size={14} />
</button>
</li>
))}
{feldfilter.map((f, i) => {
const feld = moeglich.find((m) => m.schluessel === `feld:${f.feld_id}`);
const aendern = (teil) => setFeldfilter(feldfilter.map((x, j) =>
(j === i ? { ...x, ...teil } : x)));
return (
<li key={f.feld_id}>
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(`feld:${f.feld_id}`)}</span>
<FilterEingabe feld={feld} bedingung={f} onAendern={aendern} />
<button type="button" className="btn-icon danger" title="Bedingung entfernen"
onClick={() => setFeldfilter(feldfilter.filter((_, j) => j !== i))}>
<Icon name="close" size={14} />
</button>
</li>
);
})}
</ul>
<select value="" style={{ marginTop: "var(--sp-1)" }}
onChange={(e) => {
@@ -284,8 +346,13 @@ function VorlageEditor({ vorlage, onGespeichert, onError }) {
if (!id) return;
// Ein Feld nur einmal: zwei Bedingungen darauf koennten sich nie
// beide erfuellen.
const feld = moeglich.find((m) => m.schluessel === e.target.value);
// Ja/Nein startet als gesetzter Haken, eine Auswahl auf ihrem
// ersten Wert - so steht nie eine Bedingung da, die nichts trifft.
const start = feld?.typ === "boolean" ? "true"
: feld?.typ === "select" ? (feld.optionen?.[0] || "") : "";
setFeldfilter([...feldfilter.filter((x) => x.feld_id !== id),
{ feld_id: id, wert: "" }]);
{ feld_id: id, op: "=", wert: start }]);
}}>
<option value="">+ Bedingung auf einem eigenen Feld …</option>
{moeglich.filter((m) => m.schluessel.startsWith("feld:")).map((m) => (
@@ -293,9 +360,10 @@ function VorlageEditor({ vorlage, onGespeichert, onError }) {
))}
</select>
<p className="muted small">
Wert genau so eingeben, wie er im Feld steht; bei Ja/Nein-Feldern
„ja" oder „nein". <strong>Leer heißt: ohne Wert</strong> – also alle,
bei denen das Feld nicht ausgefüllt ist.
Bei Textfeldern genau so eingeben, wie es im Feld steht;{" "}
<strong>leer heißt dort: ohne Wert</strong> – also alle, bei denen
das Feld nicht ausgefüllt ist. Größer/kleiner vergleicht als Zahl;
Zeilen ohne Wert treffen dabei nie.
</p>
</>
)}