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:
co-authored by
Claude Opus 5
parent
dd57d3356a
commit
8de222072c
@@ -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
@@ -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 = ""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user