Compare commits

..
61 Commits
Author SHA1 Message Date
ScarriffleandClaude Opus 5 757d06100a Seitenleiste: Eintraege ausblenden, als Vorgabe und je Konto
In den Einstellungen legt der Administrator fest, was es im Haushalt
ueberhaupt gibt; in "Mein Konto" blendet jeder zusaetzlich aus, was er
selbst nicht braucht. Angehakt heisst sichtbar - auch wenn der Server das
Gegenteil speichert. Was der Administrator ausgeblendet hat, steht beim
Konto abgehakt und gesperrt da: Die eigene Auswahl addiert nur.

Uebersicht und Einstellungen fehlen in der Liste mit Absicht - dort landen
alle Anmeldewege bzw. es ist der einzige Weg zurueck. Wer beides
verstecken koennte, sperrte sich aus.

Ausgeblendet wird ueber einen Kontext statt per CSS-Klasse: Unter 768px
blendet die CSS die Listen-Kinder wieder ein, eine Klasse bisse sich
damit. Faellt dabei die ganze Listen-Gruppe weg, verschwindet auch ihr
Aufklapp-Knopf, und die Ueberschrift "Stammdaten" steht nur noch, solange
etwas darunter ist.

Die Leiste zieht sofort nach, ueber dasselbe Ereignis-Muster wie bei den
Dashboards - kein Neuladen noetig.

Versteckt wird nur der Eintrag, nicht die Seite: /shopping bleibt per
Lesezeichen und ueber die Dashboard-Karten erreichbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-28 20:31:50 +01:00
ScarriffleandClaude Opus 5 be267e70a5 Seitenleiste: Eintraege ausblendbar (Backend)
Zwei Ebenen in einer Tabelle, nach dem Vorbild von DashboardLayout:
user_id NULL ist die Vorgabe des Administrators, eine Zeile mit user_id
die zusaetzliche Auswahl eines Kontos. Beide Listen gelten zusammen - wer
etwas versteckt, versteckt es zusaetzlich; einblenden, was der
Administrator ausgeblendet hat, kann niemand. Das erspart einen dritten
Zustand je Eintrag.

Als Schluessel dient der Routenpfad; der ist ohnehin eindeutig, eine
zweite Benennung waere nur eine weitere Stelle zum Auseinanderlaufen.

Drei Routen: lesen (Vorgabe, eigene Auswahl und die fertige Vereinigung
in einem Zug), eigene Auswahl setzen, Vorgabe setzen (nur Admin).

Die Eindeutigkeit laeuft ueber einen Index auf coalesce(user_id, -1).
Ein schlichtes UNIQUE(user_id) haette zwei Vorgabezeilen zugelassen, weil
NULL in SQL nie gleich NULL ist - das war im Entwurf noch falsch.

Versteckt wird nur der Eintrag, nicht die Seite: Lesezeichen,
Dashboard-Karten und Lagerort-QR sollen weiter funktionieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-28 20:27:14 +01:00
ScarriffleandClaude Opus 5 8f5d679d9d Artikelseite: Einzelstuecke neben das Formular statt darunter
Bei einem Artikel mit Verwaltung "Einzelstuecke" blieb die rechte
Rasterspalte komplett leer - dort steht sonst der Bestand je Lagerort
bzw. die Chargen, und beides gibt es hier nicht. Die Einzelstueck-Tabelle
hing stattdessen unterhalb, man musste zu ihr erst scrollen, obwohl
daneben die halbe Seite frei war.

Jetzt fuellt sie genau diese Spalte. Die EAN-Codes bleiben unten, die
braucht man selten.

Das Raster und die volle Seitenbreite gab es bereits (grid-2
form-plus-table, Route mit "wide") - es wandert nur ein Block.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-28 20:23:50 +01:00
ScarriffleandClaude Opus 5 0d3724766f Etiketten: CSV-Download nicht zwischenspeichern lassen
Der Sinn dieses Downloads ist der aktuelle Stand. Die Antwort trug weder
Cache-Control noch ETag noch Last-Modified - damit darf ein Browser oder
ein zwischengeschalteter Proxy selbst entscheiden, ob er eine aeltere
Antwort wiederverwendet, und dann fehlen neu angelegte Zeilen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-25 16:36:49 +01:00
ScarriffleandClaude Opus 5 7cb61d226f Lagerorte: ueberall ein aufklappbarer Baum statt flacher Pfadliste
Die Lagerort-Auswahl schrieb in jeder Zeile den ganzen Pfad aus
("Hedingen → Guido Zimmer → Kallax → Fach Oben Links → Oben"). Bei einem
tiefen Baum ist das eine Wand aus Wiederholungen, und Geschwister stehen
nur zufaellig beieinander.

Jetzt dasselbe Auswahlfeld wie bei den Kategorien: eingerueckter Baum,
Aeste ein- und ausklappbar. Geschlossen steht weiter der volle Pfad da -
"Oben" allein waere nicht eindeutig, genau dafuer war der Pfad gedacht.

CategorySelect konnte das schon und war auf Lagerort-Codes vorbereitet
(seine IDs duerfen Zeichenketten sein). Dazu kam nur LocationSelect als
duenne Huelle: Baumaufbau und die Umrechnung "" <-> null, damit die 16
Aufrufstellen weiter mit dem leeren String fuer "kein Ort" arbeiten.

Zwei Kleinigkeiten dabei: CategorySelect bekommt showRoot, weil bei den
Mindestbestaenden "nichts gewaehlt" kein gueltiger Wert ist - dort heisst
die oberste Zeile "Ueberall". Und LocationSelect sortiert Geschwister
selbst nach Namen; der Server sortiert zeichenweise, dort stuende "Fach
oben Rechts" hinter "Fach Unten Rechts".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-24 17:37:00 +02:00
ScarriffleandClaude Opus 5 290ff9341a Verwendungsstatus: in der App umstellen, filtern und nachsehen
Im Einzelstueck steht jetzt ueber dem Verleih ein Abschnitt "Verwendung"
mit vier Symbolen: verfuegbar, in Verwendung, in Reinigung, in Reparatur.
Ein Tipp bucht sofort, ohne "Speichern" - wie beim Verleih, denn alle
Scan-Wege (QR, NFC, vorrania://i/<UID>) landen genau in dieser Ansicht.
Vier Woerter waeren auf einem iPhone zu breit, vier Symbole passen immer;
darunter steht der Name im Klartext samt "seit wann".

Dazu der Verwendungs-Verlauf mit Zeitstempeln und Konto, und in der
Einzelstueck-Liste ein Filter ueber alle vier Zustaende plus die Zeile
unter dem Namen, solange etwas laeuft. Stuecke ohne Verwendungsstatus
fallen aus dem Filter "Verfuegbar" heraus - ein Schraubenzieher ist nicht
verfuegbar, er fuehrt den Zustand gar nicht.

Der Haken je Kategorie sitzt im Kategorie-Editor und gilt beim Bearbeiten
auch fuer die Unterkategorien.

Zwei Dinge, die beim Pruefen am Simulator auffielen: Die Leiste haette
beim Oeffnen jedes Mal einmal ins Leere gebucht, weil onChange auch beim
Laden feuert - dagegen der Abgleich mit dem zuletzt gemeldeten Stand. Und
SQLite liefert Zeitstempel ohne Zeitzone, woran ISO8601DateFormatter
scheitert; in der App stand deshalb der rohe ISO-Text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 22:07:27 +02:00
ScarriffleandClaude Opus 5 b9d097fe96 Verwendungsstatus: Weboberflaeche zum Umstellen und eine Spalte dafuer
Auf der Scan-Zielseite /i/<uid> stehen vier Knoepfe - Verfuegbar, In
Verwendung, In Reinigung, In Reparatur -, der aktuelle hervorgehoben. Ein
Griff genuegt, genau dafuer ist die Seite da. Dazu der Verlauf mit
Zeitstempeln, wer wann umgestellt hat.

Dieselbe Bedienung in der Einzelstueck-Tabelle eines Artikels, und in der
Liste aller Einzelstuecke eine Spalte mit Plakette: gelb in Reinigung, rot
in Reparatur. Filterbar nach allen vier Zustaenden, "Verfuegbar"
eingeschlossen - "was liegt bereit" ist eine echte Frage.

Der Haken je Kategorie steht in der Kategorientabelle und im
Anlegen-Formular. Setzt man ihn an einer Oberkategorie, wandert er in die
Unterkategorien; das sieht man dort auch stehen.

Ohne den Haken erscheint nirgends etwas davon.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:57:27 +02:00
ScarriffleandClaude Opus 5 98e2d18142 Verwendungsstatus: Backend fuer "getragen / in Reinigung / in Reparatur"
Einzelstuecke hatten bisher keinen Zustand - der Lagerort sagt, wohin ein
Stueck gehoert, aber nicht, woran es gerade ist. Neue Tabelle item_usages
nach dem Vorbild von item_loans: ein Zeitraum je Zustand, ended_at NULL
heisst "laeuft noch", und ein Teilindex laesst nur einen laufenden zu.

"Verfuegbar" wird bewusst nicht gespeichert - keine offene Zeile IST
verfuegbar, wie bei lots.opened_on und beim Verleih. Damit kostet der
Ruhezustand nichts und die Tabelle fuellt sich nicht mit Rauschen.
Abgeschlossene Zeilen bleiben stehen, sie sind der Verlauf.

Eigener Endpunkt (PUT /items/{id}/verwendung) und ausdruecklich nicht
PATCH /items/{id}: Das ist Admins vorbehalten, und die App schickt dort
immer alle Felder mitsamt null - jedes Speichern wuerde den Status
stillschweigend zuruecksetzen. Umstellen darf deshalb jeder angemeldete
Benutzer, wie Verleihen.

Der Zustand aendert den Bestand nicht und schreibt keine Bewegung: ein
getragenes Hemd ist weiterhin im Haushalt, anders als ein verliehenes.

Sichtbar nur bei Kategorien mit usage_tracking. Ein Haken an einer
Oberkategorie wandert in den Unterbaum, weil die Garderobe als
Kategoriebaum ja schon existiert. Die Markierung fahrt in beiden
Sicherungswegen mit; der Verlauf nicht, weil Einzelstuecke dort
ueberhaupt nicht vorkommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:47:02 +02:00
ScarriffleandClaude Opus 5 8c752e3d1f App: NFC-Fehler kurz statt "Stack Error", Knopf weg nach Erfolg
Apples localizedDescription kam unveraendert durch und lieferte beim
Beschreiben von Tags wiederholt "Stack Error" - ein Text, der niemandem
sagt, was zu tun ist. Jetzt uebersetzt NFCDienst.kurz die CoreNFC-Codes
in kurze Meldungen ("Tag zu frueh entfernt.", "NFC gerade belegt."); ein
unbekannter Code behaelt seine Nummer, damit der fehlende Fall beim
naechsten Auftreten feststeht.

Die Meldung steht jetzt auch in Apples Systemblatt: im Fehlerfall wurde
die Sitzung wortlos beendet, deshalb las man sie erst danach in der App.

Nach einem erfolgreichen Schreibvorgang verschwinden Knopf und
Schreibschutz-Schalter - sie standen vorher weiter da und waren aktiv,
ein weiterer Tipp hat das Tag ein zweites Mal beschrieben. Stattdessen
steht dort jetzt "Fertig".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-20 21:41:35 +02:00
ScarriffleandClaude Opus 5 04f8c1fccd App: "Einzelstuecke anlegen" schloss sich beim ersten Mal sofort wieder
Artikel oeffnen, "Einzelstuecke anlegen" tippen - das Blatt ging kurz auf und
sofort wieder zu. Erst der zweite Versuch hielt. Danach blieb es stabil, bis
man den Artikel neu oeffnete.

Das Blatt hing an der Section, nicht am Formular. Genau davor warnt
ProductDetailView:368 seit laengerem im eigenen Kommentar - die Mengen-Sheets
wurden deshalb schon auf die Form-Ebene gelegt. Fuer die Einzelstuecke wurde
das nie nachgezogen.

Jetzt gibt ProductItemsSection das Blatt nach oben ab: Die Bindung gehoert
ProductDetailView, gezeigt wird es neben den Mengen-Sheets. Weil damit der
Rueckruf in den Abschnitt wegfaellt, bekommt er einen Nachlade-Zaehler - die
Elternansicht zaehlt ihn nach dem Anlegen hoch, und die Liste frischt auf.

Im Simulator vorher reproduziert und nachher geprueft: Erster Tipp nach dem
Oeffnen laesst das Blatt stehen, und ein angelegtes Stueck erscheint sofort
in der Liste ("Einzelstuecke (1)").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-17 19:30:48 +02:00
ScarriffleandClaude Opus 5 9f5d0a2310 Rezepte: Listenbreite ziehbar, lange Namen brechen um
Die Rezeptliste hing auf hoechstens 320px fest, und .btn ist nowrap - ein
Name wie "Cheesecake mit Streuseln und Fruchteinlage" lief damit einfach aus
der Spalte heraus und war abgeschnitten.

Jetzt sitzt zwischen Liste und Detail ein Griff zum Ziehen, Bedienung wie der
Spaltengriff der Tabellen (Zeiger-Ereignisse, col-resize). Die Breite wird
gemerkt und zwischen 180 und 640 Pixel gehalten: darunter ist die Liste
unlesbar, darueber bleibt fuer die Zutaten nichts mehr uebrig.

Dazu eine kleine Klasse .btn.umbruch, die das nowrap aufhebt - ein Knopf,
dessen Beschriftung umbrechen darf, ist auch anderswo brauchbar.

In der laufenden Oberflaeche nachgemessen: Ziehen um 120px ergibt genau 120px
mehr und landet im Speicher, die Grenzen klemmen bei 180 und 640, und der
lange Name braucht mit der Klasse zwei Zeilen (Text 372px bei 372px Spalte)
statt ueber den Rand zu laufen (434px).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 16:48:43 +02:00
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
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
ScarriffleandClaude Opus 5 83f7134459 App: Folgeblatt nach dem Anlegen ging beim ersten Mal sofort wieder zu
Zwei sheet-Modifikatoren hingen am selben View: einzelstueckeNachAnlegen und
mengeNachAnlegen, beide in ea7a42a nebeneinandergesetzt. Zwei Praesentationen
am selben View vertragen sich in SwiftUI nicht zuverlaessig - eine davon wird
verschluckt, und genau deshalb hielt das Blatt erst im zweiten Anlauf.

Jetzt ein einziger Modifikator, der innen verzweigt: Einzelstuecke oder Menge
je Lagerort, je nach Verwaltungsart. Statt zweier Zustaende nur noch einer.

In der laufenden App geprueft, beide Zweige einzeln: Gegenstand mit
Einzelstuecken angelegt -> "Einzelstuecke anlegen" bleibt beim ERSTEN Mal
offen; Gegenstand mit Menge je Lagerort angelegt -> "Menge hinzufuegen"
ebenso.

Dabei nebenbei bestaetigt: Ein Gegenstand ohne Kategorie bekommt vom Server
tracking="food" und damit zu Recht gar keinen Folgeschritt - die
Verwaltungsart allein macht noch keinen Gegenstand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-16 06:35:37 +02:00
ScarriffleandClaude Opus 5 1e8c4b2797 App: sichtbarer Knopf "Anlegen & NFC-Tag beschreiben" im Anlege-Formular
Gewuenscht war ein Knopf, den man sieht. Gebaut war ein Schritt, der erst
NACH dem Antippen von "Anlegen" kam - im Formular also nichts zu sehen. Und
er kam nicht einmal dann: await onDone() laedt die Elternansicht neu, dabei
wird das Anlege-Blatt abgeraeumt, und jeder Zustand, den man danach setzt,
laeuft ins Leere. Deshalb war von dem Schritt nie etwas zu sehen.

Jetzt steht "Anlegen & NFC-Tag beschreiben" neben "Anlegen", mit Wellensymbol
und nur bei Anzahl 1 (ein Tag gehoert zu genau einem Stueck; bei mehr sagt
ein Hinweis, warum er fehlt). Frueher kann der Knopf nicht stehen: Die UID
vergibt der Server erst beim Anlegen, vorher gibt es nichts zu schreiben.

Das Neuladen holt jetzt "Fertig" im Tag-Schritt nach, statt vorher zu laufen.

Diesmal in der laufenden App geprueft, nicht nur uebersetzt: Backend lokal
auf SQLite, Testartikel angelegt, Ablauf im Simulator durchgespielt. Der
Knopf erscheint, legt das Stueck an und fuehrt zum Tag-Schritt mit der
richtigen UID und vorrania://i/<UID> als Nutzlast.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:51:19 +02:00
ScarriffleandClaude Opus 5 75899714b3 App: Listendarstellung der Auswahl zurueckgenommen - sie warf aus dem Formular
Beim Anlegen eines Artikels fuehrte die Kategoriewahl zurueck in die
Artikelliste: Das Formular steht in einem Blatt, und die geschobene Auswahl
riss beim Zurueckspringen den ganzen Navigationsstapel mit - das Blatt also
gleich dazu. Der Artikel liess sich so nicht mehr anlegen.

Damit ist .pickerStyle(.navigationLink) an allen acht Stellen wieder
draussen. Die Auswahl ist wieder ein Menue und rueckt mit fuehrenden
Leerzeichen ein, wie vor d7002e2.

Der Umbruch langer Namen bleibt damit ungeloest: Faellt "Schublade Links
Unten" auf eine zweite Zeile, faengt die wieder ganz links an. Die Grenze
steht jetzt als Kommentar an Baumzeile.label, damit der naechste Versuch
nicht wieder in dieselbe Falle laeuft. Funktionierendes Anlegen wiegt
schwerer als saubere Einrueckung.

Der mittige Loeschdialog aus demselben Commit bleibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:37:11 +02:00
ScarriffleandClaude Opus 5 38489a5217 App: der Tag-Schritt beim Anlegen erschien nie - jetzt geschoben statt aufgelegt
Nach dem Anlegen eines einzelnen Stuecks sollte das Tag-Blatt aufgehen. Es
kam nie. Ein zweites Blatt aus einem Blatt heraus ist hier gleich dreifach
riskant: ItemAddSheet traegt schon ein fullScreenCover (Lagerort scannen) -
zwei Praesentations-Modifikatoren am selben View vertragen sich in SwiftUI
nicht zuverlaessig - und das Formular wird selbst aus einer Section heraus
gezeigt. Vor beidem warnen ProductDetailView:349 und ItemViews:775 bereits
aus eigener Erfahrung.

Der Tag-Schritt wird deshalb in den ohnehin vorhandenen NavigationStack
geschoben. Ein Schub kennt keine dieser Fallen und ist fuer einen Schritt in
einem Ablauf ohnehin die passendere Form.

NFCSchreibenView bekommt dafuer ein optionales onFertig. Ohne Angabe
schliesst sie sich selbst wie bisher - der Aufruf aus der Stueck-Bearbeitung
bleibt unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:27:16 +02:00
ScarriffleandClaude Opus 5 d7002e2252 App: Einrueckung haelt auch bei Umbruch, Loeschen fragt mittig
Eingerueckt wurde mit fuehrenden Leerzeichen. Die verschieben aber nur die
ERSTE Zeile: Bricht ein langer Name um ("Schublade Links Unten"), faengt die
Fortsetzung wieder ganz links an und die Hierarchie ist weg.

In einem aufklappenden Menue laesst sich das nicht loesen - dort liefert man
nur Text ab, das System ignoriert jedes Layout (auch lineLimit). Die
Lagerort- und Kategorieauswahl erscheint deshalb jetzt als Liste
(.pickerStyle(.navigationLink)). Dort ist jede Zeile eine echte Ansicht, ein
Innenabstand rueckt den ganzen Block ein - auch die zweite Zeile. Das liest
sich bei vielen Lagerorten ohnehin besser als ein langes Aufklappmenue.

Zwei enge Zeilen bleiben Menues: Mindestbestand je Ort bei Artikel und
Gruppe, wo direkt daneben das Mengenfeld steht. Dort wuerde eine
Listenauswahl die Zeile sprengen; sie behalten die Leerzeichen-Einrueckung.

Der Loeschdialog war ein confirmationDialog und fuhr als Blase von unten
herein. Jetzt ein alert: steht mittig und will gelesen werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 21:12:29 +02:00
ScarriffleandClaude Opus 5 ea7a42ac22 App: Tag beim Anlegen, Menge beim Anlegen, Loeschen und NFC beim Ein-/Auslagern
Vier Bruchstellen im taeglichen Gebrauch, alle an derselben Wurzel: Nach dem
Anlegen endete der Weg, und was danach kam, musste man sich selbst suchen.

NFC beim Anlegen: createItems liefert die angelegten Stuecke samt UID laengst
zurueck, ItemAddSheet warf sie nur weg. Bei genau EINEM Stueck geht es jetzt
direkt zum Tag-Blatt. Bei mehreren bleibt es wie bisher - die App koennte
nicht wissen, welches der fuenf Stuecke gerade am Tag haengt. Ohne NFC im
Geraet (iPad) ebenso unveraendert.

Menge beim Anlegen: Ein Gegenstand, der nur als Menge je Lagerort gezaehlt
wird, landete nach dem Anlegen im Nichts. Das Blatt dafuer gab es schon
(ObjectAddSheet), nur fuehrte kein Weg dorthin. Jetzt derselbe Uebergang wie
bei Einzelstuecken, an beiden Einstiegen. Es holt sich die Lagerorte selbst -
ObjectAddSheet nimmt sie als Konstante und laedt sie nicht nach.

Loeschen: Der Knopf sitzt unten in der Artikelansicht, nur fuer Admins - die
Route verlangt das, ohne Riegel gaebe es nur ein wortkarges 403. Lehnt der
Server ab (noch Einzelstuecke oder Bestand), bleibt die Ansicht stehen und
seine Begruendung steht unveraendert in der Fehlerzeile.

NFC beim Ein- und Auslagern: Ein Tag benennt immer genau ein Einzelstueck,
deshalb fuehrt er zur Stueck-Ansicht - beim Auslagern ist "Entfernen (mit
Grund)" genau die passende Aktion. Bewusst NICHT ueber resolve() abgekuerzt:
das faellt bei unbekannter UID auf lookup(barcode:) durch und boete an, einen
Artikel mit einer URL als Barcode anzulegen. Beim Nachschlagen gab es NFC
schon, nur unscheinbar unter der Kamerakarte - Knopf abgesetzt und in der
Bildunterschrift erwaehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:55:37 +02:00
ScarriffleandClaude Opus 5 501d80a9b8 Lagerort- und Kategorieauswahl zeigen den Baum statt einer Mischung
Die Auswahl war nicht flach, weil die Beschriftung gefehlt haette - der volle
Pfad stand laengst drin. Sie kam vom Server nach dem BLATTnamen sortiert, und
so standen "Garage -> Fach A" und "Keller -> Fach A" irgendwo zwischen allem
anderen. Geschwister und Kinder fanden nie zusammen.

Neu ist baum() in Baum.swift: bringt eine flache Eltern-Kind-Liste in
Baumreihenfolge und rueckt je Ebene ein. Waisen gelten als oberste Ebene,
damit nichts lautlos aus der Auswahl faellt.

Derselbe Lauf lag bisher dreimal im Projekt (CategoryPicker, TreeMasterView,
LocationEditor). Alle drei zeigen jetzt auf den einen - unterm Strich
verschwinden mehr Zeilen als dazukommen. Zwei der Kopien waren auch nicht
gleichwertig: LocationEditor.pfad hatte den Zyklusschutz von path(in:) nicht
und waere bei einer ringfoermigen Elternkette endlos gelaufen.

Umgestellt sind neun Auswahlfelder. Die Lagerortzeile beim Kassenzettel zeigte
nur den nackten Namen und bekommt den vollen Pfad - dort ist kein Platz zum
Einruecken.

MARKETING_VERSION stand auf 1.2 in der project.pbxproj, aber auf 1.1 in der
project.yml, aus der sie erzeugt wird. Die Quelle nachgezogen, sonst haette
das naechste "xcodegen generate" die Anhebung zurueckgedreht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:11:25 +02:00
ScarriffleandClaude Opus 5 264d3886f3 Artikelsuche findet die Marke - und Loeschen fragt erst nach dem Bestand
Die Suche verglich bisher einen einzigen Textschnipsel gegen den Namen. Wer
"Vogel Jack and Jones" eintippte, bekam nichts: Dieser Ausdruck steht in
keinem Artikelnamen, und die Marke wurde ueberhaupt nie durchsucht. Jetzt
wird der Suchtext in Woerter zerlegt, jedes muss in Name oder Marke
vorkommen, die Reihenfolge ist egal.

Gefiltert wird in Python und nicht per ILIKE, weil normalisieren() Umlaute
aufloest ("Muehle" findet "Mühle") - auf der Spalte faende ein ILIKE den
gespeicherten Wert nicht wieder. Dieselbe Form nutzt recipes.zuordnung_ziele
schon laenger. Alle Artikellisten haengen an dieser einen Route, die
Verbesserung wirkt also auch bei Gegenstaenden und beim Ein-/Auslagern.

Die Artikelzeile in der App zeigt nun die Marke, sonst waere ein Treffer
darauf nicht zu erklaeren.

Dazu die zweite Haelfte: delete_product hatte keinerlei Schutz und riss
Chargen, Einzelstuecke, Barcodes, Bilder und den ganzen Bewegungsverlauf per
Kaskade mit. Jetzt lehnt der Server mit 409 ab, solange Einzelstuecke oder
Chargen dranhaengen. Bewegungen allein halten bewusst nicht auf - sonst waere
jeder je benutzte Artikel fuer immer unloeschbar. Der Bestaetigungstext im
Web versprach den Verlust des Bestands und stimmte damit nicht mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-15 20:06:07 +02:00
ScarriffleandClaude Opus 5 94f8ed4db9 Etikettenvorlage: der Umlaut aus macOS sprengte den Download
macOS legt Dateinamen zerlegt ab - das "ae" in "Lagerplaetze.lbx" ist dort
nicht ein Zeichen, sondern "a" plus ein kombinierender Trema-Akzent. Genau
diese Form landet beim Hochladen in der Datenbank. Starlette schreibt
Header aber als latin-1, und fuer den Akzent gibt es dort nichts - die
Route starb mit UnicodeEncodeError und antwortete 500, obwohl der Name am
Bildschirm voellig harmlos aussah.

Der Header wird jetzt nach RFC 6266 zweigleisig gebaut: filename= traegt
eine ASCII-Fassung als Rueckfall, filename*= den echten Namen
prozentkodiert. Browser bevorzugen filename* und zeigen den Umlaut richtig.

Derselbe Riegel liegt auf dem CSV-Download, wo der frei getippte
Vorlagenname in den Header geht - gleicher Defekt, nur noch nicht
aufgefallen.

Fuer die Download-Route gab es bisher keinen Test; zwei kommen dazu.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-10 14:47:52 +02:00
ScarriffleandClaude Opus 5 b3256457d2 App: Chargenansicht zeigt auch Einlagerdatum und Verlauf
Nachtrag zur Detailansicht von gerade eben: Zwei Angaben fehlten, die
man an einer Charge sucht. "Seit wann liegt das hier?" - das
Einlagerdatum kam im Swift-Modell gar nicht erst an, obwohl /lots/rows
es liefert. Und "was ist damit passiert?" - dafuer gibt es jetzt den
Weg in den Verlauf des Artikels. Eine einzelne Charge hat im Backend
keinen eigenen Verlauf, die Bewegungen haengen am Artikel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 09:10:29 +02:00
ScarriffleandClaude Opus 5 ce05e5f4f6 App: eine Charge laesst sich jetzt oeffnen und ansehen
Die neue Chargenliste reagierte nur aufs Wischen. Das sieht man nicht,
und man kam an die Angaben der Charge ueberhaupt nicht heran - Menge,
Fristen und Lagerort standen zwar gekuerzt in der Zeile, aber mehr gab
es nicht.

Antippen oeffnet jetzt die Charge: Artikel und Marke, Menge (bei einer
offenen Packung auch die Menge beim Oeffnen), Zustand, Lagerort, MHD,
Anbruchtag und Verbrauchsdatum. Darunter beide Handlungen als sichtbare
Knoepfe - damit haengt "Anbrechen" nicht mehr allein an einer Geste, die
niemand findet. Das Wischen bleibt als Abkuerzung.

Das Verbrauchen-Formular wird aus der Detailansicht BLAETTERND geoeffnet,
nicht als zweites Fenster. Ein Sheet im Sheet aufzuziehen und beide
gleich wieder zu schliessen ist in SwiftUI eine zuverlaessige Quelle
fuer haengende Ansichten; so bleibt es eine Ebene.

Nicht kompiliert (kein Xcode hier): geprueft sind Klammer- und
Anfuehrungsbalance, dass keine Code-Zeile ein unpaariges
Anfuehrungszeichen traegt, und dass vom entfernten Zustand nichts
stehengeblieben ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 08:57:59 +02:00
ScarriffleandClaude Opus 5 c860c82912 App: Chargenliste, Anbrechen und Teilverbrauch
In der App gab es beides nicht. Chargen waren ueberhaupt nur innerhalb
eines Artikels zu sehen, und dort nur zum Korrigieren - eine
Chargenliste wie im Web fehlte, und damit jeder Weg, eine Packung
anzubrechen oder eine Teilmenge zu verbrauchen. Das Backend kann das
seit zwei Wochen.

Neu im Tab "Listen": der Eintrag "Chargen" mit allen Chargen ueber alle
Artikel, durchsuchbar, mit Schalter "Nur angebrochene". Je Zeile stehen
Menge, MHD bzw. Verbrauchsdatum, Lagerort und ein Abzeichen
"angebrochen"; bei einer offenen Packung die Restmenge in Basiseinheiten
("980 g von 1.000 g"), denn "0,98 Packungen" sagt niemandem etwas.

Nach links wischen gibt die zwei Buchungen:

"Anbrechen" mit Rueckfrage - eine Packung wird abgespalten und bekommt
ihr Verbrauchsdatum, der Rest bleibt zu und behaelt sein MHD.

"Verbrauchen" oeffnet ein Fenster fuer die VERBRAUCHTE Menge (nicht den
Rest - das ist die Zahl, die man kennt). Die Einheitenauswahl holt die
Hausmasse der Kategorie dazu, "2 EL" ist damit buchbar und landet bei
Mehl als 20 g. War die Charge noch zu, bricht die Buchung sie an; der
Haken dafuer ist abwaehlbar.

Loeschen fehlt in der Liste mit Absicht: Eine Charge verschwindet durchs
Verbrauchen, und das Korrigieren sitzt beim Artikel.

Dort zeigt die Chargenzeile jetzt auch "angebrochen · verbrauchen bis
..." - zwei Zeilen mit gleicher Menge waren sonst nicht zu
unterscheiden.

Alles in bestehende Dateien gelegt, keine neue Quelldatei: Das
mitgelieferte Xcode-Projekt listet die Dateien einzeln, eine neue waere
ohne "xcodegen generate" stillschweigend nicht mitgebaut worden.

Gegen den laufenden Server geprueft, genau mit den Aufrufen, die die App
macht: /lots/rows liefert alle Felder, die das Swift-Modell liest;
{"quantity":null} an /lots/{id}/open spaltet 1000 von 2000 g ab und
setzt consume_by; 2 Essloeffel an /lots/{id}/consume buchen 20 g ab und
die Antwort traegt opened und lot.

Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind Klammer-
und Anfuehrungsbalance (und dabei EIN echter Fehler gefunden: ein
deutsches Anfuehrungszeichen mit geradem Schlusszeichen haette eine
Zeichenkette vorzeitig beendet), dass formatAmount global erreichbar
ist, und dass die benutzten Hilfsfunktionen so heissen. Der Build in
Xcode bleibt die Probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:40:23 +02:00
ScarriffleandClaude Opus 5 f04d80a330 App: die selbst draufgesetzten Posten fehlten in der Einkaufsliste
Die Einkaufsliste besteht aus zwei Haelften: gerechnete Bedarfe (Artikel
und Gruppen unter ihrem Mindestbestand, Bedarfe je Lagerort) und das,
was jemand selbst draufgesetzt hat - eigene Eintraege und die aus einem
Rezept uebernommenen Zutaten.

Die App kannte nur die erste Haelfte. Sie fragte /shopping-list,
/shopping-list/groups und /shopping-list/by-location ab - drei Routen,
die ausschliesslich rechnen. Die gespeicherten Eintraege liegen in
/shopping-list/entries und kommen gebuendelt ueber /shopping-list/all;
danach hat die App nie gefragt. Ergebnis: Man setzt im Web etwas auf die
Liste, nimmt das Telefon mit in den Laden, und es ist nicht da.

Jetzt holt die App die gebuendelte Route - ein Aufruf statt drei - und
zeigt die eigenen Posten in einem eigenen Abschnitt "Selbst
draufgesetzt", mit Herkunft ("fuer Pfannkuchen") und Menge. Antippen
hakt ab: Der Posten verschwindet sofort, geht das Speichern schief,
kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.

Dieselbe Blindstelle hatte die Dashboard-Karte der App; sie meldete
"alle Mindestbestaende sind gedeckt", waehrend Posten auf der Liste
standen. Auch sie nimmt jetzt die gebuendelte Route.

Dazu ein Hinweis im Docstring von GET /shopping-list: Diese Route ist
ein Drittel der Liste, wer "die Liste" will, nimmt /all. Die Aufteilung
hat in zwei Tagen zweimal jemanden glauben lassen, es sei nichts da -
beim zweiten Mal war es die App selbst.

Gegen den laufenden Server geprueft: Mit einem Eintrag "Backpapier"
liefert /shopping-list weiterhin [] und /shopping-list/all den Eintrag
samt art, name, menge_text, source, recipe_name und done - genau die
Felder, die das neue Swift-Modell liest.

Nicht kompiliert: kein Xcode auf dieser Maschine. Geprueft sind
Klammer- und Anfuehrungsbalance, dass die drei alten Routen niemand mehr
aufruft, und dass die benutzten Hilfsfunktionen (makeRequest, jsonBody,
send) wirklich so heissen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-03 07:24:58 +02:00
ScarriffleandClaude Opus 5 892c52e49a App: /expiring kann jetzt ohne MHD kommen - das hat sie nicht ausgehalten
Beim Zusammenstellen der Testliste fuer den naechsten iOS-Build
aufgefallen, bevor es jemandem begegnet ist: Seit den angebrochenen
Packungen liefert /expiring auch Chargen OHNE aufgedrucktes MHD - eine
geoeffnete Packung hat nur ein Verbrauchsdatum, und genau die gehoert in
diese Liste. `best_before` ist dort seither null.

Im Swift-Modell stand `let bestBefore: String`, nicht optional. Eine
einzige solche Charge haette das Entschluesseln der GANZEN Antwort
scheitern lassen: keine Ablaufliste, keine Ablaufkarte - und keine
Erinnerungen, denn der Planer laedt dieselbe Antwort.

Gegen den laufenden Server geprueft: Eine angebrochene Packung ohne MHD
kommt als {"best_before": null, "consume_by": "2026-10-05",
"warn_date": "2026-10-05", "warn_source": "consume_by"}.

Mitgenommen, weil es ohnehin an derselben Stelle sitzt: Das Modell kennt
jetzt opened_on, consume_by, warn_date und warn_source. Die Erinnerungen
haengen am Warndatum statt am MHD - eine angebrochene Packung ohne
aufgedrucktes Datum braucht sie am dringendsten und bekam bisher keine.
Liste und Dashboard-Karte schreiben "verbrauchen bis 05.10." statt
"MHD ohne MHD", denn das sind zwei verschiedene Aussagen.

Nicht kompiliert: Auf dieser Maschine gibt es kein Xcode. Geprueft sind
Klammerbalance, Anfuehrungszeichen, dass item.bestBefore nirgends mehr
nicht-optional benutzt wird, dass ExpiringItem nirgends von Hand erzeugt
wird (nur entschluesselt), und der Abgleich jedes Feldnamens gegen eine
echte Serverantwort. Der Build in Xcode bleibt die Probe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 21:13:43 +02:00
ScarriffleandClaude Opus 5 25e7f23c6c Tabellenfilter und Sortierung in die Adresszeile
Filter, Sortierung und ausgeblendete Spalten lagen nur im Browserspeicher.
Ein Neuladen ueberstanden sie damit schon, aber verschicken liess sich ein
gefilterter Stand nicht, und der Adresszeile sah man nicht an, was man
gerade ansieht.

Das Format kommt ohne Maskierung aus, weil Mehrfachauswahlen als
WIEDERHOLTER Parameter stehen statt als Liste mit eigenem Trennzeichen:

  ?f.product=milch&v.art=Lebensmittel&v.art=Gegenstand&sort=menge:desc&aus=created

Sonst zerbraeche ein Filterwert wie "Klamotten -> kurze Hosen" oder
"(ohne)" an der erstbesten Kommastelle. Der Sonderfall "keiner der Werte
gewaehlt" bekommt ein eigenes vn.<spalte>=1 - ein leerer Wert ist belegt,
es gibt echte Leerwerte ("(Leere)").

Die Vorrangregel: Der Browserspeicher ist die persoenliche Vorgabe, die
URL schlaegt sie. Geschrieben wird der Speicher aber NUR von
Bedienhandlungen. Wer einen fremden gefilterten Link oeffnet, soll seine
eigenen Einstellungen nicht verlieren, bloss weil er geschaut hat.

Dabei ist mein erster Versuch durchgefallen, und zwar sichtbar: Ich
wollte den ersten Speicherlauf ueberspringen. React fuehrt Effekte im
Entwicklungsmodus doppelt aus - der zweite Lauf schrieb dann doch, und
die Probe zeigte die fremden Filter im eigenen Speicher stehen. Jetzt
haengt ein Merker an den Settern statt am Mounten, und Spaltenbreiten und
-reihenfolge werden davon ausgenommen: Die entstehen immer durch Ziehen
und sind reines Layout.

Fremde Parameter derselben Seite bleiben stehen - auf der
Kategorienseite stehen kat und panel in derselben Adresse. Deshalb
bekommt setSearchParams eine Funktion und keinen fertigen Wert, sonst
raeumte ein veralteter Abschluss sie weg.

Von Hand durchgeklickt: Filter, Sortierung und ausgeblendete Spalte
stehen in der URL; eine blanke Adresse fuellt sich aus der gemerkten
Vorgabe; ein fremder Link gewinnt in der Ansicht und laesst den eigenen
Speicher unangetastet; eine eigene Aenderung wird gemerkt; "Filter
loeschen" raeumt beides; sieben Tastendruecke im Filterfeld erzeugen
NULL Verlaufseintraege; ein Tabellenfilter laesst kat/panel stehen;
/auth/callback wird weiterhin nicht vom Auffangmuster geschluckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 16:19:55 +02:00
ScarriffleandClaude Opus 5 18481bbca0 Seitenzustand in die Adresszeile
Neu web/src/urlState.js mit useUrlWert/useUrlZahl/useUrlWerte. Zwei
Regeln stecken darin, beide damit die Adresszeile brauchbar bleibt:
geschrieben wird immer mit replace - sonst legt jeder Tastendruck in
einem Suchfeld einen Verlaufseintrag an und die Zurueck-Taste braeuchte
zwanzig Versuche, um die Seite zu verlassen -, und Vorgabewerte fliegen
aus der URL, sonst sammelt sich "?typ=&q=&kat=" an.

useUrlWerte gibt es, weil zwei getrennte Schreibvorgaenge im selben
Durchlauf einander ueberschreiben: Beide bekommen denselben
Ausgangsstand. Wo ein Klick zwei Dinge aendert ("Kategorie 12, Karte
Masse"), muss es ein Aufruf sein.

Damit wandern in die URL:

- Rezepte: Das Rezept steht jetzt im PFAD (/rezepte/<slug>), weil es
  benennt, was man ansieht - so wie /products/:id und /i/:uid. Portionen
  und Suchbegriff als Parameter. "Pfannkuchen fuer 12" ist damit ein
  verschickbarer Link.
- Kategorien: Segment-Umschalter und die geoeffnete Seitenkarte
  (?typ=food&kat=6&panel=masse). Gespeichert wird die Kennung, nicht das
  Objekt - die Kategorien kommen erst vom Server.
- Gruppen: die geoeffnete Seitenkarte.
- Artikel: der Segment-Umschalter.
- Ein- und Auslagern: der gewaehlte Artikel als Kennung, nachgeladen beim
  Aufruf. Eine halbfertige Buchung ueberlebt damit ein Neuladen.

Die Portionszahl landet NUR in der URL, wenn jemand sie selbst gestellt
hat. Die Vorbelegung aus dem Rezept bleibt draussen - sonst traegt jedes
geoeffnete Rezept ein ?portionen mit sich herum, das gar keine
Entscheidung ist.

Dazu im Backend: Ein unbekanntes Rezept ist jetzt 404 statt 502 und sagt
"Dieses Rezept gibt es in Mealie nicht (mehr)" statt "Mealie kennt
/api/recipes/x nicht". Das wurde noetig, weil ein Rezept seit diesem
Commit verlinkbar ist und ein alter Link irgendwann ins Leere zeigt -
eine gewoehnliche Auskunft, kein Gateway-Fehler.

Von Hand durchgeklickt: Rezept oeffnen und Portionen auf 12 ->
/rezepte/pfannkuchen?portionen=12, Neuladen bringt 250 g -> 750 g zurueck;
unbekannter Slug zeigt den klaren Hinweis und laesst die Liste stehen;
Kategorie-Karte und Segment ueberstehen F5, Schliessen raeumt die URL;
/checkin?artikel=1 laedt den Artikel nach, ?artikel=999 raeumt die
Adresse und zeigt die Suchmaske. 1 neuer Test, Suite bei 518.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 16:13:37 +02:00
ScarriffleandClaude Opus 5 a1b10d9ec3 Startseite heisst /home, Bearbeiten steht in der URL
Die Adresszeile sagte nicht, wo man ist. Die Startseite lag auf "/",
waehrend jede andere Seite einen sprechenden Pfad hat, und der
Bearbeitungsmodus des Dashboards stand nirgends: Neuladen warf einen
heraus, der Stand liess sich nicht verschicken, die Zurueck-Taste tat
nichts.

Jetzt /home und /home/edit, fuer weitere Dashboards /d/:id und
/d/:id/edit. "/" und jeder unbekannte Pfad leiten sichtbar auf /home um.

Der Kern der Arbeit steckte in Dashboard.jsx: Der Modus war nicht als
eigenes Flag gefuehrt, sondern aus `entwurf !== null` abgeleitet - eine
Variable, die zugleich Modus und ungespeicherte Aenderung war. Der Modus
kommt jetzt als Prop aus der Route, `entwurf` ist nur noch das, was sein
Name sagt. Weil der Entwurf allein im Speicher lebt, baut ihn ein Effekt
aus dem gespeicherten Layout auf, sobald dieses geladen ist - sonst
koennte man /home/edit nicht direkt aufrufen oder neu laden.

Dabei eine Falle vermieden: `aktives` ist nach jedem setDashboards ein
neues Objekt. Ohne Merker, zu welchem Dashboard der Entwurf gehoert,
haette jedes Neuladen der Liste waehrend des Bearbeitens die
ungespeicherte Anordnung verworfen.

"Anordnen" navigiert mit push, damit die Zurueck-Taste den Modus
verlaesst; "Speichern" und "Abbrechen" mit replace, damit sie nicht
hineinfuehrt. /home/edit bei verbindlicher Admin-Vorgabe faellt auf die
Ansicht zurueck, statt eine Leiste ohne Speichern-Knopf zu zeigen.

Dazu zwei Dinge, die sonst stillschweigend gebrochen waeren:

- Der ErrorBoundary-Schluessel hing am ganzen Pfad. /home/edit haette als
  andere Seite gegolten, den Baum neu eingehaengt und beim Umschalten
  Dashboards, Artikel und Lagerorte erneut geladen. Jetzt nur das erste
  Pfadsegment - im Mitschnitt steht beim Umschalten kein einziger Aufruf.
- Die Seitenleisten-Links trugen `end` (exakter Match), weil "/" sonst
  ueberall als Treffer galt. Bei /home ist das nicht noetig, und ohne
  `end` bleibt der Eintrag auch unter /home/edit markiert.

Von Hand durchgeklickt: "/" und "/gibtsnicht" landen auf /home;
Anordnen -> /home/edit, Neuladen bleibt im Modus mit sieben Karten als
Entwurf, Zurueck verlaesst ihn, nach Abbrechen fuehrt Zurueck nicht
wieder hinein; zweites Dashboard -> /d/1/edit; bei erzwungener Vorgabe
faellt /home/edit auf /home zurueck. Backend-Suite unveraendert bei 517.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 16:04:23 +02:00
ScarriffleandClaude Opus 5 98db3339a2 Vier Anzeigefehler aus dem Gebrauch
"NaN Glaeser" auf der Karte "Artikel im Blick". Die Karte reichte einen
ARTIKEL an articlePrimary() weiter, das fuer CHARGEN geschrieben ist und
die Menge unter dem Namen `quantity` sucht. Ein Artikel nennt sie
`stock` - also undefined / 190 = NaN. Neu bestandPrimary() und
bestandSecondary() in units.js als die eine Stelle, an der zwischen den
beiden Namen uebersetzt wird; sie delegieren an die vorhandenen
Formatierer, damit es nicht zwei Fassungen gibt. Steckte seit 8959814
drin.

Dazu gibt fmt() fuer nicht-endliche Zahlen jetzt einen Strich aus statt
"NaN". Dem Benutzer "NaN" hinzuschreiben ist nie richtig - der eigentliche
Fehler wird trotzdem an der Quelle behoben, nicht hier.

Die Spalte "Verbrauch" schnitt ab. Zwei beschriftete Knoepfe brauchen
rund 230 px, die Spalte hatte 190, und .datatable ist table-layout:fixed
mit overflow:hidden. Weil die Zelle rechtsbuendig ist, fiel der ANFANG
weg - sichtbar stand dort "rbrauchen". Jetzt 240 px, dazu flex-wrap in
der Zelle, damit die Knoepfe umbrechen statt abzuschneiden, wenn jemand
die Spalte von Hand schmaler zieht.

Die LETZTE Spalte liess sich nicht verbreitern. Ihr Anfasser sitzt exakt
am rechten Rand des Rollbereichs (gemessen: 0 px Luft), und nach rechts
ist dort Schluss - der Zeiger kann nicht weiter, also kam nie ein
positiver Abstand zum Startpunkt zustande. Der Resize rechnet jetzt mit
der BEWEGUNG seit dem letzten Schritt statt mit dem Abstand zum Start,
und in der Randzone waechst die Spalte von selbst weiter, solange die
Taste haelt; ein Taktgeber treibt das an, denn ein stehender Zeiger
meldet keine Bewegung. Die Tabelle rollt dabei mit, damit der Anfasser
unter dem Zeiger bleibt.

"3 Glas" statt "3 Glaeser" in der Chargenliste, waehrend die Uebersicht
"3 Glaeser" schrieb. Die Mehrzahlformen liegen in einer Modulvariablen,
weil auch Nicht-Komponenten sie brauchen - eine Aenderung daran loest
aber kein Neuzeichnen aus. Wer seine Liste zeichnete, bevor
/package-types zurueck war, blieb bei der Einzahl. Ein Zaehler im
Einstellungs-Kontext zieht die Formatierer jetzt nach.

Von Hand geprueft: Karte zeigt "3 Glaeser / 570 g"; beide Knoepfe stehen
vollstaendig in der Spalte; die letzte Spalte waechst beim Halten am
Rand von 240 auf ueber 470 und bleibt beim Loslassen stehen, waehrend
eine mittlere Spalte weiterhin exakt der Ziehstrecke folgt (170 -> 230);
Chargenliste schreibt "3 Glaeser".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 15:39:44 +02:00
ScarriffleandClaude Opus 5 4972feef09 Artikelauswahl im Dashboard: Marke dazu, Suche davor
Die Auswahl zeigte nur den Namen. In einem Vorrat mit mehreren
Herstellern standen damit zwei Zeilen "Pesto Rosso" untereinander, ohne
jeden Unterschied - man konnte nur raten und hinterher nachsehen, ob die
richtige Karte entstanden ist. Dazu war es eine blanke Liste ueber den
ganzen Katalog, hunderte Zeilen lang.

Jetzt steht in jeder Zeile "Name - Marke - Gebindegroesse", und darueber
ein Suchfeld, das wortweise filtert ("pesto mutti" findet eins von
vier). Der bereits gewaehlte Artikel bleibt in der Liste, auch wenn die
Suche ihn ausblendet - sonst zeigte das Feld nichts an und ein Klick
daneben haette die Wahl verworfen.

Der Kartentitel traegt die Marke jetzt ebenfalls, sonst hiessen zwei
Karten weiterhin gleich. Die Gebindegroesse bleibt dort weg, sie waere
im Titel zu lang; dafuer gibt es den Schalter an artikelLabel().

Die Beschriftung liegt in units.js, weil sie ueberall gebraucht wird, wo
Artikel zur Auswahl stehen - zwei Fassungen davon liefen sonst
auseinander.

Von Hand geprueft: zwei gleichnamige Pesto angelegt, Auswahl
unterscheidet sie, Suche kuerzt auf einen, Titel liest "Pesto Rosso -
Mutti". Suite bei 517.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 15:23:48 +02:00
ScarriffleandClaude Opus 5 2e610677aa Dashboard kennt die Einkaufsliste jetzt ganz
Der Knopf im Rezept schrieb die fehlenden Zutaten weg, aber auf der
Uebersicht blieb davon nichts zu sehen: Die Karte "Einkaufsliste" holte
die drei GERECHNETEN Quellen einzeln und wusste von den gespeicherten
Eintraegen nichts. Wer die Uebersicht als Zettel benutzt, hat sie
schlicht nicht gesehen.

Die Karte holt jetzt alles mit einem Aufruf und zeigt oben den
Abschnitt "Selbst draufgesetzt" - eigene Eintraege und die aus einem
Rezept uebernommenen, mit Herkunft und Menge. Abgehakte verschwinden
dort, die Karte ist zum Einkaufen da und nicht zum Nachlesen.

Das Abhaken war in der Karte reiner Sitzungszustand und damit nach dem
Neuladen weg. Es benutzt jetzt dieselben Schluessel und dieselben Routen
wie die volle Seite: Der Haken wandert sofort, speichert im
Hintergrund, und springt zurueck, wenn das schiefgeht.

Dazu die Kennzahl "Nachzukaufen": Sie zaehlte nur die gerechneten
Zeilen und meldete "alles erreicht", waehrend auf der Liste fuenf Posten
standen. Offene eigene Eintraege zaehlen jetzt mit, abgehakte nicht -
die sind gekauft.

Von Hand geprueft: Zwei Eintraege angelegt, Kennzahl springt auf 2, die
Karte zeigt beide samt "2 Packungen", und ein Haken aus der Karte steht
anschliessend in der Datenbank. 1 neuer Test, Suite bei 517.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 15:20:05 +02:00
ScarriffleandClaude Opus 5 b903de3841 Masse einer Kategorie: nur zeigen, was etwas bedeutet
Jedes angelegte Mass bekam eine Zeile, und jede Zeile drei gestapelte
Reihen (Name, Menge, Art) plus Muelleimer. Bei sechs Massen sah das aus
wie ein Formular voller Pflichtangaben - und keine davon ist eine.

Jetzt drei Sorten:

- Hier eingetragen: bearbeitbare Zeile mit Muelleimer.
- Von oben geerbt: eine Zeile zum Lesen ("Essloeffel (EL) 10 g geerbt")
  mit Knopf "abweichen", der sie bei Bedarf zur Eingabezeile macht.
- Alles andere: steckt in der Auswahl "Mass hinzufuegen" und stoert
  nicht. Hinzufuegen klappt eine leere Zeile auf; das Kreuz daneben
  klappt sie wieder zu, ohne etwas zu speichern.

Dazu das Zeilenlayout auf EINE Zeile gebracht: Name, Menge, Art, Aktion
nebeneinander, Spalten duerfen schrumpfen, Einheiten als Kurzform
(g/ml/Stk) - in der schmalen Seitenkarte ist das lesbarer, der Name
steht ja daneben.

Von Hand geprueft: Eine Kategorie mit zwei eingetragenen Massen zeigt
zwei Zeilen statt sechs, die Unterkategorie drei geerbte Lesezeilen und
kein einziges Eingabefeld. "Tasse" ueber die Auswahl hinzugefuegt, 200
ml eingetragen, gespeichert. Zeilenhoehe 29 bzw. 40 Pixel, kein
Umbruch bei 270 Pixel Kartenbreite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 15:09:53 +02:00
ScarriffleandClaude Opus 5 7ce07daa97 Globale Oeffnungsfrist in die Einstellungen
Die letzte Station der Vererbungskette hatte keine Oberflaeche: Artikel >
Kategorie (naechster Vorfahr) > global. Die Kategorien konnten seit dem
letzten Commit abweichen, aber der Wert, von dem alles ausgeht, liess
sich nur per API setzen.

Er steht jetzt in der Karte "Ablaufwarnung", direkt unter der
Vorwarnzeit - beides sind Fristen, die man im selben Moment einstellt.
Mindestens 1 Tag, denn er ist Pflicht: Waere er leer, bekaeme eine
angebrochene Packung gar kein "zu verbrauchen bis".

Von Hand geprueft: 3 auf 4 Tage geaendert und gespeichert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:39:20 +02:00
ScarriffleandClaude Opus 5 f61c76034c Hausmasse und Oeffnungsfrist bekommen eine Oberflaeche
Die Endpunkte standen seit dem Backend-Commit, aber eintragen liess sich
nichts - die Mengen gingen nur per API rein. Damit war "2 EL Mehl" im
Verbrauchen-Dialog zwar vorgesehen, aber fuer niemanden erreichbar.

Zwei Stellen dafuer:

Stammdaten > Masse verwaltet nur die NAMEN ("Essloeffel", "Tasse"). Die
Seite ist absichtlich kurz, denn die Mengen gehoeren nicht an das Mass:
Ein Essloeffel Mehl sind etwa 10 g, einer Zucker etwa 12 g, und eine
Tasse Milch ist Milliliter. Ein globaler Wert waere bei jedem zweiten
Artikel falsch.

Kategorien > "Masse & Frist" ist deshalb der Ort, an dem die Arbeit
passiert. Beides steckt in einem Bedienfeld, weil es dieselbe Mechanik
ist: Der Wert gilt fuer die Kategorie und alles darunter, und der
naechste Vorfahr mit einem Eintrag gewinnt. Leere Felder erben - und
zeigen als Platzhalter, WAS sie erben ("erbt 5 Tage (Kategorie: Mehl &
Zucker)", "10 g" mit Abzeichen "geerbt"). Ohne das muesste man den
Kategorienbaum im Kopf verfolgen, um eine leere Eingabe zu verstehen.

Dafuer zwei kleine Ergaenzungen am Backend: CategoryOut nennt die
geerbte Frist samt Herkunft, und /categories/{id}/measures nimmt
?vererbt=true, um neben den eigenen auch die von oben geerbten Mengen zu
liefern. Die Bearbeitungsansicht ohne Flagge bleibt wie sie war.

Von Hand durchgeklickt - und dabei einen echten Fehler gefunden: Der
API-Aufruf im Browser hat das vererbt-Flag verschluckt, weil die
Funktion nur ein Argument annahm. Die Unterkategorie zeigte dadurch
leere Felder, obwohl der Server die geerbten Werte lieferte. Jetzt
stehen dort "10 g" und "3 g" mit der Quelle im Tooltip.

4 neue Tests, Suite bei 516.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:37:53 +02:00
ScarriffleandClaude Opus 5 795e233a1b Einkaufsliste bekommt ein Gedaechtnis - und den Knopf aus dem Rezept
Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es
ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert
drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir
fuer das Rezept". Der Knopf konnte nirgends hinschreiben.

Zwei Tabellen dafuer, und die Trennung ist der Kern:

shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines
von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn
eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den
Zettel, und gerade die unbekannte Zutat ist die, die man vergisst.

shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht
den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie
aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort
falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem
Neuladen weg - mitten im Supermarkt.

Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge:
Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit
der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es
hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen
der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber
verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe
Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts -
abgehakt heisst gekauft.

Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die
Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet,
weil man keine halbe Packung kauft.

In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld,
Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide
Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das
Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl,
springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf
statt mit drei.

Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48
Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier
und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen
aktualisiert diese vier statt acht daraus zu machen; ein Haken
ueberlebt das Neuladen. 17 neue Tests, Suite bei 512.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:30:00 +02:00
ScarriffleandClaude Opus 5 f02e9a3840 Rezeptseite: Portionen drehen laesst die Seite stehen
Bisher setzte jede Aenderung an den Portionen das Rezept auf null - die
ganze rechte Karte verschwand und zeigte "Links ein Rezept waehlen",
bevor sie neu aufbaute. Bei einem Druck auf den Zahlenpfeil ist das ein
Flackern, bei getippten Zahlen dreimal hintereinander.

Jetzt bleibt stehen, was sich nicht aendert: Rezeptname, Portionenfeld
und die Zutatenliste. Nur die drei Wertespalten (Gebraucht, Im Haus,
Zustand) sind waehrend des Rechnens unklar und zeigen einen schimmernden
Platzhalter; das Ergebnis blendet sich danach ein (.wert-ein, 0.28s, mit
Ruecksicht auf prefers-reduced-motion). Das Kopf-Abzeichen sagt in der
Zeit "wird gerechnet..." statt eine veraltete Zahl, und die roten/gelben
Zeilenfarben setzen aus - sie gelten fuer die alten Mengen.

Dazu zwei Dinge, die ohne das Standenbleiben nicht aufgefallen waeren:

- Das Zahlenfeld reagiert sofort, die Anfrage wartet 280 ms. Wer von 4
  auf 12 tippt, bekam sonst erst ein Ergebnis fuer 1.
- Antworten koennen in falscher Reihenfolge eintreffen. Eine laufende
  Nummer je Anfrage verwirft alles, was nicht die neueste ist - sonst
  gewinnt beim schnellen Klicken zufaellig die langsamere Antwort.

Ausserdem: Eine Knopfreihe direkt unter einer Angabenliste klebte am
letzten Wert (Mealie-Karte in den Einstellungen). .kv hat bewusst keinen
Aussenabstand und .field-inline nur einen nach unten - den fehlenden
setzt jetzt eine Geschwisterregel, damit es ueberall gilt, wo dieses
Paar vorkommt.

Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen von 4 auf 12
Portionen - waehrend des Ladens 15 Platzhalter und die Zutatennamen
stehen, danach 250 g -> 750 g und 2 EL -> 6 EL (60 g), keine leere Karte
dazwischen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:20:33 +02:00
ScarriffleandClaude Opus 5 53a8b9e0b6 Anbrechen und Teilverbrauch bekommen Knoepfe
Das Backend konnte das seit dem letzten Commit, aber ohne Oberflaeche kam
niemand dran. Jetzt traegt jede Charge zwei Knoepfe:

"Anbrechen" spaltet eine Packung ab, gibt ihr ein "zu verbrauchen bis" und
laesst den Rest ungeoeffnet mit seinem eigenen MHD - mit Rueckfrage, weil
sich die abgespaltene Packung danach anders verhaelt.

"Verbrauchen" nimmt die VERBRAUCHTE Menge ("100 ml genommen", "2 von 6
Broetchen"), nicht den Rest - das ist die Zahl, die man kennt. War die
Charge noch zu, wird sie dabei angebrochen; der Haken dafuer ist
abwaehlbar, falls man nur eine Buchung nachtraegt. Die Einheitenauswahl
holt die Hausmasse der Kategorie dazu, deshalb laesst sich "2 EL" buchen
und landet bei Mehl als 20 g im Bestand.

Die Liste zeigt den Zustand jetzt auch: Spalte "Zustand" (angebrochen),
Spalte "Verbrauchen bis", und bei einer offenen Packung die Restmenge in
Basiseinheiten statt im Gebinde - "980 g von 1.000 g" sagt etwas,
"0,98 Packungen" nicht.

Gewarnt wird nach dem FRUEHEREN von MHD und Verbrauchsdatum (units.js::
faelligAm, Spiegelbild von Lot.faellig_am): Eine angebrochene Milch ist
nicht mehr bis zum aufgedruckten Datum gut. Dafuer ein eigenes Farb-Token
--open in ruhigem Blaugrau - angebrochen ist ein Zustand, keine Warnung.

Dazu ein Fehler, der die ganze Anzeige wertlos gemacht haette: In
list_all_lots fehlten opened_on, consume_by und opened_quantity. Die
Liste haette eine offene Packung wie jede andere Charge gezeigt, samt
falscher Ablaufwarnung. Test dagegen.

Von Hand durchgeklickt: 4x1l Milch anbrechen ergibt 1 l offen mit
Verbrauchsdatum 5.10. und 3 l zu mit MHD 15.11.; "2 EL" aus dem Mehl
ergibt 980 g offen und 1 Packung zu. Suite bei 495.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:17:20 +02:00
ScarriffleandClaude Opus 5 fe6327c913 Rezept-Zuordnung: ein Eigenschaftswort frisst keinen Artikelnamen mehr
Gefunden bei der Probe, ob ein Embedding-Modell die Zuordnung besser
hinbekommt: Die Sperrliste der Eigenschaftswoerter lief als eine einzige
Liste mit Praefix-Vergleich. "Frischkaese" fing mit "frisch" an, galt
damit als blosse Eigenschaft und fiel aus dem Vergleich - die Zutat
"Kaese" fand den Frischkaese deshalb NIE. Ebenso "Biomilch" (bio),
"Rohrzucker" (roh), "Kleingebaeck" (klein), "Ganzkornbrot" (ganz),
"Grobgriess" (grob).

Unsichtbar, weil kein falscher Treffer entstand, sondern ein richtiger
ausblieb - die Zutat stand einfach als "nicht zugeordnet" da.

Jetzt zwei Toepfe statt einem. Stammformen wie "getrock" oder "vegan"
vergleichen weiter ueber den Wortanfang, weil Deutsch beugt und diese
Stuecke keine Lebensmittelnamen anfangen. Kurze Alltagswoerter wie
"frisch", "bio", "roh", "klein" zaehlen nur noch als Eigenschaft, wenn
hoechstens eine Beugungsendung dranhaengt: "frische" ja, "frischkase"
nein.

Gegenprobe im Test: "frische Hefe" findet die "Frischen Kraeuter"
weiterhin nicht - die Sperre ist praeziser geworden, nicht schwaecher.

7 neue Tests, Suite bei 494.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 14:01:56 +02:00
ScarriffleandClaude Opus 5 8072e7f566 Zutaten von Hand zuordnen - und Vorrania merkt es sich
Der unscharfe Abgleich muss raten und wird das immer tun: "Lime Curd" hat
mit "Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben.
Bisher war das eine Sackgasse - die Zutat blieb unzugeordnet, bei jedem
Aufruf neu.

Jetzt traegt jede Zutatenzeile einen Knopf "Zuordnen". Entschieden wird
gegen Artikel UND Gruppen, gemerkt unter dem normalisierten Namen des
Lebensmittels: "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " treffen
dieselbe Zeile. Ab dann gilt die Entscheidung fuer jedes Rezept, in dem
diese Zutat vorkommt - eine Zutat wird hoechstens einmal zugeordnet.

Schluessel ist der Name, nicht die Mealie-ID: IDs sind UUIDs, die beim
Neuanlegen eines Lebensmittels wechseln, und nur ueber den Namen greift
die Zuordnung auch fuer Zutaten ohne verknuepftes Lebensmittel ("2 Eier").

Im Auswahldialog stehen oben die Vorschlaege, die der Abgleich selbst
gefunden hat - meist ist der Richtige darunter, nur nicht an erster
Stelle -, darunter die freie Suche ueber alles. Die Basiseinheit steht
dabei, damit nicht wieder Milliliter auf ein Gramm-Produkt wandern.

Beim Raten bleiben Gegenstaende aussen vor; von Hand darf alles gewaehlt
werden ausser Einzelstuecken. Backpapier ist kein Lebensmittel und steht
trotzdem in Rezepten, waehrend ein Einzelstueck nie eine Zutat ist.

Zeigt das Ziel ins Leere, weil der Artikel geloescht wurde, faellt die
Zeile beim Laden still heraus: Auf SQLite greift kein Fremdschluessel,
und ein geloeschter Artikel darf kein Rezept mehr sprengen.

Die Zuordnungs-Routen stehen bewusst vor /recipes/{slug}, sonst liest
FastAPI "zuordnungen" als Rezept-Slug und fragt Mealie danach. Ein Test
wacht ueber die Reihenfolge, weil ein Umsortieren der Datei den Fehler
sonst still einbauen wuerde.

16 neue Tests, Suite bei 487.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:40:23 +02:00
ScarriffleandClaude Opus 5 2a56cca3c8 Rezept-Zuordnung: die Einheit verraet das falsche Produkt
"200 ml Schlagsahne" landete auf "Kraeuter Sahne Haehnchen" - beide teilen
das Wort "Sahne", der Rest ist Zufall. Die Einheit haette es verraten: Das
Haehnchen wird in Gramm gefuehrt, das Rezept sagt Milliliter. Wo sich die
Rezept-Einheit gar nicht umrechnen laesst, wird deshalb nicht mehr
zugeordnet, sondern offen gelassen - lieber "nichts Passendes gefunden"
samt Begruendung als eine falsche Behauptung.

Entscheidend ist, WARUM die Umrechnung scheitert, und nur einer der Gruende
sagt etwas ueber den Artikel:

- Bekannte Einheit falscher Art ohne Zweiteinheit -> spricht gegen den
  Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
- Unbekanntes Wort oder nicht hinterlegtes Hausmass ("1 Tasse Linsen") ->
  sagt gar nichts. Die Linsen koennen voellig richtig sein, es fehlt bloss
  die Massangabe, und die laesst sich nachtragen.
- Gar keine Einheit ("3 Eier") -> passt immer.

Die erste Fassung hat das nicht unterschieden und damit "1 Tasse Linsen"
zu "nicht zugeordnet" gemacht, obwohl der Artikel stimmte - die beiden
bestehenden Tests haben das sofort gezeigt.

Bei mehreren Kandidaten gewinnt jetzt der mit passender Einheit, auch gegen
eine hoehere Namensaehnlichkeit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:25:41 +02:00
ScarriffleandClaude Opus 5 41d262cb2d Rezept-Zuordnung: "vegan" ist eine Eigenschaft, kein Lebensmittel
Der Wortanker nahm jedes geteilte Wort ab vier Zeichen - also auch
"vegan". Damit passte jedes vegane Produkt auf jede vegane Zutat: "vegane
Butter" landete auf "veganes Muehlen Knusper Bleu", "veganer griechischer
Joghurt" auf "Vegane Muehlen Nuggets" und "veganer Lime Curd" auf "Vegane
Muehlen-Schnitzel auf Basis von Soja". Geteilt wurde dort nur die
Eigenschaft, nicht die Sache.

Beschreibende Woerter begruenden deshalb keinen Treffer mehr - vegan,
vegetarisch, bio, frisch, geschmolzen, ungesalzen, geschaelt, geschnitten,
Zimmertemperatur, oder, optional und so weiter, dazu reine Zahlen. Als
Wortanfaenge aufgefuehrt, weil Deutsch beugt ("vegan", "vegane",
"veganer", "veganes"). Sie sperren aber nicht: "vegane Butter" findet
weiterhin eine "Vegane Butter", nur eben ueber das Wort "Butter".

Wichtig war, die Liste auf BEIDEN Seiten anzuwenden. Sonst steckt "vegan"
weiter im Produktnamen und der Teilstring-Vergleich greift von dort aus.

Gegenprobe mit dem echten Katalog: Von zehn Zutaten bleiben die drei
richtigen Treffer (Mehl, Vollmilch, Aepfel), die sieben erfundenen sind
weg.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:22:38 +02:00
ScarriffleandClaude Opus 5 4e34accee3 Rezept-Zuordnung: Gegenstaende raus, Wortanker rein, Kategorie zuerst
Die Zuordnung war unbrauchbar: "Mehl Typ 405" landete auf "Muehlen
Geschnetzeltes", "Eier" auf "Reis", "Zucker" auf "Joystick fuer WII",
"Apfel" auf "Snack Collection Platten 4 (Waffeln)" und "Salz" auf "Shirt
Casual Fit". Drei Fehler steckten darin.

1. Gegenstaende standen ueberhaupt zur Auswahl. Der Kassenzettel-Abgleich
   filtert sie seit immer heraus, mein Rezeptabgleich nicht - also konnten
   ein Joystick, ein Shirt und eine Waffelplatte als Zutat gelten. Jetzt
   zaehlen nur Lebensmittel, und Einzelstuecke bleiben ebenfalls draussen.

2. Entschieden hat eine Buchstaben-Aehnlichkeit. Die haelt "Eier" und
   "Reis" fuer verwandt, weil sich Zeichen ueberlappen. Neu muss ein ganzes
   Wort geteilt sein: "Mehl" in "Weissmehl Typ 550" ja, "Mehl" in "Muehlen
   Geschnetzeltes" nein. Umlaute und Mehrzahl werden dabei zusammengezogen,
   damit "Apfel" auch "Aepfel" findet.

3. Gesucht wurde im ganzen Katalog. Jetzt zuerst dort, wo es hingehoert:
   Teilt der Zutatenname ein Wort mit einer Kategorie, gilt nur deren
   Teilbaum - "Mehl Typ 405" sucht in "Mehl & Zucker", nicht im Fleisch.
   Erst wenn darin nichts passt, zaehlt der Rest.

Die Punktzahl ist damit keine Sperre mehr, sondern nur noch die
Reihenfolge. Als Sperre war sie untauglich in beide Richtungen: "Vollmilch
Zimmertemperatur" gegen "H Vollmilch" kommt auf etwa 40 Punkte, weil das
lange Beiwort den Durchschnitt zieht - offensichtlich dasselbe Produkt.
"Mehl" gegen "Muehlen Geschnetzeltes" kam dagegen ueber 60.

Folge, die so gewollt ist: Wo nichts passt, steht jetzt "nicht zugeordnet"
und gar kein Vorschlag mehr. Ein Vorschlag, den niemand nachvollziehen
kann, ist schlimmer als keiner - er verleitet zum Durchklicken. Die
dauerhafte Loesung fuer echte Luecken ist die gemerkte Zuordnung
("Mealie-Lebensmittel X ist bei uns Y"), die noch aussteht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:18:27 +02:00
ScarriffleandClaude Opus 5 2247c8cf69 Rezepte aus Mealie gegen den Bestand pruefen
Neue Seite "Rezepte" (nur sichtbar, wenn Mealie eingerichtet ist): links
die Rezeptliste mit Suche, rechts das gewaehlte Rezept mit allen Zutaten
und der einen Frage, die zaehlt - kann ich das heute kochen, oder muss ich
vorher einkaufen? Je Zutat steht der Zustand: vorhanden, zu wenig, fehlt,
Menge unklar oder nicht zugeordnet; Fehlendes ist rot.

Die Portionenzahl laesst sich hochdrehen. Vorrania rechnet die Mengen dann
hoch, bevor es sie mit dem Bestand vergleicht - ein Rezept fuer 4, das man
fuer 8 kocht, braucht die doppelte Menge, und genau danach soll der
Abgleich gehen. Nennt das Rezept keine Portionszahl, wird nicht skaliert
und die Seite sagt das auch.

Drei Dinge entscheidet der Abgleich nacheinander (services/rezepte.py,
bewusst ohne HTTP, damit sich alles mit erfundenen Zutaten pruefen laesst):

1. Welcher Artikel ist gemeint? Unscharf ueber den Namen, gegen Artikel
   UND Gruppen - "Milch" ist markenuebergreifend gemeint, und genau dafuer
   gibt es Gruppen. Die Schwelle liegt hoeher als beim Kassenzettel: Dort
   sieht ein Mensch die Vorschlaege durch, hier darf nicht stillschweigend
   das Falsche als vorhanden gelten. Unter der Schwelle bleibt es bei
   "nicht zugeordnet" samt Kandidatenliste.
2. Wie viel ist das in Gramm? Ueber die Einheiten und die Hausmasse der
   Kategorie - "2 EL Zucker" wird zu 24 g. Fehlt das Mass fuer diese
   Kategorie, meldet die Zutat genau das zurueck (Massname und
   Kategorie-ID), damit daraus ein "Mass jetzt anlegen" werden kann.
3. Reicht der Bestand? Bei Gruppen ueber dieselbe gewichtete Summe wie in
   der Einkaufsliste, damit Untergruppen und Zweiteinheiten gleich zaehlen.

Die Fuzzy-Heuristik lag in routers/products.py und ist nach
services/matching.py gezogen: Der Rezeptabgleich stellt dieselbe Frage, und
zwei Kopien waeren mit der Zeit auseinandergelaufen. Der Kassenzettel-Scan
nutzt sie unveraendert weiter, test_receipt_match.py bleibt gruen.

Dazu zwei Dinge aus deiner Rueckmeldung zur Einstellungs-Karte:

- Der verbundene Zustand bleibt jetzt stehen, statt nur als Meldung
  aufzublitzen: Adresse, Version und "Dauertoken hinterlegt" samt den
  Knoepfen Pruefen / Zugang aendern / Trennen. Das Formular erscheint nur,
  solange nichts eingerichtet ist - ein Dauerzustand gehoert nicht in ein
  Eingabefeld, sonst sieht man nie, ob es geklappt hat.
- Leere Felder melden sich auf Deutsch ("Bitte das Passwort eingeben.")
  statt mit der rohen Schema-Meldung "String should have at least 1
  character", die niemandem sagt, welches Feld gemeint ist.

Der Knopf "Fehlende zur Einkaufsliste" fehlt noch: Vorranias Einkaufsliste
wird heute vollstaendig aus Mindestbestaenden gerechnet und hat keinen Ort
fuer einen eigenen Eintrag. Das ist der naechste Schritt; die Seite sagt es
auch so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 13:10:04 +02:00
ScarriffleandClaude Opus 5 7d8a54a3fc Mealie anbinden: Passwort einmal, danach nur noch ein Dauertoken
Unter Einstellungen gibt es jetzt eine Karte "Mealie (Rezepte)": Adresse,
Benutzername, Passwort. Vorrania meldet sich damit EINMAL an
(POST /api/auth/token), laesst sich von Mealie ueber
POST /api/users/api-tokens ein Dauertoken ausstellen und verwirft das
Passwort sofort - es steht in keiner Zeile der Datenbank, in keiner
Antwort und in keinem Fehlertext. Wer sein Passwort gar nicht erst
eintippen will, haekelt "Ich gebe lieber selbst ein Token ein" an.

Das Token liegt NICHT in den Einstellungen: GET /settings liefert jedem
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort
waere beim naechsten Geheimnis vergessen, deshalb eine eigene Tabelle ohne
jeden Leseweg - setzen, loeschen, und die Auskunft "ist gesetzt". Was
keinen Ausgang hat, kann nicht durchrutschen. Ein Test prueft genau das.

Der Dienst folgt services/oidc.py, nicht off.py: Bei Open Food Facts ist
"kein Treffer" der Normalfall und wird still zu None, hier ist "Mealie
antwortet nicht" ein Fehler, den der Nutzer sehen muss. Die Kennungen
trennen auch, was verschieden ist - ein falsches Passwort (400) ist etwas
anderes als ein weggetretener Server (502).

Mealie 1.x legt die Haushalts-Routen unter /api/groups/, 2.x unter
/api/households/. Erkannt wird das zur Laufzeit mit einer Anfrage und
dann eine Stunde gepuffert; ueber die Versionsnummer waere es unzuverlaessig,
die melden Installationen uneinheitlich.

Rezeptliste und -abgleich kommen als Naechstes; dies ist der Teil, mit dem
sich der Server ueberhaupt erst verbinden laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 12:54:43 +02:00
ScarriffleandClaude Opus 5 37ab1ba47d Seitenleiste: "Listen" ist ein Eintrag wie jeder andere
Die Gruppe sah aus wie eine Abschnittsueberschrift - klein, versal,
gedaempft - und stand damit optisch neben der Leiste statt darin. Jetzt
traegt sie die Klasse .nav-link wie alle anderen Eintraege und misst sich
genauso; nur das Verhalten ist anders, sie klappt auf statt zu navigieren.

In der CSS bleiben deshalb nur die Eigenheiten, die ein <button> braucht.
Alles Sichtbare kommt von .nav-link, damit die Hoehe nicht auseinanderlaeuft,
sobald dort jemand etwas aendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 12:49:37 +02:00
ScarriffleandClaude Opus 5 c66bd0b09e Chargen: mehrere auf einmal ausbuchen, teilweise abbuchen oder loeschen
Die Mehrfachauswahl gab es schon, sie konnte aber nur den Lagerort setzen.
Jetzt kommen drei Aktionen dazu: komplett ausbuchen, von jeder Charge
dieselbe Teilmenge abbuchen, und loeschen als Korrektur.

Anders als bulk-location kein Sammel-UPDATE: Je Charge muss eine Bewegung
entstehen, sonst fehlte der Vorgang im Verlauf und in der
Verbrauchsstatistik. Schlaegt eine Charge fehl, weil sie zu wenig enthaelt,
wird alles zurueckgerollt - ein halb gebuchter Stand waere schlimmer als
gar keiner, dieselbe Haltung wie beim Stapel-Einlagern.

Die gemeinsame Teilmenge gibt es nur, wenn alle ausgewaehlten Chargen zum
selben Artikel gehoeren. "1 Packung" bedeutet bei Milch und Broetchen
Verschiedenes; der Server lehnt es ab, und die Oberflaeche sperrt das Feld
gleich mit einem Hinweis, statt den Fehler erst nach dem Klick zu zeigen.

Loeschen schreibt wie das einzelne Loeschen eine Korrektur-Bewegung
(adjust, negative Menge) statt einer Entnahme - es ist eine Fehleingabe,
kein Verbrauch, und darf die Verbrauchsstatistik nicht verfaelschen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 12:41:23 +02:00
ScarriffleandClaude Opus 5 a182037153 Seitenleiste: die vier Bestandslisten klappen zusammen
Lebensmittel, Gegenstaende, Einzelstuecke und Chargen stehen jetzt unter
einer Gruppe "Listen". Sie ist der laengste Block der Leiste und wird im
Alltag selten gebraucht - wer einlagert, nimmt die Knoepfe darueber.
Vorgabe ist zugeklappt, der Zustand bleibt im Browser (wie die
Spalteneinstellungen der Tabellen).

Steht man auf einer dieser Seiten, klappt die Gruppe auf, ohne den
gemerkten Zustand zu ueberschreiben - sonst saehe man nicht, wo man ist.

Die Eintraege bleiben dabei im Dokument und werden nur per Klasse
verborgen: Auf schmalen Bildschirmen wird die Leiste waagerecht, dort gibt
es keine Abschnitte und kein Platzproblem, also blendet die CSS sie wieder
ein und die Gruppe selbst aus. Waeren sie ausgehaengt, waeren sie dort weg.

Die Mobil-Regeln brauchen `.sidebar` davor: Ein Media-Query erhoeht die
Spezifitaet nicht, und die Grundregeln stehen weiter unten in der Datei -
ohne das gewinnt die spaetere Regel und die Ausnahme greift nie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 12:36:23 +02:00
ScarriffleandClaude Opus 5 afacf01f11 FEFO: angebrochene Packung immer zuerst
Ich hatte das nach dem frueheren wirksamen Datum sortiert, sodass eine
ungeoeffnete Packung mit knappem MHD vor einer offenen drankam. Das ist
falsch herum: Das MHD einer geschlossenen Packung ist konservativ und wird
real meist ueberlebt, eine offene verdirbt dagegen tatsaechlich. Und wer
die zweite aufmacht, waehrend die erste offen steht, hat am Ende zwei
angebrochene.

Angebrochenes kommt also immer zuerst; innerhalb beider Gruppen gilt
weiter FEFO ueber das naehere von MHD und Verbrauchsdatum.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 12:33:02 +02:00
ScarriffleandClaude Opus 5 fa5a2bc4fa Angebrochene Packungen, Teilverbrauch und Hausmasse (Backend)
Eine Teilmenge auszulagern ging rechnerisch schon, aber es blieb keine Spur
davon: Eine Charge mit 900 von urspruenglich 1000 ml war von einer frisch
eingelagerten 900-ml-Charge nicht zu unterscheiden, und eine geoeffnete
Milch galt weiter bis zum aufgedruckten MHD.

Anbrechen spaltet jetzt eine Packung ab. Aus 4 x 1 l wird eine offene
Charge mit 1 l und eine ungeoeffnete mit 3 l - sonst truegen die
restlichen 3 l das kurze Verbrauchsdatum mit, obwohl sie noch zu sind. Der
Gesamtbestand aendert sich dabei nicht, es ist eine Umbuchung wie das
Aufteilen und schreibt wie dieses keine Bewegung.

Verbrauchen geht in einem Schritt: Wer "100 ml verbraucht" eintraegt,
bricht die Charge dabei an (abschaltbar ueber auto_open). Gerechnet wird
vor dem Anbrechen - scheitert die Einheit, soll keine Packung grundlos
offen stehen.

MHD und "zu verbrauchen bis" stehen nebeneinander und bleiben beide
sichtbar; gewarnt wird nach dem FRUEHEREN (Lot.faellig_am). Dafuer musste
in /expiring die Vorbedingung "best_before IS NOT NULL" weg, sonst fehlte
ausgerechnet die angebrochene Packung ohne aufgedrucktes MHD. ExpiringItem
traegt beide Daten plus warn_date und warn_source, und best_before ist dort
jetzt optional.

Die Frist kommt aus drei Ebenen: Artikel schlaegt Kategorie, Kategorie
schlaegt ueber ihre Oberkategorien hinweg den globalen Wert. Der Helfer
dafuer (services/vererbung.py) liest die Kategoriekette von
services/fields.py rueckwaerts - dort werden Felder ALLER Vorfahren
gesammelt, hier gewinnt der naechste. Dieselbe Mechanik traegt die
Hausmasse.

Hausmasse sind der zweite Teil: "2 EL Mehl" liess sich bisher nicht
buchen, weil eine globale Einheit "Essloeffel" bei Mehl und Zucker
zwangslaeufig gleich schwer waere. Der Name ist deshalb global, die Menge
haengt an Kategorie und Artikel. to_base loest sie nur mit hausmasse=True
auf - beim Auslagern und Verbrauchen ja, beim Einlagern und bei
Mindestbestaenden ausdruecklich nicht, denn ein Loeffel ist ein
Schaetzwert. Verwaltete Einheiten werden zuerst geprueft, damit ein Mass
namens "Liter" die echte Einheit nicht verdeckt.

Zwei Stellen waeren sonst still kaputtgegangen:

- Die Konsolidierung haette die offene Packung beim naechsten Einlagern am
  selben Ort einkassiert und ihr Verbrauchsdatum verschluckt. Angebrochene
  Chargen werden deshalb nie zusammengefasst - in consolidate_lot_group
  und im Start-Aufraeumer, beide bisher ohne jeden Test. Das Netz dafuer
  ist jetzt da (test_konsolidierung.py).
- FEFO sortiert nach dem wirksamen Datum statt nur nach dem MHD. Eine
  offene Packung kommt damit meist von selbst zuerst, aber eine
  ungeoeffnete, die morgen ablaeuft, geht vor - Verderb schlaegt
  Bequemlichkeit. Erst bei gleichem Datum wird das Angebrochene vorgezogen.

Dashboard bekommt "angebrochen" als fuenften Zustand, mit Vorrang
abgelaufen > bald > angebrochen > in Ordnung: Was offen ist und morgen
ablaeuft, gehoert in die Warnung, nicht in den ruhigeren Zustand.

Oberflaechen in Web und App folgen in eigenen Commits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-02 11:25:36 +02:00
ScarriffleandClaude Opus 5 254737086e Sitzungsdauer einstellbar, bis hin zu unbegrenzt
Die Anmeldung galt fest 7 Tage - und die Frist lief ab dem *Anmelden*, nicht
ab der letzten Nutzung. Wer die App taeglich benutzte, flog trotzdem
punktgenau nach einer Woche hinaus.

Beides ist jetzt geregelt:

- Einstellungen -> Anmeldung: 7 Tage bis 1 Jahr oder unbegrenzt. Gilt fuer
  Website und App gemeinsam, weil beide dasselbe Token bekommen - auch nach
  Single Sign-on.
- POST /auth/refresh schiebt die Frist beim Start nach vorn. Damit zaehlt die
  eingestellte Dauer ab dem letzten Besuch, und eine geaenderte Einstellung
  greift ohne Neuanmeldung.

"Unbegrenzt" heisst ein Token ganz ohne exp. Das ist bewusst folgenreich: Es
gibt keine Sperrliste, eine erteilte Anmeldung laesst sich nicht einzeln
zurueckziehen. Die Oberflaeche sagt das dazu und nennt den einzigen Ausweg
(JWT_SECRET aendern).

/auth/refresh nimmt bewusst nur JWTs an, keine langlebigen API-Token: aus
einem jederzeit widerrufbaren Zugang (Home Assistant) waere sonst ein
unwiderruflicher geworden.

Der Rueckfallwert aus der Umgebung steigt von 7 auf 30 Tage - er gilt nur noch,
solange nichts eingestellt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-24 23:15:46 +02:00
ScarriffleandClaude Opus 5 2b9b98dd14 Etiketten-Vorlagen: die ganze Zeile klappt auf und zu
Aufklappen ging nur ueber ein kleines Stift-Symbol am rechten Rand - und dass
derselbe Knopf auch wieder schliesst, sah man ihm nicht an.

Jetzt ist die Zeile selbst der Schalter, mit einem Pfeil links, der den Zustand
zeigt (wie in der Einzelstueck-Tabelle). Der Stift faellt damit weg; die
Knoepfe rechts - CSV, .lbx, Loeschen - bleiben eigenstaendig und loesen das
Auf- und Zuklappen nicht mit aus.

Dafuer eine kleine Klasse .zeilen-schalter: .link-btn waere naheliegend gewesen,
faerbt aber alles im Akzent - Name und graues Beiwerk sollen ihre Farben
behalten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 17:30:03 +02:00
ScarriffleandClaude Opus 5 c741df5963 Etiketten-Vorlagen: gespeicherte Exporte mit frei waehlbaren Spalten
Der Etikettenexport konnte genau eine Sache, und zwar unveraenderlich: sechs
feste Spalten ueber Einzelstuecke. Was fehlte, war genau das, was ein Etikett
brauchbar macht - die eigenen Felder. Eine Kabellaenge oder die Kapazitaet einer
Powerbank steht laengst am Artikel, kam aber in keiner CSV vor. Und jede
Zusammenstellung war beim naechsten Mal wieder weg: Kategorien im React-State,
Trennzeichen im localStorage, Spalten hartkodiert.

Jetzt gibt es benannte Vorlagen mit eigener Spaltenwahl und Reihenfolge, fuer
Einzelstuecke, Artikel und Lagerorte. Eine Tabelle nach dem Vorbild von
DashboardLayout: benannt, Nutzlast als JSON-Text. Ohne user_id - eine Vorlage
gehoert allen, nicht einem Konto.

Spaltenschluessel fuer eigene Felder ist die ID, nicht der key: FieldDefinition.key
ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren.

Die CSV entsteht jetzt im Server statt im Browser. Mit waehlbaren Spalten,
eigenen Feldern und drei Bezugsarten gehoert das an eine Stelle - und zwar
dorthin, wo es sich pruefen laesst. Formel-Injektionsschutz, BOM und CRLF sind
aus der Browser-Fassung mitgenommen. Der Grund, aus dem der QR-Inhalt bisher im
Browser entstand ("nur die Oberflaeche kennt die oeffentliche Adresse"), gilt
allein fuer Lagerorte; dafuer reicht sie ihre Herkunft als Parameter durch.

Die beiden alten Abschnitte weichen den Vorlagen. Beim ersten Start entstehen
zwei mitgelieferte, die genau die bisherigen Spalten fuehren - wer nichts
einstellt, bekommt dasselbe Ergebnis wie vorher.

Dazu laesst sich eine P-touch-Datei hinterlegen. Geprueft wird ueber Endung und
ZIP-Inhalt (label.xml), nicht ueber den Content-Type: Fuer .lbx meldet der
Browser meist application/octet-stream. Eine .lbx merkt sich den Pfad zu ihrer
Datenquelle - auf einem fremden Rechner muss die CSV in P-touch einmal neu
verknuepft werden. Das laesst sich von hier aus nicht loesen; die Datei
dabeizuhaben erspart trotzdem das Suchen.

Sicherung auf Version 4: Vorlagen kommen mit, Kategorien als Pfad und eigene
Felder als Label, weil IDs zwischen zwei Instanzen nicht dieselben sind. Die
.lbx als Base64 - das erste Binaerformat in dieser Sicherung, vertretbar bei
wenigen Kilobyte und einer Vorlage, die ohne ihre Datei nur eine halbe waere.

23 neue Tests (343 gesamt), dazu im Browser durchgespielt: Kabel-Kategorie mit
eigenem Feld, Spalte hinzugefuegt, CSV mit "Länge (m)" und dem Wert, .lbx
hoch- und wieder heruntergeladen (Pruefsumme identisch), PNG und Nicht-ZIP
sauber abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:07:49 +02:00
ScarriffleandClaude Opus 5 7372590171 Zubehoer: anklickbar, mit Lagerort, und mehr Platz
Die Liste war eine Sackgasse - man sah einen Namen und eine UID, kam aber
nirgends hin. Jetzt fuehrt der Name zum Artikel und die UID zu genau diesem
Stueck; beide Fragen kommen vor ("was ist das fuer ein Netzteil?" gegen "wo ist
dieses eine?").

Dazu der Lagerort je Teil. ItemKurz trug ihn schon, angezeigt wurde er nie -
dabei ist das bei Zubehoer die haeufigste Frage ueberhaupt.

Ab zwei Teilen nimmt der Block zwei Rasterspalten. In einer einzigen Spalte
(minmax 240px) brach schon ein mittellanger Name um.

Und ein Fehler von mir: Die "Gehoert zu"-Zeile war ein rohes <a href> und lud
damit die ganze Seite neu, statt im Router zu navigieren.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 15:01:04 +02:00
ScarriffleandClaude Opus 5 a82405b92a Zubehoer-Liste: langer Name schiebt den Loesen-Knopf nicht mehr um
.clean-list li bricht bewusst um (flex-wrap), damit lange Inhalte nicht aus der
Karte laufen. In der Zubehoer-Liste traf das den Loesen-Knopf: Bei einem Namen
wie "Shooter Grip fuer WII Controller" landete das x allein in einer zweiten
Zeile unter dem Namen.

Jetzt waechst der Textblock (flex:1, min-width:0) und alles rechts davon bleibt
stehen. Der Name bricht innerhalb seines Blocks um, das x bleibt rechts und
mittig. overflow-wrap: anywhere fuer den Fall, dass ein einzelnes Wort breiter
ist als die Spalte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:58:46 +02:00
ScarriffleandClaude Opus 5 8a56b13036 Einzelstueck-Details: "Gekauft bei" steht in einer Reihe mit den Nachbarn
Im aufgeklappten Bereich stehen die Beschriftungen ueber den Feldern. Beim
Auswahlfeld "Gekauft bei" klebte sie stattdessen links daneben, waehrend
"Gekauft am" und "Garantie bis" darueber standen - die Zeile sah dadurch
verrutscht aus.

Ursache war nicht das Markup, das ist identisch zu den Nachbarn, sondern
td select { width: auto } (styles.css). Die Regel ist fuer die kompakte
Tabellenzeile richtig, im Detailbereich schrumpft sie das Feld aber auf
Inhaltsbreite, sodass es neben seine Beschriftung passt.

Nur direkte Kinder eines label: Waehrung und Preisvorschlag sitzen in eigenen
Zeilen-Behaeltern und bleiben schmal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:22:51 +02:00
ScarriffleandClaude Opus 5 1553e36b78 Artikelformular: Typ und Verwaltung als Auswahlliste
Zwei Umschalter mit zusammen fuenf Knoepfen beanspruchten zwei volle Zeilen im
Formular - der dreifache ("Menge je Lagerort / Einzelstuecke /
Verbrauchsgegenstand") brauchte dafuer sogar eine eigene Umbruch-Regel. Als
Auswahllisten stehen sie kompakt untereinander und sehen aus wie die
Kategorie-Auswahl direkt dazwischen.

Am Verhalten aendert sich nichts: changeModus und setObjMode bekommen denselben
Wert, nur aus onChange statt onClick.

.seg-wrap wird dadurch nirgends mehr gebraucht und faellt weg. .segmented und
.seg-label bleiben - die nutzen Mindestbestaende, Produkte und Kategorien
weiterhin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:16:01 +02:00
ScarriffleandClaude Opus 5 a3fd9819c4 Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.

Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.

In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.

Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.

ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.

Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.

Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.

Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.

In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.

12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:08:11 +02:00
ScarriffleandClaude Opus 5 ca4e9a2264 Einzelstueck anlegen: im Verlauf sichtbar und vom Artikel aus erreichbar
Zwei Luecken, die zusammen den Eindruck machten, ein angelegtes Einzelstueck
sei verschwunden.

Der Verlauf kannte nur das Verschwinden. remove_item schreibt seit jeher eine
Bewegung, create_items nicht - Abgaenge von Einzelstuecken tauchten also auf,
Zugaenge nie, im Verlauf wie im Dashboard. Jetzt schreibt das Anlegen je Stueck
eine in-Bewegung mit UID (oder der eigenen Notiz, falls angegeben), Lagerort und
Benutzer, spiegelbildlich zum Entfernen. Der Bestand bleibt unberuehrt: er
zaehlt bei Einzelstuecken die Zeilen in items und rechnet nicht aus Bewegungen.

Und der Weg dorthin fehlte. ProductFormView liefert den angelegten Artikel, aber
zwei der drei Aufrufer warfen ihn weg ("{ _ in showNew = false }") - nur der
Einlagern-Weg nutzte ihn. "Einzelstuecke anlegen" steckt ausschliesslich in der
Artikelansicht, die man vom Scan-Tab und aus den Listen nach dem Anlegen gar
nicht erreicht. Man musste den frischen Artikel von Hand wiederfinden.

Bei Gegenstaenden mit eigener UID je Stueck oeffnet sich jetzt direkt das
Anlege-Formular. Gesetzt wird das im onDismiss des Artikelformulars: beides im
selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet erscheinen soll,
waehrend das erste noch verschwindet.

Der Parameter von create_items heisst dadurch user statt _; die drei Testaufrufe
sind mitgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 12:12:42 +02:00
ScarriffleandClaude Opus 5 c924dffad6 Einzelstuecke: Zeile zeigt nur noch, was man im Alltag braucht
Die Tabelle trug neun Spalten, davon sechs als dauernd offene
Eingabefelder - bei einem einzigen Stueck schon eine Wand aus
Bedienelementen. Jetzt stehen in der Zeile QR, UID, Lagerort, "Verliehen
an" und die Aktionen; Kaufdatum, Garantie, Shop, Notiz, Kaufpreis, Belege
und der Verleih-Verlauf klappen je Stueck auf. Aenderbar bleibt alles,
nur nicht alles gleichzeitig sichtbar.

Dazu zwei Fehler von mir aus dem Verleih-Commit:

- Die drei Aktionssymbole standen uebereinander statt nebeneinander. Die
  Aktionszelle benutzte .field-inline, und das bricht in Tabellen bewusst
  um (styles.css) - mit dem vierten Knopf reichte der Platz nicht mehr.
  Jetzt .cell-row, das umbricht nicht.
- "ueberfaellig (vor 1 Woche)" las sich als Zeitpunkt, gemeint ist eine
  Dauer. Jetzt "7 Tage ueberfaellig".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 10:13:27 +02:00
131 changed files with 16188 additions and 653 deletions
+3
View File
@@ -52,6 +52,9 @@ Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
- **Single Sign-on (optional)** über OpenID Connect, z. B. gegen ein selbst
gehostetes [Authentik](https://goauthentik.io). Der Passwort-Login bleibt
daneben bestehen – siehe [Single Sign-on](#single-sign-on-optional).
- **Sitzungsdauer einstellbar** (Einstellungen → Anmeldung): 7 Tage bis 1 Jahr
oder unbegrenzt, für Website und App gemeinsam. Die Frist zählt ab dem letzten
Besuch, nicht ab dem Anmelden.
### In der iOS-App zusätzlich
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
+11 -1
View File
@@ -12,7 +12,10 @@ class Settings(BaseSettings):
# Auth / JWT
jwt_secret: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
# Rueckfallwert fuer die Sitzungsdauer. Im Betrieb entscheidet die
# Einstellung "session_days" (Verwaltung -> Einstellungen); dieser Wert gilt
# nur, solange dort nichts gesetzt ist.
jwt_expire_minutes: int = 60 * 24 * 30 # 30 Tage
# First admin, created on startup if no users exist
admin_username: str = "admin"
@@ -32,6 +35,13 @@ class Settings(BaseSettings):
# Behaviour
expiry_warning_days_default: int = 7
# Wie lange eine angebrochene Packung haelt, wenn weder Artikel noch
# Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung
# "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist.
open_days_default: int = 3
# Mealie: Adresse und Token stehen in der Datenbank (Admin traegt sie in der
# Oberflaeche ein), hier nur die Geduld fuer eine Antwort.
mealie_timeout_seconds: float = 10.0
# CORS (comma separated); "*" allows all
cors_origins: str = "*"
+4
View File
@@ -27,6 +27,7 @@ from .services.conversion import (
zweit_faktor,
)
from .services.loans import nicht_verliehen
from .services.vererbung import frist_herkunft
from .services.min_stock import UEBERALL_NAME, lies_ueberall
from .services.stock import current_stock, location_subtree_stock_base
@@ -87,6 +88,9 @@ def product_tracking(db: Session, product: Product) -> str:
def product_to_out(db: Session, product: Product) -> ProductOut:
out = ProductOut.model_validate(product)
out.stock = current_stock(db, product.id)
# Wirksame Oeffnungsfrist samt Herkunft – das Formular soll den geerbten
# Wert als Platzhalter zeigen koennen, ohne die Vererbung nachzubauen.
out.effective_open_days, out.open_days_source = frist_herkunft(db, product)
out.expired_count = (
db.query(Lot)
.filter(
+2 -2
View File
@@ -17,7 +17,7 @@ _credentials_exc = HTTPException(
)
def _nutzer_aus_payload(db: Session, payload: dict) -> User | None:
def nutzer_aus_payload(db: Session, payload: dict) -> User | None:
"""Loest die Sitzung auf – bevorzugt ueber die Benutzer-ID.
Der Benutzername ist Anzeige und darf sich aendern; die ID nicht. Deshalb
@@ -44,7 +44,7 @@ def get_current_user(
"""Akzeptiert ein JWT aus dem Login oder ein langlebiges API-Token."""
payload = decode_access_token(token)
if payload is not None:
user = _nutzer_aus_payload(db, payload)
user = nutzer_aus_payload(db, payload)
if user is None:
raise _credentials_exc
return user
+41
View File
@@ -12,25 +12,34 @@ from .routers import (
branding,
categories,
dashboard,
etiketten,
field_definitions,
groups,
items,
loans,
locations,
maintenance,
mealie,
measures,
navigation,
oidc,
package_types,
products,
recipes,
settings as settings_router,
shopping,
shops,
stock,
transfer,
units,
users,
verwendung,
views,
)
from .seed import (
ensure_builtin_categories,
ensure_builtin_measures,
ensure_builtin_etiketten_vorlagen,
ensure_builtin_package_types,
ensure_builtin_units,
ensure_example_object_categories,
@@ -222,6 +231,27 @@ def _ensure_schema() -> None:
"ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) "
"WHERE email IS NOT NULL",
# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop).
"ALTER TABLE items ADD COLUMN IF NOT EXISTS parent_item_id INTEGER "
"REFERENCES items(id) ON DELETE SET NULL",
"CREATE INDEX IF NOT EXISTS ix_items_parent_item_id ON items (parent_item_id)",
# Angebrochene Packungen: Oeffnungstag, daraus gerechnetes
# Verbrauchsdatum und die Menge beim Oeffnen (fuer „noch 400 von 1000 ml").
# Alle drei nullbar – NULL heisst ungeoeffnet, und genau das sind alle
# bestehenden Chargen.
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS opened_on DATE",
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS consume_by DATE",
"ALTER TABLE lots ADD COLUMN IF NOT EXISTS opened_quantity DOUBLE PRECISION",
# Frist nach dem Anbrechen. NULL = erbt (Artikel -> Kategorie ->
# Oberkategorie -> globale Einstellung), siehe services/vererbung.py.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS open_days INTEGER",
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS open_days INTEGER",
# Verwendungsstatus an Einzelstuecken dieser Kategorie (Kleidung,
# Werkzeug). Aus, solange niemand sie setzt - bestehende Kategorien
# aendern sich also nicht. Die Tabelle item_usages selbst kommt aus
# create_all, nur Spalten brauchen diesen Weg hier.
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS usage_tracking "
"BOOLEAN NOT NULL DEFAULT FALSE",
]
with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
@@ -250,7 +280,9 @@ async def lifespan(app: FastAPI):
db = SessionLocal()
try:
ensure_builtin_units(db)
ensure_builtin_measures(db)
ensure_builtin_package_types(db)
ensure_builtin_etiketten_vorlagen(db)
ensure_builtin_categories(db)
ensure_example_object_categories(db)
ensure_first_admin(db)
@@ -287,6 +319,7 @@ def health() -> dict:
app.include_router(auth.router)
app.include_router(etiketten.router)
app.include_router(oidc.router)
app.include_router(users.router)
app.include_router(products.router)
@@ -296,14 +329,22 @@ app.include_router(groups.router)
app.include_router(units.router)
app.include_router(package_types.router)
app.include_router(views.router)
# Nach views.router: Die gerechnete Liste steht dort, die gespeicherten
# Eintraege und Haken hier - gleicher Pfadanfang /shopping-list.
app.include_router(shopping.router)
app.include_router(transfer.router)
app.include_router(api_tokens.router)
app.include_router(settings_router.router)
app.include_router(branding.router)
app.include_router(categories.router)
app.include_router(maintenance.router)
app.include_router(measures.router)
app.include_router(mealie.router)
app.include_router(recipes.router)
app.include_router(dashboard.router)
app.include_router(shops.router)
app.include_router(field_definitions.router)
app.include_router(items.router)
app.include_router(loans.router)
app.include_router(verwendung.router)
app.include_router(navigation.router)
+442
View File
@@ -98,6 +98,24 @@ class RemovalReason(str, enum.Enum):
other = "other" # sonstiges
class UsageStatus(str, enum.Enum):
"""Woran ein Einzelstueck gerade ist (Hemd getragen, Auto in der Wasche).
Bewusst KEIN Wert fuer "verfuegbar": Fehlt die offene Zeile in
:class:`ItemUsage`, ist das Stueck verfuegbar - wie bei ``lots.opened_on``
und beim Verleih. Ein eigener Wert daneben koennte ihr widersprechen.
Absichtlich allgemein und nicht auf Kleidung zugeschnitten: "in_use" traegt
auch eine Bohrmaschine auf der Baustelle.
Als kurzer String gespeichert (wie CategoryTracking, RemovalReason), damit
sich die Spalte per ADD COLUMN nachziehen laesst.
"""
in_use = "in_use" # wird gerade benutzt/getragen
cleaning = "cleaning" # in der Waesche/Reinigung
repair = "repair" # in Reparatur
class Unit(Base):
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
@@ -349,6 +367,19 @@ class Category(Base):
default=CategoryTracking.object.value,
server_default=CategoryTracking.food.value,
)
# Wie lange eine angebrochene Packung dieser Kategorie noch haelt.
# NULL = erbt von der Oberkategorie, zuletzt vom globalen Wert
# (services/vererbung.py::oeffnungsfrist).
open_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren
# (Kleidung, Werkzeug - nicht Konserven). Wie tracking eine Kopie beim
# Anlegen und keine Aufloesung beim Lesen: So kommt die Markierung ohne
# zusaetzliche Abfrage am Einzelstueck an (services/items.py::item_to_out).
# Ein Haken an einer Oberkategorie wandert beim Speichern in den Unterbaum,
# siehe routers/categories.py::update_category.
usage_tracking: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)
class Product(Base):
@@ -385,6 +416,11 @@ class Product(Base):
secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True)
secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True)
secondary_amount: Mapped[float | None] = mapped_column(Float, nullable=True)
# Frist nach dem Anbrechen, nur fuer diesen Artikel. NULL = erbt von der
# Kategorie (und die von ihren Oberkategorien, zuletzt vom globalen Wert).
# Noetig fuer Ausnahmen: H-Milch haelt geoeffnet so lange wie Frischmilch,
# steckt aber in derselben Kategorie.
open_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
date_precision: Mapped[str] = mapped_column(
@@ -511,10 +547,35 @@ class Lot(Base):
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
# Angebrochen: NULL = ungeoeffnet. Bewusst KEIN eigenes Statusfeld – das
# Datum traegt den Zustand mit und wird fuer die Frist sowieso gebraucht;
# ein zweites Feld koennte ihm widersprechen.
opened_on: Mapped[date | None] = mapped_column(Date, nullable=True)
# „Zu verbrauchen bis" = opened_on + Frist (services/vererbung.py). Beim
# Oeffnen gerechnet und GESPEICHERT: so bleibt das Datum an der offenen
# Packung stehen, wenn die Frist spaeter geaendert wird, und die
# Ablaufliste muss nicht je Zeile die Kategorie-Vererbung aufloesen.
consume_by: Mapped[date | None] = mapped_column(Date, nullable=True)
# Menge beim Oeffnen, damit „noch 400 ml von 1 l" anzeigbar ist.
opened_quantity: Mapped[float | None] = mapped_column(Float, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship(back_populates="lots")
@property
def ist_angebrochen(self) -> bool:
return self.opened_on is not None
@property
def faellig_am(self) -> date | None:
"""Das frueheste der beiden Daten – danach wird gewarnt.
MHD und Verbrauchsdatum bleiben getrennt sichtbar; fuer Ablaufliste,
Zustand und Erinnerungen zaehlt aber immer das naehere von beiden.
"""
termine = [d for d in (self.best_before, self.consume_by) if d is not None]
return min(termine) if termine else None
class Movement(Base):
__tablename__ = "movements"
@@ -561,6 +622,81 @@ class PackageType(Base):
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class Measure(Base):
"""Ein Hausmaß wie „Esslöffel" – nur der NAME, ohne Menge.
Die Menge gehört nicht an den Namen, sondern an die Kategorie bzw. den
Artikel: 1 EL Mehl sind 10 g, 1 EL Zucker 12 g, 1 EL Öl 15 ml. Ein globaler
Faktor wie bei :class:`Unit` wäre also schlicht falsch. Auch die ART
(Gramm/Milliliter/Stück) steckt deshalb in der Menge, nicht hier.
Der Name ist trotzdem global, damit „EL" überall dasselbe Maß bezeichnet –
sonst wären „EL" und „Esslöffel" in zwei Kategorien zwei verschiedene Dinge
und ein Rezept fände keines davon wieder.
"""
__tablename__ = "measures"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(40), nullable=False, unique=True)
# Kurzform fuer Rezepte und schmale Spalten: „EL", „TL".
short: Mapped[str | None] = mapped_column(String(10), nullable=True)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
class CategoryMeasure(Base):
"""Was ein Hausmaß in DIESER Kategorie wiegt bzw. fasst.
Gilt über den Kategorie-Baum auch für die Unterkategorien; der nächste
Vorfahr mit Eintrag gewinnt (services/vererbung.py). Eigene Tabelle statt
nullbarer Spalten in einer gemeinsamen, wie es
:class:`ProductLocationMinStock` und :class:`GroupLocationMinStock` schon
vormachen.
"""
__tablename__ = "category_measures"
__table_args__ = (
UniqueConstraint("measure_id", "category_id", name="uq_cat_measure"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
measure_id: Mapped[int] = mapped_column(
ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True
)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, index=True
)
# Menge in ``base`` – also 10 fuer „1 EL = 10 g".
amount: Mapped[float] = mapped_column(Float, nullable=False)
# Art der Menge: BaseUnit-Wert (piece/gram/milliliter). Als String wie
# ``Product.secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist.
base: Mapped[str] = mapped_column(String(16), nullable=False)
class ProductMeasure(Base):
"""Hausmaß-Menge für EINEN Artikel – schlägt die der Kategorie.
Für Ausnahmen innerhalb einer Kategorie: Puderzucker ist deutlich leichter
als Kristallzucker, steckt aber in derselben Kategorie.
"""
__tablename__ = "product_measures"
__table_args__ = (
UniqueConstraint("measure_id", "product_id", name="uq_prod_measure"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
measure_id: Mapped[int] = mapped_column(
ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True
)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
amount: Mapped[float] = mapped_column(Float, nullable=False)
base: Mapped[str] = mapped_column(String(16), nullable=False)
class ProductImage(Base):
"""Lokale Kopie des Artikelbilds.
@@ -637,6 +773,86 @@ class DashboardLayout(Base):
)
class NavigationSichtbarkeit(Base):
"""Welche Eintraege der Seitenleiste jemand ausgeblendet hat.
``user_id = NULL`` ist die Vorgabe des Administrators: Was dort steht,
bekommt niemand zu sehen. Eine Zeile mit ``user_id`` ist die zusaetzliche
Auswahl eines Kontos.
**Beide Listen gelten zusammen.** Wer etwas versteckt, versteckt es
zusaetzlich; einblenden, was der Administrator ausgeblendet hat, kann
niemand. Das erspart einen dritten Zustand je Eintrag ("erben / zeigen /
verstecken") und laesst sich in einem Satz erklaeren.
Versteckt wird nur der EINTRAG, nicht die Seite: Lesezeichen,
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
abgewaehlt sein koennen.
Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt :class:`Setting`
(dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je
Schluessel), Nutzlast als JSON-Text. Als Schluessel dient der Routenpfad -
der ist ohnehin schon eindeutig, eine zweite Benennung waere nur eine
weitere Stelle, die auseinanderlaufen kann.
"""
__tablename__ = "navigation_sichtbarkeit"
__table_args__ = (
# Eine Zeile je Konto UND genau eine Vorgabezeile - beides mit einem
# Index. Ueber coalesce, weil NULL in SQL nie gleich NULL ist: ein
# schlichtes UNIQUE(user_id) liesse beliebig viele Vorgabezeilen zu.
Index("uq_navigation_zeile", text("coalesce(user_id, -1)"), unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=True
)
# JSON-Liste von Routenpfaden, z.B. ["/rezepte", "/shopping"].
versteckt: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
# Loescht man ein Konto, geht seine Zeile mit (ON DELETE CASCADE oben).
# In den Tests greift das nicht: SQLite erzwingt Fremdschluessel nur mit
# PRAGMA foreign_keys=ON, und das setzt die Testumgebung nicht.
class EtikettenVorlage(Base):
"""Eine gespeicherte Zusammenstellung fuer den Etikettenexport.
Benannt, damit man sie wieder aufrufen kann („Kabelexport"), und mit frei
gewaehlten Spalten - erst dadurch kommen eigene Felder wie eine Kabellaenge
oder die Kapazitaet einer Powerbank ueberhaupt aufs Etikett.
Vorbild ist :class:`DashboardLayout`: eigene Tabelle statt ``Setting``
(dessen ``value`` fasst 255 Zeichen und haelt genau einen Wert je Schluessel),
Nutzlast als JSON-Text. Anders als dort ohne ``user_id`` - eine Vorlage
gehoert allen, nicht einem Konto.
"""
__tablename__ = "etiketten_vorlagen"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(80), unique=True, nullable=False)
# Worauf sich die Vorlage bezieht: items | products | locations.
# Als kurzer String und nicht als DB-Enum, wie DatePrecision und
# CategoryTracking - so laesst sich eine weitere Art spaeter ohne neuen
# Postgres-Typ nachziehen.
art: Mapped[str] = mapped_column(String(16), nullable=False, default="items")
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
# Spaltenwahl, Reihenfolge, Filter und Trennzeichen als JSON-Text.
konfiguration: Mapped[str] = mapped_column(Text, nullable=False, default="{}")
# Hinterlegte P-touch-Vorlage (.lbx). Hoechstens eine je Eintrag, deshalb
# Spalten derselben Zeile statt einer zweiten Tabelle.
datei_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
# deferred: Sonst zoege jede Liste der Vorlagen die Binaerdaten mit - genau
# das vermeidet ItemDocument.data aus demselben Grund.
datei: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True, deferred=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class Setting(Base):
__tablename__ = "settings"
@@ -644,6 +860,152 @@ class Setting(Base):
value: Mapped[str] = mapped_column(String(255), nullable=False)
class IntegrationSecret(Base):
"""Zugangsgeheimnis für einen externen Dienst (heute: das Mealie-Dauertoken).
Bewusst **nicht** in :class:`Setting`: ``GET /settings`` liefert jedem
angemeldeten Nutzer alle Zeilen im Klartext. Eine Ausschlussliste dort wäre
beim nächsten Geheimnis vergessen. Diese Tabelle hat deshalb überhaupt
keinen Leseweg nach außen — setzen, löschen, und eine Auskunft „ist
gesetzt". Was keinen Ausgang hat, kann nicht durchrutschen.
Anders als bei :class:`ApiToken` muss der Klartext erhalten bleiben: Wir
legen ihn einem fremden Dienst vor, ein Hash nützte dort nichts. Er liegt
damit unverschlüsselt in der Datenbank — eine Verschlüsselung wäre Theater,
solange der Schlüssel daneben läge. Der Schutz ist: kein Leseweg, nur Admin
beim Setzen, nie in einer Antwort.
"""
__tablename__ = "integration_secrets"
service: Mapped[str] = mapped_column(String(32), primary_key=True)
secret: Mapped[str] = mapped_column(Text, nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class ShoppingEntry(Base):
"""Ein Eintrag, den jemand bewusst auf die Einkaufsliste gesetzt hat.
Die Einkaufsliste war bis hierher vollstaendig **gerechnet**: Artikel unter
ihrem Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt
die Staerke der Liste und wird nicht ersetzt. Was fehlte, war ein Ort fuer
"Milch gehoert drauf, weil ich das sage" - und damit auch fuer "diese
Zutaten fehlen mir fuer das Rezept".
Genau EINES von ``product_id``, ``group_id`` und ``free_text`` ist gesetzt.
Geprueft wird das beim Anlegen (services/einkaufsliste.py), nicht in der
Datenbank: Als CHECK-Bedingung waere es ueber drei nullbare Spalten
unleserlich, und die Fehlermeldung waere keine, die man jemandem zeigen
kann. Der Freitext ist unverzichtbar - fuer eine Rezeptzutat, die hier
keinem Artikel entspricht ("Ras el Hanout"), ist er die einzige Form, in
der sie auf den Zettel kommt.
``quantity_base`` steht in Basiseinheiten, wie alle Mengen im Projekt. Bei
einem Freitext bleibt sie leer: "etwas Hefe" hat keine Zahl.
``recipe_slug``/``recipe_name`` halten die Herkunft fest. Sie sind mehr als
Zierde: Derselbe Artikel kann fuer zwei Rezepte fehlen, und dann braucht man
ihn zweimal. Je Rezept und Ziel gibt es deshalb hoechstens einen offenen
Eintrag - ein zweiter Druck auf "fehlende Zutaten hinzufuegen" rechnet die
Menge neu, statt die Liste zu verdoppeln.
"""
__tablename__ = "shopping_entries"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int | None] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True
)
free_text: Mapped[str | None] = mapped_column(String(120), nullable=True)
quantity_base: Mapped[float | None] = mapped_column(Float, nullable=True)
# BaseUnit-Wert (piece/gram/milliliter) als String - wie ``Product
# .secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist.
base: Mapped[str | None] = mapped_column(String(16), nullable=True)
# "manual" | "recipe"
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
recipe_slug: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
recipe_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
done: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
done_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_by: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, nullable=False
)
product: Mapped[Product | None] = relationship()
group: Mapped[Group | None] = relationship()
class ShoppingCheck(Base):
"""Ein abgehakter Posten der GERECHNETEN Einkaufsliste.
Die gerechneten Vorschlaege haben keine Zeile in der Datenbank - sie
entstehen bei jedem Aufruf neu aus den Mindestbestaenden. Abhaken liess sich
deshalb nur im Browser merken und war nach dem Neuladen weg, mitten im
Supermarkt.
Gemerkt wird hier nur der Schluessel des Postens, nicht der Posten selbst:
``p12`` (Artikel), ``g3`` (Gruppe), ``l KUECHE p12`` (Artikel an einem
Lagerort) - dieselben Schluessel, die die Oberflaeche ohnehin bildet. Damit
bleibt die Liste gerechnet, und das Abhaken haengt sich nur daran. Sinkt der
Bestand spaeter wieder, taucht der Posten mit seinem alten Haken auf; das
Aufraeumen erledigt "Erledigte entfernen".
"""
__tablename__ = "shopping_checks"
key: Mapped[str] = mapped_column(String(80), primary_key=True)
checked_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, nullable=False
)
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
class MealieFoodMap(Base):
"""Gemerkte Zuordnung: Mealie-Lebensmittel X ist bei uns Artikel/Gruppe Y.
Der unscharfe Abgleich in ``services/rezepte.py`` raet, dieser Tisch weiss.
Jede Korrektur von Hand landet hier und gilt ab dann fuer jedes Rezept, in
dem dasselbe Lebensmittel vorkommt — eine Zutat wird also hoechstens einmal
im Leben zugeordnet.
Schluessel ist der **normalisierte** Name (``matching.normalisieren``), damit
"Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " dieselbe Zeile treffen.
Daneben steht der Name, wie Mealie ihn schreibt — nur zur Anzeige in der
Liste der gemerkten Zuordnungen.
Genau eines von ``product_id`` und ``group_id`` ist gesetzt; geprueft wird
das beim Speichern (``services/zuordnung.py``), nicht in der Datenbank —
dasselbe Muster wie bei den Bedarfszeilen. Verschwindet das Ziel spaeter,
wird die Zeile beim Laden stillschweigend uebersprungen: Auf SQLite greift
kein Fremdschluessel, und eine Zuordnung auf einen geloeschten Artikel darf
den Rezeptabgleich nicht sprengen.
"""
__tablename__ = "mealie_food_map"
food_key: Mapped[str] = mapped_column(String(200), primary_key=True)
food_name: Mapped[str] = mapped_column(String(200), nullable=False)
product_id: Mapped[int | None] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=True, index=True
)
group_id: Mapped[int | None] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=True, index=True
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
@@ -742,9 +1104,30 @@ class Item(Base):
# entsteht; Währung getrennt (z.B. CHF, EUR).
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
# Zubehoer: Dieses Stueck gehoert zu einem anderen (Netzteil -> Laptop).
# Genau ein Geraet je Teil, beliebig viele Teile je Geraet - dafuer genuegt
# eine Spalte, es braucht keine Verknuepfungstabelle wie bei den Gruppen.
# SET NULL statt CASCADE: Verschwindet das Geraet, wird das Netzteil wieder
# eigenstaendig, statt ungefragt mitgeloescht zu werden.
parent_item_id: Mapped[int | None] = mapped_column(
ForeignKey("items.id", ondelete="SET NULL"), nullable=True, index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship()
# Beide Richtungen sind gefragt: am Geraet "was gehoert dazu", am Teil
# "wozu gehoert das". selectin, weil sonst jede Einzelstueckliste je Zeile
# eine Extraabfrage ausloest (wie bei Group.parents).
gehoert_zu: Mapped["Item | None"] = relationship(
"Item", remote_side="Item.id", back_populates="zubehoer", lazy="joined",
# joined statt selectin: Es ist hoechstens EIN Geraet je Teil, ein
# many-to-one-Join kann die Zeilen also nicht vervielfachen - und ohne
# das holte jede Einzelstueckliste je Zeile eine Extraabfrage.
join_depth=1,
)
zubehoer: Mapped[list["Item"]] = relationship(
"Item", back_populates="gehoert_zu", lazy="selectin", order_by="Item.uid"
)
location: Mapped[Location | None] = relationship()
shop: Mapped[Shop | None] = relationship()
documents: Mapped[list["ItemDocument"]] = relationship(
@@ -756,6 +1139,12 @@ class Item(Base):
back_populates="item", cascade="all, delete-orphan",
order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()",
)
# Verwendungs-Historie, neueste zuerst. Der laufende Zustand (ended_at NULL)
# ist hoechstens einer, siehe ItemUsage. Keine offene Zeile = verfuegbar.
usages: Mapped[list["ItemUsage"]] = relationship(
back_populates="item", cascade="all, delete-orphan",
order_by="ItemUsage.started_at.desc(), ItemUsage.id.desc()",
)
class ItemDocument(Base):
@@ -828,6 +1217,59 @@ class ItemLoan(Base):
user: Mapped[User | None] = relationship()
class ItemUsage(Base):
"""Ein Zeitraum, in dem ein Einzelstueck in einem Verwendungszustand war.
Bewusst eine eigene Tabelle und keine Spalte am Einzelstueck: So bleibt
nachvollziehbar, seit wann ein Hemd in der Waesche liegt und wie oft es
getragen wurde. Abgeschlossene Zeitraeume bleiben stehen - damit ist die
Tabelle gleichzeitig die Historie je Stueck.
``ended_at`` NULL heisst "laeuft noch"; ein Stueck kann nur einen solchen
laufenden Zustand haben (Teilindex unten). Es gibt keine Zeile fuer
"verfuegbar" - keine offene Zeile IST verfuegbar.
Anders als der Verleih aendert der Zustand den Bestand NICHT: ein getragenes
Hemd ist weiterhin im Haushalt. Und wie beim Verleih wird keine Bewegung
(Movement) geschrieben, siehe routers/loans.py.
"""
__tablename__ = "item_usages"
__table_args__ = (
# Zwei Zustaende gleichzeitig kann ein Stueck nicht haben. Als Teilindex,
# weil nur die LAUFENDEN eindeutig sein duerfen: abgeschlossene bleiben
# beliebig oft je Stueck stehen.
Index(
"uq_item_usage_offen",
"item_id",
unique=True,
postgresql_where=text("ended_at IS NULL"),
sqlite_where=text("ended_at IS NULL"),
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
item_id: Mapped[int] = mapped_column(
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
)
# UsageStatus-Wert als kurzer String (kein Postgres-Enumtyp).
status: Mapped[str] = mapped_column(String(16), nullable=False)
# Zeitstempel und nicht nur ein Datum: Ein Hemd kann an einem Tag getragen,
# gewaschen und wieder eingeraeumt werden.
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Wer umgestellt hat. SET NULL, damit ein geloeschtes Konto den Vorgang
# nicht mitnimmt.
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
item: Mapped[Item] = relationship(back_populates="usages")
user: Mapped[User | None] = relationship()
class ProductLocationMinStock(Base):
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
+36 -3
View File
@@ -3,10 +3,11 @@ from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..deps import get_current_user, nutzer_aus_payload, oauth2_scheme
from ..models import User
from ..schemas import Token, UserOut
from ..security import create_access_token, verify_password
from ..security import create_access_token, decode_access_token, verify_password
from .settings import get_session_minutes
router = APIRouter(prefix="/auth", tags=["auth"])
@@ -21,10 +22,42 @@ def login(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Benutzername oder Passwort falsch",
)
token = create_access_token(user_id=user.id, role=user.role.value)
token = create_access_token(
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
)
return Token(access_token=token, role=user.role, username=user.username)
@router.post("/refresh", response_model=Token)
def refresh(
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
) -> Token:
"""Stellt der laufenden Sitzung ein frisches Token aus.
Ohne das laeuft die Frist ab dem *Anmelden*, nicht ab der letzten Nutzung:
Wer die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf
hinaus. Weboberflaeche und App rufen das beim Start auf; damit zaehlt die
eingestellte Dauer ab dem letzten Besuch.
Bewusst NICHT ueber ``get_current_user``: das nimmt auch langlebige
API-Token an (Home Assistant). Aus so einem Token hier ein JWT zu machen,
haette aus einem jederzeit widerrufbaren Zugang einen unwiderruflichen
gemacht.
"""
payload = decode_access_token(token)
user = nutzer_aus_payload(db, payload) if payload is not None else None
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Ungültige oder abgelaufene Anmeldung",
headers={"WWW-Authenticate": "Bearer"},
)
neu = create_access_token(
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
)
return Token(access_token=neu, role=user.role, username=user.username)
@router.get("/me", response_model=UserOut)
def me(current_user: User = Depends(get_current_user)) -> User:
return current_user
+33 -1
View File
@@ -12,6 +12,7 @@ from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import (
Category,
CategoryMeasure,
CategoryTracking,
FieldDefinition,
Product,
@@ -20,6 +21,7 @@ from ..models import (
)
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
from ..services.fields import effective_field_definitions
from ..services.vererbung import geerbte_kategorie_frist
from .field_definitions import to_out as field_to_out
router = APIRouter(prefix="/categories", tags=["categories"])
@@ -53,6 +55,7 @@ def _to_out(db: Session, category: Category) -> CategoryOut:
out.product_count = (
db.query(Product).filter(Product.category_id == category.id).count()
)
out.inherited_open_days, out.open_days_source = geerbte_kategorie_frist(db, category)
return out
@@ -82,7 +85,18 @@ def create_category(
tracking = parent.tracking
else:
tracking = CategoryTracking.object.value
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
# Verwendungsstatus genauso: gewaehlt > vom Elternteil geerbt > aus.
# Eine Unterkategorie von "Kleidung" soll ihn ohne Zutun fuehren.
if payload.usage_tracking is not None:
usage_tracking = payload.usage_tracking
elif parent is not None:
usage_tracking = parent.usage_tracking
else:
usage_tracking = False
category = Category(
name=payload.name, parent_id=payload.parent_id, tracking=tracking,
open_days=payload.open_days, usage_tracking=usage_tracking,
)
db.add(category)
db.commit()
db.refresh(category)
@@ -119,6 +133,19 @@ def update_category(
data["tracking"] = CategoryTracking(data["tracking"]).value
for field, value in data.items():
setattr(category, field, value)
# Der Verwendungsstatus wandert in den ganzen Unterbaum. Ohne das erreichte
# ein Haken an einer bestehenden Kategorie ("Kleidung") deren vorhandene
# Unterkategorien nicht - und genau die gibt es ja schon. Bewusst sichtbar:
# Die Haken stehen danach auch dort und lassen sich einzeln wieder abwaehlen.
if data.get("usage_tracking") is not None:
kinder = descendant_ids(db, category_id)
if kinder:
(
db.query(Category)
.filter(Category.id.in_(kinder))
.update({"usage_tracking": data["usage_tracking"]},
synchronize_session=False)
)
db.commit()
db.refresh(category)
return _to_out(db, category)
@@ -156,6 +183,11 @@ def delete_category(
db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id
).delete(synchronize_session=False)
# Hausmass-Mengen dieser Kategorie – aus demselben Grund ausdruecklich:
# sonst blieben Waisen stehen und heften sich an neu vergebene IDs.
db.query(CategoryMeasure).filter(
CategoryMeasure.category_id == category_id
).delete(synchronize_session=False)
db.delete(category)
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+38 -8
View File
@@ -48,6 +48,7 @@ from ..services.conversion import article_unit
from ..services.loans import nicht_verliehen
from ..services.stock import current_stock
from .settings import get_expiry_warning_days
from ..services import einkaufsliste
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -283,14 +284,28 @@ def set_enforced(
# --------------------------------------------------------------------------
# Auswertungen
# --------------------------------------------------------------------------
def _zustand(best_before: date | None, heute: date, warnfrist: int) -> str:
"""Ablaufzustand einer Charge: ohne MHD / abgelaufen / bald / in Ordnung."""
if best_before is None:
return "no_date"
def _zustand(
best_before: date | None, heute: date, warnfrist: int, *, angebrochen: bool = False
) -> str:
"""Ablaufzustand einer Charge: abgelaufen / bald / angebrochen / ohne MHD / ok.
``best_before`` ist hier das WIRKSAME Datum – das frühere aus aufgedrucktem
MHD und Verbrauchsdatum (``Lot.faellig_am``).
Vorrang: abgelaufen > bald ablaufend > angebrochen > in Ordnung.
Dringlichkeit schlägt Zustand: Eine angebrochene Packung, die morgen weg
muss, gehört in „bald ablaufend" – sonst verstoeckte der ruhigere Zustand
die Warnung. „angebrochen" ist damit das, was offen ist und noch Zeit hat.
"""
if best_before is not None:
if best_before < heute:
return "expired"
if (best_before - heute).days <= warnfrist:
return "soon"
if angebrochen:
return "opened"
if best_before is None:
return "no_date"
return "ok"
@@ -321,7 +336,10 @@ def _bestand_beitraege(
continue
faktor, _label = article_unit(product)
menge = lot.quantity / (faktor or 1.0)
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
beitraege.append((
product, menge,
_zustand(lot.faellig_am, heute, warnfrist, angebrochen=lot.ist_angebrochen),
))
for product in db.query(Product).filter(Product.individual.is_(True)).all():
# Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit.
@@ -356,7 +374,16 @@ def stats(
# Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen –
# inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
# gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db))
#
# Dazu die selbst draufgesetzten Eintraege, offene wie die aus einem Rezept
# uebernommenen: Sonst meldet die Kennzahl „alles erreicht", waehrend auf der
# Liste fuenf Posten stehen. Abgehakte zaehlen nicht mehr mit - sie sind
# gekauft.
bedarf = (
len(einkaufsliste_artikel(db))
+ len(einkaufsliste_gruppen(db))
+ len(einkaufsliste.eintraege(db, mit_erledigten=False))
)
return DashboardStats(
products_in_stock=len(bestand_produkte),
@@ -374,7 +401,7 @@ def expiry_split(
) -> ExpirySplit:
heute = date.today()
warnfrist = get_expiry_warning_days(db)
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "opened": 0.0, "no_date": 0.0}
for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
summen[zustand] += menge
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
@@ -417,7 +444,10 @@ def by_category(
rollup_cache[cid] = ziel
return ziel
leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
leer = lambda: {
"article_units": 0.0, "ok": 0.0, "soon": 0.0,
"expired": 0.0, "opened": 0.0, "no_date": 0.0,
}
eimer: dict[int | None, dict] = defaultdict(leer)
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
topf = eimer[rollup(product.category_id)]
+333
View File
@@ -0,0 +1,333 @@
"""Etiketten-Vorlagen: gespeicherte Zusammenstellungen fuer den Export.
Eine Vorlage haelt fest, worauf sie sich bezieht (Einzelstuecke, Artikel oder
Lagerorte), welche Spalten in welcher Reihenfolge exportiert werden und wie
gefiltert wird. Dazu kann eine P-touch-Datei (.lbx) hinterlegt werden, damit
man sie auf einem fremden Rechner nicht suchen muss.
Vorlagen gehoeren allen: Anlegen und Aendern bleibt Administratoren
vorbehalten, Exportieren darf jeder Angemeldete – es ist ein Lesevorgang.
"""
from __future__ import annotations
import io
import re
import unicodedata
import zipfile
from urllib.parse import quote
from fastapi import APIRouter, Depends, File, HTTPException, Response, UploadFile, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import EtikettenVorlage, User
from ..schemas import (
EtikettenVorlageCreate,
EtikettenVorlageOut,
EtikettenVorlageUpdate,
SpalteOut,
)
from ..services.etiketten import (
ARTEN,
csv_erzeugen,
konfiguration_lesen,
konfiguration_schreiben,
verfuegbare_spalten,
)
router = APIRouter(prefix="/etiketten", tags=["etiketten"])
# Eine .lbx ist ein ZIP von wenigen Kilobyte. Die Grenze ist grosszuegig und
# bleibt weit unter der nginx-Grenze (client_max_body_size 12m).
LBX_MAX_BYTES = 2 * 1024 * 1024
def _vorlage_oder_404(db: Session, vorlage_id: int) -> EtikettenVorlage:
vorlage = db.get(EtikettenVorlage, vorlage_id)
if vorlage is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Vorlage nicht gefunden")
return vorlage
def _safe_filename(name: str | None) -> str:
"""Steuerzeichen und Anfuehrungszeichen raus – sonst Header-Injektion."""
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
return cleaned[:255] or "vorlage.lbx"
def _disposition(name: str | None) -> str:
"""``Content-Disposition`` fuer einen Dateinamen aus einem Upload.
Starlette kodiert Header-Werte als latin-1. Ein Dateiname kommt aber vom
Dateisystem des Hochladenden und darf alles enthalten, was dort erlaubt
ist: macOS legt ein "ä" ZERLEGT ab, als "a" + U+0308, und fuer diesen
kombinierenden Akzent gibt es keine latin-1-Entsprechung. Roh eingesetzt
sprengt so ein Name die Kodierung – die Route antwortet dann mit 500
statt mit der Datei, obwohl der Name am Bildschirm voellig harmlos
aussieht (".../Lagerplätze.lbx" von einem Mac ist genau dieser Fall).
Deshalb nach RFC 6266 zweigleisig: ``filename`` traegt eine ASCII-Fassung
als Rueckfall, ``filename*`` den echten Namen prozentkodiert. Browser
bevorzugen ``filename*`` und zeigen damit die Umlaute richtig an.
"""
sicher = _safe_filename(name)
# NFKD zerlegt "ä" in "a" + Akzent, "ignore" wirft dann nur den Akzent
# weg: "Lagerplätze.lbx" wird zu "Lagerplatze.lbx" und bleibt lesbar.
ascii_fassung = (
unicodedata.normalize("NFKD", sicher).encode("ascii", "ignore").decode("ascii")
)
# Ein Name ganz ohne ASCII-Anteil (z. B. rein kyrillisch) bliebe sonst leer.
return (
f'attachment; filename="{ascii_fassung.strip() or "vorlage.lbx"}"; '
f"filename*=UTF-8''{quote(sicher, safe='')}"
)
def _pruefe_lbx(dateiname: str, daten: bytes) -> None:
"""Ist das plausibel eine P-touch-Vorlage?
Eine .lbx ist ein ZIP mit label.xml (und prop.xml) darin. Geprueft wird
genau das – nicht der vom Browser gemeldete Content-Type, denn fuer .lbx
meldet der meist application/octet-stream oder gar nichts.
"""
if not daten:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
if len(daten) > LBX_MAX_BYTES:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Datei zu groß (max. {LBX_MAX_BYTES // (1024 * 1024)} MB).",
)
if not dateiname.lower().endswith(".lbx"):
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"Nur .lbx-Dateien aus dem P-touch Editor.",
)
try:
with zipfile.ZipFile(io.BytesIO(daten)) as archiv:
namen = {n.lower() for n in archiv.namelist()}
except zipfile.BadZipFile:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"Das ist keine P-touch-Vorlage (die Datei ist kein ZIP-Archiv).",
) from None
if "label.xml" not in namen:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
"Das ist keine P-touch-Vorlage (label.xml fehlt).",
)
def _als_out(vorlage: EtikettenVorlage) -> EtikettenVorlageOut:
konf = konfiguration_lesen(vorlage)
return EtikettenVorlageOut(
id=vorlage.id,
name=vorlage.name,
art=vorlage.art,
position=vorlage.position,
spalten=konf["spalten"],
kategorie_ids=konf["kategorie_ids"],
trennzeichen=konf["trennzeichen"],
feldfilter=konf["feldfilter"],
datei_name=vorlage.datei_name,
updated_at=vorlage.updated_at,
)
@router.get("/spalten", response_model=list[SpalteOut])
def spalten(
art: str = "items",
kategorie_ids: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[SpalteOut]:
"""Welche Spalten fuer diese Bezugsart und Kategorien zur Wahl stehen.
Die eigenen Felder haengen an den Kategorien – ohne diesen Endpunkt
wuesste die Oberflaeche nicht, dass es „Kapazität (mAh)" ueberhaupt gibt.
"""
if art not in ARTEN:
raise HTTPException(status.HTTP_400_BAD_REQUEST, f"Unbekannte Bezugsart „{art}“")
ids = [int(t) for t in (kategorie_ids or "").split(",") if t.strip().isdigit()]
return [SpalteOut(**s) for s in verfuegbare_spalten(db, art, ids)]
@router.get("/vorlagen", response_model=list[EtikettenVorlageOut])
def vorlagen_auflisten(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[EtikettenVorlageOut]:
rows = (
db.query(EtikettenVorlage)
.order_by(EtikettenVorlage.position, EtikettenVorlage.id)
.all()
)
return [_als_out(v) for v in rows]
@router.post(
"/vorlagen", response_model=EtikettenVorlageOut, status_code=status.HTTP_201_CREATED
)
def vorlage_anlegen(
payload: EtikettenVorlageCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> EtikettenVorlageOut:
name = payload.name.strip()
if db.query(EtikettenVorlage).filter(EtikettenVorlage.name == name).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen")
hoechste = (
db.query(EtikettenVorlage).order_by(EtikettenVorlage.position.desc()).first()
)
vorlage = EtikettenVorlage(
name=name,
art=payload.art,
position=(hoechste.position + 1) if hoechste else 0,
konfiguration=konfiguration_schreiben(
payload.spalten,
payload.kategorie_ids,
payload.trennzeichen,
# json.dumps kann mit Pydantic-Modellen nichts anfangen.
[f.model_dump() for f in payload.feldfilter],
),
)
db.add(vorlage)
db.commit()
db.refresh(vorlage)
return _als_out(vorlage)
@router.patch("/vorlagen/{vorlage_id}", response_model=EtikettenVorlageOut)
def vorlage_aendern(
vorlage_id: int,
payload: EtikettenVorlageUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> EtikettenVorlageOut:
vorlage = _vorlage_oder_404(db, vorlage_id)
data = payload.model_dump(exclude_unset=True)
if "name" in data:
neuer = data["name"].strip()
doppelt = (
db.query(EtikettenVorlage)
.filter(EtikettenVorlage.name == neuer, EtikettenVorlage.id != vorlage.id)
.first()
)
if doppelt:
raise HTTPException(
status.HTTP_409_CONFLICT, "Es gibt schon eine Vorlage mit diesem Namen"
)
vorlage.name = neuer
if "art" in data:
vorlage.art = data["art"]
if "position" in data:
vorlage.position = data["position"]
# Die Konfigurationswerte liegen zusammen in einem JSON-Text; wer nur
# einen schickt, darf die anderen nicht verlieren.
if {"spalten", "kategorie_ids", "trennzeichen", "feldfilter"} & data.keys():
alt = konfiguration_lesen(vorlage)
vorlage.konfiguration = konfiguration_schreiben(
data.get("spalten", alt["spalten"]),
data.get("kategorie_ids", alt["kategorie_ids"]),
data.get("trennzeichen", alt["trennzeichen"]),
data.get("feldfilter", alt["feldfilter"]),
)
db.commit()
db.refresh(vorlage)
return _als_out(vorlage)
@router.delete("/vorlagen/{vorlage_id}")
def vorlage_loeschen(
vorlage_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Response:
db.delete(_vorlage_oder_404(db, vorlage_id))
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/vorlagen/{vorlage_id}/csv")
def vorlage_csv(
vorlage_id: int,
basis_url: str = "",
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
"""Die CSV zu einer Vorlage.
``basis_url`` braucht nur der Lagerort-QR (…/l/<id>); Einzelstuecke tragen
vorrania://i/<UID> ohne jede Adresse. Die Oberflaeche reicht ihre eigene
Herkunft durch, weil nur sie weiss, unter welcher Adresse sie erreichbar ist.
"""
vorlage = _vorlage_oder_404(db, vorlage_id)
inhalt = csv_erzeugen(db, vorlage, basis_url=basis_url)
dateiname = f"{vorlage.name}.csv".replace("/", "-")
return Response(
content=inhalt.encode("utf-8"),
media_type="text/csv; charset=utf-8",
headers={
# Derselbe Riegel wie beim .lbx-Download: Der Vorlagenname ist frei
# getippt und kann genauso Zeichen tragen, die latin-1 nicht kennt.
"Content-Disposition": _disposition(dateiname),
# Der Sinn dieses Downloads ist der aktuelle Stand. Ohne Angabe
# darf ein Browser oder Proxy selbst entscheiden, ob er eine alte
# Antwort wiederverwendet - und dann fehlen neue Zeilen.
"Cache-Control": "no-store",
},
)
@router.post("/vorlagen/{vorlage_id}/datei", response_model=EtikettenVorlageOut)
async def datei_hochladen(
vorlage_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> EtikettenVorlageOut:
"""Die P-touch-Vorlage (.lbx) hinterlegen."""
vorlage = _vorlage_oder_404(db, vorlage_id)
daten = await file.read()
name = _safe_filename(file.filename)
_pruefe_lbx(name, daten)
vorlage.datei_name = name
vorlage.datei = daten
db.commit()
db.refresh(vorlage)
return _als_out(vorlage)
@router.get("/vorlagen/{vorlage_id}/datei")
def datei_herunterladen(
vorlage_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
vorlage = _vorlage_oder_404(db, vorlage_id)
if vorlage.datei is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Zu dieser Vorlage ist keine Datei hinterlegt")
return Response(
content=vorlage.datei,
# attachment und nicht inline: Die Datei soll gespeichert werden, der
# Browser kann mit ihr ohnehin nichts anfangen.
media_type="application/octet-stream",
headers={"Content-Disposition": _disposition(vorlage.datei_name)},
)
@router.delete("/vorlagen/{vorlage_id}/datei")
def datei_entfernen(
vorlage_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Response:
vorlage = _vorlage_oder_404(db, vorlage_id)
vorlage.datei = None
vorlage.datei_name = None
db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
+115 -3
View File
@@ -9,7 +9,7 @@ import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from ..database import get_db
from ..deps import get_current_user, require_admin
@@ -17,6 +17,7 @@ from ..models import (
Item,
ItemDocument,
ItemLoan,
ItemUsage,
Location,
Movement,
MovementType,
@@ -31,6 +32,7 @@ from ..schemas import (
ItemOut,
ItemRemove,
ItemUpdate,
ZubehoerZuordnen,
)
from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
@@ -56,6 +58,18 @@ def _product_or_404(db: Session, product_id: int) -> Product:
return product
def _zubehoer_freigeben(item: Item) -> None:
"""Zubehoer vom Geraet loesen, bevor das Geraet verschwindet.
ON DELETE SET NULL steht zwar am Fremdschluessel, aber SQLite erzwingt
Fremdschluessel nicht - in den Tests bliebe sonst eine tote Zuordnung
stehen. Dieselbe Vorsicht wie beim Loeschen einer Gruppe.
"""
for teil in list(item.zubehoer):
teil.parent_item_id = None
item.parent_item_id = None
def _item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
@@ -89,7 +103,7 @@ def create_items(
product_id: int,
payload: ItemCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
user: User = Depends(require_admin),
) -> list[ItemOut]:
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
product = _product_or_404(db, product_id)
@@ -109,6 +123,23 @@ def create_items(
)
db.add(item)
db.flush()
# Wie beim Entfernen eine Bewegung schreiben (siehe remove_item). Ohne
# sie kannte der Verlauf nur das Verschwinden eines Einzelstuecks, nie
# sein Entstehen - und das Dashboard zeigte Abgaenge ohne Zugaenge.
# Fuer den Bestand aendert das nichts: der zaehlt bei Einzelstuecken die
# Zeilen in items (services/stock.py), er rechnet nicht aus Bewegungen.
db.add(
Movement(
product_id=product.id,
lot_id=None,
user_id=user.id,
type=MovementType.in_,
quantity=1,
unit_used="Stück",
note=(payload.note or f"Einzelstück {item.uid}"),
location_id=payload.location_id,
)
)
created.append(item)
db.commit()
for item in created:
@@ -136,6 +167,10 @@ def list_all_items(
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
# verliehenem Stueck einzeln nach (N+1).
joinedload(Item.loans).joinedload(ItemLoan.user),
# selectin und nicht joined: Hier haengen schon drei Sammlungen
# dran, eine vierte vervielfachte die Zeilen ueber Kreuz. So kostet
# es eine zusaetzliche Abfrage statt eines Kreuzprodukts.
selectinload(Item.usages).joinedload(ItemUsage.user),
)
.order_by(Item.id)
.all()
@@ -191,6 +226,7 @@ def remove_item(
) -> None:
"""Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell)."""
item = _item_or_404(db, item_id)
_zubehoer_freigeben(item)
db.add(
Movement(
product_id=item.product_id,
@@ -215,7 +251,83 @@ def delete_item(
_: User = Depends(require_admin),
) -> None:
"""Einzelstück ohne Grund löschen (Korrektur)."""
db.delete(_item_or_404(db, item_id))
item = _item_or_404(db, item_id)
_zubehoer_freigeben(item)
db.delete(item)
db.commit()
# --------------------------------------------------------------------------
# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop)
# --------------------------------------------------------------------------
def _ringpruefung(db: Session, geraet: Item, teil: Item) -> None:
"""Verhindert, dass ein Stueck ueber Umwege sein eigenes Zubehoer wird.
Mit nur einem Elternteil je Teil ist die Kette kurz, aber A->B->A waere
ohne Pruefung moeglich - und danach liefe jedes Hochlaufen der Kette
endlos. Deshalb vom Geraet aus nach oben gehen, wie beim Kategoriebaum.
"""
if geraet.id == teil.id:
raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Einzelstück kann nicht sein eigenes Zubehör sein"
)
gesehen: set[int] = set()
lauf = geraet.gehoert_zu
while lauf is not None and lauf.id not in gesehen:
if lauf.id == teil.id:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"„{teil.uid}“ ist bereits weiter oben in dieser Zubehör-Kette",
)
gesehen.add(lauf.id)
lauf = lauf.gehoert_zu
@router.post("/items/{item_id}/zubehoer", response_model=ItemOut)
def zubehoer_zuordnen(
item_id: int,
payload: ZubehoerZuordnen,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemOut:
"""Ein Einzelstück als Zubehör an dieses Gerät hängen.
Eigener Endpunkt und nicht über ``ItemUpdate``: Das Schema sendet bewusst
immer alle Felder mit, auch leere, damit sich Felder loeschen lassen. Eine
Beziehung darueber zu pflegen hiesse, dass ein Aufrufer sie still kappt,
sobald er das Feld vergisst - genau so gingen schon einmal Kaufpreis und
Waehrung verloren.
"""
geraet = _item_or_404(db, item_id)
teil = _item_or_404(db, payload.child_item_id)
_ringpruefung(db, geraet, teil)
# Ein Teil haengt an genau einem Geraet - ein Wechsel ist deshalb einfach
# ein Umhaengen und braucht kein vorheriges Loesen.
teil.parent_item_id = geraet.id
db.commit()
db.refresh(geraet)
return item_to_out(geraet)
@router.delete(
"/items/{item_id}/zubehoer/{child_item_id}", status_code=status.HTTP_204_NO_CONTENT
)
def zubehoer_loesen(
item_id: int,
child_item_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Zubehör vom Gerät lösen. Das Stück selbst bleibt bestehen."""
geraet = _item_or_404(db, item_id)
teil = _item_or_404(db, child_item_id)
if teil.parent_item_id != geraet.id:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
f"„{teil.uid}“ gehört nicht zu „{geraet.uid}“",
)
teil.parent_item_id = None
db.commit()
+53
View File
@@ -51,6 +51,28 @@ def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | No
)
def _zubehoer_pruefen(db: Session, geraet: Item, ids: list[int]) -> list[Item]:
"""Die benannten Teile holen und pruefen, dass sie zu diesem Geraet gehoeren.
Ohne die Pruefung koennte ein Aufrufer ueber diesen Weg ein beliebiges
fremdes Stueck mitverleihen.
"""
teile: list[Item] = []
for teil_id in dict.fromkeys(ids):
teil = db.get(Item, teil_id)
if teil is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Zubehörteil nicht gefunden"
)
if teil.parent_item_id != geraet.id:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"„{teil.uid}“ ist kein Zubehör von „{geraet.uid}“",
)
teile.append(teil)
return teile
@router.post(
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
)
@@ -81,6 +103,27 @@ def lend_item(
user_id=user.id,
)
db.add(loan)
# Zubehoer geht nur mit, wenn es ausdruecklich benannt wurde - die
# Oberflaeche fragt danach. Ein Netzteil, das im Schrank bleibt, darf nicht
# als verliehen gefuehrt werden, nur weil es am Geraet haengt.
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
if offener_verleih(db, teil.id) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"„{teil.uid}“ ist bereits verliehen.",
)
db.add(
ItemLoan(
item_id=teil.id,
borrower=name,
reason=(payload.reason or "").strip() or None,
lent_on=lent_on,
due_on=payload.due_on,
user_id=user.id,
)
)
db.commit()
db.refresh(loan)
return loan_to_out(loan)
@@ -103,6 +146,16 @@ def return_item(
returned_on = payload.returned_on or date.today()
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
loan.returned_on = returned_on
# Spiegelbild zum Verleihen: Ohne das kaeme das Geraet zurueck und sein
# Netzteil bliebe ewig „verliehen“, weil niemand mehr danach sucht.
for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer):
offen = offener_verleih(db, teil.id)
if offen is None:
continue # war gar nicht mit draussen – kein Fehler, nur nichts zu tun
_pruefe_zeitfolge(offen.lent_on, offen.due_on, returned_on)
offen.returned_on = returned_on
db.commit()
db.refresh(loan)
return loan_to_out(loan)
+149
View File
@@ -0,0 +1,149 @@
"""Mealie einrichten und prüfen.
Die Zugangsdaten nimmt dieser Router entgegen, gibt sie aber nie zurück. Die
Oberfläche erfährt nur „eingerichtet ja/nein" — dasselbe Muster wie bei
``GET /auth/oidc/providers``, wo Client-Secrets ebenfalls nie in der Antwort
stehen.
Bewusst eigene Routen mit JSON-Rumpf statt ``PUT /settings/{key}?value=``:
Query-Parameter landen in Zugriffsprotokollen, und ein Passwort hat dort
nichts zu suchen.
"""
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Setting, User
from ..schemas import (
MealieAvailable,
MealieConfigIn,
MealieStatus,
MealieTokenIn,
)
from ..services import mealie
router = APIRouter(prefix="/mealie", tags=["mealie"])
def _setze_adresse(db: Session, base_url: str) -> str:
sauber = base_url.strip().rstrip("/")
row = db.get(Setting, mealie.BASE_URL_KEY)
if row is None:
db.add(Setting(key=mealie.BASE_URL_KEY, value=sauber))
else:
row.value = sauber
db.commit()
return sauber
def _status(db: Session, pruefen: bool) -> MealieStatus:
adresse = mealie.basis_adresse(db)
hat_token = bool(mealie.token(db))
status_out = MealieStatus(
configured=bool(adresse) and hat_token,
base_url=adresse,
token_set=hat_token,
)
if not pruefen or not status_out.configured:
return status_out
try:
status_out.version = mealie.version(db)
# Eine echte Abfrage – „erreichbar" soll heissen, dass auch das Token gilt.
mealie.rezepte(db, pro_seite=1)
status_out.reachable = True
except mealie.MealieFehler as fehler:
status_out.reachable = False
status_out.error = str(fehler)
return status_out
@router.get("/status", response_model=MealieStatus)
def mealie_status(
pruefen: bool = False,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MealieStatus:
"""Was eingerichtet ist – ohne das Token. ``pruefen=true`` fragt Mealie an."""
return _status(db, pruefen)
@router.get("/available", response_model=MealieAvailable)
def mealie_available(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> MealieAvailable:
"""Ob die Rezept-Seite etwas anzeigen kann.
Für alle angemeldeten Benutzer, damit die Oberfläche den Menüpunkt nur dann
zeigt. Anders als bei den Anmeldeanbietern **mit** Anmeldung: Es gibt keinen
Grund, Unbekannten zu verraten, dass hier eine Mealie-Instanz hängt.
"""
return MealieAvailable(available=mealie.eingerichtet(db))
@router.put("/config", response_model=MealieStatus)
def mealie_konfigurieren(
payload: MealieConfigIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MealieStatus:
"""Mit Benutzername und Passwort anmelden und ein Dauertoken holen.
Das Passwort wird nur für diese eine Anmeldung benutzt und danach verworfen
– es steht in keiner Zeile der Datenbank und in keiner Antwort.
"""
try:
dauertoken = mealie.hole_dauertoken(
payload.base_url, payload.username, payload.password
)
except mealie.MealieFehler as fehler:
raise HTTPException(_http_code(fehler), str(fehler)) from fehler
_setze_adresse(db, payload.base_url)
mealie.speichere_token(db, dauertoken)
return _status(db, pruefen=True)
@router.put("/token", response_model=MealieStatus)
def mealie_token_setzen(
payload: MealieTokenIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MealieStatus:
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen.
Für alle, die ihr Passwort nicht eintippen wollen: In Mealie unter Profil ▸
API-Tokens eines anlegen und hier einfügen.
"""
_setze_adresse(db, payload.base_url)
mealie.speichere_token(db, payload.token.strip())
return _status(db, pruefen=True)
@router.delete("/config", status_code=status.HTTP_204_NO_CONTENT)
def mealie_trennen(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Verbindung lösen: Adresse und Token hier entfernen.
Das Token selbst bleibt in Mealie bestehen – wer es dort endgültig los sein
will, löscht es unter Profil ▸ API-Tokens.
"""
mealie.loesche_zugang(db)
def _http_code(fehler: mealie.MealieFehler) -> int:
"""Fehlerkennung auf einen Statuscode abbilden.
Getrennt, weil „Mealie ist weg" (502) etwas anderes ist als „dein Passwort
stimmt nicht" (400) – und die Oberfläche beides verschieden erklären soll.
"""
if fehler.kennung in ("anmeldung_fehlgeschlagen",):
return status.HTTP_400_BAD_REQUEST
if fehler.kennung == "nicht_eingerichtet":
return status.HTTP_503_SERVICE_UNAVAILABLE
return status.HTTP_502_BAD_GATEWAY
+319
View File
@@ -0,0 +1,319 @@
"""Hausmaße: die Namen (Stammdaten) und ihre Mengen je Kategorie und Artikel.
Der Name ist global („Esslöffel"), die Menge nicht: 1 EL Mehl sind 10 g, 1 EL
Zucker 12 g. Deshalb zwei Ebenen – ein Maß anlegen ist Stammdatenpflege, ihm
eine Menge geben hängt an Kategorie oder Artikel.
"""
# ACHTUNG: Hier steht bewusst KEIN ``from __future__ import annotations`` –
# aus demselben Grund wie in package_types.py (``-> None`` wuerde als
# Antwortmodell gewertet und bricht mit 204 den Start des Dienstes ab).
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import (
Category,
CategoryMeasure,
Measure,
Product,
ProductMeasure,
User,
)
from ..schemas import (
MeasureAmountIn,
MeasureAmountOut,
MeasureIn,
MeasureOut,
)
from ..services.conversion import PACKAGE_TOKENS, find_unit
from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse
router = APIRouter(tags=["measures"])
def _measure_or_404(db: Session, measure_id: int) -> Measure:
measure = db.get(Measure, measure_id)
if measure is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Maß nicht gefunden")
return measure
def _pruefe_namen(db: Session, name: str, short: str | None, ausser_id: int | None = None) -> None:
"""Name und Kurzform müssen eindeutig und frei von Kollisionen sein.
Ein Maß, das genauso heißt wie eine verwaltete Einheit oder wie „Packung",
wäre dauerhaft unerreichbar: ``to_base`` prüft beide vorher. Lieber hier
ablehnen als still etwas anlegen, das nie greift.
"""
for wert, feld in ((name, "Name"), (short, "Kurzform")):
if not wert:
continue
t = wert.strip().lower()
if t in PACKAGE_TOKENS:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"'{wert}' ist als Packungs-Angabe vergeben und würde nie greifen.",
)
if find_unit(db, t) is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
f"'{wert}' ist bereits eine verwaltete Einheit. Als Maß würde es "
f"nie greifen, weil Einheiten zuerst geprüft werden.",
)
doppelt = db.query(Measure).filter(
(func.lower(Measure.name) == t) | (func.lower(Measure.short) == t)
)
if ausser_id is not None:
doppelt = doppelt.filter(Measure.id != ausser_id)
if doppelt.first() is not None:
raise HTTPException(status.HTTP_409_CONFLICT, f"{feld} '{wert}' gibt es schon.")
# --------------------------------------------------------------------------
# Stammdaten
# --------------------------------------------------------------------------
@router.get("/measures", response_model=list[MeasureOut])
def list_measures(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Measure]:
return db.query(Measure).order_by(Measure.position, Measure.id).all()
@router.post("/measures", response_model=MeasureOut, status_code=status.HTTP_201_CREATED)
def create_measure(
payload: MeasureIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Measure:
_pruefe_namen(db, payload.name, payload.short)
measure = Measure(
name=payload.name.strip(),
short=(payload.short or "").strip() or None,
position=payload.position,
)
db.add(measure)
db.commit()
db.refresh(measure)
return measure
@router.patch("/measures/{measure_id}", response_model=MeasureOut)
def update_measure(
measure_id: int,
payload: MeasureIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Measure:
"""Umbenennen – auch bei eingebauten Maßen, wie bei Einheiten und Gebinden.
Die Mengen hängen an der ID, nicht am Namen, und wandern deshalb mit.
"""
measure = _measure_or_404(db, measure_id)
_pruefe_namen(db, payload.name, payload.short, ausser_id=measure.id)
measure.name = payload.name.strip()
measure.short = (payload.short or "").strip() or None
measure.position = payload.position
db.commit()
db.refresh(measure)
return measure
@router.delete("/measures/{measure_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_measure(
measure_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Maß löschen – samt aller Mengen, die daran hängen.
Die Mengen werden ausdrücklich abgeräumt: SQLite setzt Fremdschlüssel nicht
ohne Weiteres um, sonst blieben Waisen stehen (gleiche Begründung wie in
``delete_category``).
"""
measure = _measure_or_404(db, measure_id)
if measure.is_builtin:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Eingebaute Maße lassen sich nicht löschen – umbenennen geht aber.",
)
db.query(CategoryMeasure).filter(CategoryMeasure.measure_id == measure.id).delete()
db.query(ProductMeasure).filter(ProductMeasure.measure_id == measure.id).delete()
db.delete(measure)
db.commit()
# --------------------------------------------------------------------------
# Mengen je Kategorie und Artikel
# --------------------------------------------------------------------------
@router.get("/products/{product_id}/measures", response_model=list[MeasureAmountOut])
def product_measures(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MeasureAmountOut]:
"""Die für diesen Artikel WIRKSAMEN Maße, mit Herkunft.
Grundlage für die Einheitenauswahl beim Verbrauchen – dort soll „EL" nur
auftauchen, wenn dafür auch eine Menge bekannt ist.
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
return [
MeasureAmountOut(
measure_id=a.measure.id, name=a.measure.name, short=a.measure.short,
amount=a.amount, base=a.base, herkunft=a.herkunft,
)
for a in verfuegbare_masse(db, product)
]
@router.put("/products/{product_id}/measures/{measure_id}", response_model=MeasureAmountOut)
def set_product_measure(
product_id: int,
measure_id: int,
payload: MeasureAmountIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MeasureAmountOut:
"""Menge nur für diesen Artikel – schlägt die der Kategorie."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
measure = _measure_or_404(db, measure_id)
row = (
db.query(ProductMeasure)
.filter(ProductMeasure.product_id == product.id,
ProductMeasure.measure_id == measure.id)
.first()
)
if row is None:
row = ProductMeasure(product_id=product.id, measure_id=measure.id,
amount=payload.amount, base=payload.base.value)
db.add(row)
else:
row.amount = payload.amount
row.base = payload.base.value
db.commit()
return MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=row.amount, base=row.base, herkunft="Artikel",
)
@router.delete("/products/{product_id}/measures/{measure_id}",
status_code=status.HTTP_204_NO_CONTENT)
def delete_product_measure(
product_id: int,
measure_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Eigene Menge entfernen – danach gilt wieder die der Kategorie."""
db.query(ProductMeasure).filter(
ProductMeasure.product_id == product_id,
ProductMeasure.measure_id == measure_id,
).delete()
db.commit()
@router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut])
def category_measures(
category_id: int,
vererbt: bool = False,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MeasureAmountOut]:
"""Die an DIESER Kategorie hinterlegten Mengen.
Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit
``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer
Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was
gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende
bekommt, zeigt ``/products/{id}/measures``.
"""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
rows = (
db.query(CategoryMeasure, Measure)
.join(Measure, CategoryMeasure.measure_id == Measure.id)
.filter(CategoryMeasure.category_id == category_id)
.order_by(Measure.position, Measure.id)
.all()
)
eigene = [
MeasureAmountOut(
measure_id=m.id, name=m.name, short=m.short,
amount=cm.amount, base=cm.base, herkunft="Kategorie",
)
for cm, m in rows
]
if not vererbt:
return eigene
# Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit
# beides: was hier steht und was gelten wuerde, wenn man es loescht.
category = db.get(Category, category_id)
gesetzt = {e.measure_id for e in eigene}
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
if measure.id in gesetzt:
continue
angabe = geerbtes_mass_kategorie(db, category, measure.id)
if angabe is None:
continue
eigene.append(MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft,
))
return eigene
@router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut)
def set_category_measure(
category_id: int,
measure_id: int,
payload: MeasureAmountIn,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> MeasureAmountOut:
"""Menge für eine Kategorie – gilt auch für ihre Unterkategorien."""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
measure = _measure_or_404(db, measure_id)
row = (
db.query(CategoryMeasure)
.filter(CategoryMeasure.category_id == category_id,
CategoryMeasure.measure_id == measure.id)
.first()
)
if row is None:
row = CategoryMeasure(category_id=category_id, measure_id=measure.id,
amount=payload.amount, base=payload.base.value)
db.add(row)
else:
row.amount = payload.amount
row.base = payload.base.value
db.commit()
return MeasureAmountOut(
measure_id=measure.id, name=measure.name, short=measure.short,
amount=row.amount, base=row.base, herkunft="Kategorie",
)
@router.delete("/categories/{category_id}/measures/{measure_id}",
status_code=status.HTTP_204_NO_CONTENT)
def delete_category_measure(
category_id: int,
measure_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
db.query(CategoryMeasure).filter(
CategoryMeasure.category_id == category_id,
CategoryMeasure.measure_id == measure_id,
).delete()
db.commit()
+105
View File
@@ -0,0 +1,105 @@
"""Welche Einträge der Seitenleiste jemand ausgeblendet hat.
Zwei Ebenen: die **Vorgabe des Administrators** gilt für alle (``user_id``
NULL), die **eigene Auswahl** eines Kontos kommt obendrauf. Beide Listen gelten
zusammen — wer etwas versteckt, versteckt es zusätzlich; einblenden, was der
Administrator ausgeblendet hat, kann niemand.
Die eigene Auswahl darf jeder für sich ändern (es betrifft nur seine Ansicht),
die Vorgabe nur ein Administrator.
Versteckt wird ausschliesslich der **Eintrag**, nicht die Seite: Lesezeichen,
Dashboard-Karten und Lagerort-QR verweisen weiter auf Routen, die hier
abgewählt sein können, und sollen weiter funktionieren.
"""
import json
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import NavigationSichtbarkeit, User
from ..schemas import NavigationOut, NavigationSetzen
router = APIRouter(prefix="/navigation", tags=["navigation"])
def _liste(zeile: NavigationSichtbarkeit | None) -> list[str]:
"""Die gespeicherte Liste – kaputter Inhalt gilt als leer.
Lieber eine vollständige Leiste als eine Fehlermeldung: Ein unlesbarer
Eintrag darf niemanden aussperren.
"""
if zeile is None:
return []
try:
werte = json.loads(zeile.versteckt)
except ValueError:
return []
return [w for w in werte if isinstance(w, str)] if isinstance(werte, list) else []
def _zeile(db: Session, user_id: int | None) -> NavigationSichtbarkeit | None:
return (
db.query(NavigationSichtbarkeit)
.filter(NavigationSichtbarkeit.user_id.is_(None) if user_id is None
else NavigationSichtbarkeit.user_id == user_id)
.first()
)
def _schreiben(db: Session, user_id: int | None, versteckt: list[str]) -> None:
"""Die Zeile setzen oder anlegen.
Doppelte und leere Einträge fallen weg; die Reihenfolge ist bedeutungslos
und wird sortiert, damit zwei gleiche Auswahlen auch gleich aussehen.
"""
sauber = sorted({w.strip() for w in versteckt if w and w.strip()})
zeile = _zeile(db, user_id)
if zeile is None:
zeile = NavigationSichtbarkeit(user_id=user_id, versteckt=json.dumps(sauber))
db.add(zeile)
else:
zeile.versteckt = json.dumps(sauber)
db.commit()
def _antwort(db: Session, user: User) -> NavigationOut:
vorgabe = _liste(_zeile(db, None))
eigen = _liste(_zeile(db, user.id))
return NavigationOut(
vorgabe=vorgabe, eigen=eigen, versteckt=sorted(set(vorgabe) | set(eigen))
)
@router.get("", response_model=NavigationOut)
def navigation_lesen(
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> NavigationOut:
"""Vorgabe, eigene Auswahl und die fertige Vereinigung."""
return _antwort(db, user)
@router.put("", response_model=NavigationOut)
def eigene_auswahl_setzen(
payload: NavigationSetzen,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> NavigationOut:
"""Die eigene Auswahl – betrifft nur die eigene Ansicht."""
_schreiben(db, user.id, payload.versteckt)
return _antwort(db, user)
@router.put("/vorgabe", response_model=NavigationOut)
def vorgabe_setzen(
payload: NavigationSetzen,
db: Session = Depends(get_db),
user: User = Depends(require_admin),
) -> NavigationOut:
"""Die Vorgabe für alle."""
_schreiben(db, None, payload.versteckt)
return _antwort(db, user)
+11 -6
View File
@@ -35,6 +35,7 @@ from ..schemas import (
Token,
)
from ..security import create_access_token
from .settings import get_session_minutes
from ..services import oidc as dienst
from ..services import oidc_state
@@ -63,8 +64,12 @@ def _provider_oder_404(slug: str) -> OidcProvider:
return provider
def _token_fuer(user: User) -> Token:
token = create_access_token(user_id=user.id, role=user.role.value)
def _token_fuer(user: User, db: Session) -> Token:
# Die Sitzungsdauer steht in den Einstellungen und gilt fuer JEDEN Weg
# herein - nach Single Sign-on genauso wie nach dem Passwort-Login.
token = create_access_token(
user_id=user.id, role=user.role.value, gueltig_minuten=get_session_minutes(db)
)
return Token(access_token=token, role=user.role, username=user.username)
@@ -209,7 +214,7 @@ def anmeldung_abschliessen(
logger.warning("SSO '%s' gescheitert: %s", slug, fehler)
return _fehler_weiterleitung(fehler.schluessel, verknuepfung=verknuepfen_fuer is not None)
token = _token_fuer(nutzer)
token = _token_fuer(nutzer, db)
logger.info("SSO '%s': '%s' angemeldet.", slug, nutzer.username)
# Token im Fragment, nicht in der Abfragezeichenkette: Fragmente werden nicht
# an den Server geschickt und landen damit auch nicht in den nginx-Logs.
@@ -253,7 +258,7 @@ def anmeldung_app(slug: str, payload: OidcNativeLogin, db: Session = Depends(get
raise HTTPException(status.HTTP_401_UNAUTHORIZED, str(fehler)) from fehler
logger.info("SSO '%s' (App): '%s' angemeldet.", slug, nutzer.username)
return _token_fuer(nutzer)
return _token_fuer(nutzer, db)
# ---------------------------------------------------------------------------
@@ -394,7 +399,7 @@ def offene_anmeldung_neues_konto(code: str, db: Session = Depends(get_db)) -> To
except dienst.OidcFehler as fehler:
raise HTTPException(status.HTTP_403_FORBIDDEN, str(fehler)) from fehler
logger.info("SSO '%s': neues Konto '%s' auf Wunsch angelegt.", provider.slug, nutzer.username)
return _token_fuer(nutzer)
return _token_fuer(nutzer, db)
@router.post("/pending/{code}/verknuepfen", response_model=Token)
@@ -417,4 +422,4 @@ def offene_anmeldung_verknuepfen(
)
raise HTTPException(code_http, str(fehler)) from fehler
logger.info("SSO '%s': mit bestehendem Konto '%s' verknuepft.", provider.slug, nutzer.username)
return _token_fuer(nutzer)
return _token_fuer(nutzer, db)
+46 -38
View File
@@ -23,7 +23,9 @@ from ..models import (
Category,
CategoryTracking,
Group,
Item,
Location,
Lot,
Movement,
MovementType,
Product,
@@ -55,45 +57,17 @@ from .settings import get_receipt_match_threshold
from ..services.conversion import ConversionError, resolve_product_unit
from ..services.fields import FieldError, apply_field_values
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
from ..services.matching import match_score, normalisieren
from ..services.min_stock import schreibe_ueberall
from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"])
# ---------------------------------------------------------------------------
# Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen.
# ---------------------------------------------------------------------------
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def _normalisieren(text: str) -> str:
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
text = (text or "").lower().translate(_UMLAUTE)
return re.sub(r"[^a-z0-9]+", " ", text).strip()
def _match_score(zeile: str, name: str) -> int:
"""Ähnlichkeit 0–100 zwischen Kassenzeile und Produktname.
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen.
"""
a, b = _normalisieren(zeile), _normalisieren(name)
if not a or not b:
return 0
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
a_tok, b_tok = a.split(), b.split()
bester = []
for t in a_tok:
m = 0.0
for u in b_tok:
if u.startswith(t) or t.startswith(u):
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
else:
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
bester.append(m)
token = sum(bester) / len(bester) if bester else 0.0
return round(100 * max(gesamt, token))
# Kassenzettel-Abgleich: eine OCR-Zeile den aehnlichsten Lebensmitteln zuordnen.
# Die Heuristik liegt in services/matching.py, weil der Rezeptabgleich sie
# genauso braucht.
_normalisieren = normalisieren
_match_score = match_score
@router.post("/match", response_model=list[MatchLine])
@@ -141,7 +115,12 @@ def list_products(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ProductOut]:
"""Artikel auflisten, optional nach Name und Kategorie eingegrenzt.
"""Artikel auflisten, optional nach Suchtext und Kategorie eingegrenzt.
``q`` wird in Wörter zerlegt; jedes muss in Name **oder** Marke vorkommen,
die Reihenfolge ist egal. "Vogel Jack and Jones" findet damit dasselbe
Shirt wie "Jack and Jones Vogel" – vorher suchte ein einzelnes ILIKE nach
dem ganzen Ausdruck und fand gar nichts.
``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
@@ -157,14 +136,24 @@ def list_products(
selectinload(Product.field_values),
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
)
if q:
like = f"%{q}%"
query = query.filter(Product.name.ilike(like))
if category_id == 0:
query = query.filter(Product.category_id.is_(None))
elif category_id is not None:
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
products = query.order_by(Product.name).all()
# Gleiche Form wie recipes.zuordnung_ziele. Bewusst in Python und nicht in
# SQL: normalisieren() loest Umlaute auf ("Mühle" -> "muehle"), ein ILIKE
# auf der Spalte faende den gespeicherten Wert damit nicht wieder. Die
# Route laedt bei der normalen Artikelliste ohnehin alle Zeilen, der
# gefilterte Weg kostet also nie mehr als der ungefilterte.
teile = normalisieren(q or "").split()
if teile:
def passt(text: str) -> bool:
ziel = normalisieren(text)
return all(t in ziel for t in teile)
products = [p for p in products if passt(f"{p.name} {p.brand or ''}")]
return products_to_out_bulk(db, products)
@@ -680,9 +669,28 @@ def delete_product(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Artikel löschen – nur, solange nichts mehr daran hängt.
Ohne diese Sperre reisst ein Klick Chargen, Einzelstücke, Barcodes, Bilder
und den gesamten Bewegungsverlauf per Datenbank-Kaskade mit, ohne zu
fragen. Bewegungen allein halten aber nicht auf: Sonst waere jeder je
benutzte Artikel fuer immer unloeschbar. Geprueft wird der *heutige*
Bestand, und der steht in Einzelstuecken und Chargen – auch bei
Gegenstaenden, deren Menge je Lagerort ebenfalls als Charge liegt.
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
if db.query(Item).filter(Item.product_id == product_id).first() is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Der Artikel hat noch Einzelstücke. Erst entfernen, dann löschen.",
)
if db.query(Lot).filter(Lot.product_id == product_id).first() is not None:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Der Artikel hat noch Bestand. Erst auslagern, dann löschen.",
)
detach_group_code(db, product)
db.delete(product)
db.commit()
+295
View File
@@ -0,0 +1,295 @@
"""Rezepte aus Mealie, gegen den eigenen Bestand geprüft.
Die Seite beantwortet eine Frage: Kann ich das heute kochen, oder muss ich
vorher einkaufen? Das Rechnen steckt in ``services/rezepte.py`` (ohne Netz),
das Holen in ``services/mealie.py`` (ohne Rechnen).
"""
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Group, MealieFoodMap, Product, User
from ..schemas import (
RecipeDetail,
RecipePage,
RecipeSummary,
ShoppingFromRecipe,
ZielVorschlag,
ZuordnungIn,
ZuordnungOut,
ZutatKandidat,
ZutatOut,
)
from ..services import einkaufsliste, mealie, rezepte, zuordnung
from ..services.conversion import group_min_context
from ..services.einkaufsliste_text import fehlmengen_text
from .shopping import eintrag_out
from ..services.matching import match_score, normalisieren
router = APIRouter(prefix="/recipes", tags=["recipes"])
def _fehler(fehler: mealie.MealieFehler) -> HTTPException:
"""Mealie-Kennung auf einen Statuscode abbilden.
503 heisst „hier ist nichts eingerichtet", 502 „Mealie antwortet nicht" –
die Oberfläche soll beides verschieden erklären können.
404 kam dazu, als das Rezept in den Pfad wanderte (/rezepte/<slug>): Seither
lässt sich ein Rezept verlinken, und ein alter Link zeigt irgendwann ins
Leere. Das ist kein Gateway-Fehler, sondern eine ganz gewöhnliche Auskunft –
und sie soll auch so klingen, nicht nach „Mealie kennt /api/recipes/x nicht".
"""
if fehler.kennung == "nicht_gefunden":
return HTTPException(
status.HTTP_404_NOT_FOUND,
"Dieses Rezept gibt es in Mealie nicht (mehr).",
)
code = (
status.HTTP_503_SERVICE_UNAVAILABLE
if fehler.kennung == "nicht_eingerichtet"
else status.HTTP_502_BAD_GATEWAY
)
return HTTPException(code, str(fehler))
def _bild(eintrag: dict, basis: str) -> str | None:
"""Mealie liefert Bilder unter /api/media/recipes/<id>/images/min-original.webp."""
kennung = eintrag.get("id")
if not kennung or not basis:
return None
return f"{basis}/api/media/recipes/{kennung}/images/min-original.webp"
@router.get("", response_model=RecipePage)
def list_recipes(
page: int = 1,
per_page: int = 50,
q: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> RecipePage:
"""Rezeptliste aus Mealie, blätterbar und durchsuchbar."""
try:
daten = mealie.rezepte(db, seite=page, pro_seite=per_page, suche=q)
except mealie.MealieFehler as fehler:
raise _fehler(fehler) from fehler
basis = mealie.basis_adresse(db)
eintraege = [
RecipeSummary(
slug=str(e.get("slug") or ""),
name=str(e.get("name") or e.get("slug") or "Ohne Titel"),
description=str(e.get("description") or "")[:300],
image_url=_bild(e, basis),
servings=e.get("recipeServings") or e.get("recipe_servings") or None,
)
for e in (daten.get("items") or [])
if e.get("slug")
]
return RecipePage(
items=eintraege, page=page, per_page=per_page,
total=int(daten.get("total") or len(eintraege)),
)
# --------------------------------------------------------------------------
# Zuordnungen von Hand
#
# ACHTUNG: Diese Routen stehen bewusst VOR ``/{slug}``. FastAPI probiert in
# Reihenfolge der Deklaration – stünden sie darunter, würde „zuordnungen" als
# Rezept-Slug gelesen und bei Mealie nachgefragt. ``test_zuordnung.py`` wacht
# darüber.
# --------------------------------------------------------------------------
@router.get("/zuordnungen", response_model=list[ZuordnungOut])
def list_zuordnungen(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ZuordnungOut]:
"""Alle gemerkten Zuordnungen – zum Nachsehen und Widerrufen."""
aus = []
for key, (art, ziel_id) in sorted(zuordnung.lade(db).items()):
zeile = db.get(MealieFoodMap, key)
aus.append(ZuordnungOut(
food_key=key,
food_name=(zeile.food_name if zeile is not None else key),
art=art, ziel_id=ziel_id,
ziel_name=zuordnung.ziel_name(db, art, ziel_id),
))
return aus
@router.get("/zuordnungen/ziele", response_model=list[ZielVorschlag])
def zuordnung_ziele(
q: str | None = None,
limit: int = 40,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ZielVorschlag]:
"""Artikel und Gruppen zur Auswahl, nach Suchtext eingegrenzt.
Anders als beim Raten wird hier **nicht** auf Lebensmittel eingeschränkt:
Die Heuristik soll keine Gegenstände vorschlagen, aber wer von Hand
zuordnet, weiss was er will – „Alufolie" steht in manchem Rezept. Nur
Einzelstücke fallen weg, die sind per Definition unverbrauchbar.
Mitgeliefert wird die Basiseinheit, damit man vor dem Zuordnen sieht, ob
das Ziel in Gramm oder Milliliter geführt wird.
"""
teile = normalisieren(q or "").split()
def passt(text: str) -> bool:
ziel = normalisieren(text)
return all(t in ziel for t in teile)
vorschlaege: list[ZielVorschlag] = []
for gruppe in db.query(Group).all():
if not passt(gruppe.name):
continue
ctx = group_min_context(gruppe)
vorschlaege.append(ZielVorschlag(
art="group", id=gruppe.id, name=gruppe.name, zusatz="Gruppe",
basis_einheit=(ctx.base_unit.value if ctx.base_unit else ""),
))
for produkt in db.query(Product).all():
if produkt.individual:
continue
voll = f"{produkt.name} {produkt.brand or ''}"
if not passt(voll):
continue
vorschlaege.append(ZielVorschlag(
art="product", id=produkt.id, name=produkt.name,
zusatz=(produkt.brand or ""),
basis_einheit=produkt.base_unit.value,
))
if teile:
vorschlaege.sort(key=lambda v: (-match_score(q or "", f"{v.name} {v.zusatz}"), v.name))
else:
vorschlaege.sort(key=lambda v: (0 if v.art == "group" else 1, v.name))
return vorschlaege[: max(1, min(limit, 200))]
@router.put("/zuordnungen", response_model=ZuordnungOut)
def setze_zuordnung(
payload: ZuordnungIn,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ZuordnungOut:
"""Eine Zutat von Hand zuordnen – gilt ab dann für jedes Rezept."""
try:
zeile = zuordnung.setze(db, payload.food, payload.art, payload.ziel_id)
except zuordnung.ZuordnungFehler as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
return ZuordnungOut(
food_key=zeile.food_key, food_name=zeile.food_name,
art=payload.art, ziel_id=payload.ziel_id,
ziel_name=zuordnung.ziel_name(db, payload.art, payload.ziel_id),
)
@router.delete("/zuordnungen", status_code=status.HTTP_204_NO_CONTENT)
def loesche_zuordnung(
food: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
"""Zuordnung vergessen; danach wird für diese Zutat wieder geraten.
Der Name reist als Abfrageparameter, nicht im Pfad: Lebensmittelnamen
enthalten Schrägstriche („Öl/Fett") und würden den Pfad zerlegen.
"""
zuordnung.loesche(db, food)
@router.get("/{slug}", response_model=RecipeDetail)
def get_recipe(
slug: str,
servings: float | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> RecipeDetail:
"""Ein Rezept mit allen Zutaten, auf ``servings`` Portionen hochgerechnet."""
try:
daten = mealie.rezept(db, slug)
except mealie.MealieFehler as fehler:
raise _fehler(fehler) from fehler
faktor, hinweis = rezepte.faktor_fuer_portionen(daten, servings)
zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or []
pruefungen = rezepte.pruefe_zutaten(
db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db),
)
basis_portionen = (
daten.get("recipeServings") or daten.get("recipe_servings")
or daten.get("recipeYieldQuantity") or daten.get("recipe_yield_quantity") or None
)
return RecipeDetail(
slug=slug,
name=str(daten.get("name") or slug),
description=str(daten.get("description") or ""),
image_url=_bild(daten, mealie.basis_adresse(db)),
base_servings=basis_portionen,
servings=servings or basis_portionen,
skalierung_hinweis=hinweis,
kochbar=rezepte.kochbar(pruefungen),
ingredients=[_zutat_out(db, p) for p in pruefungen],
)
@router.post("/{slug}/to-shopping-list", response_model=ShoppingFromRecipe)
def recipe_to_shopping_list(
slug: str,
servings: float | None = None,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ShoppingFromRecipe:
"""Was für dieses Rezept fehlt, auf die Einkaufsliste setzen.
Gerechnet wird dabei frisch — mit derselben Portionszahl, die auf dem
Bildschirm steht. Sonst landete die Menge für vier Portionen auf der Liste,
während die Seite zwölf anzeigt.
Übernommen wird die **Fehlmenge**, nicht die Rezeptmenge: Wer 250 g braucht
und 100 g hat, kauft 150 g. Zutaten ohne Zuordnung kommen als Freitext drauf
— gerade die unbekannte Zutat ist die, die man vergisst.
"""
try:
daten = mealie.rezept(db, slug)
except mealie.MealieFehler as fehler:
raise _fehler(fehler) from fehler
faktor, _ = rezepte.faktor_fuer_portionen(daten, servings)
zutaten = daten.get("recipeIngredient") or daten.get("recipe_ingredient") or []
pruefungen = rezepte.pruefe_zutaten(
db, zutaten, faktor=faktor, zuordnung=zuordnung.lade(db),
)
eintraege, aktualisiert = einkaufsliste.aus_rezept(
db, pruefungen, slug=slug, name=str(daten.get("name") or slug), user=user,
)
return ShoppingFromRecipe(
entries=[eintrag_out(db, e) for e in eintraege],
aktualisiert=aktualisiert,
freitext=sum(1 for e in eintraege if e.free_text),
)
def _zutat_out(db: Session, p: rezepte.ZutatPruefung) -> ZutatOut:
return ZutatOut(
text=p.text, menge=p.menge, einheit=p.einheit,
food=p.food, manuell=p.manuell,
art=p.art, ziel_id=p.ziel_id, ziel_name=p.ziel_name,
kandidaten=[
ZutatKandidat(art=k.art, id=k.id, name=k.name, score=k.score)
for k in p.kandidaten
],
benoetigt_base=p.benoetigt_base, bestand_base=p.bestand_base,
fehlt_base=p.fehlt_base, basis_einheit=p.basis_einheit,
status=p.status, hinweis=p.hinweis,
fehlendes_mass=p.fehlendes_mass, kategorie_id=p.kategorie_id,
fehlt_text=fehlmengen_text(db, p),
)
+61 -1
View File
@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from ..config import get_settings
@@ -6,6 +6,7 @@ from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Setting, User
from ..schemas import SettingOut
from ..services.vererbung import globale_oeffnungsfrist
router = APIRouter(prefix="/settings", tags=["settings"])
@@ -14,6 +15,16 @@ EXPIRY_WARNING_KEY = "expiry_warning_days"
# Treffer vorgeschlagen wird.
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
RECEIPT_THRESHOLD_DEFAULT = 45
# Wie lange eine Anmeldung gilt, in Tagen. 0 = unbegrenzt.
SESSION_DAYS_KEY = "session_days"
# Zehn Jahre. Wer laenger will, nimmt "unbegrenzt" – ein Token mit einem
# Ablauf im naechsten Jahrhundert waere dasselbe, nur unehrlicher.
SESSION_DAYS_MAX = 3650
# Wie lange eine angebrochene Packung gilt, wenn weder Artikel noch Kategorie
# etwas sagen. Pflichtwert: Ohne ihn haette eine offene Packung kein
# Verbrauchsdatum, und das Anbrechen waere sinnlos.
OPEN_DAYS_KEY = "open_days"
OPEN_DAYS_MAX = 3650
def get_expiry_warning_days(db: Session) -> int:
@@ -37,6 +48,31 @@ def get_receipt_match_threshold(db: Session) -> int:
return RECEIPT_THRESHOLD_DEFAULT
def get_session_days(db: Session) -> int:
"""Gueltigkeitsdauer einer Anmeldung in Tagen; **0 heisst unbegrenzt**.
Ohne Eintrag gilt der Rueckfallwert aus der Umgebung
(``JWT_EXPIRE_MINUTES``), damit eine frische Installation nicht erst
eingestellt werden muss.
"""
vorgabe = max(1, round(get_settings().jwt_expire_minutes / (60 * 24)))
row = db.get(Setting, SESSION_DAYS_KEY)
if row is None:
return vorgabe
try:
return max(0, min(SESSION_DAYS_MAX, int(row.value)))
except ValueError:
return vorgabe
def get_session_minutes(db: Session) -> int:
"""Dasselbe in Minuten, wie :func:`create_access_token` es erwartet.
Die 0 fuer "unbegrenzt" ueberlebt die Umrechnung unveraendert.
"""
return get_session_days(db) * 24 * 60
@router.get("", response_model=list[SettingOut])
def list_settings(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -45,6 +81,8 @@ def list_settings(
known = {r.key: r.value for r in rows}
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db)))
known.setdefault(SESSION_DAYS_KEY, str(get_session_days(db)))
known.setdefault(OPEN_DAYS_KEY, str(globale_oeffnungsfrist(db)))
return [SettingOut(key=k, value=v) for k, v in known.items()]
@@ -55,6 +93,28 @@ def set_setting(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> SettingOut:
if key == OPEN_DAYS_KEY:
# Gleiche Begruendung wie unten: ein Tippfehler darf nicht still auf die
# Vorgabe zurueckfallen, waehrend der Admin glaubt, er haette etwas gesetzt.
try:
tage = int(value)
except ValueError:
raise HTTPException(400, "Haltbarkeit nach dem Anbrechen muss eine Zahl in Tagen sein")
if not 1 <= tage <= OPEN_DAYS_MAX:
raise HTTPException(400, f"Haltbarkeit nach dem Anbrechen: 1 bis {OPEN_DAYS_MAX} Tage")
value = str(tage)
if key == SESSION_DAYS_KEY:
# Ohne diese Pruefung wuerde get_session_days einen Tippfehler still
# auf die Vorgabe zurueckdrehen – und der Admin glaubte, es sei gesetzt.
try:
tage = int(value)
except ValueError:
raise HTTPException(400, "Sitzungsdauer muss eine Zahl in Tagen sein (0 = unbegrenzt)")
if not 0 <= tage <= SESSION_DAYS_MAX:
raise HTTPException(400, f"Sitzungsdauer: 0 (unbegrenzt) bis {SESSION_DAYS_MAX} Tage")
value = str(tage)
row = db.get(Setting, key)
if row is None:
row = Setting(key=key, value=value)
+150
View File
@@ -0,0 +1,150 @@
"""Die gespeicherte Seite der Einkaufsliste: eigene Einträge und Haken.
Die gerechnete Liste steht in ``routers/views.py`` und bleibt dort. Dieser
Router verwaltet nur, was jemand selbst draufgesetzt hat — und das Abhaken, das
bisher im Browser verfiel und nach dem Neuladen mitten im Supermarkt weg war.
"""
# ACHTUNG: Kein ``from __future__ import annotations`` – siehe package_types.py.
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import ShoppingEntry, User
from ..schemas import ShoppingEntryIn, ShoppingEntryOut, ShoppingEntryPatch
from ..services import einkaufsliste
router = APIRouter(prefix="/shopping-list", tags=["shopping"])
def eintrag_out(db: Session, eintrag: ShoppingEntry) -> ShoppingEntryOut:
art = (
"product" if eintrag.product_id is not None
else "group" if eintrag.group_id is not None
else "text"
)
return ShoppingEntryOut(
id=eintrag.id, art=art,
product_id=eintrag.product_id, group_id=eintrag.group_id,
free_text=eintrag.free_text,
name=einkaufsliste.bezeichnung(db, eintrag),
quantity_base=eintrag.quantity_base, base=eintrag.base,
menge_text=einkaufsliste.mengen_text(db, eintrag),
source=eintrag.source, recipe_slug=eintrag.recipe_slug,
recipe_name=eintrag.recipe_name, done=eintrag.done,
created_at=eintrag.created_at,
)
@router.get("/entries", response_model=list[ShoppingEntryOut])
def list_entries(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ShoppingEntryOut]:
"""Die eigenen Einträge, offene zuerst."""
return [eintrag_out(db, e) for e in einkaufsliste.eintraege(db)]
@router.post("/entries", response_model=ShoppingEntryOut,
status_code=status.HTTP_201_CREATED)
def create_entry(
payload: ShoppingEntryIn,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ShoppingEntryOut:
"""Etwas auf die Liste setzen – Artikel, Gruppe oder einfach Text."""
try:
eintrag = einkaufsliste.anlegen(
db, product_id=payload.product_id, group_id=payload.group_id,
free_text=payload.free_text, quantity_base=payload.quantity_base,
base=(payload.base.value if payload.base is not None else None),
user=user,
)
except einkaufsliste.EintragFehler as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
return eintrag_out(db, eintrag)
@router.patch("/entries/{entry_id}", response_model=ShoppingEntryOut)
def update_entry(
entry_id: int,
payload: ShoppingEntryPatch,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ShoppingEntryOut:
"""Abhaken oder die Menge korrigieren."""
eintrag = db.get(ShoppingEntry, entry_id)
if eintrag is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
if payload.quantity_base is not None:
eintrag.quantity_base = payload.quantity_base
if payload.free_text is not None:
if eintrag.free_text is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Dieser Eintrag zeigt auf einen Artikel – sein Text lässt sich "
"nicht ändern.",
)
eintrag.free_text = payload.free_text.strip()[:120]
if payload.done is not None:
einkaufsliste.abhaken(db, eintrag, payload.done)
else:
db.commit()
db.refresh(eintrag)
return eintrag_out(db, eintrag)
@router.delete("/entries/{entry_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_entry(
entry_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
eintrag = db.get(ShoppingEntry, entry_id)
if eintrag is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Eintrag nicht gefunden")
einkaufsliste.loeschen(db, eintrag)
@router.post("/clear-done", response_model=int)
def clear_done(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> int:
"""Nach dem Einkauf aufräumen: alles Abgehakte weg.
Bewusst ein POST und kein DELETE auf einer Sammlung: Es löscht zwei
verschiedene Dinge (eigene Einträge und Haken an gerechneten Posten), und
das ist eine Handlung, keine Ressource.
"""
return einkaufsliste.erledigte_entfernen(db)
@router.put("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT)
def set_check(
key: str,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
"""Einen gerechneten Posten abhaken – dauerhaft.
Der Schlüssel kommt aus der Oberfläche („p12", „g3", „lKUECHEp12"). Der
Server prüft ihn nicht gegen die gerechnete Liste: Sie ändert sich mit jedem
Einlagern, und ein Haken auf einen Posten, der gerade nicht mehr fehlt,
schadet nichts — er verschwindet mit „Erledigte entfernen".
"""
try:
einkaufsliste.haken_setzen(db, key, user)
except einkaufsliste.EintragFehler as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
@router.delete("/checks/{key}", status_code=status.HTTP_204_NO_CONTENT)
def clear_check(
key: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> None:
einkaufsliste.haken_loesen(db, key)
+182 -23
View File
@@ -8,11 +8,16 @@ from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Pr
from ..schemas import (
BatchCheckInRequest,
BatchCheckInResponse,
BulkLotCheckout,
BulkLotDelete,
BulkLotLocation,
CheckInRequest,
CheckInResponse,
CheckOutRequest,
CheckOutResponse,
LotConsume,
LotConsumeOut,
LotOpen,
LotOut,
LotRow,
LotSplit,
@@ -25,6 +30,8 @@ from ..services.conversion import ConversionError, display_unit_info
from ..services.dates import clean_precision, normalize_best_before
from ..services.stock import (
StockError,
abspalten,
anbrechen,
check_in,
check_out,
check_out_lot,
@@ -33,6 +40,7 @@ from ..services.stock import (
object_add,
object_relocate,
object_remove,
verbrauchen,
)
router = APIRouter(tags=["stock"])
@@ -272,6 +280,11 @@ def list_all_lots(
unit_name=name, unit_factor=factor,
best_before=lot.best_before, best_before_precision=lot.best_before_precision,
location_id=lot.location_id, created_at=lot.created_at,
# Angebrochen: ohne diese drei Felder saehe die Liste eine offene
# Packung wie jede andere Charge - samt falscher Ablaufwarnung,
# denn gewarnt wird nach dem fruheren der beiden Daten.
opened_on=lot.opened_on, consume_by=lot.consume_by,
opened_quantity=lot.opened_quantity,
))
return rows
@@ -307,6 +320,91 @@ def bulk_lot_location(
return n
@router.post("/lots/bulk-checkout", response_model=int)
def bulk_lot_checkout(
payload: BulkLotCheckout,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> int:
"""Mehrere Chargen ausbuchen – komplett oder je Charge dieselbe Teilmenge.
Anders als ``bulk-location`` **kein** Sammel-UPDATE: Je Charge muss eine
Bewegung entstehen, sonst fehlte der Vorgang im Verlauf und in der
Verbrauchsstatistik. Schlägt eine Charge fehl (zu wenig Bestand), wird
alles zurückgerollt – ein halb gebuchter Stand wäre schlimmer als gar
keiner; dieselbe Haltung wie beim Stapel-Einlagern.
Gibt die Anzahl der betroffenen Chargen zurück.
"""
if not payload.lot_ids:
return 0
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
if not lots:
return 0
if payload.quantity is not None:
# Eine gemeinsame Menge setzt eine gemeinsame Einheit voraus: „1 Packung"
# bei Milch und Broetchen sind verschiedene Dinge.
artikel = {lot.product_id for lot in lots}
if len(artikel) > 1:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Eine gemeinsame Menge geht nur, wenn alle ausgewählten Chargen "
"zum selben Artikel gehören. Sonst bitte komplett ausbuchen.",
)
betroffen = 0
try:
for lot in lots:
product = db.get(Product, lot.product_id)
if product is None:
continue
menge = payload.quantity if payload.quantity is not None else lot.quantity
einheit = payload.unit if payload.quantity is not None else product.base_unit.value
check_out_lot(db, product, lot, menge, einheit, user, payload.note)
betroffen += 1
except ConversionError as fehler:
db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
except StockError as fehler:
db.rollback()
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
db.commit()
return betroffen
@router.post("/lots/bulk-delete", response_model=int)
def bulk_lot_delete(
payload: BulkLotDelete,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> int:
"""Mehrere Chargen als Korrektur entfernen – nicht als Verbrauch.
Je Charge eine Korrektur-Bewegung wie beim einzelnen Löschen, damit der
Bestandsverlauf stimmt. Unbekannte IDs werden still übergangen, wie bei
``bulk-location``.
"""
if not payload.lot_ids:
return 0
lots = db.query(Lot).filter(Lot.id.in_(payload.lot_ids)).all()
for lot in lots:
db.add(
Movement(
product_id=lot.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.adjust,
quantity=-lot.quantity,
unit_used="base",
note="Charge gelöscht",
)
)
db.delete(lot)
db.commit()
return len(lots)
@router.post("/lots/{lot_id}/split", response_model=LotOut)
def split_lot(
lot_id: int,
@@ -321,32 +419,16 @@ def split_lot(
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
# Nur echt weniger als die Gesamtmenge lässt sich abteilen – die ganze Menge
# zu verschieben ist Sache des Lagerort-Feldes, nicht des Aufteilens.
if payload.quantity >= lot.quantity - 1e-9:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"So viel enthält die Charge nicht – zum kompletten Umlagern das "
"Lagerort-Feld nutzen.",
)
if (payload.location_id or None) == (lot.location_id or None):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Bitte einen anderen Lagerort als den aktuellen wählen.",
)
if payload.location_id is not None and db.get(Location, payload.location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
lot.quantity -= payload.quantity
neu = Lot(
product_id=lot.product_id,
quantity=payload.quantity,
best_before=lot.best_before,
best_before_precision=lot.best_before_precision,
location_id=payload.location_id,
)
db.add(neu)
db.flush()
# Die eigentliche Arbeit macht der Dienst; hier gilt weiter die strengere
# Variante: nur echt weniger als die Gesamtmenge, und nur an einen ANDEREN
# Ort. Das Anbrechen benutzt dieselbe Funktion mit gelockerten Regeln.
try:
neu = abspalten(db, lot, payload.quantity, payload.location_id)
except StockError as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
# Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide.
survivor = consolidate_lot_group(
db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id
@@ -356,6 +438,83 @@ def split_lot(
return survivor
@router.post("/lots/{lot_id}/open", response_model=LotOut)
def open_lot(
lot_id: int,
payload: LotOpen,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Lot:
"""Eine Packung der Charge anbrechen und als eigene Charge führen.
Aus 4 × 1 l wird eine offene Charge mit 1 l (mit Verbrauchsdatum) und eine
ungeöffnete mit 3 l. Bewusst **ohne** anschließende Konsolidierung: Die
offene Packung soll gerade nicht mit der ungeöffneten verschmelzen.
"""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
product = db.get(Product, lot.product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Nur Chargen lassen sich anbrechen. Gegenstände, die nur als Menge "
"je Lagerort geführt werden, haben keine Packung.",
)
try:
offen = anbrechen(db, product, lot, payload.quantity)
except StockError as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
db.commit()
db.refresh(offen)
return offen
@router.post("/lots/{lot_id}/consume", response_model=LotConsumeOut)
def consume_lot(
lot_id: int,
payload: LotConsume,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> LotConsumeOut:
"""Teilmenge aus einer Charge verbrauchen („100 ml verwendet", „2 EL").
War die Charge noch zu, wird sie dabei angebrochen (``auto_open``) – die
Rückfrage dazu stellt die Oberfläche. Die Einheit kennt neben den
verwalteten Einheiten auch die Hausmaße der Kategorie.
"""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
product = db.get(Product, lot.product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden")
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Gegenstände werden über 'Entfernen' (mit Grund) gebucht, nicht verbraucht.",
)
try:
affected, offen, geoeffnet = verbrauchen(
db, product, lot, payload.quantity, payload.unit, user,
payload.note, payload.auto_open,
)
except ConversionError as fehler:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler
except StockError as fehler:
raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler
db.commit()
if offen is not None:
db.refresh(offen)
return LotConsumeOut(
affected_lots=affected,
lot=LotOut.model_validate(offen) if offen is not None else None,
opened=geoeffnet,
)
@router.patch("/lots/{lot_id}", response_model=LotOut)
def update_lot(
lot_id: int,
+190 -3
View File
@@ -6,6 +6,7 @@ werden ergänzt – es wird nie etwas gelöscht oder überschrieben.
from __future__ import annotations
import base64
import csv
import io
import json
@@ -22,11 +23,13 @@ from ..services.dates import MONTH, clean_precision, normalize_best_before
from ..services import gruppen as gruppen_graph
from ..services.group_codes import sync as sync_group_code
from ..services.min_stock import lies_ueberall, schreibe_ueberall
from ..services.etiketten import FELD_PRAEFIX, konfiguration_lesen, konfiguration_schreiben
from ..models import (
BaseUnit,
Category,
CategoryTracking,
DatePrecision,
EtikettenVorlage,
FieldDefinition,
Group,
GroupLocationMinStock,
@@ -166,10 +169,11 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations}
data = {
# v4: Etiketten-Vorlagen samt hinterlegter P-touch-Datei.
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
# (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben
# (Ort null = „Ueberall") in Basiseinheiten. Aeltere Sicherungen bleiben
# lesbar – ihnen fehlen diese Listen einfach.
"version": 3,
"version": 4,
"exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [
@@ -196,7 +200,11 @@ def export_backup_json(
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
"categories": [
{"path": _category_path(db, c), "tracking": c.tracking}
{
"path": _category_path(db, c),
"tracking": c.tracking,
"usage_tracking": c.usage_tracking,
}
for c in db.query(Category).order_by(Category.id).all()
],
# Bezugsquellen (nur für Gegenstände).
@@ -256,12 +264,19 @@ def export_backup_json(
"best_before": lot.best_before.isoformat() if lot.best_before else None,
"best_before_precision": lot.best_before_precision,
"location": loc_name.get(lot.location_id),
# Angebrochene Packungen sollen eine Sicherung ueberleben –
# sonst waere nach dem Zurueckspielen alles wieder zu.
"opened_on": lot.opened_on.isoformat() if lot.opened_on else None,
"consume_by": lot.consume_by.isoformat() if lot.consume_by else None,
"opened_quantity": lot.opened_quantity,
}
for lot in lots
],
}
)
data["etiketten_vorlagen"] = _vorlagen_exportieren(db)
return Response(
content=json.dumps(data, ensure_ascii=False, indent=2),
media_type="application/json; charset=utf-8",
@@ -340,6 +355,27 @@ def export_labels(
# --------------------------------------------------------------------------
# Import (nur additiv)
# --------------------------------------------------------------------------
def _parse_tag(value) -> "date | None":
"""ISO-Datum aus einer Sicherung lesen; Muell wird still zu None.
Der Import ist bewusst additiv und soll nicht an einer einzelnen Zeile
scheitern – dieselbe Haltung wie bei ``_parse_best_before``.
"""
if not value:
return None
try:
return date.fromisoformat(str(value)[:10])
except ValueError:
return None
def _zahl_oder_none(value) -> float | None:
try:
return float(value) if value is not None else None
except (TypeError, ValueError):
return None
def _num(value) -> float | None:
if value is None:
return None
@@ -435,6 +471,146 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None:
return loc
def _vorlagen_exportieren(db: Session) -> list[dict]:
"""Etiketten-Vorlagen fuer die Sicherung.
Kategorien als PFAD und eigene Felder als LABEL – IDs sind zwischen zwei
Instanzen nicht dieselben, genau wie bei Gruppen und Artikeln.
Die hinterlegte .lbx kommt als Base64 mit. Das ist das erste Binaerformat in
dieser Sicherung; vertretbar, weil eine .lbx wenige Kilobyte gross ist und
eine Vorlage ohne ihre Datei nur eine halbe Vorlage waere.
"""
zeilen: list[dict] = []
for v in db.query(EtikettenVorlage).order_by(EtikettenVorlage.position).all():
konf = konfiguration_lesen(v)
spalten: list[str] = []
for schluessel in konf["spalten"]:
if schluessel.startswith(FELD_PRAEFIX):
rest = schluessel[len(FELD_PRAEFIX):]
fd = db.get(FieldDefinition, int(rest)) if rest.isdigit() else None
# Kategoriepfad mit dazu: Zwei Kategorien duerfen dasselbe Label
# fuehren, erst beides zusammen ist eindeutig.
spalten.append(
f"{FELD_PRAEFIX}{_category_path(db, db.get(Category, fd.category_id))}"
f"::{fd.label}" if fd else schluessel
)
else:
spalten.append(schluessel)
# Der Wertefilter zeigt ebenfalls auf Feld-IDs und braucht dieselbe
# Umschreibung wie die Spalten - sonst zeigt er nach dem Zurueckspielen
# auf ein fremdes Feld oder ist still weg.
feldfilter = []
for bedingung in konf["feldfilter"]:
fd = db.get(FieldDefinition, bedingung["feld_id"])
if fd is None:
continue
pfad = _category_path(db, db.get(Category, fd.category_id))
feldfilter.append({
"feld": f"{pfad}::{fd.label}",
"op": bedingung.get("op", "="),
"wert": bedingung["wert"],
})
zeilen.append({
"name": v.name,
"art": v.art,
"position": v.position,
"spalten": spalten,
"kategorien": [
_category_path(db, db.get(Category, kid)) for kid in konf["kategorie_ids"]
],
"trennzeichen": konf["trennzeichen"],
"feldfilter": feldfilter,
"datei_name": v.datei_name,
"datei_base64": base64.b64encode(v.datei).decode() if v.datei else None,
})
return zeilen
def _vorlagen_importieren(db: Session, zeilen: list, errors: list[str]) -> int:
"""Vorlagen additiv einspielen – Bestehendes bleibt unberuehrt."""
angelegt = 0
pfad_zu_id: dict[str, int] = {}
for cat in db.query(Category).all():
pfad_zu_id[_category_path(db, cat)] = cat.id
for entry in zeilen:
try:
name = (entry.get("name") or "").strip()
if not name or db.query(EtikettenVorlage).filter(
EtikettenVorlage.name == name
).first():
continue
spalten: list[str] = []
for roh in entry.get("spalten", []):
if not str(roh).startswith(FELD_PRAEFIX):
spalten.append(str(roh))
continue
rest = str(roh)[len(FELD_PRAEFIX):]
pfad, _, label = rest.rpartition("::")
kat_id = pfad_zu_id.get(pfad)
fd = (
db.query(FieldDefinition)
.filter(
FieldDefinition.category_id == kat_id,
FieldDefinition.label == label,
).first()
if kat_id else None
)
# Gibt es das Feld hier nicht, faellt die Spalte weg statt die
# ganze Vorlage scheitern zu lassen.
if fd is not None:
spalten.append(f"{FELD_PRAEFIX}{fd.id}")
feldfilter: list[dict] = []
for bedingung in entry.get("feldfilter", []):
if not isinstance(bedingung, dict):
continue
pfad, _, label = str(bedingung.get("feld", "")).rpartition("::")
kat_id = pfad_zu_id.get(pfad)
fd = (
db.query(FieldDefinition)
.filter(
FieldDefinition.category_id == kat_id,
FieldDefinition.label == label,
).first()
if kat_id else None
)
# 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,
"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
]
roh_datei = entry.get("datei_base64")
db.add(EtikettenVorlage(
name=name,
art=entry.get("art") or "items",
position=int(entry.get("position") or 0),
konfiguration=konfiguration_schreiben(
spalten,
kategorie_ids,
entry.get("trennzeichen") or ",",
feldfilter,
),
datei_name=entry.get("datei_name"),
datei=base64.b64decode(roh_datei) if roh_datei else None,
))
angelegt += 1
except Exception as exc:
errors.append(f"Etiketten-Vorlage {entry.get('name')!r}: {exc}")
db.commit()
return angelegt
def _category_path(db: Session, category: Category | None) -> str:
"""Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte.
@@ -791,6 +967,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
)
if cat is not None and entry.get("tracking"):
cat.tracking = entry["tracking"]
# Fehlt der Schluessel (aelteres Backup), bleibt die Markierung aus.
if cat is not None:
cat.usage_tracking = bool(entry.get("usage_tracking", False))
for entry in data.get("shops", []):
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
db.flush()
@@ -883,6 +1062,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
best_before=best_before,
best_before_precision=precision,
location_id=location.id if location else None,
opened_on=_parse_tag(lot_entry.get("opened_on")),
consume_by=_parse_tag(lot_entry.get("consume_by")),
opened_quantity=_zahl_oder_none(lot_entry.get("opened_quantity")),
)
db.add(lot)
db.flush()
@@ -901,9 +1083,14 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
except Exception as exc:
errors.append(f"Produkt {entry.get('name')!r}: {exc}")
vorlagen_created = _vorlagen_importieren(
db, data.get("etiketten_vorlagen", []), errors
)
return {
"format": "json",
"mode": mode,
"etiketten_vorlagen_created": vorlagen_created,
"units_created": units_created,
"products_created": len(created_products),
"products_cleared": len(cleared),
+104
View File
@@ -0,0 +1,104 @@
"""Verwendungsstatus von Einzelstücken: umstellen und Verlauf.
Woran ist das Stück gerade? Getragen, in der Wäsche, in Reparatur – oder
einfach verfügbar. Gedacht für eine Garderobe, aber bewusst allgemein: ein Auto
in der Waschanlage ist genauso „in Reinigung".
Umstellen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
angemeldete Benutzer es tun (wie Verleihen, Ein- und Auslagern), und es wird mit
dem Konto protokolliert. Ausdrücklich **nicht** über ``PATCH /items/{id}``:
Diese Route ist Admins vorbehalten, und die App schickt dort immer alle Felder
mitsamt ``null`` (siehe ``ItemUpdateRequest.encode`` in ios/Sources/Models.swift)
– jedes beliebige Speichern würde den Status stillschweigend zurücksetzen.
Verleih und Zubehör haben aus demselben Grund eigene Endpunkte.
Bewusst **keine** Bewegung (``Movement``), aus demselben Grund wie beim Verleih
(siehe ``routers/loans.py``). Und bewusst **keine** Auswirkung auf den Bestand:
Ein getragenes Hemd ist weiterhin im Haushalt – anders als ein verliehenes.
"""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from ..database import get_db
from ..deps import get_current_user
from ..models import Item, ItemUsage, User
from ..schemas import ItemOut, VerwendungOut, VerwendungSetzen
from ..services.items import item_to_out, verwendung_to_out
router = APIRouter(tags=["verwendung"])
def _item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
return item
def _laufend(db: Session, item_id: int) -> ItemUsage | None:
"""Der laufende Zeitraum dieses Stücks, falls einer offen ist."""
return (
db.query(ItemUsage)
.filter(ItemUsage.item_id == item_id, ItemUsage.ended_at.is_(None))
.first()
)
@router.put("/items/{item_id}/verwendung", response_model=ItemOut)
def setze_verwendung(
item_id: int,
payload: VerwendungSetzen,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ItemOut:
"""Verwendungszustand setzen. Leerer Status = verfügbar.
Eine Route für alle Zustände, auch für den Ruhezustand: „verfügbar" ist
nichts anderes als „kein laufender Zeitraum", also beendet der leere Status
nur den aktuellen. Damit ist der Aufruf idempotent.
Antwortet mit dem ganzen Stück, damit die Oberflächen nach der Buchung nicht
noch einmal laden müssen.
"""
item = _item_or_404(db, item_id)
neu = payload.status.value if payload.status is not None else None
laufend = _laufend(db, item.id)
if laufend is not None and laufend.status == neu:
# Zweimal dasselbe angetippt – sonst entstünde ein Zeitraum der Länge 0.
return item_to_out(item)
if laufend is not None:
laufend.ended_at = datetime.now(UTC)
# Vor dem Einfügen schreiben: Sonst darf SQLAlchemy das INSERT vor das
# UPDATE stellen, und dann liegen kurzzeitig zwei offene Zeiträume an –
# der Teilindex uq_item_usage_offen schlägt zu.
db.flush()
if neu is not None:
db.add(ItemUsage(item_id=item.id, status=neu, user_id=user.id))
db.commit()
db.refresh(item)
return item_to_out(item)
@router.get("/items/{item_id}/verwendungen", response_model=list[VerwendungOut])
def list_verwendungen(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[VerwendungOut]:
"""Der ganze Verlauf dieses Stücks, neueste zuerst."""
_item_or_404(db, item_id)
rows = (
db.query(ItemUsage)
.options(joinedload(ItemUsage.user))
.filter(ItemUsage.item_id == item_id)
.order_by(ItemUsage.started_at.desc(), ItemUsage.id.desc())
.all()
)
return [verwendung_to_out(u) for u in rows]
+46 -6
View File
@@ -5,6 +5,7 @@ from datetime import date, timedelta
from typing import Callable, TypeVar
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import and_, or_
from sqlalchemy.orm import Session
from ..database import get_db
@@ -41,7 +42,9 @@ from ..services.conversion import (
display_unit_info,
group_min_context,
)
from ..services import einkaufsliste
from ..services import gruppen as gruppen_graph
from .shopping import eintrag_out
from ..services.stock import (
current_stock,
descendant_location_ids,
@@ -194,6 +197,14 @@ def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]:
"""Artikel unter ihrem „Ueberall"-Mindestbestand – NUR die gerechneten.
Diese Route ist ein Drittel der Einkaufsliste. Was jemand selbst
draufgesetzt hat (eigene Eintraege, uebernommene Rezeptzutaten) steht hier
nicht, ebenso wenig Gruppen und Bedarfe je Lagerort. Wer „die Liste" will,
nimmt ``/shopping-list/all`` – diese Aufteilung hat schon zweimal jemanden
glauben lassen, es sei nichts da.
"""
return einkaufsliste_artikel(db)
@@ -468,13 +479,20 @@ def shopping_list_by_location(
def shopping_list_all(
db: Session = Depends(get_db), user: User = Depends(get_current_user)
) -> ShoppingListAll:
"""Komplette Einkaufsliste in einem Aufruf: Produkte, Gruppen und Bedarfe je
Lagerort – genau die drei Quellen, die auch die Oberfläche zusammenführt.
Erspart drei getrennte Abfragen."""
"""Komplette Einkaufsliste in einem Aufruf: die gerechneten Bedarfe
(Produkte, Gruppen, je Lagerort), die gespeicherten eigenen Einträge und die
dauerhaft gesetzten Haken. Erspart der Oberfläche fünf Abfragen.
Gerechnet und gespeichert stehen bewusst nebeneinander: Die Bedarfe aus den
Mindestbeständen entstehen bei jedem Aufruf neu, die eigenen Einträge
bleiben liegen, bis sie abgehakt sind.
"""
return ShoppingListAll(
products=shopping_list(db, user),
groups=group_shopping_list(db, user),
by_location=shopping_list_by_location(db, user),
entries=[eintrag_out(db, e) for e in einkaufsliste.eintraege(db)],
checked=einkaufsliste.abgehakte(db),
)
@@ -531,14 +549,26 @@ def expiring(
today = date.today()
threshold = today + timedelta(days=days)
# Gewarnt wird nach dem FRÜHEREN von MHD und Verbrauchsdatum. Die frühere
# Vorbedingung „best_before IS NOT NULL" musste weg: Eine angebrochene
# Packung ohne aufgedrucktes MHD hat nur ein Verbrauchsdatum – und genau um
# die geht es hier.
lots = (
db.query(Lot)
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
.order_by(Lot.best_before)
.filter(
Lot.quantity > 0,
or_(
and_(Lot.best_before.isnot(None), Lot.best_before <= threshold),
and_(Lot.consume_by.isnot(None), Lot.consume_by <= threshold),
),
)
.all()
)
result: list[ExpiringItem] = []
for lot in lots:
faellig = lot.faellig_am
if faellig is None:
continue # kann nicht vorkommen, der Filter oben schliesst es aus
product = lot.product
unit_name, unit_factor = display_unit_info(product)
result.append(
@@ -550,13 +580,23 @@ def expiring(
base_unit=product.base_unit,
best_before=lot.best_before,
best_before_precision=lot.best_before_precision,
days_left=(lot.best_before - today).days,
opened_on=lot.opened_on,
consume_by=lot.consume_by,
warn_date=faellig,
warn_source=(
"consume_by" if lot.consume_by is not None and faellig == lot.consume_by
else "best_before"
),
days_left=(faellig - today).days,
package_size=product.package_size,
package_label=product.package_label,
unit_name=unit_name,
unit_factor=unit_factor,
)
)
# Nach dem wirksamen Datum sortieren – in Python, wie in _fefo_lots, damit
# SQLite und Postgres dasselbe liefern.
result.sort(key=lambda e: (e.warn_date, e.lot_id))
return result
+463 -3
View File
@@ -12,6 +12,7 @@ from .models import (
RemovalReason,
Role,
UnitKind,
UsageStatus,
)
@@ -275,6 +276,16 @@ class CategoryOut(BaseModel):
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
tracking: CategoryTracking = CategoryTracking.food
product_count: int = 0
# Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der
# Oberkategorie, zuletzt vom globalen Wert.
open_days: int | None = None
# Was gelten wuerde, wenn ``open_days`` leer bleibt – samt Herkunft
# („Kategorie: Molkerei" / „Einstellungen"). Fuer den Platzhalter in der
# Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein.
inherited_open_days: int = 0
open_days_source: str = ""
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren.
usage_tracking: bool = False
class CategoryCreate(BaseModel):
@@ -282,12 +293,21 @@ class CategoryCreate(BaseModel):
parent_id: int | None = None
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
tracking: CategoryTracking | None = None
open_days: int | None = Field(default=None, ge=1)
# None: erbt vom Elternteil bzw. neue Oberkategorie = aus.
usage_tracking: bool | None = None
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: int | None = None
tracking: CategoryTracking | None = None
# 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None
# heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst.
open_days: int | None = Field(default=None, ge=1)
# Wird beim Speichern in den ganzen Unterbaum uebernommen, siehe
# routers/categories.py::update_category.
usage_tracking: bool | None = None
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
@@ -403,6 +423,9 @@ class ProductBase(BaseModel):
secondary_base: BaseUnit | None = None
secondary_count: float | None = Field(default=None, gt=0)
secondary_amount: float | None = Field(default=None, gt=0)
# Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel.
# None = erbt von der Kategorie, zuletzt vom globalen Wert.
open_days: int | None = Field(default=None, ge=1)
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
date_precision: DatePrecision = DatePrecision.day
group_id: int | None = None
@@ -442,6 +465,9 @@ class ProductUpdate(BaseModel):
secondary_base: BaseUnit | None = None
secondary_count: float | None = Field(default=None, gt=0)
secondary_amount: float | None = Field(default=None, gt=0)
# Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel.
# None = erbt von der Kategorie, zuletzt vom globalen Wert.
open_days: int | None = Field(default=None, ge=1)
date_precision: DatePrecision | None = None
group_id: int | None = None
category_id: int | None = None
@@ -470,6 +496,12 @@ class ProductOut(BaseModel):
secondary_base: BaseUnit | None = None
secondary_count: float | None = None
secondary_amount: float | None = None
open_days: int | None = None
# Die tatsaechlich geltende Frist und woher sie kommt – damit das
# Formular „geerbt: 3 Tage (Kategorie: Molkerei)" zeigen kann, ohne
# die Vererbung selbst nachzubauen.
effective_open_days: int | None = None
open_days_source: str = ""
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels –
# damit die Oberflächen nicht selbst dividieren müssen.
secondary_factor: float | None = None
@@ -574,6 +606,11 @@ class LotOut(BaseModel):
best_before_precision: DatePrecision = DatePrecision.day
location_id: str | None
created_at: datetime
# Angebrochen: NULL = zu. MHD und Verbrauchsdatum stehen bewusst
# nebeneinander – gewarnt wird nach dem fruheren (Lot.faellig_am).
opened_on: date | None = None
consume_by: date | None = None
opened_quantity: float | None = None
class LotUpdate(BaseModel):
@@ -601,6 +638,9 @@ class LotRow(BaseModel):
best_before_precision: DatePrecision = DatePrecision.day
location_id: str | None = None
created_at: datetime
opened_on: date | None = None
consume_by: date | None = None
opened_quantity: float | None = None
class BulkLotLocation(BaseModel):
@@ -609,6 +649,25 @@ class BulkLotLocation(BaseModel):
location_id: str | None = None
class BulkLotCheckout(BaseModel):
"""Mehrere Chargen auf einmal ausbuchen.
``quantity`` leer = jede Charge komplett. Mit Menge wird je Charge
dieselbe Menge abgebucht – das ergibt nur Sinn, wenn alle zum selben
Artikel gehören, sonst wäre die Einheit mehrdeutig (der Server lehnt es
dann ab).
"""
lot_ids: list[int]
quantity: float | None = Field(default=None, gt=0)
unit: str | None = None
note: str | None = Field(default=None, max_length=255)
class BulkLotDelete(BaseModel):
"""Mehrere Chargen als Korrektur entfernen (ohne sie als Verbrauch zu buchen)."""
lot_ids: list[int]
class LotSplit(BaseModel):
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
@@ -616,6 +675,195 @@ class LotSplit(BaseModel):
location_id: str | None = None
class LotOpen(BaseModel):
"""Eine Packung der Charge anbrechen.
``quantity`` in Basiseinheiten; leer = eine Artikeleinheit (Packung), bzw.
die ganze Charge, wenn sie kleiner ist.
"""
quantity: float | None = Field(default=None, gt=0)
class LotConsume(BaseModel):
"""Teilmenge aus einer Charge verbrauchen.
``unit`` nimmt alles, was auch beim Auslagern geht – Basiseinheit, verwaltete
Einheit, „package" und die Hausmaße der Kategorie („EL", „Tasse").
``auto_open`` bricht eine noch geschlossene Charge dabei an; die Rückfrage
dazu stellt die Oberfläche, nicht der Server.
"""
quantity: float = Field(gt=0)
unit: str
note: str | None = Field(default=None, max_length=255)
auto_open: bool = True
class LotConsumeOut(BaseModel):
"""Was der Verbrauch bewirkt hat."""
affected_lots: list[dict] = []
# Die offene Charge danach – None, wenn sie dabei leer geworden ist.
lot: LotOut | None = None
# Ob die Charge durch diesen Vorgang angebrochen wurde.
opened: bool = False
# ---- Mealie ----
class MealieConfigIn(BaseModel):
"""Zugangsdaten für die einmalige Anmeldung bei Mealie.
Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal
an, lässt sich ein Dauertoken ausstellen und vergisst es.
"""
base_url: str = Field(min_length=1, max_length=255)
username: str = Field(min_length=1, max_length=120)
password: str = Field(min_length=1)
class MealieTokenIn(BaseModel):
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort)."""
base_url: str = Field(min_length=1, max_length=255)
token: str = Field(min_length=1)
class MealieStatus(BaseModel):
"""Auskunft über die Verbindung – **nie** das Token selbst."""
configured: bool = False
base_url: str = ""
token_set: bool = False
# Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen".
reachable: bool | None = None
version: str | None = None
error: str | None = None
class MealieAvailable(BaseModel):
"""Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer)."""
available: bool = False
class RecipeSummary(BaseModel):
"""Ein Rezept in der Liste."""
slug: str
name: str
description: str = ""
image_url: str | None = None
servings: float | None = None
class RecipePage(BaseModel):
items: list[RecipeSummary] = []
page: int = 1
per_page: int = 50
total: int = 0
class ZutatKandidat(BaseModel):
art: str # product | group
id: int
name: str
score: int
class ZutatOut(BaseModel):
"""Eine Zutat, auf den Bestand bezogen."""
text: str
menge: float | None = None
einheit: str = ""
# Reiner Lebensmittelname – damit die Oberfläche eine Zuordnung dafür merken kann.
food: str = ""
# True = so zugeordnet, weil es jemand einmal von Hand entschieden hat.
manuell: bool = False
art: str | None = None
ziel_id: int | None = None
ziel_name: str = ""
kandidaten: list[ZutatKandidat] = []
benoetigt_base: float | None = None
bestand_base: float | None = None
fehlt_base: float | None = None
basis_einheit: str = ""
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
status: str = "kein_treffer"
hinweis: str = ""
fehlendes_mass: str = ""
kategorie_id: int | None = None
# Fehlmenge als zählbares Gebinde („2 Packungen") – wie in der Einkaufsliste.
fehlt_text: str = ""
class RecipeDetail(BaseModel):
slug: str
name: str
description: str = ""
image_url: str | None = None
# Portionen laut Rezept und die, für die gerechnet wurde.
base_servings: float | None = None
servings: float | None = None
skalierung_hinweis: str = ""
kochbar: bool = False
ingredients: list[ZutatOut] = []
# ---- Gemerkte Zutaten-Zuordnungen ----
class ZuordnungIn(BaseModel):
"""Eine Zutat von Hand auf einen Artikel oder eine Gruppe legen."""
food: str = Field(min_length=1, max_length=200)
art: str = Field(pattern="^(product|group)$")
ziel_id: int
class ZuordnungOut(BaseModel):
"""Eine gemerkte Zuordnung, wie sie in der Liste steht."""
food_key: str
food_name: str
art: str
ziel_id: int
ziel_name: str = ""
class ZielVorschlag(BaseModel):
"""Ein Artikel oder eine Gruppe als Ziel – die Auswahlliste beim Zuordnen."""
art: str
id: int
name: str
# Zur Unterscheidung gleichnamiger Artikel: Marke bzw. Kategorie.
zusatz: str = ""
basis_einheit: str = ""
# ---- Hausmaße ----
class MeasureIn(BaseModel):
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
name: str = Field(min_length=1, max_length=40)
short: str | None = Field(default=None, max_length=10)
position: int = 0
class MeasureOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
short: str | None = None
position: int = 0
is_builtin: bool = False
class MeasureAmountIn(BaseModel):
"""Was ein Hausmaß hier wiegt bzw. fasst: ``amount`` in ``base``."""
amount: float = Field(gt=0)
base: BaseUnit
class MeasureAmountOut(BaseModel):
"""Ein für einen Artikel (oder eine Kategorie) geltendes Maß samt Herkunft."""
measure_id: int
name: str
short: str | None = None
amount: float
base: BaseUnit
# „Artikel", „Kategorie: Mehl & Zucker" oder „–" (nicht hinterlegt).
herkunft: str = ""
# ---- Kassenzettel-Abgleich ----
class MatchRequest(BaseModel):
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
@@ -778,6 +1026,9 @@ class LoanCreate(BaseModel):
lent_on: date | None = None
# Erwartete Rückgabe – optional, siehe ItemLoan.
due_on: date | None = None
# Zubehörteile, die mitgehen. Leer = nur das Gerät selbst. Die Oberfläche
# fragt danach; der Server nimmt nie ungefragt etwas mit.
mit_zubehoer: list[int] = []
class LoanUpdate(BaseModel):
@@ -792,6 +1043,9 @@ class LoanUpdate(BaseModel):
class LoanReturn(BaseModel):
"""Rückgabe buchen. Leeres Datum = heute."""
returned_on: date | None = None
# Zubehörteile, die mit zurückkommen. Spiegelbild zu LoanCreate: ohne das
# käme das Gerät zurück und sein Netzteil bliebe ewig „verliehen".
mit_zubehoer: list[int] = []
class LoanOut(BaseModel):
@@ -818,6 +1072,52 @@ class LoanOut(BaseModel):
location_name: str | None = None
class VerwendungSetzen(BaseModel):
"""Verwendungsstatus eines Einzelstuecks setzen.
``status`` leer heisst verfuegbar – dafuer gibt es keinen eigenen Wert, es
wird nur der laufende Zustand beendet (siehe models.py::UsageStatus).
"""
status: UsageStatus | None = None
class VerwendungOut(BaseModel):
"""Ein Zeitraum in einem Verwendungszustand – laufend oder abgeschlossen."""
model_config = ConfigDict(from_attributes=True)
id: int
item_id: int
status: UsageStatus
started_at: datetime
# None = laeuft noch.
ended_at: datetime | None = None
# Wer umgestellt hat (Konto kann spaeter geloescht sein).
username: str | None = None
class ItemKurz(BaseModel):
"""Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen.
Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes
Teil wieder sein Geraet nach, endlos.
"""
model_config = ConfigDict(from_attributes=True)
id: int
uid: str
product_id: int
product_name: str | None = None
product_brand: str | None = None
location_name: str | None = None
# Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann.
verliehen: bool = False
class ZubehoerZuordnen(BaseModel):
"""Welches Stueck als Zubehoer an dieses Geraet gehaengt wird."""
child_item_id: int
class ItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
@@ -842,6 +1142,104 @@ class ItemOut(BaseModel):
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
loan: LoanOut | None = None
# Der laufende Verwendungszustand – None heisst verfuegbar. Anders als beim
# Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg.
verwendung: VerwendungOut | None = None
# Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt.
# Steuert, ob die Oberflaeche die Bedienung dafuer zeigt.
verwendung_moeglich: bool = False
# Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses
# Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen.
zubehoer: list[ItemKurz] = []
gehoert_zu: ItemKurz | None = None
# ---- Seitenleiste ----
class NavigationOut(BaseModel):
"""Was die Oberflaechen brauchen, in einem Zug.
``versteckt`` ist die fertige Vereinigung aus Vorgabe und eigener Auswahl –
die Leiste rechnet damit nichts mehr selbst. ``vorgabe`` und ``eigen``
stehen daneben, weil die Einstellungsseite bzw. die Kontoseite genau ihre
eigene Ebene zum Bearbeiten brauchen.
"""
vorgabe: list[str] = []
eigen: list[str] = []
versteckt: list[str] = []
class NavigationSetzen(BaseModel):
versteckt: list[str] = []
# ---- Etiketten-Vorlagen ----
class SpalteOut(BaseModel):
"""Eine waehlbare Spalte – fest oder ein eigenes Feld."""
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.
``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 = ""
class EtikettenVorlageOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
art: str
position: int
# Aus dem JSON-Text der Konfiguration geholt, damit die Oberflaeche nicht
# selbst parsen muss.
spalten: list[str] = []
kategorie_ids: list[int] = []
trennzeichen: str = ","
feldfilter: list[FeldFilter] = []
# Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt.
datei_name: str | None = None
updated_at: datetime
class EtikettenVorlageCreate(BaseModel):
name: str = Field(min_length=1, max_length=80)
art: str = Field(default="items", pattern="^(items|products|locations)$")
spalten: list[str] = []
kategorie_ids: list[int] = []
trennzeichen: str = ","
feldfilter: list[FeldFilter] = []
class EtikettenVorlageUpdate(BaseModel):
"""Nur mitgeschickte Felder zaehlen."""
name: str | None = Field(default=None, min_length=1, max_length=80)
art: str | None = Field(default=None, pattern="^(items|products|locations)$")
spalten: list[str] | None = None
kategorie_ids: list[int] | None = None
trennzeichen: str | None = None
feldfilter: list[FeldFilter] | None = None
position: int | None = None
# ---- Views ----
@@ -879,8 +1277,17 @@ class ExpiringItem(BaseModel):
product_name: str
quantity: float # in Basiseinheiten
base_unit: BaseUnit
best_before: date
# Darf fehlen: Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein
# Verbrauchsdatum – und genau die soll hier auftauchen.
best_before: date | None = None
best_before_precision: DatePrecision = DatePrecision.day
# Angebrochen: Öffnungstag und das daraus gerechnete Verbrauchsdatum.
opened_on: date | None = None
consume_by: date | None = None
# Das Datum, nach dem gewarnt wird (das frühere der beiden), und welches es
# ist – damit die Anzeige „läuft ab" von „angebrochen" unterscheiden kann.
warn_date: date
warn_source: str = "best_before" # best_before | consume_by
days_left: int
# Für die Anzeige in Artikeleinheiten:
package_size: float | None = None
@@ -933,12 +1340,61 @@ class LocationNeeds(BaseModel):
groups: list[LocationNeedGroup] = []
# ---- Gespeicherte Einkaufsliste ----
class ShoppingEntryIn(BaseModel):
"""Ein eigener Eintrag. Genau eines von Artikel, Gruppe, Freitext."""
product_id: int | None = None
group_id: int | None = None
free_text: str | None = Field(default=None, max_length=120)
quantity_base: float | None = Field(default=None, gt=0)
base: BaseUnit | None = None
class ShoppingEntryPatch(BaseModel):
"""Abhaken oder die Menge korrigieren."""
done: bool | None = None
quantity_base: float | None = Field(default=None, gt=0)
free_text: str | None = Field(default=None, max_length=120)
class ShoppingEntryOut(BaseModel):
"""Ein gespeicherter Eintrag, fertig für die Liste."""
id: int
art: str # product | group | text
product_id: int | None = None
group_id: int | None = None
free_text: str | None = None
name: str = ""
quantity_base: float | None = None
base: BaseUnit | None = None
# „2 Packungen" bzw. „150 g" – dieselbe Sprache wie die gerechneten Posten.
menge_text: str = ""
source: str = "manual" # manual | recipe
recipe_slug: str | None = None
recipe_name: str | None = None
done: bool = False
created_at: datetime
class ShoppingFromRecipe(BaseModel):
"""Was die Übernahme eines Rezepts bewirkt hat."""
entries: list[ShoppingEntryOut] = []
# Wie viele davon schon dastanden und nur neu gerechnet wurden.
aktualisiert: int = 0
# Zutaten ohne Zuordnung, die als Freitext draufkamen.
freitext: int = 0
class ShoppingListAll(BaseModel):
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
die Oberfläche zusammenführt."""
"""Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die
eigenen Einträge und die dauerhaft gesetzten Haken."""
products: list[ShoppingItem] = []
groups: list[GroupShoppingItem] = []
by_location: list[LocationNeeds] = []
# Eigene und aus Rezepten übernommene Einträge.
entries: list[ShoppingEntryOut] = []
# Schlüssel der abgehakten GERECHNETEN Posten („p12", „g3", „lKUECHEp12").
checked: list[str] = []
class LocationContentEntry(BaseModel):
@@ -1036,6 +1492,9 @@ class ExpirySplit(BaseModel):
ok: float
soon: float
expired: float
# Angebrochen und noch nicht in der Warnfrist. Was offen ist UND bald
# abläuft, zählt als "soon" – Dringlichkeit schlägt Zustand.
opened: float = 0.0
no_date: float # Chargen ohne MHD zählen separat, nicht als "ok"
@@ -1046,6 +1505,7 @@ class CategoryShare(BaseModel):
ok: float
soon: float
expired: float
opened: float = 0.0
no_date: float
+14 -3
View File
@@ -31,7 +31,9 @@ def verify_password(password: str, password_hash: str | None) -> bool:
return False
def create_access_token(user_id: int, role: str) -> str:
def create_access_token(
user_id: int, role: str, gueltig_minuten: int | None = None
) -> str:
"""Stellt ein Sitzungs-Token aus, das den Benutzer ueber seine ID benennt.
Frueher stand hier der Benutzername. Damit war der Name die Kennung der
@@ -41,9 +43,18 @@ def create_access_token(user_id: int, role: str) -> str:
``uid`` steht zusaetzlich zu ``sub`` drin, damit sich alte Token (``sub`` =
Name) eindeutig von neuen unterscheiden lassen – ein Benutzer koennte sonst
schlicht "42" heissen.
``gueltig_minuten`` kommt aus der Einstellung ``session_days``; ``None``
nimmt den Rueckfallwert aus der Umgebung. **0 heisst unbegrenzt** – dann
bekommt das Token gar kein ``exp`` und laeuft nie ab. Diese Wahl ist
bewusst folgenreich: Ein ausgestelltes Token laesst sich nicht
zurueckrufen, es gibt keine Sperrliste. Wer alle Anmeldungen beenden will,
muss JWT_SECRET aendern.
"""
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
payload = {"sub": str(user_id), "uid": user_id, "role": role, "exp": expire}
minuten = settings.jwt_expire_minutes if gueltig_minuten is None else gueltig_minuten
payload: dict = {"sub": str(user_id), "uid": user_id, "role": role}
if minuten > 0:
payload["exp"] = datetime.now(timezone.utc) + timedelta(minutes=minuten)
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
+66 -1
View File
@@ -5,7 +5,18 @@ import json
from sqlalchemy.orm import Session
from .config import get_settings
from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User
from .models import (
Category,
CategoryTracking,
EtikettenVorlage,
FieldDefinition,
Measure,
PackageType,
Role,
Unit,
UnitKind,
User,
)
from .security import hash_password
from .services.fields import slugify
@@ -62,6 +73,30 @@ def ensure_builtin_units(db: Session) -> None:
db.commit()
# Hausmaße: nur die Namen. Was eines davon WIEGT, steht an der Kategorie bzw.
# am Artikel (models.py::CategoryMeasure) – 1 EL Mehl ist nicht 1 EL Zucker.
# Ohne hinterlegte Menge taucht ein Maß in der Eingabe gar nicht auf, die Liste
# hier schadet also nicht, wenn man sie nicht braucht.
BUILTIN_MEASURES: list[tuple[str, str | None]] = [
("Esslöffel", "EL"),
("Teelöffel", "TL"),
("Tasse", None),
("Prise", None),
("Messerspitze", None),
("Handvoll", None),
]
def ensure_builtin_measures(db: Session) -> None:
changed = False
for position, (name, short) in enumerate(BUILTIN_MEASURES):
if not db.query(Measure).filter(Measure.name == name).first():
db.add(Measure(name=name, short=short, position=position, is_builtin=True))
changed = True
if changed:
db.commit()
# Startliste für den Überblick über den Vorrat. Zweistufig, damit die
# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar –
# umbenennen, löschen und eigene ergänzen ist ausdrücklich vorgesehen.
@@ -197,3 +232,33 @@ def ensure_first_admin(db: Session) -> None:
)
db.add(admin)
db.commit()
# Die zwei Vorlagen, die den frueheren festen Etikettenexport nachbilden: Wer
# nichts einstellt, bekommt genau dieselben Spalten wie bisher.
BUILTIN_ETIKETTEN_VORLAGEN = [
("Einzelstücke", "items",
["qr", "uid", "produkt", "marke", "kategorie", "lagerort"]),
("Lagerorte", "locations", ["qr", "id", "name", "pfad"]),
]
def ensure_builtin_etiketten_vorlagen(db: Session) -> None:
"""Legt die mitgelieferten Etiketten-Vorlagen an – einmalig.
Nur wenn noch GAR KEINE Vorlage existiert: Wer seine eigenen gebaut und die
mitgelieferten geloescht hat, soll sie nicht bei jedem Start zurueckbekommen.
"""
if db.query(EtikettenVorlage).first() is not None:
return
for position, (name, art, spalten) in enumerate(BUILTIN_ETIKETTEN_VORLAGEN):
db.add(EtikettenVorlage(
name=name,
art=art,
position=position,
konfiguration=json.dumps(
{"spalten": spalten, "kategorie_ids": [], "trennzeichen": ","},
ensure_ascii=False,
),
))
db.commit()
+69 -2
View File
@@ -98,8 +98,27 @@ def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> fl
return menge * anzahl / zweit
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
def to_base(
db: Session,
product: Product,
quantity: float,
unit_token: str,
*,
hausmasse: bool = False,
) -> float:
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um.
Reihenfolge: Packung → verwaltete Einheit → Hausmaß → Zweiteinheit-Brücke.
Die Einheitentabelle steht VOR den Hausmaßen, weil sie die harte Physik ist:
Ein versehentlich angelegtes Hausmaß namens „Liter" würde sonst die echte
Einheit dauerhaft verdecken.
``hausmasse=True`` lässt Hausmaße überhaupt zu („2 EL"). Vorgabe ist False,
weil ein Esslöffel ein Schätzwert ist: Beim **Verbrauchen und Auslagern** ist
das richtig, beim **Einlagern** und bei **Mindestbeständen** ausdrücklich
nicht. Der Schalter hält das in der Signatur fest, statt es der Disziplin an
jeder Aufrufstelle zu überlassen.
"""
if quantity <= 0:
raise ConversionError("Menge muss größer als 0 sein")
@@ -113,6 +132,10 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
unit = find_unit(db, t)
if unit is None:
if hausmasse:
gerechnet = _hausmass_to_base(db, product, quantity, t)
if gerechnet is not None:
return gerechnet
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product):
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
@@ -130,6 +153,50 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
return quantity * unit.factor
def _hausmass_to_base(
db: Session, product: Product, quantity: float, token: str
) -> float | None:
"""Hausmaß-Token auflösen. ``None`` = kein Hausmaß dieses Namens.
Unterschied, der wichtig ist: Ein **unbekannter Name** gibt ``None`` zurück,
damit danach die normale Einheitensuche greift. Ein **bekanntes Maß ohne
Menge** für diesen Artikel ist dagegen ein Fehler mit klarer Ansage – genau
die Meldung, aus der der Rezeptabgleich sein „Maß jetzt anlegen" macht.
"""
from .vererbung import mass_aufloesen, mass_in_basiseinheit
angabe = mass_aufloesen(db, product, token)
if angabe is None:
bekannt = _bekanntes_mass(db, token)
if bekannt is not None:
# Den richtigen Namen nennen, nicht das klein geschriebene Token –
# die Meldung steht so in der Oberflaeche.
raise ConversionError(
f"Für diese Kategorie ist kein Maß '{bekannt.name}' hinterlegt."
)
return None
ergebnis = mass_in_basiseinheit(db, product, token, quantity)
if ergebnis is None:
raise ConversionError(
f"Das Maß '{angabe.measure.name}' ist in "
f"{BASE_LABEL[BaseUnit(angabe.base)]} hinterlegt und passt nicht zur "
f"Art dieses Artikels ({BASE_LABEL[product.base_unit]}). Mit einer "
f"Zweiteinheit am Artikel ginge es."
)
return ergebnis
def _bekanntes_mass(db: Session, token: str):
"""Das Hausmaß mit diesem Namen oder dieser Kurzform – sonst ``None``."""
from ..models import Measure
t = (token or "").strip().lower()
for measure in db.query(Measure).all():
if t == measure.name.lower() or (measure.short and t == measure.short.lower()):
return measure
return None
def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]:
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
unit = db.get(Unit, unit_id)
+287
View File
@@ -0,0 +1,287 @@
"""Die gespeicherte Seite der Einkaufsliste.
Gerechnet wird die Liste in ``routers/views.py`` – Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt so. Hier
liegt daneben das, was jemand **bewusst** draufgesetzt hat: eigene Einträge,
übernommene Rezeptzutaten, und das dauerhafte Abhaken der gerechneten
Vorschläge.
Beides getrennt zu halten ist Absicht. Die gerechneten Posten dürfen keine
Zeilen in der Datenbank bekommen: Sie ändern sich mit jedem Einlagern, und eine
materialisierte Kopie würde sofort von der Wirklichkeit abweichen. Was gemerkt
wird, ist nur der Haken daran.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from sqlalchemy.orm import Session
from ..models import (
Group,
Product,
ShoppingCheck,
ShoppingEntry,
User,
)
from .einkaufsliste_text import fehlmengen_text
class EintragFehler(ValueError):
"""Unbrauchbarer Eintrag – der Router macht daraus eine 400."""
def _jetzt() -> datetime:
return datetime.now(timezone.utc)
# --------------------------------------------------------------------------
# Eigene Einträge
# --------------------------------------------------------------------------
def eintraege(db: Session, *, mit_erledigten: bool = True) -> list[ShoppingEntry]:
"""Alle gespeicherten Einträge, offene zuerst.
Zeigt ein Eintrag auf einen Artikel oder eine Gruppe, die es nicht mehr
gibt, fällt er hier still heraus: Auf SQLite greift kein Fremdschlüssel, und
einen gelöschten Artikel kann man nicht einkaufen.
"""
reihen = db.query(ShoppingEntry).order_by(
ShoppingEntry.done, ShoppingEntry.created_at
).all()
gueltig = [e for e in reihen if _ziel_existiert(db, e)]
if mit_erledigten:
return gueltig
return [e for e in gueltig if not e.done]
def _ziel_existiert(db: Session, eintrag: ShoppingEntry) -> bool:
if eintrag.product_id is not None:
return db.get(Product, eintrag.product_id) is not None
if eintrag.group_id is not None:
return db.get(Group, eintrag.group_id) is not None
return bool(eintrag.free_text)
def bezeichnung(db: Session, eintrag: ShoppingEntry) -> str:
"""Was auf dem Zettel steht."""
if eintrag.product_id is not None:
produkt = db.get(Product, eintrag.product_id)
return produkt.name if produkt is not None else ""
if eintrag.group_id is not None:
gruppe = db.get(Group, eintrag.group_id)
return gruppe.name if gruppe is not None else ""
return eintrag.free_text or ""
@dataclass
class _Menge:
"""Die Form, die ``fehlmengen_text`` erwartet.
Umweg mit Absicht: So formatiert die Einkaufsliste ihre Mengen mit genau
derselben Funktion wie der Rezeptabgleich („2 Packungen" statt „1000 ml",
aufgerundet auf ganze Gebinde). Zwei Formatierungen wuerden auseinander
laufen.
"""
art: str
ziel_id: int | None
fehlt_base: float | None
basis_einheit: str
def mengen_text(db: Session, eintrag: ShoppingEntry) -> str:
"""„2 Packungen" bzw. „150 g" – leer, wenn keine Menge dransteht."""
if not eintrag.quantity_base:
return ""
art = "product" if eintrag.product_id is not None else "group"
return fehlmengen_text(db, _Menge(
art=art, ziel_id=eintrag.product_id or eintrag.group_id,
fehlt_base=eintrag.quantity_base, basis_einheit=eintrag.base or "",
))
def anlegen(
db: Session,
*,
product_id: int | None = None,
group_id: int | None = None,
free_text: str | None = None,
quantity_base: float | None = None,
base: str | None = None,
source: str = "manual",
recipe_slug: str | None = None,
recipe_name: str | None = None,
user: User | None = None,
) -> ShoppingEntry:
"""Einen Eintrag anlegen. Genau ein Ziel, und es muss existieren."""
text = (free_text or "").strip() or None
ziele = [z for z in (product_id, group_id, text) if z is not None]
if len(ziele) != 1:
raise EintragFehler(
"Ein Eintrag zeigt auf genau eines: einen Artikel, eine Gruppe oder "
"einen freien Text."
)
if product_id is not None and db.get(Product, product_id) is None:
raise EintragFehler("Diesen Artikel gibt es nicht.")
if group_id is not None and db.get(Group, group_id) is None:
raise EintragFehler("Diese Gruppe gibt es nicht.")
if quantity_base is not None and quantity_base <= 0:
raise EintragFehler("Eine Menge muss größer als 0 sein.")
eintrag = ShoppingEntry(
product_id=product_id, group_id=group_id, free_text=text,
quantity_base=quantity_base, base=base, source=source,
recipe_slug=recipe_slug, recipe_name=recipe_name,
created_by=(user.id if user is not None else None),
)
db.add(eintrag)
db.commit()
db.refresh(eintrag)
return eintrag
def abhaken(db: Session, eintrag: ShoppingEntry, done: bool) -> ShoppingEntry:
eintrag.done = done
eintrag.done_at = _jetzt() if done else None
db.commit()
db.refresh(eintrag)
return eintrag
def loeschen(db: Session, eintrag: ShoppingEntry) -> None:
db.delete(eintrag)
db.commit()
def erledigte_entfernen(db: Session) -> int:
"""Alles Abgehakte weg – eigene Einträge und die Haken an den gerechneten.
Ein Durchgang nach dem Einkauf. Bewusst beides zusammen: Für den Nutzer ist
es eine Liste, auch wenn sie aus zwei Quellen kommt.
"""
anzahl = 0
for eintrag in db.query(ShoppingEntry).filter(ShoppingEntry.done.is_(True)).all():
db.delete(eintrag)
anzahl += 1
for haken in db.query(ShoppingCheck).all():
db.delete(haken)
anzahl += 1
db.commit()
return anzahl
# --------------------------------------------------------------------------
# Haken an den gerechneten Posten
# --------------------------------------------------------------------------
def abgehakte(db: Session) -> list[str]:
return [h.key for h in db.query(ShoppingCheck).all()]
def haken_setzen(db: Session, key: str, user: User | None = None) -> None:
sauber = (key or "").strip()[:80]
if not sauber:
raise EintragFehler("Ohne Kennung lässt sich kein Haken merken.")
if db.get(ShoppingCheck, sauber) is None:
db.add(ShoppingCheck(
key=sauber, user_id=(user.id if user is not None else None),
))
db.commit()
def haken_loesen(db: Session, key: str) -> None:
haken = db.get(ShoppingCheck, (key or "").strip()[:80])
if haken is not None:
db.delete(haken)
db.commit()
# --------------------------------------------------------------------------
# Aus einem Rezept übernehmen
# --------------------------------------------------------------------------
def aus_rezept(
db: Session,
pruefungen: list,
*,
slug: str,
name: str,
user: User | None = None,
) -> tuple[list[ShoppingEntry], int]:
"""Was für dieses Rezept fehlt, auf die Liste setzen.
Übernommen wird, was fehlt oder zu wenig ist — mit der **Fehlmenge**, nicht
der Rezeptmenge: Wer 250 g braucht und 100 g hat, kauft 150 g.
Zutaten ohne Zuordnung kommen als Freitext drauf. Das ist der wichtigere
Teil, als es klingt: Gerade die unbekannte Zutat ist die, die man vergisst.
Je Rezept und Ziel gibt es höchstens einen offenen Eintrag. Ein zweiter
Druck auf den Knopf — etwa nach dem Hochdrehen der Portionen — rechnet die
Menge neu, statt die Liste zu verdoppeln. Über verschiedene Rezepte hinweg
bleiben es getrennte Zeilen, denn derselbe Artikel kann zweimal fehlen.
Rückgabe: die betroffenen Einträge und wie viele davon aktualisiert (statt
neu angelegt) wurden.
"""
betroffen: list[ShoppingEntry] = []
aktualisiert = 0
for p in pruefungen:
if p.status in ("fehlt", "zu_wenig"):
menge = p.fehlt_base if p.fehlt_base else None
vorhanden = _offener_eintrag(
db, slug, product_id=(p.ziel_id if p.art == "product" else None),
group_id=(p.ziel_id if p.art == "group" else None),
)
if vorhanden is not None:
vorhanden.quantity_base = menge
vorhanden.base = p.basis_einheit or None
aktualisiert += 1
betroffen.append(vorhanden)
continue
betroffen.append(anlegen(
db,
product_id=(p.ziel_id if p.art == "product" else None),
group_id=(p.ziel_id if p.art == "group" else None),
quantity_base=menge, base=p.basis_einheit or None,
source="recipe", recipe_slug=slug, recipe_name=name, user=user,
))
elif p.status == "kein_treffer":
text = (p.food or p.text or "").strip()[:120]
if not text:
continue
vorhanden = _offener_eintrag(db, slug, free_text=text)
if vorhanden is not None:
aktualisiert += 1
betroffen.append(vorhanden)
continue
betroffen.append(anlegen(
db, free_text=text, source="recipe",
recipe_slug=slug, recipe_name=name, user=user,
))
db.commit()
return betroffen, aktualisiert
def _offener_eintrag(
db: Session,
slug: str,
*,
product_id: int | None = None,
group_id: int | None = None,
free_text: str | None = None,
) -> ShoppingEntry | None:
"""Ein noch nicht abgehakter Eintrag dieses Rezepts für dasselbe Ziel."""
frage = db.query(ShoppingEntry).filter(
ShoppingEntry.recipe_slug == slug,
ShoppingEntry.done.is_(False),
)
if product_id is not None:
frage = frage.filter(ShoppingEntry.product_id == product_id)
elif group_id is not None:
frage = frage.filter(ShoppingEntry.group_id == group_id)
else:
frage = frage.filter(ShoppingEntry.free_text == free_text)
return frage.first()
@@ -0,0 +1,37 @@
"""Fehlmenge als lesbare Angabe – „2 Packungen (1 l)" statt „1000 ml".
Eigene kleine Datei, damit der Rezeptabgleich dieselbe Sprache spricht wie die
Einkaufsliste: Dort wird eine Fehlmenge ebenfalls als zählbares Gebinde
ausgegeben und aufgerundet, weil man nur ganze Packungen kauft.
"""
from __future__ import annotations
import math
from sqlalchemy.orm import Session
from ..models import Product
from .conversion import BASE_LABEL, BaseUnit, article_unit
def fehlmengen_text(db: Session, pruefung) -> str:
"""„2 Packungen" bzw. „300 g" – leer, wenn nichts fehlt."""
fehlt = getattr(pruefung, "fehlt_base", None)
if not fehlt or fehlt <= 1e-9:
return ""
if pruefung.art == "product" and pruefung.ziel_id is not None:
produkt = db.get(Product, pruefung.ziel_id)
if produkt is not None:
faktor, label = article_unit(produkt)
if produkt.package_size and produkt.package_size > 0:
# Ganze Gebinde: man kauft keine halbe Packung.
anzahl = math.ceil(fehlt / (faktor or 1.0) - 1e-9)
return f"{anzahl:g} {label}" + ("" if anzahl == 1 else "en" if label.endswith("ung") else "")
return f"{fehlt:g} {label}"
basis = pruefung.basis_einheit
try:
name = BASE_LABEL[BaseUnit(basis)]
except (ValueError, KeyError):
name = basis
return f"{fehlt:g} {name}"
+478
View File
@@ -0,0 +1,478 @@
"""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()
+8 -3
View File
@@ -40,8 +40,13 @@ def options_list(fd: FieldDefinition) -> list[str]:
return [str(o) for o in data] if isinstance(data, list) else []
def _ancestry(db: Session, category_id: int) -> list[Category]:
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
def ancestry(db: Session, category_id: int) -> list[Category]:
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich).
Auch von ``services/vererbung.py`` benutzt – dort allerdings umgedreht,
weil ein einzelner geerbter Wert vom NÄCHSTEN Vorfahren kommen soll,
während Felder von allen Vorfahren gesammelt werden.
"""
chain: list[Category] = []
seen: set[int] = set()
cid: int | None = category_id
@@ -67,7 +72,7 @@ def effective_field_definitions(
if category_id is None:
return []
result: list[tuple[FieldDefinition, bool]] = []
for cat in _ancestry(db, category_id):
for cat in ancestry(db, category_id):
defs = (
db.query(FieldDefinition)
.filter(FieldDefinition.category_id == cat.id)
+46 -3
View File
@@ -6,8 +6,8 @@ import secrets
from sqlalchemy.orm import Session
from ..models import Item, ItemLoan
from ..schemas import ItemOut
from ..models import Item, ItemLoan, ItemUsage
from ..schemas import ItemKurz, ItemOut, VerwendungOut
from .loans import loan_to_out
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
@@ -34,8 +34,41 @@ def _offener(item: Item) -> ItemLoan | None:
return None
def _offene_verwendung(item: Item) -> ItemUsage | None:
"""Der laufende Verwendungszustand aus den schon geladenen Zeitraeumen.
Wie ``_offener``: ueber die Beziehung statt per eigener Abfrage, damit die
Einzelstueck-Liste nicht je Zeile nachfragt. Keine offene Zeile heisst
verfuegbar - dafuer gibt es keinen gespeicherten Wert.
"""
for usage in item.usages:
if usage.ended_at is None:
return usage
return None
def verwendung_to_out(usage: ItemUsage) -> VerwendungOut:
"""VerwendungOut samt Konto, das umgestellt hat."""
out = VerwendungOut.model_validate(usage)
out.username = usage.user.username if usage.user else None
return out
def item_kurz(item: Item) -> ItemKurz:
"""Ein verknuepftes Stueck in Kurzform (Zubehoer, Geraet)."""
return ItemKurz(
id=item.id,
uid=item.uid,
product_id=item.product_id,
product_name=item.product.name if item.product else None,
product_brand=item.product.brand if item.product else None,
location_name=item.location.name if item.location else None,
verliehen=_offener(item) is not None,
)
def item_to_out(item: Item) -> ItemOut:
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih."""
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen, Verleih und Zubehoer."""
out = ItemOut.model_validate(item)
out.location_name = item.location.name if item.location else None
out.shop_name = item.shop.name if item.shop else None
@@ -46,4 +79,14 @@ def item_to_out(item: Item) -> ItemOut:
out.category_name = kategorie.name if kategorie else None
loan = _offener(item)
out.loan = loan_to_out(loan) if loan is not None else None
# Die Kategorie steckt schon oben in ``kategorie`` - die Markierung kostet
# deshalb keine zusaetzliche Abfrage (siehe Category.usage_tracking).
out.verwendung_moeglich = bool(kategorie.usage_tracking) if kategorie else False
verwendung = _offene_verwendung(item)
out.verwendung = verwendung_to_out(verwendung) if verwendung is not None else None
# Beide Richtungen ausdruecklich setzen: model_validate wuerde die Beziehung
# zwar finden, aber nur die Spalten fuellen - Artikelname und Lagerort des
# verknuepften Stuecks blieben leer.
out.zubehoer = [item_kurz(t) for t in item.zubehoer]
out.gehoert_zu = item_kurz(item.gehoert_zu) if item.gehoert_zu else None
return out
+3 -1
View File
@@ -35,7 +35,8 @@ def export_master_data(db: Session) -> dict:
return {
FORMAT_KEY: FORMAT_VERSION,
"categories": [
{"id": c.id, "name": c.name, "parent_id": c.parent_id, "tracking": c.tracking}
{"id": c.id, "name": c.name, "parent_id": c.parent_id,
"tracking": c.tracking, "usage_tracking": c.usage_tracking}
for c in db.query(Category).order_by(Category.id).all()
],
"field_definitions": [
@@ -152,6 +153,7 @@ def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
for row in cats:
upsert(Category, row, "categories", lambda r: {
"name": r["name"], "tracking": r.get("tracking") or "food",
"usage_tracking": bool(r.get("usage_tracking", False)),
}, is_tree=True)
set_parents(Category, cats)
+45
View File
@@ -0,0 +1,45 @@
"""Unscharfer Namensabgleich – aus dem Kassenzettel-Scan, jetzt auch für Rezepte.
Lag bis dahin in ``routers/products.py``. Hierher gezogen, weil der
Rezeptabgleich dieselbe Frage stellt: Welcher Artikel ist mit „Mehl" oder
„MÜHLEN SCHNITZ" gemeint? Zwei Kopien derselben Heuristik würden mit der Zeit
auseinanderlaufen.
"""
from __future__ import annotations
import difflib
import re
_UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"})
def normalisieren(text: str) -> str:
"""Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg."""
text = (text or "").lower().translate(_UMLAUTE)
return re.sub(r"[^a-z0-9]+", " ", text).strip()
def match_score(zeile: str, name: str) -> int:
"""Ähnlichkeit 0–100 zwischen gesuchtem Text und einem Namen.
Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit
abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen –
und damit eine Rezeptzutat „Mehl" den „Weissmehl Typ 550" findet.
"""
a, b = normalisieren(zeile), normalisieren(name)
if not a or not b:
return 0
gesamt = difflib.SequenceMatcher(None, a, b).ratio()
a_tok, b_tok = a.split(), b.split()
bester = []
for t in a_tok:
m = 0.0
for u in b_tok:
if u.startswith(t) or t.startswith(u):
m = max(m, min(len(t), len(u)) / max(len(t), len(u)))
else:
m = max(m, difflib.SequenceMatcher(None, t, u).ratio())
bester.append(m)
token = sum(bester) / len(bester) if bester else 0.0
return round(100 * max(gesamt, token))
+281
View File
@@ -0,0 +1,281 @@
"""Anbindung an eine selbst gehostete Mealie-Instanz (Rezepte).
Vorbild ist ``services/oidc.py``, nicht ``off.py``. Der Unterschied ist
Absicht: Bei Open Food Facts ist „kein Treffer" der Normalfall und wird still
zu ``None``. Hier ist „Mealie antwortet nicht" ein Fehler, den der Nutzer sehen
muss — sonst stünde eine leere Rezeptliste da, und niemand wüsste, ob es keine
Rezepte gibt oder der Server weg ist.
Zugangsdaten: Der Admin tippt Adresse, Benutzername und Passwort **einmal**.
Vorrania meldet sich damit an, lässt sich von Mealie ein Dauertoken ausstellen
und verwirft das Passwort sofort. Gespeichert wird nur das Token — widerrufbar
in Mealie unter Profil ▸ API-Tokens.
"""
from __future__ import annotations
import time
import httpx
from sqlalchemy.orm import Session
from ..config import get_settings
from ..models import IntegrationSecret, Setting
# Schlüssel in den Einstellungen bzw. in der Geheimnis-Tabelle.
BASE_URL_KEY = "mealie_base_url"
SECRET_SERVICE = "mealie"
# Name des Tokens, wie er in Mealie erscheint – damit erkennbar ist, wer da
# zugreift, und man es gezielt widerrufen kann.
TOKEN_NAME = "Vorrania"
CACHE_SEKUNDEN = 3600.0 # erkannte Pfadvariante
REZEPTE_CACHE_SEKUNDEN = 60.0 # kurz: ein neues Rezept soll bald auftauchen
# Pfadvariante je Adresse. Mealie 2.x legt die Haushalts-Routen unter
# /api/households/, 1.x unter /api/groups/. Die Rezeptpfade sind in beiden
# gleich; der Unterschied zählt erst für die Einkaufslisten.
_variante_cache: dict[str, tuple[float, str]] = {}
_rezepte_cache: dict[str, tuple[float, dict]] = {}
# Fuer Tests austauschbar – ohne das muesste jeder Test einen Server starten.
_transport: httpx.BaseTransport | None = None
class MealieFehler(Exception):
"""Mealie hat nicht geantwortet oder abgelehnt.
``kennung`` ist die kurze Kennung für die Oberfläche, ``args[0]`` der
Klartext fürs Protokoll – wie ``OidcFehler``. **Das Passwort steht in
keinem der beiden.**
"""
def __init__(self, kennung: str, text: str) -> None:
super().__init__(text)
self.kennung = kennung
def cache_leeren() -> None:
"""Nur für Tests und zum erzwungenen Neuladen."""
_variante_cache.clear()
_rezepte_cache.clear()
def setze_transport(transport: httpx.BaseTransport | None) -> None:
"""Nur für Tests: HTTP-Antworten vorgeben, ohne einen Server zu starten."""
global _transport
_transport = transport
def _client() -> httpx.Client:
return httpx.Client(
timeout=get_settings().mealie_timeout_seconds,
headers={"User-Agent": "Vorrania-Selfhosted/1.0"},
follow_redirects=True,
transport=_transport,
)
# --------------------------------------------------------------------------
# Konfiguration
# --------------------------------------------------------------------------
def basis_adresse(db: Session) -> str:
row = db.get(Setting, BASE_URL_KEY)
return (row.value if row else "").strip().rstrip("/")
def token(db: Session) -> str:
row = db.get(IntegrationSecret, SECRET_SERVICE)
return row.secret if row else ""
def eingerichtet(db: Session) -> bool:
return bool(basis_adresse(db)) and bool(token(db))
def _zugang(db: Session) -> tuple[str, str]:
basis, tok = basis_adresse(db), token(db)
if not basis or not tok:
raise MealieFehler(
"nicht_eingerichtet",
"Für Mealie sind noch keine Zugangsdaten hinterlegt.",
)
return basis, tok
def speichere_token(db: Session, wert: str) -> None:
row = db.get(IntegrationSecret, SECRET_SERVICE)
if row is None:
db.add(IntegrationSecret(service=SECRET_SERVICE, secret=wert))
else:
row.secret = wert
db.commit()
cache_leeren()
def loesche_zugang(db: Session) -> None:
row = db.get(IntegrationSecret, SECRET_SERVICE)
if row is not None:
db.delete(row)
adresse = db.get(Setting, BASE_URL_KEY)
if adresse is not None:
db.delete(adresse)
db.commit()
cache_leeren()
# --------------------------------------------------------------------------
# Einrichten: Passwort einmal, danach nur noch das Dauertoken
# --------------------------------------------------------------------------
def hole_dauertoken(base_url: str, username: str, password: str) -> str:
"""Einmal anmelden, Dauertoken ausstellen lassen, zurückgeben.
1. ``POST /api/auth/token`` mit Benutzername und Passwort als Formularfelder
liefert ein kurzlebiges Sitzungstoken.
2. Damit ``POST /api/users/api-tokens`` → ein Dauertoken (rund fünf Jahre),
das genau einmal im Klartext in der Antwort steht.
Das Passwort verlässt diese Funktion nicht: Es steht in keinem Rückgabewert,
in keiner Fehlermeldung und in keiner Protokollzeile.
"""
basis = base_url.strip().rstrip("/")
if not basis:
raise MealieFehler("nicht_eingerichtet", "Bitte die Adresse von Mealie angeben.")
with _client() as client:
try:
antwort = client.post(
f"{basis}/api/auth/token",
data={"username": username, "password": password},
)
except httpx.HTTPError as fehler:
raise MealieFehler(
"nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
) from fehler
if antwort.status_code in (401, 403):
raise MealieFehler(
"anmeldung_fehlgeschlagen",
"Benutzername oder Passwort stimmen bei Mealie nicht.",
)
if antwort.status_code >= 400:
raise MealieFehler(
"abgelehnt",
f"Mealie hat die Anmeldung abgelehnt (HTTP {antwort.status_code}).",
)
try:
sitzung = antwort.json().get("access_token")
except ValueError:
sitzung = None
if not sitzung:
raise MealieFehler(
"abgelehnt", "Mealie hat kein Sitzungstoken zurückgegeben."
)
try:
erstellt = client.post(
f"{basis}/api/users/api-tokens",
json={"name": TOKEN_NAME, "integration_id": "vorrania"},
headers={"Authorization": f"Bearer {sitzung}"},
)
except httpx.HTTPError as fehler:
raise MealieFehler(
"nicht_erreichbar", f"Mealie nicht erreichbar: {fehler}"
) from fehler
if erstellt.status_code >= 400:
raise MealieFehler(
"kein_dauertoken",
f"Mealie wollte kein Dauertoken ausstellen (HTTP {erstellt.status_code}).",
)
try:
dauertoken = erstellt.json().get("token")
except ValueError:
dauertoken = None
if not dauertoken:
raise MealieFehler(
"kein_dauertoken", "Mealie hat kein Dauertoken zurückgegeben."
)
return dauertoken
# --------------------------------------------------------------------------
# Abfragen
# --------------------------------------------------------------------------
def _hole(db: Session, pfad: str) -> dict:
basis, tok = _zugang(db)
with _client() as client:
try:
antwort = client.get(
f"{basis}{pfad}", headers={"Authorization": f"Bearer {tok}"}
)
except httpx.HTTPError as fehler:
raise MealieFehler(
"nicht_erreichbar", f"Mealie unter {basis} nicht erreichbar: {fehler}"
) from fehler
if antwort.status_code in (401, 403):
raise MealieFehler(
"token_fehlt",
"Mealie weist das hinterlegte Token zurück. Bitte die Verbindung erneuern.",
)
if antwort.status_code == 404:
raise MealieFehler("nicht_gefunden", f"Mealie kennt {pfad} nicht.")
if antwort.status_code >= 400:
raise MealieFehler(
"abgelehnt", f"Mealie antwortete mit HTTP {antwort.status_code} auf {pfad}."
)
try:
return antwort.json()
except ValueError as fehler:
raise MealieFehler("abgelehnt", "Mealie antwortete nicht mit JSON.") from fehler
def pfadvariante(db: Session) -> str:
"""„households" (Mealie 2.x) oder „groups" (1.x), gepuffert je Adresse.
Die Rezeptpfade sind in beiden Fassungen gleich; der Unterschied zählt erst
für die Einkaufslisten. Geprüft wird mit einer Anfrage: 404 heißt „falscher
Pfad", alles andere „richtiger". Bewusst nicht über die Versionsnummer —
die melden Installationen uneinheitlich.
"""
basis = basis_adresse(db)
gepuffert = _variante_cache.get(basis)
if gepuffert and gepuffert[0] > time.monotonic():
return gepuffert[1]
variante = "households"
try:
_hole(db, "/api/households/shopping/lists?perPage=1")
except MealieFehler as fehler:
if fehler.kennung == "nicht_gefunden":
variante = "groups"
elif fehler.kennung != "abgelehnt":
raise
_variante_cache[basis] = (time.monotonic() + CACHE_SEKUNDEN, variante)
return variante
def version(db: Session) -> str | None:
"""Versionsangabe der Instanz, nur zur Anzeige. Fehlt sie, ist das kein Fehler."""
try:
return str(_hole(db, "/api/app/about").get("version") or "") or None
except MealieFehler:
return None
def rezepte(db: Session, *, seite: int = 1, pro_seite: int = 50, suche: str | None = None) -> dict:
"""Eine Seite der Rezeptliste. Kurz gepuffert, damit Blättern nicht hakt."""
pfad = f"/api/recipes?page={seite}&perPage={pro_seite}"
if suche:
pfad += f"&search={httpx.QueryParams({'s': suche})['s']}"
schluessel = f"{basis_adresse(db)}|{pfad}"
gepuffert = _rezepte_cache.get(schluessel)
if gepuffert and gepuffert[0] > time.monotonic():
return gepuffert[1]
daten = _hole(db, pfad)
_rezepte_cache[schluessel] = (time.monotonic() + REZEPTE_CACHE_SEKUNDEN, daten)
return daten
def rezept(db: Session, slug: str) -> dict:
"""Ein einzelnes Rezept samt Zutaten. Bewusst **nicht** gepuffert –
es wird gezielt geöffnet und soll dann aktuell sein."""
return _hole(db, f"/api/recipes/{slug}")
+490
View File
@@ -0,0 +1,490 @@
"""Rezeptzutaten gegen den Bestand prüfen: Kann ich das kochen?
Bewusst **ohne HTTP** – die Zutaten kommen als Wörterbücher herein, so wie
Mealie sie liefert. Dadurch lässt sich der ganze Abgleich mit handgeschriebenen
Zutaten prüfen, ohne eine Mealie-Instanz zu brauchen; das Netz steckt allein in
``services/mealie.py``.
Je Zutat beantwortet das hier drei Fragen nacheinander:
1. **Welcher Artikel ist gemeint?** Erst eine gemerkte Zuordnung, sonst unscharf
über Namen – gegen Gruppen und Artikel, denn „Milch" ist markenübergreifend
gemeint und das ist genau, wofür es Gruppen gibt.
2. **Wie viel ist das in Gramm/Milliliter/Stück?** Über die Einheiten und die
Hausmaße der Kategorie („2 EL Mehl").
3. **Reicht der Bestand?**
"""
from __future__ import annotations
from dataclasses import dataclass, field
from sqlalchemy.orm import Session
from ..models import Category, CategoryTracking, Group, Product
from .conversion import (
BASE_OF_KIND,
ConversionError,
article_unit,
find_unit,
group_min_context,
kind_of_product,
to_base,
zweit_faktor,
)
from .matching import match_score, normalisieren
from .stock import current_stock, summe_bestand_gewichtet
# Gesperrt wird über den Wortanker (siehe ``_hat_anker``), nicht über eine
# Punktzahl. Eine Ähnlichkeitszahl ist als Sperre untauglich: „Vollmilch
# Zimmertemperatur" gegen „H Vollmilch" landet nur bei etwa 40 Punkten, weil das
# lange Beiwort den Durchschnitt zieht – obwohl es offensichtlich dasselbe ist.
# Umgekehrt kam „Mehl" auf über 60 gegen „Mühlen Geschnetzeltes". Die Punkte
# entscheiden deshalb nur noch die Reihenfolge unter den Kandidaten, die den
# Anker schon bestanden haben.
@dataclass
class Kandidat:
art: str # "product" | "group"
id: int
name: str
score: int
# Laesst sich die Rezept-Einheit fuer diesen Kandidaten umrechnen? Passt sie
# nicht, ist es meist das falsche Produkt (siehe ``_einheit_passt``).
einheit_passt: bool = True
@dataclass
class ZutatPruefung:
"""Eine Zutat des Rezepts, auf den Bestand bezogen."""
text: str # wie das Rezept es schreibt
menge: float | None = None # skalierte Menge in der Rezept-Einheit
einheit: str = ""
# Der reine Lebensmittelname aus Mealie – Schluessel fuer die gemerkte
# Zuordnung von Hand (``services/zuordnung.py``).
food: str = ""
# True = die Zuordnung kommt aus einer gemerkten Entscheidung, nicht vom Raten.
manuell: bool = False
# Zuordnung
art: str | None = None # "product" | "group"
ziel_id: int | None = None
ziel_name: str = ""
kandidaten: list[Kandidat] = field(default_factory=list)
# Mengen in Basiseinheiten
benoetigt_base: float | None = None
bestand_base: float | None = None
fehlt_base: float | None = None
basis_einheit: str = ""
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
status: str = "kein_treffer"
hinweis: str = ""
# Für „Maß jetzt anlegen": welches Maß fehlt und für welche Kategorie.
fehlendes_mass: str = ""
kategorie_id: int | None = None
def _zutat_text(zutat: dict) -> str:
"""Wie das Rezept die Zutat schreibt – für die Anzeige."""
for schluessel in ("display", "originalText", "original_text", "note"):
wert = zutat.get(schluessel)
if wert:
return str(wert)
essen = zutat.get("food") or {}
return str(essen.get("name") or "Zutat")
def _lebensmittel(zutat: dict) -> str:
essen = zutat.get("food") or {}
name = essen.get("name") or ""
if name:
return str(name)
# Ohne verknüpftes Lebensmittel bleibt nur der freie Text („2 Eier").
return str(zutat.get("note") or zutat.get("display") or "")
def _einheit(zutat: dict) -> str:
einheit = zutat.get("unit") or {}
if not isinstance(einheit, dict):
return ""
return str(einheit.get("abbreviation") or einheit.get("name") or "")
def faktor_fuer_portionen(rezept: dict, portionen: float | None) -> tuple[float, str]:
"""Skalierungsfaktor und, falls nötig, ein Hinweis warum nicht skaliert wird."""
if not portionen or portionen <= 0:
return 1.0, ""
basis = rezept.get("recipeServings") or rezept.get("recipe_servings") or 0
if not basis:
basis = rezept.get("recipeYieldQuantity") or rezept.get("recipe_yield_quantity") or 0
try:
basis = float(basis)
except (TypeError, ValueError):
basis = 0.0
if basis <= 0:
return 1.0, (
"Das Rezept nennt keine Portionszahl – die Mengen gelten deshalb "
"unverändert."
)
return portionen / basis, ""
# Umlaut-Reste zusammenziehen, damit „Apfel" auch „Äpfel" (= „aepfel") findet.
# Nur fuer den Wortvergleich, nie fuer die Anzeige.
_UMLAUT_REST = str.maketrans({"a": "a"})
def _stamm(text: str) -> str:
roh = normalisieren(text)
return roh.replace("ae", "a").replace("oe", "o").replace("ue", "u")
# Wörter, die eine Zutat BESCHREIBEN, aber nicht benennen. Sie dürfen allein
# keinen Treffer begründen, sonst passt jedes vegane Produkt auf jede vegane
# Zutat: „vegane Butter" landete so auf „veganes Mühlen Knusper Bleu", und
# „veganer Lime Curd" auf „Vegane Mühlen-Schnitzel". Geteilt wird dort nur die
# Eigenschaft, nicht die Sache.
#
# Verglichen wird gegen die zusammengezogene Form aus ``_stamm`` (ae/oe/ue schon
# aufgelöst) - die Listen unten stehen deshalb ebenfalls in dieser Form.
#
# Zwei Toepfe, weil es zwei Sorten Wort sind. Die Trennung ist nicht Kosmetik:
# Als EINE Liste mit Praefix-Vergleich hat sie ganze Artikelnamen verschluckt.
# 'Frischkaese' begann mit 'frisch', galt damit als blosse Eigenschaft und fiel
# aus dem Vergleich - die Zutat 'Kaese' fand den Frischkaese deshalb NIE. Ebenso
# 'Biomilch' (bio), 'Rohrzucker' (roh), 'Kleingebaeck' (klein), 'Ganzkornbrot'
# (ganz). Der Fehler war unsichtbar: kein falscher Treffer, nur ausbleibende
# richtige.
#
# 1. Stammformen, die in JEDER Beugung gemeint sind. Praefix-Vergleich, weil
# Deutsch beugt: 'getrock' deckt getrocknet/getrocknete/getrocknetes ab.
# Diese Stuecke fangen keine Komposita an, die ein Lebensmittel benennen.
_EIGENSCHAFT_STAMM = (
"vegan", "vegetar", "biologisch",
"gekocht", "gebacken", "geschmolz", "gesalz", "ungesalz", "gezuckert",
"getrock", "gefror", "gerieb", "gewurf", "geschnitt", "geschal", "gehackt",
"gemahl", "gepresst", "zimmertemperatur", "lauwarm", "gehauft", "gestrich",
"optional",
)
# 2. Kurze Alltagswoerter, die AUCH am Anfang echter Namen stehen. Hier zaehlt
# nur das Wort selbst, hoechstens mit einer Beugungsendung dran: 'frische'
# ja, 'frischkase' nein.
_EIGENSCHAFT_WORT = (
"bio", "frisch", "roh", "klein", "gross", "mittel", "fein", "grob", "ganz",
"oder", "sowie", "etwa", "circa", "nach", "ohne", "evtl",
"block", "stueck", "stuck",
)
# Deutsche Beugungsendungen. Was laenger ist, ist keine Endung mehr, sondern ein
# zweites Wort - und genau das soll den Namen retten.
_BEUGUNG = ("", "e", "er", "es", "en", "em", "ene", "enes", "ener", "enen")
def _nur_eigenschaft(wort: str) -> bool:
if wort.isdigit() or wort.startswith(_EIGENSCHAFT_STAMM):
return True
return any(
wort.startswith(w) and wort[len(w):] in _BEUGUNG
for w in _EIGENSCHAFT_WORT
)
def _woerter(text: str) -> list[str]:
"""Bedeutungstragende Wörter einer Zutat.
Weg fallen sehr kurze („1 TL"), reine Zahlen und alles, was die Zutat nur
beschreibt (siehe ``_nur_eigenschaft``). Übrig bleibt, was sie IST: „Mehl",
„Apfel", „Butter", „Joghurt".
"""
return [
w for w in _stamm(text).split()
if len(w) >= 4 and not _nur_eigenschaft(w)
]
def _hat_anker(zutat_woerter: list[str], kandidat: str) -> bool:
"""Teilen Zutat und Kandidat ein echtes Wort?
Das ist die eigentliche Sperre. Reine Ähnlichkeitswerte halten „Eier" und
„Reis" oder „Mehl" und „Mühlen Geschnetzeltes" für verwandt, weil sich
Buchstaben überlappen – für einen Rezeptabgleich ist das unbrauchbar.
Verlangt wird deshalb, dass ein ganzes Wort im anderen Namen steckt:
„Mehl" in „Weissmehl Typ 550" ja, „Mehl" in „Mühlen Geschnetzeltes" nein.
"""
ziel = _stamm(kandidat)
if not ziel:
return False
ziel_woerter = [
w for w in ziel.split() if len(w) >= 4 and not _nur_eigenschaft(w)
]
# Auch der Teilstring-Vergleich darf nicht über eine Eigenschaft laufen:
# „vegan" steckt sonst weiter in „veganes Mühlen Knusper Bleu".
ziel_ohne = " ".join(ziel_woerter)
for wort in zutat_woerter:
if wort in ziel_ohne:
return True
# Umgekehrt auch: Artikel „Milch" passt zur Zutat „Buttermilch".
for zw in ziel_woerter:
if zw in wort:
return True
return False
def _kategorien_zur_zutat(db: Session, zutat_woerter: list[str]) -> set[int]:
"""Kategorien, deren Name ein Wort mit der Zutat teilt – samt Unterkategorien.
Damit wird zuerst dort gesucht, wo es hingehört: „Mehl Typ 405" in „Mehl &
Zucker", nicht im ganzen Katalog. Erst wenn dort nichts passt, zählt der
Rest.
"""
from ..routers.categories import descendant_ids
passende: set[int] = set()
for kategorie in db.query(Category).all():
if _hat_anker(zutat_woerter, kategorie.name):
passende |= descendant_ids(db, kategorie.id)
return passende
def _einheit_passt(db: Session, produkt: Product, token: str) -> bool:
"""Lässt sich die Rezept-Einheit für diesen Artikel überhaupt umrechnen?
Das ist ein überraschend gutes Zeichen für „falsches Produkt": Sagt das
Rezept Milliliter und der Kandidat wird in Gramm geführt, ist es meist
etwas anderes – „200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen".
Eine Zweiteinheit am Artikel („3 Stück ≙ 250 g") gilt weiterhin als
passend, dafür ist sie da.
Entscheidend ist, WARUM eine Umrechnung scheitert – nur einer der Gründe
sagt etwas über den Artikel:
* **Bekannte Einheit falscher Art, ohne Brücke** → spricht gegen den
Artikel. Milliliter an einem Gramm-Produkt ist ein anderes Produkt.
* **Unbekanntes Wort oder nicht hinterlegtes Hausmaß** („1 Tasse Linsen")
→ sagt gar nichts. Der Artikel kann völlig richtig sein, es fehlt nur die
Maßangabe – die lässt sich nachtragen.
* **Keine Einheit** (etwa „3 Eier") → passt immer.
"""
if not token:
return True
unit = find_unit(db, token)
if unit is None:
# Unbekannt oder Hausmass: kein Urteil ueber den Artikel.
return True
if unit.kind == kind_of_product(produkt):
return True
return zweit_faktor(produkt, BASE_OF_KIND[unit.kind]) is not None
def _kandidaten(db: Session, name: str, einheit: str = "") -> list[Kandidat]:
"""Artikel und Gruppen, die zur Zutat passen können – bester zuerst.
Drei Stufen, in dieser Reihenfolge:
1. **Nur Lebensmittel.** Gegenstände bleiben außen vor, wie beim
Kassenzettel-Abgleich. Ein Joystick ist kein Zucker, und eine
Waffelplatte kein Apfel.
2. **Wortanker.** Ohne ein geteiltes ganzes Wort gibt es keinen Treffer,
egal wie hoch die Buchstabenähnlichkeit wäre.
3. **Kategorie zuerst.** Passt der Zutatenname zu einer Kategorie, gilt nur
was darin liegt – sonst der ganze (gefilterte) Katalog.
"""
woerter = _woerter(name)
if not woerter:
return []
# 1. Nur Lebensmittel, und keine Einzelstücke.
from ..crud import product_tracking
lebensmittel = [
p for p in db.query(Product).all()
if not p.individual and product_tracking(db, p) != CategoryTracking.object.value
]
# 3. Kategorie zuerst – aber nur, wenn darin auch etwas mit Anker liegt.
kategorien = _kategorien_zur_zutat(db, woerter)
if kategorien:
in_kategorie = [
p for p in lebensmittel
if p.category_id in kategorien and _hat_anker(woerter, f"{p.name} {p.brand or ''}")
]
if in_kategorie:
lebensmittel = in_kategorie
treffer: list[Kandidat] = []
for gruppe in db.query(Group).all():
if _hat_anker(woerter, gruppe.name):
treffer.append(
Kandidat("group", gruppe.id, gruppe.name, match_score(name, gruppe.name))
)
for produkt in lebensmittel:
voll = f"{produkt.name} {produkt.brand or ''}".strip()
if _hat_anker(woerter, voll):
k = Kandidat("product", produkt.id, produkt.name, match_score(name, voll))
k.einheit_passt = _einheit_passt(db, produkt, einheit)
treffer.append(k)
# Reihenfolge: passende Einheit zuerst, dann Punkte; Gruppen bei Gleichstand
# vor Artikeln, weil „Milch" markenuebergreifend gemeint ist.
treffer.sort(key=lambda k: (
0 if k.einheit_passt else 1, -k.score, 0 if k.art == "group" else 1, k.name,
))
return treffer[:5]
def _bestand(db: Session, art: str, ziel_id: int) -> tuple[float, str]:
"""Bestand in Basiseinheiten plus die Bezeichnung der Basiseinheit."""
if art == "product":
produkt = db.get(Product, ziel_id)
if produkt is None:
return 0.0, ""
return current_stock(db, produkt.id), produkt.base_unit.value
gruppe = db.get(Group, ziel_id)
if gruppe is None:
return 0.0, ""
ctx = group_min_context(gruppe)
menge = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
return menge, (ctx.base_unit.value if ctx.base_unit else "")
def _referenzartikel(db: Session, art: str, ziel_id: int) -> Product | None:
"""Der Artikel, dessen Kategorie die Hausmaße liefert.
Bei einer Gruppe der erste zugehörige Artikel – „1 Tasse Milch" hängt an der
Kategorie der Milch, nicht an der Gruppe selbst.
"""
if art == "product":
return db.get(Product, ziel_id)
gruppe = db.get(Group, ziel_id)
if gruppe is None:
return None
ctx = group_min_context(gruppe)
return ctx.matching[0] if ctx.matching else None
def pruefe_zutaten(
db: Session,
zutaten: list[dict],
*,
faktor: float = 1.0,
zuordnung: dict[str, tuple[str, int]] | None = None,
) -> list[ZutatPruefung]:
"""Alle Zutaten eines Rezepts prüfen.
``zuordnung`` sind gemerkte Entscheidungen („Mealie-Lebensmittel X ist bei
uns Gruppe Y"), Schlüssel ist der normalisierte Name.
"""
zuordnung = zuordnung or {}
ergebnis: list[ZutatPruefung] = []
for zutat in zutaten:
pruefung = ZutatPruefung(text=_zutat_text(zutat))
name = _lebensmittel(zutat)
pruefung.food = name
pruefung.einheit = _einheit(zutat)
rohmenge = zutat.get("quantity")
if zutat.get("disableAmount") or zutat.get("disable_amount") or not rohmenge:
pruefung.menge = None
else:
try:
pruefung.menge = float(rohmenge) * faktor
except (TypeError, ValueError):
pruefung.menge = None
# 1. Zuordnen
gemerkt = zuordnung.get(normalisieren(name))
if gemerkt is not None:
pruefung.art, pruefung.ziel_id = gemerkt
pruefung.manuell = True
else:
pruefung.kandidaten = _kandidaten(db, name, pruefung.einheit)
bester = pruefung.kandidaten[0] if pruefung.kandidaten else None
# Passt die Einheit beim besten Kandidaten nicht, wird NICHT
# zugeordnet: Sagt das Rezept Milliliter und der Artikel wird in
# Gramm gefuehrt, ist es meist etwas anderes. Lieber offen lassen
# und von Hand zuordnen lassen, als etwas Falsches behaupten.
if bester is not None and bester.einheit_passt:
pruefung.art, pruefung.ziel_id = bester.art, bester.id
elif bester is not None:
pruefung.hinweis = (
f"Nichts Passendes gefunden: '{bester.name}' würde zum Namen "
f"passen, wird aber nicht in '{pruefung.einheit}' geführt."
)
if pruefung.ziel_id is None:
pruefung.status = "kein_treffer"
if not pruefung.hinweis:
pruefung.hinweis = f"'{name}' ist hier keinem Artikel zugeordnet."
ergebnis.append(pruefung)
continue
referenz = _referenzartikel(db, pruefung.art or "product", pruefung.ziel_id)
pruefung.ziel_name = (
referenz.name if pruefung.art == "product" and referenz is not None
else (db.get(Group, pruefung.ziel_id).name if pruefung.art == "group" else "")
)
if referenz is not None:
pruefung.kategorie_id = referenz.category_id
bestand, basis = _bestand(db, pruefung.art or "product", pruefung.ziel_id)
pruefung.bestand_base = bestand
pruefung.basis_einheit = basis
# 2. Menge umrechnen
if pruefung.menge is None:
pruefung.status = "menge_unbekannt"
pruefung.hinweis = "Das Rezept nennt keine Menge."
elif referenz is None:
pruefung.status = "menge_unbekannt"
pruefung.hinweis = "Zu dieser Gruppe gibt es noch keinen Artikel."
else:
try:
pruefung.benoetigt_base = to_base(
db, referenz, pruefung.menge,
pruefung.einheit or referenz.base_unit.value,
hausmasse=True,
)
except ConversionError as fehler:
pruefung.status = "menge_unbekannt"
pruefung.hinweis = str(fehler)
if "kein Maß" in str(fehler):
pruefung.fehlendes_mass = pruefung.einheit
ergebnis.append(pruefung)
continue
# 3. Vergleichen
if pruefung.benoetigt_base is not None:
fehlt = pruefung.benoetigt_base - (pruefung.bestand_base or 0.0)
pruefung.fehlt_base = max(0.0, fehlt)
if fehlt <= 1e-9:
pruefung.status = "vorhanden"
elif (pruefung.bestand_base or 0.0) > 1e-9:
pruefung.status = "zu_wenig"
else:
pruefung.status = "fehlt"
elif pruefung.status == "menge_unbekannt" and (pruefung.bestand_base or 0.0) <= 1e-9:
# Ohne Menge lässt sich nur sagen, ob überhaupt etwas da ist.
pruefung.status = "fehlt"
pruefung.hinweis = (pruefung.hinweis + " Vorrätig ist davon nichts.").strip()
ergebnis.append(pruefung)
return ergebnis
def kochbar(pruefungen: list[ZutatPruefung]) -> bool:
"""Reicht alles? Unklare Mengen zählen bewusst als „nicht sicher"."""
return all(p.status == "vorhanden" for p in pruefungen)
def artikeleinheiten(db: Session, pruefung: ZutatPruefung) -> tuple[float, str]:
"""Fehlmenge als zählbares Gebinde („2 Packungen") statt in Gramm."""
if pruefung.fehlt_base is None or pruefung.art != "product" or pruefung.ziel_id is None:
return 0.0, ""
produkt = db.get(Product, pruefung.ziel_id)
if produkt is None:
return 0.0, ""
faktor, label = article_unit(produkt)
return (pruefung.fehlt_base / (faktor or 1.0)), label
+143 -8
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from datetime import date, timedelta
from sqlalchemy import asc, func
from sqlalchemy.orm import Session
@@ -18,7 +18,7 @@ from ..models import (
Product,
User,
)
from .conversion import to_base
from .conversion import article_unit, to_base
from .dates import clean_precision, normalize_best_before
from .loans import nicht_verliehen
@@ -414,10 +414,17 @@ def consolidate_lot_group(
Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen
mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine
erscheinen. Der Bestand bleibt unverändert, die Historie (Bewegungen) auch.
**Angebrochene Chargen sind davon ausgenommen und werden NIE zusammengefasst.**
Eine offene Packung mit eigenem Verbrauchsdatum ist etwas anderes als eine
ungeöffnete mit demselben MHD; ohne diese Ausnahme würde die offene Packung
beim nächsten Einlagern am selben Ort still einkassiert und ihr kürzeres
Verbrauchsdatum ginge verloren. Bitte beim nächsten Umbau hier stehen lassen.
"""
query = db.query(Lot).filter(
Lot.product_id == product_id,
Lot.best_before_precision == best_before_precision,
Lot.opened_on.is_(None),
)
query = (
query.filter(Lot.best_before.is_(None))
@@ -452,6 +459,8 @@ def consolidate_duplicate_lots(db: Session) -> int:
db.query(
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
)
# Angebrochene bleiben ausgenommen, siehe consolidate_lot_group.
.filter(Lot.opened_on.is_(None))
.group_by(
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
)
@@ -467,6 +476,7 @@ def consolidate_duplicate_lots(db: Session) -> int:
Lot.best_before_precision == precision,
Lot.best_before.is_(None) if best_before is None else Lot.best_before == best_before,
Lot.location_id.is_(None) if location_id is None else Lot.location_id == location_id,
Lot.opened_on.is_(None),
)
.scalar()
)
@@ -487,7 +497,8 @@ def check_out_lot(
note: str | None = None,
) -> list[dict]:
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
needed = to_base(db, product, quantity, unit)
# Hausmasse sind beim Auslagern erlaubt („2 EL"), beim Einlagern nicht.
needed = to_base(db, product, quantity, unit, hausmasse=True)
if needed > lot.quantity + 1e-9:
raise StockError(
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
@@ -512,19 +523,143 @@ def check_out_lot(
return affected
# --------------------------------------------------------------------------
# Anbrechen und Teilverbrauch
# --------------------------------------------------------------------------
def abspalten(
db: Session,
lot: Lot,
quantity: float,
location_id: str | None,
*,
gleicher_ort_erlaubt: bool = False,
) -> Lot:
"""``quantity`` Basiseinheiten als neue Charge mit gleichem MHD abzweigen.
Reine Umbuchung, keine Bewegung – der Gesamtbestand ändert sich nicht. Gibt
die neue Charge zurück (noch nicht konsolidiert; das entscheidet der
Aufrufer, denn beim Anbrechen darf gerade NICHT konsolidiert werden).
``gleicher_ort_erlaubt`` ist der Unterschied zwischen den beiden Anlässen:
Beim Aufteilen auf einen anderen Lagerort wäre derselbe Ort sinnlos, beim
Anbrechen ist er der Normalfall – die offene Packung bleibt dort liegen,
wo sie war.
"""
if quantity <= 0:
raise StockError("Menge muss größer als 0 sein")
if quantity > lot.quantity - 1e-9:
raise StockError(
f"So viel enthält die Charge nicht (vorhanden: {lot.quantity:g})."
)
if not gleicher_ort_erlaubt and (location_id or None) == (lot.location_id or None):
raise StockError("Bitte einen anderen Lagerort als den aktuellen wählen.")
lot.quantity -= quantity
neu = Lot(
product_id=lot.product_id,
quantity=quantity,
best_before=lot.best_before,
best_before_precision=lot.best_before_precision,
location_id=location_id,
)
db.add(neu)
db.flush()
return neu
def anbrechen(db: Session, product: Product, lot: Lot, quantity: float | None = None) -> Lot:
"""Eine Packung der Charge anbrechen und als eigene Charge führen.
Aus 4000 ml (4 × 1 l) wird eine offene Charge mit 1000 ml und eine
ungeöffnete mit 3000 ml. Ohne das Abspalten würden die restlichen 3 l das
kurze Verbrauchsdatum der geöffneten Packung mittragen, obwohl sie noch zu
sind.
``quantity`` leer = eine Artikeleinheit (Packung, sonst Anzeigeeinheit);
ist die Charge kleiner, wird sie als Ganzes angebrochen.
"""
from .vererbung import oeffnungsfrist
if lot.opened_on is not None:
return lot # schon offen – nichts zu tun
menge = quantity
if menge is None:
faktor, _label = article_unit(product)
menge = faktor or lot.quantity
menge = min(menge, lot.quantity)
ziel = lot if menge >= lot.quantity - 1e-9 else abspalten(
db, lot, menge, lot.location_id, gleicher_ort_erlaubt=True
)
heute = date.today()
ziel.opened_on = heute
ziel.opened_quantity = ziel.quantity
ziel.consume_by = heute + timedelta(days=oeffnungsfrist(db, product))
db.flush()
return ziel
def verbrauchen(
db: Session,
product: Product,
lot: Lot,
quantity: float,
unit: str,
user: User | None,
note: str | None = None,
auto_open: bool = True,
) -> tuple[list[dict], Lot | None, bool]:
"""Teilmenge aus einer Charge verbrauchen, bei Bedarf vorher anbrechen.
Gibt (betroffene Chargen, offene Charge oder None wenn leer, angebrochen?)
zurück. Die Umrechnung läuft über ``to_base`` und kennt damit auch die
Hausmaße („2 EL") – deshalb wird hier zuerst gerechnet und danach
angebrochen: Scheitert die Einheit, soll keine Packung offen sein.
"""
# Erst rechnen: Scheitert die Einheit, soll keine Packung grundlos offen sein.
menge_base = to_base(db, product, quantity, unit, hausmasse=True)
angebrochen = False
ziel = lot
if lot.opened_on is None and auto_open:
ziel = anbrechen(db, product, lot, max(menge_base, article_unit(product)[0] or 0.0))
angebrochen = True
# Bewusst mit der ROH-Eingabe weiter, nicht mit menge_base: ``unit_used`` an
# der Bewegung soll festhalten, was der Nutzer getippt hat („2 EL"), nicht
# das Ergebnis der Umrechnung. Die zweite Umrechnung liefert denselben Wert.
affected = check_out_lot(db, product, ziel, quantity, unit, user, note)
offen = None if ziel.quantity <= 1e-9 else ziel
return affected, offen, angebrochen
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
"""Lots eines Produkts: Angebrochenes zuerst, dann nach dem wirksamen Datum.
Eine offene Packung wird immer vor einer geschlossenen geleert, auch wenn
die geschlossene früher datiert ist. Das aufgedruckte MHD einer
ungeöffneten Packung ist konservativ und wird real meist überlebt – eine
offene verdirbt dagegen tatsächlich. Und wer eine zweite Packung aufmacht,
während die erste offen steht, hat am Ende zwei angebrochene.
Innerhalb beider Gruppen gilt weiter FEFO über das nähere von MHD und
Verbrauchsdatum (``Lot.faellig_am``), NULL zuletzt.
"""
lots = (
db.query(Lot)
.filter(Lot.product_id == product_id, Lot.quantity > 0)
.order_by(asc(Lot.created_at))
.all()
)
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
# es über SQLite und Postgres identisch funktioniert.
# In Python sortieren, damit es über SQLite und Postgres identisch arbeitet.
return sorted(
lots,
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
key=lambda lot: (
lot.opened_on is None,
lot.faellig_am is None,
lot.faellig_am or date.max,
lot.id,
),
)
@@ -541,7 +676,7 @@ def check_out(
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
"""
needed = to_base(db, product, quantity, unit)
needed = to_base(db, product, quantity, unit, hausmasse=True)
available = current_stock(db, product.id)
if needed > available + 1e-9:
raise StockError(
+243
View File
@@ -0,0 +1,243 @@
"""Werte, die über den Kategorie-Baum geerbt werden: Öffnungsfrist und Hausmaße.
Die Regel ist bei beiden dieselbe und bewusst eine andere als bei den selbst
definierten Feldern: Dort werden die Felder **aller** Vorfahren gesammelt
(``services/fields.py::effective_field_definitions``), hier gewinnt der
**nächste** Vorfahr mit einem Eintrag. Ein einzelner Wert kann nur einmal
gelten — „Backwaren: 1 EL = 10 g" darf von „Mehl: 1 EL = 9 g" überschrieben
werden, ohne dass beide gleichzeitig zählen.
Reihenfolge immer: Artikel → nächste Kategorie mit Wert → … → global.
"""
from __future__ import annotations
from typing import Callable, TypeVar
from sqlalchemy.orm import Session
from ..models import (
BaseUnit,
CategoryMeasure,
Measure,
Product,
ProductMeasure,
Setting,
)
from .fields import ancestry
T = TypeVar("T")
# Globale Öffnungsfrist. Pflichtwert: Ohne ihn hätte eine angebrochene Packung
# ohne Kategorie-Eintrag kein Verbrauchsdatum, und der ganze Zweck entfiele.
OPEN_DAYS_KEY = "open_days"
def _vorfahren_abwaerts(db: Session, category_id: int | None):
"""Kategorien vom Artikel aufwärts: nächste zuerst, Wurzel zuletzt.
``ancestry`` liefert die Kette Wurzel-zuerst (dort richtig, weil Felder von
oben nach unten gelesen werden); hier wird sie umgedreht.
"""
if category_id is None:
return []
return list(reversed(ancestry(db, category_id)))
def naechster_wert(
db: Session, category_id: int | None, lese: Callable[[int], T | None]
) -> T | None:
"""Ersten gesetzten Wert entlang der Kategorie-Kette nach oben suchen."""
for cat in _vorfahren_abwaerts(db, category_id):
wert = lese(cat.id)
if wert is not None:
return wert
return None
# --------------------------------------------------------------------------
# Öffnungsfrist
# --------------------------------------------------------------------------
def globale_oeffnungsfrist(db: Session) -> int:
"""Globaler Wert in Tagen – Muster wie ``get_expiry_warning_days``."""
from ..config import get_settings
vorgabe = get_settings().open_days_default
row = db.get(Setting, OPEN_DAYS_KEY)
if row is None:
return vorgabe
try:
tage = int(row.value)
except (TypeError, ValueError):
return vorgabe
return tage if tage > 0 else vorgabe
def oeffnungsfrist(db: Session, product: Product) -> int:
"""Wie viele Tage eine angebrochene Packung dieses Artikels noch hält."""
if product.open_days is not None and product.open_days > 0:
return product.open_days
geerbt = naechster_wert(
db,
product.category_id,
lambda cid: _kategorie_frist(db, cid),
)
return geerbt if geerbt is not None else globale_oeffnungsfrist(db)
def _kategorie_frist(db: Session, category_id: int) -> int | None:
from ..models import Category
cat = db.get(Category, category_id)
if cat is None or cat.open_days is None or cat.open_days <= 0:
return None
return cat.open_days
def frist_herkunft(db: Session, product: Product) -> tuple[int, str]:
"""Frist plus Klartext, woher sie kommt – für den Platzhalter in der Maske."""
if product.open_days is not None and product.open_days > 0:
return product.open_days, "Artikel"
for cat in _vorfahren_abwaerts(db, product.category_id):
if cat.open_days is not None and cat.open_days > 0:
return cat.open_days, f"Kategorie: {cat.name}"
return globale_oeffnungsfrist(db), "Einstellungen"
def geerbte_kategorie_frist(db: Session, category) -> tuple[int, str]:
"""Die Frist, die fuer diese Kategorie gelten WUERDE, haette sie keine eigene.
Gefragt wird also nach dem Platzhalter in der Maske: Was passiert, wenn ich
das Feld leer lasse? Deshalb beginnt die Suche beim Elternteil, nicht bei
der Kategorie selbst.
"""
for cat in _vorfahren_abwaerts(db, category.parent_id):
if cat.open_days is not None and cat.open_days > 0:
return cat.open_days, f"Kategorie: {cat.name}"
return globale_oeffnungsfrist(db), "Einstellungen"
# --------------------------------------------------------------------------
# Hausmaße
# --------------------------------------------------------------------------
class MassAngabe:
"""Ein für einen Artikel auflösbares Hausmaß samt Herkunft."""
__slots__ = ("measure", "amount", "base", "herkunft")
def __init__(self, measure: Measure, amount: float, base: str, herkunft: str):
self.measure = measure
self.amount = amount
self.base = base
self.herkunft = herkunft
def _mass_fuer_artikel(db: Session, product: Product, measure_id: int) -> MassAngabe | None:
eigen = (
db.query(ProductMeasure)
.filter(
ProductMeasure.product_id == product.id,
ProductMeasure.measure_id == measure_id,
)
.first()
)
measure = db.get(Measure, measure_id)
if measure is None:
return None
if eigen is not None:
return MassAngabe(measure, eigen.amount, eigen.base, "Artikel")
for cat in _vorfahren_abwaerts(db, product.category_id):
row = (
db.query(CategoryMeasure)
.filter(
CategoryMeasure.category_id == cat.id,
CategoryMeasure.measure_id == measure_id,
)
.first()
)
if row is not None:
return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}")
return None
def geerbtes_mass_kategorie(
db: Session, category, measure_id: int
) -> MassAngabe | None:
"""Was eine Kategorie fuer dieses Mass ERBT, ohne ihren eigenen Eintrag.
Dasselbe wie bei der Frist: Die Maske soll zeigen, was gilt, wenn das Feld
leer bleibt. Deshalb beginnt die Suche beim Elternteil.
"""
measure = db.get(Measure, measure_id)
if measure is None:
return None
for cat in _vorfahren_abwaerts(db, category.parent_id):
row = (
db.query(CategoryMeasure)
.filter(
CategoryMeasure.category_id == cat.id,
CategoryMeasure.measure_id == measure_id,
)
.first()
)
if row is not None:
return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}")
return None
def verfuegbare_masse(db: Session, product: Product) -> list[MassAngabe]:
"""Alle Hausmaße, die für diesen Artikel eine Menge haben.
Grundlage für die Einheitenauswahl im Verbrauchen-Dialog und für den
Hinweis, woher die Menge stammt.
"""
ergebnis: list[MassAngabe] = []
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
angabe = _mass_fuer_artikel(db, product, measure.id)
if angabe is not None:
ergebnis.append(angabe)
return ergebnis
def mass_aufloesen(db: Session, product: Product, token: str) -> MassAngabe | None:
"""Ein Eingabe-Token („EL", „esslöffel", „Tasse") zum Hausmaß auflösen.
Vergleicht Name und Kurzform ohne Rücksicht auf Groß-/Kleinschreibung.
``None`` heißt: Entweder gibt es das Maß nicht, oder es ist für diesen
Artikel (und seine Kategorien) ohne Menge — beides behandelt der Aufrufer
gleich, nämlich als „Maß hier nicht hinterlegt".
"""
t = (token or "").strip().lower()
if not t:
return None
for measure in db.query(Measure).all():
namen = {measure.name.lower()}
if measure.short:
namen.add(measure.short.lower())
if t in namen:
return _mass_fuer_artikel(db, product, measure.id)
return None
def mass_in_basiseinheit(db: Session, product: Product, token: str, menge: float) -> float | None:
"""Menge in einem Hausmaß in die Basiseinheit des Artikels umrechnen.
``None`` heißt „Maß für diesen Artikel nicht hinterlegt" — der Aufrufer
entscheidet, ob das ein Fehler ist (Buchung) oder nur eine Lücke, die man
anlegen kann (Rezeptabgleich).
"""
angabe = mass_aufloesen(db, product, token)
if angabe is None:
return None
from .conversion import in_artikel_basis
roh = menge * angabe.amount
try:
quell = BaseUnit(angabe.base)
except ValueError:
return None
if quell == product.base_unit:
return roh
# Art passt nicht zur Basiseinheit des Artikels: derselbe Weg wie bei jeder
# artfremden Einheit, naemlich ueber die Zweiteinheit des Artikels.
return in_artikel_basis(product, roh, quell)
+94
View File
@@ -0,0 +1,94 @@
"""Gemerkte Zuordnungen: Mealie-Lebensmittel → eigener Artikel oder Gruppe.
Der unscharfe Abgleich in :mod:`services.rezepte` ist eine Heuristik und wird
immer Fälle haben, die er nicht erraten kann — „Lime Curd", „Ras el Hanout",
oder einfach ein Hausname. Hier liegt der Korrekturweg: Einmal von Hand
zugeordnet, gilt es für jedes Rezept, in dem dasselbe Lebensmittel vorkommt.
Der Schlüssel ist der normalisierte Name, nicht die Mealie-ID. Mealie-IDs sind
UUIDs, die beim Neuanlegen eines Lebensmittels wechseln; der Name bleibt. Und
nur über den Namen greift die Zuordnung auch für Zutaten, die in Mealie gar
kein verknüpftes Lebensmittel haben (freier Text „2 Eier").
"""
from __future__ import annotations
from sqlalchemy.orm import Session
from ..models import Group, MealieFoodMap, Product
from .matching import normalisieren
class ZuordnungFehler(ValueError):
"""Unbrauchbare Zuordnung – der Router macht daraus eine 400/404."""
def schluessel(name: str) -> str:
"""Normalisierter Schlüssel. Leer heisst „damit lässt sich nichts merken"."""
return normalisieren(name)[:200]
def lade(db: Session) -> dict[str, tuple[str, int]]:
"""Alle gültigen Zuordnungen als ``{schluessel: (art, id)}``.
Zeilen, deren Ziel es nicht mehr gibt, fallen hier still heraus statt eine
Ausnahme zu werfen: Auf SQLite greift kein Fremdschlüssel, und ein
gelöschter Artikel darf nicht dazu führen, dass sich kein Rezept mehr
öffnen lässt.
"""
ergebnis: dict[str, tuple[str, int]] = {}
for zeile in db.query(MealieFoodMap).all():
if zeile.product_id is not None:
if db.get(Product, zeile.product_id) is not None:
ergebnis[zeile.food_key] = ("product", zeile.product_id)
elif zeile.group_id is not None:
if db.get(Group, zeile.group_id) is not None:
ergebnis[zeile.food_key] = ("group", zeile.group_id)
return ergebnis
def ziel_name(db: Session, art: str, ziel_id: int) -> str:
if art == "product":
produkt = db.get(Product, ziel_id)
return produkt.name if produkt is not None else ""
gruppe = db.get(Group, ziel_id)
return gruppe.name if gruppe is not None else ""
def setze(db: Session, food: str, art: str, ziel_id: int) -> MealieFoodMap:
"""Zuordnung anlegen oder ändern. Prüft, dass das Ziel existiert."""
key = schluessel(food)
if not key:
raise ZuordnungFehler(
"Zu dieser Zutat steht kein Name da – ohne Namen lässt sich die "
"Zuordnung nicht merken."
)
if art not in ("product", "group"):
raise ZuordnungFehler("Zugeordnet wird auf einen Artikel oder eine Gruppe.")
if art == "product" and db.get(Product, ziel_id) is None:
raise ZuordnungFehler("Diesen Artikel gibt es nicht.")
if art == "group" and db.get(Group, ziel_id) is None:
raise ZuordnungFehler("Diese Gruppe gibt es nicht.")
zeile = db.get(MealieFoodMap, key)
if zeile is None:
zeile = MealieFoodMap(food_key=key)
db.add(zeile)
# Immer beide Felder setzen, damit ein Wechsel Artikel → Gruppe nicht
# beide gefüllt zurücklässt.
zeile.food_name = (food or "").strip()[:200]
zeile.product_id = ziel_id if art == "product" else None
zeile.group_id = ziel_id if art == "group" else None
db.commit()
db.refresh(zeile)
return zeile
def loesche(db: Session, food: str) -> bool:
"""Zuordnung vergessen. ``False``, wenn es keine gab."""
zeile = db.get(MealieFoodMap, schluessel(food))
if zeile is None:
return False
db.delete(zeile)
db.commit()
return True
+2 -1
View File
@@ -5,7 +5,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import BaseUnit, Product
from app.seed import ensure_builtin_units
from app.seed import ensure_builtin_measures, ensure_builtin_units
@pytest.fixture()
@@ -19,6 +19,7 @@ def db():
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = TestingSession()
ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung
ensure_builtin_measures(session) # Hausmaße (EL, TL, Tasse …) – ohne Mengen
try:
yield session
finally:
+179
View File
@@ -0,0 +1,179 @@
"""Zwei Daten an einer Charge: aufgedrucktes MHD und „zu verbrauchen bis".
Beide bleiben getrennt sichtbar, gewarnt wird nach dem früheren. Der heikle
Fall ist die angebrochene Packung **ohne** aufgedrucktes MHD – die fiel vorher
durch den Filter `best_before IS NOT NULL`.
"""
from datetime import date, timedelta
import pytest
from app.models import BaseUnit, Lot, Product, Role, User
from app.routers.dashboard import _zustand
from app.routers.stock import open_lot
from app.routers.views import expiring
from app.schemas import LotOpen
from app.services.stock import _fefo_lots
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def milch(db):
product = Product(
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _lot(db, product, menge, best_before=None, opened_on=None, consume_by=None):
lot = Lot(
product_id=product.id, quantity=menge, best_before=best_before,
opened_on=opened_on, consume_by=consume_by,
)
db.add(lot)
db.commit()
db.refresh(lot)
return lot
# --------------------------------------------------------------------------
# Ablaufliste
# --------------------------------------------------------------------------
def test_angebrochene_charge_ohne_mhd_taucht_auf(db, user, milch):
"""Der Fall, den der alte Filter verschluckt hätte."""
_lot(db, milch, 500, best_before=None,
opened_on=date.today(), consume_by=date.today() + timedelta(days=2))
liste = expiring(days=7, db=db, _=user)
assert len(liste) == 1
assert liste[0].best_before is None
assert liste[0].warn_source == "consume_by"
assert liste[0].days_left == 2
def test_beide_daten_stehen_getrennt_in_der_liste(db, user, milch):
mhd = date.today() + timedelta(days=30)
verbrauch = date.today() + timedelta(days=3)
_lot(db, milch, 500, best_before=mhd, opened_on=date.today(), consume_by=verbrauch)
eintrag = expiring(days=7, db=db, _=user)[0]
assert eintrag.best_before == mhd # bleibt sichtbar
assert eintrag.consume_by == verbrauch
assert eintrag.warn_date == verbrauch # gewarnt wird nach dem früheren
assert eintrag.days_left == 3
def test_frueheres_mhd_gewinnt_gegen_verbrauchsdatum(db, user, milch):
mhd = date.today() + timedelta(days=1)
_lot(db, milch, 500, best_before=mhd,
opened_on=date.today(), consume_by=date.today() + timedelta(days=5))
eintrag = expiring(days=7, db=db, _=user)[0]
assert eintrag.warn_date == mhd
assert eintrag.warn_source == "best_before"
def test_ungeoeffnete_charge_verhaelt_sich_wie_bisher(db, user, milch):
mhd = date.today() + timedelta(days=3)
_lot(db, milch, 1000, best_before=mhd)
eintrag = expiring(days=7, db=db, _=user)[0]
assert eintrag.warn_date == mhd
assert eintrag.warn_source == "best_before"
assert eintrag.consume_by is None
def test_ausserhalb_der_frist_bleibt_draussen(db, user, milch):
_lot(db, milch, 500, opened_on=date.today(),
consume_by=date.today() + timedelta(days=30))
assert expiring(days=7, db=db, _=user) == []
def test_liste_ist_nach_dem_wirksamen_datum_sortiert(db, user, milch):
_lot(db, milch, 100, best_before=date.today() + timedelta(days=6))
_lot(db, milch, 100, opened_on=date.today(), consume_by=date.today() + timedelta(days=1))
_lot(db, milch, 100, best_before=date.today() + timedelta(days=3))
tage = [e.days_left for e in expiring(days=7, db=db, _=user)]
assert tage == [1, 3, 6]
# --------------------------------------------------------------------------
# Zustand fürs Dashboard
# --------------------------------------------------------------------------
def test_zustand_angebrochen_ist_ein_eigener(db):
heute = date.today()
assert _zustand(heute + timedelta(days=30), heute, 7, angebrochen=True) == "opened"
def test_dringlichkeit_schlaegt_angebrochen(db):
heute = date.today()
# Angebrochen UND bald ablaufend: die Warnung muss gewinnen.
assert _zustand(heute + timedelta(days=2), heute, 7, angebrochen=True) == "soon"
assert _zustand(heute - timedelta(days=1), heute, 7, angebrochen=True) == "expired"
def test_zustand_ohne_daten_bleibt_no_date(db):
heute = date.today()
assert _zustand(None, heute, 7) == "no_date"
# Angebrochen ohne jedes Datum ist trotzdem angebrochen.
assert _zustand(None, heute, 7, angebrochen=True) == "opened"
# --------------------------------------------------------------------------
# FEFO
# --------------------------------------------------------------------------
def test_angebrochenes_wird_zuerst_geleert(db, milch):
spaet = _lot(db, milch, 1000, best_before=date.today() + timedelta(days=30))
offen = _lot(db, milch, 500, best_before=date.today() + timedelta(days=30),
opened_on=date.today(), consume_by=date.today() + timedelta(days=2))
reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)]
assert reihenfolge == [offen.id, spaet.id]
def test_angebrochenes_geht_auch_vor_knappem_mhd(db, milch):
"""Offen zuerst, selbst wenn eine geschlossene Packung früher datiert ist.
Das MHD einer ungeöffneten Packung ist konservativ und wird real meist
überlebt; eine offene verdirbt dagegen tatsächlich. Und wer die zweite
aufmacht, während die erste offen steht, hat am Ende zwei angebrochene.
"""
morgen = _lot(db, milch, 1000, best_before=date.today() + timedelta(days=1))
offen = _lot(db, milch, 500, opened_on=date.today(),
consume_by=date.today() + timedelta(days=5))
reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)]
assert reihenfolge == [offen.id, morgen.id]
def test_unter_den_angebrochenen_gilt_weiter_fefo(db, milch):
spaet = _lot(db, milch, 500, opened_on=date.today(),
consume_by=date.today() + timedelta(days=5))
frueh = _lot(db, milch, 500, opened_on=date.today(),
consume_by=date.today() + timedelta(days=1))
reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)]
assert reihenfolge == [frueh.id, spaet.id]
+299
View File
@@ -0,0 +1,299 @@
"""Angebrochene Packungen: Abspalten, Verbrauchsdatum, Teilverbrauch, Frist-Vererbung."""
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Category,
CategoryTracking,
Lot,
Movement,
MovementType,
Product,
Role,
Setting,
User,
)
from app.routers.stock import consume_lot, list_all_lots, open_lot
from app.schemas import LotConsume, LotOpen
from app.services.stock import check_in, current_stock
from app.services.vererbung import frist_herkunft, oeffnungsfrist
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def milch(db):
"""Milliliter-Artikel mit 1000 ml je Packung, in einer Lebensmittel-Kategorie."""
cat = Category(name="Molkereiprodukte", tracking=CategoryTracking.food.value)
db.add(cat)
db.flush()
product = Product(
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
category_id=cat.id, source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _charge(db, product, menge_base, best_before=None):
lot = Lot(product_id=product.id, quantity=menge_base, best_before=best_before)
db.add(lot)
db.commit()
db.refresh(lot)
return lot
# --------------------------------------------------------------------------
# Anbrechen spaltet eine Packung ab
# --------------------------------------------------------------------------
def test_anbrechen_spaltet_eine_packung_ab(db, user, milch):
lot = _charge(db, milch, 4000) # 4 x 1 l in einer Zeile
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.quantity == 1000
assert offen.opened_on == date.today()
assert offen.opened_quantity == 1000
# Der Rest bleibt zu – sonst trüge er das kurze Verbrauchsdatum mit.
rest = db.query(Lot).filter(Lot.id != offen.id).one()
assert rest.quantity == 3000
assert rest.opened_on is None
assert rest.consume_by is None
# Der Gesamtbestand ändert sich beim Anbrechen nicht.
assert current_stock(db, milch.id) == 4000
def test_anbrechen_einer_einzelnen_packung_spaltet_nichts(db, user, milch):
lot = _charge(db, milch, 1000)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.id == lot.id
assert db.query(Lot).count() == 1
assert offen.quantity == 1000
def test_anbrechen_nimmt_hoechstens_die_ganze_charge(db, user, milch):
lot = _charge(db, milch, 400) # weniger als eine Packung
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.quantity == 400
assert db.query(Lot).count() == 1
def test_anbrechen_mit_eigener_menge(db, user, milch):
lot = _charge(db, milch, 4000)
offen = open_lot(lot.id, LotOpen(quantity=500), db=db, _=user)
assert offen.quantity == 500
assert db.query(Lot).filter(Lot.id != offen.id).one().quantity == 3500
def test_zweimal_anbrechen_aendert_nichts(db, user, milch):
lot = _charge(db, milch, 4000)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
nochmal = open_lot(offen.id, LotOpen(), db=db, _=user)
assert nochmal.id == offen.id
assert nochmal.quantity == 1000
assert db.query(Lot).count() == 2
def test_verbrauchsdatum_kommt_aus_der_frist(db, user, milch):
db.add(Setting(key="open_days", value="5"))
db.commit()
lot = _charge(db, milch, 1000)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.consume_by == date.today() + timedelta(days=5)
def test_mhd_bleibt_neben_dem_verbrauchsdatum_stehen(db, user, milch):
mhd = date.today() + timedelta(days=20)
lot = _charge(db, milch, 1000, best_before=mhd)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
assert offen.best_before == mhd # unangetastet
assert offen.consume_by is not None
# Gewarnt wird nach dem früheren der beiden.
frisch = db.get(Lot, offen.id)
assert frisch.faellig_am == frisch.consume_by
def test_frueheres_mhd_schlaegt_das_verbrauchsdatum(db, user, milch):
mhd = date.today() + timedelta(days=1) # läuft vor der Öffnungsfrist ab
lot = _charge(db, milch, 1000, best_before=mhd)
open_lot(lot.id, LotOpen(), db=db, _=user)
frisch = db.query(Lot).one()
assert frisch.faellig_am == mhd
# --------------------------------------------------------------------------
# Verbrauchen
# --------------------------------------------------------------------------
def test_verbrauchen_oeffnet_in_einem_schritt(db, user, milch):
lot = _charge(db, milch, 4000)
ergebnis = consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user)
assert ergebnis.opened is True
assert ergebnis.lot is not None
assert ergebnis.lot.quantity == 900 # 1000 geöffnet, 100 verbraucht
assert ergebnis.lot.opened_quantity == 1000
assert current_stock(db, milch.id) == 3900
# Der Verbrauch ist eine Bewegung, das Anbrechen allein nicht.
bewegungen = db.query(Movement).all()
assert len(bewegungen) == 1
assert bewegungen[0].type == MovementType.out
assert bewegungen[0].quantity == 100
def test_verbrauchen_ohne_anbrechen_wenn_abgewaehlt(db, user, milch):
lot = _charge(db, milch, 4000)
ergebnis = consume_lot(
lot.id, LotConsume(quantity=100, unit="ml", auto_open=False), db=db, user=user
)
assert ergebnis.opened is False
assert db.query(Lot).count() == 1
assert db.query(Lot).one().opened_on is None
assert current_stock(db, milch.id) == 3900
def test_weiterer_verbrauch_nutzt_die_offene_packung(db, user, milch):
lot = _charge(db, milch, 4000)
consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user)
offen = db.query(Lot).filter(Lot.opened_on.isnot(None)).one()
consume_lot(offen.id, LotConsume(quantity=200, unit="ml"), db=db, user=user)
assert db.get(Lot, offen.id).quantity == 700
assert db.query(Lot).count() == 2
def test_leere_offene_packung_verschwindet(db, user, milch):
lot = _charge(db, milch, 1000)
open_lot(lot.id, LotOpen(), db=db, _=user)
ergebnis = consume_lot(lot.id, LotConsume(quantity=1000, unit="ml"), db=db, user=user)
assert ergebnis.lot is None
assert db.query(Lot).count() == 0
assert current_stock(db, milch.id) == 0
def test_mehr_verbrauchen_als_drin_ist_scheitert(db, user, milch):
lot = _charge(db, milch, 1000)
with pytest.raises(HTTPException) as fehler:
consume_lot(lot.id, LotConsume(quantity=2000, unit="ml"), db=db, user=user)
assert fehler.value.status_code == 409
def test_unbekannte_einheit_bricht_nichts_an(db, user, milch):
"""Erst rechnen, dann anbrechen – sonst stünde eine Packung grundlos offen."""
lot = _charge(db, milch, 4000)
with pytest.raises(HTTPException) as fehler:
consume_lot(lot.id, LotConsume(quantity=1, unit="furlong"), db=db, user=user)
assert fehler.value.status_code == 400
assert db.query(Lot).one().opened_on is None
def test_verbrauchen_in_packungen(db, user, rice, milch):
"""„1 Packung" muss weiter gehen – die Hausmaße dürfen das nicht verdrängen."""
lot = check_in(db, rice, 3, "package", None, None, None)
db.commit()
consume_lot(lot.id, LotConsume(quantity=1, unit="package"), db=db, user=user)
assert current_stock(db, rice.id) == 1000
# --------------------------------------------------------------------------
# Frist: Artikel > Kategorie > Oberkategorie > global
# --------------------------------------------------------------------------
def test_frist_vererbung_ueber_die_ebenen(db, milch):
# 1. Ohne alles gilt der Standard aus der Konfiguration.
assert oeffnungsfrist(db, milch) == 3
assert frist_herkunft(db, milch)[1] == "Einstellungen"
# 2. Globale Einstellung.
db.add(Setting(key="open_days", value="7"))
db.commit()
assert oeffnungsfrist(db, milch) == 7
# 3. Oberkategorie.
ober = db.get(Category, milch.category_id)
unter = Category(name="Frischmilch", parent_id=ober.id,
tracking=CategoryTracking.food.value)
db.add(unter)
db.flush()
milch.category_id = unter.id
ober.open_days = 4
db.commit()
assert oeffnungsfrist(db, milch) == 4
assert frist_herkunft(db, milch)[1] == "Kategorie: Molkereiprodukte"
# 4. Die nähere Kategorie gewinnt.
unter.open_days = 2
db.commit()
assert oeffnungsfrist(db, milch) == 2
assert frist_herkunft(db, milch)[1] == "Kategorie: Frischmilch"
# 5. Der Artikel schlägt alles.
milch.open_days = 1
db.commit()
assert oeffnungsfrist(db, milch) == 1
assert frist_herkunft(db, milch)[1] == "Artikel"
def test_kaputte_globale_einstellung_faellt_auf_den_standard(db, milch):
db.add(Setting(key="open_days", value="viele"))
db.commit()
assert oeffnungsfrist(db, milch) == 3
def test_die_chargenliste_zeigt_den_offenen_zustand(db, user, milch):
"""Ohne diese Felder saehe die Liste eine offene Packung wie jede andere.
Die Zeile traegt dann auch die falsche Ablaufwarnung, denn gewarnt wird nach
dem fruheren von MHD und Verbrauchsdatum.
"""
check_in(db, milch, 4000, "ml", date.today() + timedelta(days=30), None, None)
db.commit()
[charge] = db.query(Lot).all()
open_lot(charge.id, LotOpen(quantity=None), db=db, _=user)
zeilen = {z.quantity: z for z in list_all_lots(db=db, _=user)}
offen = zeilen[1000]
zu = zeilen[3000]
assert offen.opened_on == date.today()
assert offen.consume_by == date.today() + timedelta(days=3)
assert offen.opened_quantity == 1000
assert zu.opened_on is None
assert zu.consume_by is None
+95
View File
@@ -0,0 +1,95 @@
"""Artikel löschen – und wann der Server sich weigert.
Ohne Sperre reisst ein Klick Chargen, Einzelstücke, Barcodes, Bilder und den
gesamten Bewegungsverlauf per Kaskade mit. Geprüft wird deshalb der heutige
Bestand. Bewegungen allein halten bewusst NICHT auf, sonst waere jeder je
benutzte Artikel fuer immer unloeschbar.
Geprüft wird nur die Sperre selbst – die Kaskade nicht: Die Tests laufen auf
SQLite ohne ``PRAGMA foreign_keys=ON``, eine Kaskaden-Zusicherung waere dort
ohne Aussagekraft.
"""
import pytest
from fastapi import HTTPException
from app.models import BaseUnit, Item, Lot, Product, Role, User
from app.routers.products import delete_product
@pytest.fixture()
def admin(db):
person = User(username="chef", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _artikel(db, name="Hammer"):
product = Product(name=name, base_unit=BaseUnit.piece, package_size=1,
source="manual")
db.add(product)
db.commit()
db.refresh(product)
return product
def test_loeschen_ohne_bestand_geht_durch(db, admin):
product = _artikel(db)
delete_product(product_id=product.id, db=db, _=admin)
assert db.get(Product, product.id) is None
def test_loeschen_mit_einzelstuecken_wird_abgelehnt(db, admin):
product = _artikel(db)
db.add(Item(uid="ABC12345", product_id=product.id))
db.commit()
with pytest.raises(HTTPException) as fehler:
delete_product(product_id=product.id, db=db, _=admin)
assert fehler.value.status_code == 409
assert "Einzelstücke" in fehler.value.detail
assert db.get(Product, product.id) is not None
def test_loeschen_mit_chargen_wird_abgelehnt(db, admin):
product = _artikel(db)
db.add(Lot(product_id=product.id, quantity=5))
db.commit()
with pytest.raises(HTTPException) as fehler:
delete_product(product_id=product.id, db=db, _=admin)
assert fehler.value.status_code == 409
assert "Bestand" in fehler.value.detail
assert db.get(Product, product.id) is not None
def test_loeschen_nach_leergeraeumter_charge_geht_durch(db, admin):
"""Wer alles auslagert, muss den Artikel loswerden können.
Haelt fest, dass die Sperre am heutigen Bestand haengt und nicht zu einer
Verlaufspruefung ausgebaut wird – sonst waere ein einmal benutzter Artikel
nie wieder loeschbar.
"""
product = _artikel(db)
lot = Lot(product_id=product.id, quantity=5)
db.add(lot)
db.commit()
db.delete(lot) # wie beim vollstaendigen Auslagern
db.commit()
delete_product(product_id=product.id, db=db, _=admin)
assert db.get(Product, product.id) is None
def test_unbekannter_artikel_bleibt_ein_404(db, admin):
with pytest.raises(HTTPException) as fehler:
delete_product(product_id=9999, db=db, _=admin)
assert fehler.value.status_code == 404
+90
View File
@@ -0,0 +1,90 @@
"""Artikelsuche über Name UND Marke, wortweise.
Vorher verglich ein einzelnes ILIKE den ganzen Suchtext gegen den Namen. Wer
"Vogel Jack and Jones" eintippte, bekam nichts – dieser Ausdruck steht in
keinem Artikelnamen. Gesucht wird jetzt Wort für Wort über Name und Marke
zusammen, in beliebiger Reihenfolge.
"""
import pytest
from app.models import BaseUnit, Category, CategoryTracking, Product, Role, User
from app.routers.products import list_products
@pytest.fixture()
def nutzer(db):
person = User(username="chef", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _kategorie(db, name, tracking=CategoryTracking.object):
cat = Category(name=name, tracking=tracking.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat=None, **extra):
product = Product(name=name, base_unit=BaseUnit.piece, package_size=1,
category_id=(cat.id if cat else None), source="manual", **extra)
db.add(product)
db.commit()
db.refresh(product)
return product
def _namen(db, nutzer, **kwargs):
return [p.name for p in list_products(db=db, _=nutzer, **kwargs)]
def test_suche_findet_ueber_marke_und_name_in_beliebiger_reihenfolge(db, nutzer):
"""Der Fall, der vorher gar nichts lieferte."""
_artikel(db, "Vogel-Shirt", brand="Jack and Jones")
_artikel(db, "Hose", brand="Levis")
assert _namen(db, nutzer, q="vogel jack and jones") == ["Vogel-Shirt"]
# Dieselben Wörter andersherum – die Reihenfolge darf nichts ändern.
assert _namen(db, nutzer, q="jack and jones vogel") == ["Vogel-Shirt"]
# Nur die Marke, ohne ein Wort aus dem Namen.
assert _namen(db, nutzer, q="jones") == ["Vogel-Shirt"]
def test_suche_loest_umlaute_auf(db, nutzer):
"""Der Grund, warum gefiltert wird und nicht per ILIKE gesucht."""
_artikel(db, "Weissmehl Typ 550", brand="Mühle Nord")
_artikel(db, "Zucker")
assert _namen(db, nutzer, q="muehle") == ["Weissmehl Typ 550"]
assert _namen(db, nutzer, q="MÜHLE") == ["Weissmehl Typ 550"]
def test_suche_grenzt_weiterhin_auf_kategorie_ein(db, nutzer):
"""Der Kategoriefilter bleibt in SQL – der Umbau darf ihn nicht verlieren."""
kleidung = _kategorie(db, "Kleidung")
werkzeug = _kategorie(db, "Werkzeug")
_artikel(db, "Vogel-Shirt", kleidung, brand="Jack and Jones")
_artikel(db, "Vogel-Hammer", werkzeug)
assert _namen(db, nutzer, q="vogel") == ["Vogel-Hammer", "Vogel-Shirt"]
assert _namen(db, nutzer, q="vogel", category_id=kleidung.id) == ["Vogel-Shirt"]
def test_artikel_ohne_marke_bleibt_auffindbar(db, nutzer):
"""``brand`` ist nullbar – das darf den Vergleich nicht sprengen."""
_artikel(db, "Hammer", brand=None)
assert _namen(db, nutzer, q="hammer") == ["Hammer"]
def test_leere_suche_liefert_alles(db, nutzer):
"""Auch eine Suche aus lauter Leerzeichen: Ohne Wörter wird nicht gefiltert."""
_artikel(db, "Hammer")
_artikel(db, "Zange")
assert _namen(db, nutzer) == ["Hammer", "Zange"]
assert _namen(db, nutzer, q=" ") == ["Hammer", "Zange"]
+2 -1
View File
@@ -60,7 +60,8 @@ def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch):
db.commit()
daten = json.loads(_sicherung(db, user))
assert daten["version"] == 3
# v4 seit den Etiketten-Vorlagen; aeltere Sicherungen bleiben lesbar.
assert daten["version"] == 4
nach_name = {g["name"]: g for g in daten["groups"]}
assert sorted(nach_name["Grillwurst"]["parents"]) == ["Grillgut", "Wurst"]
assert {"location": None, "min_stock": 5000} in nach_name["Wurst"]["min_stocks"]
@@ -0,0 +1,303 @@
"""Die Einkaufsliste bekommt ein Gedächtnis: eigene Einträge, Haken, Rezepte.
Bisher war die Liste vollständig gerechnet. Was hier geprüft wird, ist das
Daneben: Dinge, die jemand selbst draufgesetzt hat, das dauerhafte Abhaken, und
die Übernahme der fehlenden Zutaten eines Rezepts — ohne Mealie, die Zutaten
stehen direkt im Test.
"""
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Category,
CategoryTracking,
Product,
ProductLocationMinStock,
Role,
ShoppingCheck,
ShoppingEntry,
User,
)
from app.routers.shopping import (
clear_check,
clear_done,
create_entry,
delete_entry,
list_entries,
set_check,
update_entry,
)
from app.routers.dashboard import stats as dashboard_stats
from app.routers.views import shopping_list_all
from app.schemas import ShoppingEntryIn, ShoppingEntryPatch
from app.services import einkaufsliste
from app.services.rezepte import pruefe_zutaten
from app.services.stock import check_in
@pytest.fixture()
def nutzer(db):
person = User(username="einkaeufer", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _zutat(name, menge=None, einheit=None):
return {
"quantity": menge,
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
"food": {"name": name},
"display": f"{menge or ''} {einheit or ''} {name}".strip(),
}
def _kategorie(db, name="Vorrat"):
cat = Category(name=name, tracking=CategoryTracking.food.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0):
produkt = Product(name=name, base_unit=base, package_size=package_size,
category_id=cat.id, source="manual")
db.add(produkt)
db.commit()
db.refresh(produkt)
if bestand:
check_in(db, produkt, bestand, base.value, None, None, None)
db.commit()
return produkt
def _uebernehmen(db, zutaten, nutzer, *, slug="pfannkuchen", name="Pfannkuchen"):
"""Wie der Endpunkt, nur ohne Mealie davor."""
pruefungen = pruefe_zutaten(db, zutaten)
return einkaufsliste.aus_rezept(db, pruefungen, slug=slug, name=name, user=nutzer)
# --------------------------------------------------------------------------
# Eigene Einträge
# --------------------------------------------------------------------------
def test_freitext_landet_auf_der_liste(db, nutzer):
"""Der Fall, für den es bisher keinen Ort gab: 'Milch, weil ich das sage'."""
create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
[eintrag] = list_entries(db=db, _=nutzer)
assert eintrag.art == "text"
assert eintrag.name == "Backpapier"
assert eintrag.done is False
def test_artikel_eintrag_nennt_die_menge_lesbar(db, nutzer):
cat = _kategorie(db)
mehl = _artikel(db, "Weissmehl", cat)
eintrag = create_entry(
ShoppingEntryIn(product_id=mehl.id, quantity_base=1500, base=BaseUnit.gram),
db=db, user=nutzer,
)
assert eintrag.name == "Weissmehl"
# Man kauft keine halbe Packung - dieselbe Sprache wie die gerechnete Liste.
assert eintrag.menge_text == "2 Packungen"
def test_genau_ein_ziel_ist_pflicht(db, nutzer):
cat = _kategorie(db)
mehl = _artikel(db, "Weissmehl", cat)
with pytest.raises(HTTPException) as leer:
create_entry(ShoppingEntryIn(), db=db, user=nutzer)
assert leer.value.status_code == 400
with pytest.raises(HTTPException) as doppelt:
create_entry(
ShoppingEntryIn(product_id=mehl.id, free_text="Mehl"), db=db, user=nutzer,
)
assert doppelt.value.status_code == 400
assert db.query(ShoppingEntry).count() == 0
def test_abhaken_haelt_das_neuladen_aus(db, nutzer):
"""Der eigentliche Punkt: Im Supermarkt wird die Seite neu geladen."""
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
[frisch] = list_entries(db=db, _=nutzer)
assert frisch.done is True
assert db.get(ShoppingEntry, eintrag.id).done_at is not None
def test_text_eines_artikeleintrags_laesst_sich_nicht_umschreiben(db, nutzer):
"""Sonst stünde im Eintrag ein Name, der nichts mit dem Artikel zu tun hat."""
cat = _kategorie(db)
mehl = _artikel(db, "Weissmehl", cat)
eintrag = create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer)
with pytest.raises(HTTPException) as fehler:
update_entry(eintrag.id, ShoppingEntryPatch(free_text="Zucker"), db=db, _=nutzer)
assert fehler.value.status_code == 400
def test_eintrag_auf_einen_geloeschten_artikel_faellt_heraus(db, nutzer):
cat = _kategorie(db)
mehl = _artikel(db, "Weissmehl", cat)
create_entry(ShoppingEntryIn(product_id=mehl.id), db=db, user=nutzer)
db.delete(mehl)
db.commit()
assert list_entries(db=db, _=nutzer) == []
def test_loeschen_entfernt_den_eintrag(db, nutzer):
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
delete_entry(eintrag.id, db=db, _=nutzer)
assert db.query(ShoppingEntry).count() == 0
# --------------------------------------------------------------------------
# Haken an den gerechneten Posten
# --------------------------------------------------------------------------
def test_haken_am_gerechneten_posten_bleibt(db, nutzer):
"""Gerechnete Posten haben keine Zeile - gemerkt wird nur der Haken."""
set_check("p12", db=db, user=nutzer)
assert einkaufsliste.abgehakte(db) == ["p12"]
clear_check("p12", db=db, _=nutzer)
assert einkaufsliste.abgehakte(db) == []
def test_derselbe_haken_zweimal_ist_kein_fehler(db, nutzer):
set_check("g3", db=db, user=nutzer)
set_check("g3", db=db, user=nutzer)
assert db.query(ShoppingCheck).count() == 1
def test_erledigte_entfernen_raeumt_beide_quellen(db, nutzer):
eintrag = create_entry(ShoppingEntryIn(free_text="Hefe"), db=db, user=nutzer)
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
offen = create_entry(ShoppingEntryIn(free_text="Salz"), db=db, user=nutzer)
set_check("p12", db=db, user=nutzer)
anzahl = clear_done(db=db, _=nutzer)
assert anzahl == 2
assert [e.id for e in list_entries(db=db, _=nutzer)] == [offen.id]
assert einkaufsliste.abgehakte(db) == []
def test_die_gebuendelte_liste_fuehrt_beides(db, nutzer):
cat = _kategorie(db)
mehl = _artikel(db, "Weissmehl", cat, bestand=200)
db.add(ProductLocationMinStock(product_id=mehl.id, location_id=None, min_stock=1000))
db.commit()
create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
set_check(f"p{mehl.id}", db=db, user=nutzer)
alles = shopping_list_all(db=db, user=nutzer)
assert [i.name for i in alles.products] == ["Weissmehl"]
assert [e.name for e in alles.entries] == ["Backpapier"]
assert alles.checked == [f"p{mehl.id}"]
# --------------------------------------------------------------------------
# Aus einem Rezept übernehmen
# --------------------------------------------------------------------------
def test_uebernommen_wird_die_fehlmenge_nicht_die_rezeptmenge(db, nutzer):
"""Wer 250 g braucht und 100 g hat, kauft 150 g."""
cat = _kategorie(db)
_artikel(db, "Weissmehl", cat, bestand=100)
eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
assert aktualisiert == 0
[eintrag] = eintraege
assert eintrag.quantity_base == 150
assert eintrag.source == "recipe"
assert eintrag.recipe_name == "Pfannkuchen"
def test_vorhandene_zutaten_kommen_nicht_drauf(db, nutzer):
cat = _kategorie(db)
_artikel(db, "Weissmehl", cat, bestand=5000)
eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
assert eintraege == []
assert db.query(ShoppingEntry).count() == 0
def test_zutat_ohne_zuordnung_kommt_als_freitext_drauf(db, nutzer):
"""Gerade die unbekannte Zutat ist die, die man vergisst."""
_kategorie(db)
eintraege, _ = _uebernehmen(db, [_zutat("Ras el Hanout", 1, "Prise")], nutzer)
[eintrag] = eintraege
assert eintrag.free_text == "Ras el Hanout"
assert eintrag.product_id is None
def test_zweimal_uebernehmen_verdoppelt_die_liste_nicht(db, nutzer):
"""Nach dem Hochdrehen der Portionen drückt man den Knopf noch einmal."""
cat = _kategorie(db)
_artikel(db, "Weissmehl", cat, bestand=100)
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
eintraege, aktualisiert = _uebernehmen(db, [_zutat("Weissmehl", 500, "g")], nutzer)
assert aktualisiert == 1
assert db.query(ShoppingEntry).count() == 1
assert eintraege[0].quantity_base == 400 # 500 gebraucht, 100 da
def test_zwei_rezepte_brauchen_denselben_artikel_zweimal(db, nutzer):
cat = _kategorie(db)
_artikel(db, "Weissmehl", cat, bestand=0)
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
_uebernehmen(db, [_zutat("Weissmehl", 300, "g")], nutzer,
slug="brot", name="Brot")
zeilen = db.query(ShoppingEntry).all()
assert len(zeilen) == 2
assert {z.recipe_name for z in zeilen} == {"Pfannkuchen", "Brot"}
def test_ein_abgehakter_eintrag_blockiert_die_neue_uebernahme_nicht(db, nutzer):
"""Abgehakt heisst gekauft – braucht man es wieder, ist es eine neue Zeile."""
cat = _kategorie(db)
_artikel(db, "Weissmehl", cat, bestand=0)
eintraege, _ = _uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
einkaufsliste.abhaken(db, db.get(ShoppingEntry, eintraege[0].id), True)
_uebernehmen(db, [_zutat("Weissmehl", 250, "g")], nutzer)
assert db.query(ShoppingEntry).count() == 2
def test_die_kennzahl_zaehlt_die_eigenen_eintraege_mit(db, nutzer):
"""Sonst meldet das Dashboard „alles erreicht", waehrend die Liste voll ist."""
vorher = dashboard_stats(db=db, _=nutzer).shopping_items
eintrag = create_entry(ShoppingEntryIn(free_text="Backpapier"), db=db, user=nutzer)
assert dashboard_stats(db=db, _=nutzer).shopping_items == vorher + 1
# Abgehakt heisst gekauft - und zaehlt nicht mehr als Bedarf.
update_entry(eintrag.id, ShoppingEntryPatch(done=True), db=db, _=nutzer)
assert dashboard_stats(db=db, _=nutzer).shopping_items == vorher
+735
View File
@@ -0,0 +1,735 @@
"""Etiketten-Vorlagen: Spaltenwahl, eigene Felder, CSV-Form und .lbx-Prüfung.
Der Export ist eine reine Funktion über einer Datenbank-Sitzung – geprüft wird
deshalb ohne HTTP, direkt am Ergebnis.
"""
import io
import json
import unicodedata
import zipfile
from urllib.parse import unquote
import pytest
from fastapi import HTTPException
from app.models import (
Category,
CategoryTracking,
EtikettenVorlage,
FieldDefinition,
Item,
Location,
Product,
ProductFieldValue,
Role,
User,
)
from app.routers.etiketten import _pruefe_lbx, datei_herunterladen, spalten
from app.services.etiketten import csv_erzeugen, konfiguration_lesen, verfuegbare_spalten
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def kabel(db):
"""Kategorie „Kabel" mit eigenem Feld „Länge" in Metern."""
cat = Category(name="Kabel", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
fd = FieldDefinition(
category_id=cat.id, label="Länge", key="laenge", field_type="number", unit="m"
)
db.add(fd)
db.commit()
db.refresh(cat)
db.refresh(fd)
return cat, fd
def _vorlage(db, spalten, art="items", kategorie_ids=None, trenn=",", feldfilter=None):
v = EtikettenVorlage(
name=f"Test {len(db.query(EtikettenVorlage).all())}",
art=art,
konfiguration=json.dumps({
"spalten": spalten,
"kategorie_ids": kategorie_ids or [],
"trennzeichen": trenn,
"feldfilter": feldfilter or [],
}),
)
db.add(v)
db.commit()
db.refresh(v)
return v
def _stueck(db, cat, name="HDMI-Kabel", laenge=None, fd=None, ort=None):
product = Product(name=name, category_id=cat.id, individual=True, package_size=1)
db.add(product)
db.flush()
if laenge is not None and fd is not None:
db.add(ProductFieldValue(
product_id=product.id, field_definition_id=fd.id, value=laenge
))
item = Item(uid=f"UID{product.id:05d}", product_id=product.id,
location_id=(ort.id if ort else None))
db.add(item)
db.commit()
db.refresh(item)
return item
def _zeilen(csv_text):
"""Ohne BOM, als Liste von Zeilen."""
assert csv_text.startswith(""), "BOM fehlt – Excel zeigt sonst kaputte Umlaute"
return csv_text[1:].rstrip("\r\n").split("\r\n")
# --------------------------------------------------------------------------
# Spalten und Reihenfolge
# --------------------------------------------------------------------------
def test_spalten_kommen_in_gespeicherter_reihenfolge(db, kabel):
cat, _ = kabel
_stueck(db, cat)
v = _vorlage(db, ["produkt", "uid", "qr"])
kopf = _zeilen(csv_erzeugen(db, v))[0]
assert kopf == "Produkt,UID,QR-Inhalt"
def test_eigenes_feld_landet_in_der_csv(db, kabel):
cat, fd = kabel
_stueck(db, cat, laenge="3", fd=fd)
v = _vorlage(db, ["uid", f"feld:{fd.id}"])
kopf, zeile = _zeilen(csv_erzeugen(db, v))
assert kopf == "UID,Länge (m)"
assert zeile.endswith(",3")
def test_fehlender_feldwert_gibt_eine_leere_zelle(db, kabel):
"""Nicht etwa eine Luecke – sonst verrutschen alle folgenden Spalten."""
cat, fd = kabel
_stueck(db, cat) # ohne Wert
v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"])
zeile = _zeilen(csv_erzeugen(db, v))[1]
assert zeile.count(",") == 2
assert ",," in zeile
def test_geloeschtes_feld_zerreisst_den_export_nicht(db, kabel):
cat, fd = kabel
_stueck(db, cat)
v = _vorlage(db, ["uid", f"feld:{fd.id}", "produkt"])
db.delete(fd)
db.commit()
kopf = _zeilen(csv_erzeugen(db, v))[0]
assert kopf == "UID,Produkt" # die tote Spalte faellt still weg
def test_ohne_gueltige_spalte_greifen_die_festen(db, kabel):
cat, _ = kabel
_stueck(db, cat)
v = _vorlage(db, ["gibtsnicht"])
kopf = _zeilen(csv_erzeugen(db, v))[0]
assert kopf.startswith("QR-Inhalt,UID,Produkt")
# --------------------------------------------------------------------------
# CSV-Form
# --------------------------------------------------------------------------
def test_trennzeichen_semikolon_und_tab(db, kabel):
cat, _ = kabel
_stueck(db, cat)
assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn=";")))[0] \
== "UID;Produkt"
assert _zeilen(csv_erzeugen(db, _vorlage(db, ["uid", "produkt"], trenn="\t")))[0] \
== "UID\tProdukt"
def test_zelle_mit_trennzeichen_wird_eingefasst(db, kabel):
cat, _ = kabel
_stueck(db, cat, name="Kabel, lang")
v = _vorlage(db, ["produkt"])
assert _zeilen(csv_erzeugen(db, v))[1] == '"Kabel, lang"'
def test_formel_wird_entschaerft(db, kabel):
"""Ohne das fuehrt Excel den Zellinhalt als Formel aus."""
cat, _ = kabel
_stueck(db, cat, name="=1+1")
v = _vorlage(db, ["produkt"])
assert _zeilen(csv_erzeugen(db, v))[1] == "'=1+1"
def test_zeilenende_ist_crlf(db, kabel):
cat, _ = kabel
_stueck(db, cat)
assert "\r\n" in csv_erzeugen(db, _vorlage(db, ["uid"]))
# --------------------------------------------------------------------------
# Bezugsarten und Filter
# --------------------------------------------------------------------------
def test_kategoriefilter_greift_samt_unterkategorien(db, kabel):
cat, _ = kabel
unter = Category(name="HDMI", tracking=CategoryTracking.object.value, parent_id=cat.id)
andere = Category(name="Werkzeug", tracking=CategoryTracking.object.value)
db.add_all([unter, andere])
db.commit()
_stueck(db, unter, name="HDMI 2.1")
_stueck(db, andere, name="Hammer")
v = _vorlage(db, ["produkt"], kategorie_ids=[cat.id])
zeilen = _zeilen(csv_erzeugen(db, v))
assert zeilen[1:] == ["HDMI 2.1"]
def test_lagerorte_brauchen_die_basis_adresse_fuer_den_qr(db):
ort = Location(name="Keller")
db.add(ort)
db.commit()
v = _vorlage(db, ["qr", "name"], art="locations")
zeile = _zeilen(csv_erzeugen(db, v, basis_url="https://haus.example/"))[1]
assert zeile.startswith(f"https://haus.example/l/{ort.id},")
def test_artikel_als_bezugsart(db, kabel):
cat, fd = kabel
_stueck(db, cat, name="USB-C", laenge="1", fd=fd)
v = _vorlage(db, ["name", f"feld:{fd.id}"], art="products")
kopf, zeile = _zeilen(csv_erzeugen(db, v))
assert kopf == "Name,Länge (m)"
assert zeile == "USB-C,1"
# --------------------------------------------------------------------------
# Konfiguration lesen
# --------------------------------------------------------------------------
def test_kaputte_konfiguration_ergibt_vorgaben(db):
v = EtikettenVorlage(name="Kaputt", art="items", konfiguration="{nicht json")
db.add(v)
db.commit()
konf = konfiguration_lesen(v)
assert konf == {
"spalten": [],
"kategorie_ids": [],
"trennzeichen": ",",
"feldfilter": [],
}
def test_unerlaubtes_trennzeichen_faellt_auf_komma_zurueck(db):
v = _vorlage(db, ["uid"], trenn="|")
assert konfiguration_lesen(v)["trennzeichen"] == ","
# --------------------------------------------------------------------------
# Spaltenliste für die Oberfläche
# --------------------------------------------------------------------------
def test_spaltenliste_nennt_eigene_felder_der_kategorie(db, admin, kabel):
cat, fd = kabel
liste = spalten(art="items", kategorie_ids=str(cat.id), db=db, _=admin)
schluessel = {s.schluessel for s in liste}
assert "qr" in schluessel and "uid" in schluessel
assert f"feld:{fd.id}" in schluessel
assert next(s.titel for s in liste if s.schluessel == f"feld:{fd.id}") == "Länge (m)"
def test_lagerorte_haben_keine_eigenen_felder(db, admin, kabel):
liste = spalten(art="locations", kategorie_ids=None, db=db, _=admin)
assert not any(s.schluessel.startswith("feld:") for s in liste)
def test_unbekannte_bezugsart_wird_abgelehnt(db, admin):
with pytest.raises(HTTPException) as fehler:
spalten(art="quatsch", kategorie_ids=None, db=db, _=admin)
assert fehler.value.status_code == 400
# --------------------------------------------------------------------------
# .lbx-Prüfung
# --------------------------------------------------------------------------
def _zip(namen):
puffer = io.BytesIO()
with zipfile.ZipFile(puffer, "w") as z:
for n in namen:
z.writestr(n, "<xml/>")
return puffer.getvalue()
def test_echte_lbx_wird_angenommen(db):
_pruefe_lbx("etikett.lbx", _zip(["label.xml", "prop.xml"])) # wirft nicht
def test_zip_ohne_label_xml_wird_abgelehnt(db):
with pytest.raises(HTTPException) as fehler:
_pruefe_lbx("etikett.lbx", _zip(["irgendwas.txt"]))
assert fehler.value.status_code == 415
def test_nicht_zip_wird_abgelehnt(db):
with pytest.raises(HTTPException) as fehler:
_pruefe_lbx("etikett.lbx", b"\x89PNG\r\n\x1a\n und noch was")
assert fehler.value.status_code == 415
def test_falsche_endung_wird_abgelehnt(db):
with pytest.raises(HTTPException) as fehler:
_pruefe_lbx("bild.png", _zip(["label.xml"]))
assert fehler.value.status_code == 415
def test_leere_datei_wird_abgelehnt(db):
with pytest.raises(HTTPException) as fehler:
_pruefe_lbx("etikett.lbx", b"")
assert fehler.value.status_code == 400
# --------------------------------------------------------------------------
# Backup-Umlauf
# --------------------------------------------------------------------------
def test_vorlage_uebersteht_die_sicherung(db, admin, kabel):
"""Kategorien als Pfad, eigene Felder als Label – IDs sind zwischen zwei
Instanzen nicht dieselben."""
import json as _json
from app.routers.transfer import _import_json, export_backup_json
from app.seed import ensure_builtin_units
cat, fd = kabel
_stueck(db, cat, laenge="5", fd=fd)
v = _vorlage(db, ["uid", f"feld:{fd.id}"], kategorie_ids=[cat.id], trenn=";")
v.name = "Kabelexport"
v.datei_name = "kabel.lbx"
v.datei = _zip(["label.xml"])
db.commit()
inhalt = export_backup_json(db=db, _=admin).body
daten = _json.loads(inhalt)
gesichert = daten["etiketten_vorlagen"]
assert [e["name"] for e in gesichert] == ["Kabelexport"]
# Das Feld steht als Pfad::Label drin, nicht als ID.
assert gesichert[0]["spalten"][1] == "feld:Kabel::Länge"
# In eine zweite, leere Instanz einspielen.
from app.database import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
zweiter_motor = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
Base.metadata.create_all(bind=zweiter_motor)
zweite = sessionmaker(bind=zweiter_motor)()
ensure_builtin_units(zweite)
person = User(username="admin", password_hash="x", role=Role.admin)
zweite.add(person)
zweite.commit()
_import_json(zweite, inhalt, person, "add")
neu = zweite.query(EtikettenVorlage).filter(
EtikettenVorlage.name == "Kabelexport"
).one()
konf = konfiguration_lesen(neu)
assert konf["trennzeichen"] == ";"
assert neu.datei_name == "kabel.lbx"
assert neu.datei == _zip(["label.xml"])
# Das Feld wurde ueber Pfad und Label wiedergefunden – mit NEUER ID.
feld_spalte = [s for s in konf["spalten"] if s.startswith("feld:")][0]
neue_fd = zweite.query(FieldDefinition).filter(FieldDefinition.label == "Länge").one()
assert feld_spalte == f"feld:{neue_fd.id}"
zweite.close()
# --------------------------------------------------------------------------
# Download der hinterlegten .lbx
# --------------------------------------------------------------------------
def _disposition_pruefen(antwort, erwarteter_name):
"""Header muss sich senden lassen UND den echten Namen tragen."""
roh = antwort.headers["content-disposition"]
# Starlette kodiert Header als latin-1. Wirft das hier, antwortet die
# Route im Betrieb mit 500 statt mit der Datei.
roh.encode("latin-1")
stern = roh.split("filename*=UTF-8''", 1)[1]
assert unquote(stern) == erwarteter_name
def test_lbx_mit_zerlegtem_umlaut_laesst_sich_herunterladen(db, admin):
"""macOS legt Dateinamen ZERLEGT ab: das "ä" ist dort "a" + U+0308.
Am Bildschirm sieht das wie die zusammengesetzte Form aus, aber nur die
passt in latin-1 - die Kodierung, mit der HTTP-Header geschrieben werden.
Ein Name, den der Dateiwaehler eines Macs liefert, darf den Download
nicht sprengen.
"""
name = unicodedata.normalize("NFD", "Lagerplätze.lbx")
v = _vorlage(db, ["uid"])
v.datei_name = name
v.datei = _zip(["label.xml"])
db.commit()
antwort = datei_herunterladen(vorlage_id=v.id, db=db, _=admin)
assert antwort.body == _zip(["label.xml"])
_disposition_pruefen(antwort, name)
def test_lbx_mit_schlichtem_namen_bleibt_unveraendert(db, admin):
"""Der haeufige Fall darf von der Umstellung nicht beruehrt werden."""
v = _vorlage(db, ["uid"])
v.datei_name = "kabel.lbx"
v.datei = _zip(["label.xml"])
db.commit()
antwort = datei_herunterladen(vorlage_id=v.id, db=db, _=admin)
assert 'filename="kabel.lbx"' in antwort.headers["content-disposition"]
_disposition_pruefen(antwort, "kabel.lbx")
# --------------------------------------------------------------------------
# Wertefilter: nur Zeilen mit bestimmten Feldwerten
# --------------------------------------------------------------------------
@pytest.fixture()
def haken(db):
"""Kategorie „Kleidung" mit einem Ja/Nein-Feld „Verliehen"."""
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
fd = FieldDefinition(
category_id=cat.id, label="Verliehen", key="verliehen", field_type="boolean"
)
db.add(fd)
db.commit()
db.refresh(cat)
db.refresh(fd)
return cat, fd
def _artikel(db, cat, name, fd=None, wert=None):
"""Artikel, optional mit einem Feldwert. ``wert=None`` legt KEINE Zeile an."""
product = Product(name=name, category_id=cat.id, package_size=1)
db.add(product)
db.flush()
if fd is not None and wert is not None:
db.add(ProductFieldValue(
product_id=product.id, field_definition_id=fd.id, value=wert
))
db.commit()
db.refresh(product)
return product
def _namen(db, vorlage):
"""Die Namensspalte des erzeugten CSV, ohne Kopfzeile."""
return _zeilen(csv_erzeugen(db, vorlage))[1:]
def test_feldfilter_waehlt_nur_passende_artikel(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, "wert": "ja"}])
assert _namen(db, v) == ["Jacke"]
def test_feldfilter_nein_trifft_auch_artikel_ohne_zeile(db, haken):
"""Der Kern: Haken wegnehmen loescht die Zeile.
„nein" und „nie gesetzt" sind in den Daten dasselbe. Ohne diese Gleichung
faende „alle mit Nein" nur die, die einmal angehakt und wieder abgehakt
wurden – und das ist fast niemand.
"""
cat, fd = haken
_artikel(db, cat, "Jacke", fd, "true")
_artikel(db, cat, "Hose", fd, "false")
_artikel(db, cat, "Schal") # nie angefasst, gar keine Zeile
v = _vorlage(db, ["name"], art="products",
feldfilter=[{"feld_id": fd.id, "wert": "nein"}])
assert sorted(_namen(db, v)) == ["Hose", "Schal"]
def test_leerer_wert_trifft_artikel_ohne_wert(db, kabel):
cat, fd = kabel
_artikel(db, cat, "Mit Laenge", fd, "5")
_artikel(db, cat, "Ohne Laenge")
v = _vorlage(db, ["name"], art="products",
feldfilter=[{"feld_id": fd.id, "wert": ""}])
assert _namen(db, v) == ["Ohne Laenge"]
def test_feldfilter_vergleicht_genau_nicht_teilweise(db, kabel):
cat, fd = kabel
_artikel(db, cat, "Kurz", fd, "3")
_artikel(db, cat, "Lang", fd, "30")
v = _vorlage(db, ["name"], art="products",
feldfilter=[{"feld_id": fd.id, "wert": "3"}])
assert _namen(db, v) == ["Kurz"]
def test_mehrere_bedingungen_muessen_alle_zutreffen(db, haken):
cat, fd_bool = haken
fd_text = FieldDefinition(
category_id=cat.id, label="Farbe", key="farbe", field_type="text"
)
db.add(fd_text)
db.commit()
db.refresh(fd_text)
blau = _artikel(db, cat, "Blaue Jacke", fd_bool, "true")
db.add(ProductFieldValue(
product_id=blau.id, field_definition_id=fd_text.id, value="blau"
))
rot = _artikel(db, cat, "Rote Jacke", fd_bool, "true")
db.add(ProductFieldValue(
product_id=rot.id, field_definition_id=fd_text.id, value="rot"
))
db.commit()
v = _vorlage(db, ["name"], art="products", feldfilter=[
{"feld_id": fd_bool.id, "wert": "ja"},
{"feld_id": fd_text.id, "wert": "blau"},
])
assert _namen(db, v) == ["Blaue Jacke"]
def test_feldfilter_und_kategorie_greifen_zusammen(db, haken):
cat, fd = haken
zweite = Category(name="Werkzeug", tracking=CategoryTracking.object.value)
db.add(zweite)
db.commit()
db.refresh(zweite)
_artikel(db, cat, "Jacke", fd, "true")
# Gleicher Feldwert, andere Kategorie – die Kategorie muss ihn aussperren.
fremder = _artikel(db, zweite, "Hammer")
db.add(ProductFieldValue(
product_id=fremder.id, field_definition_id=fd.id, value="true"
))
db.commit()
v = _vorlage(db, ["name"], art="products", kategorie_ids=[cat.id],
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
assert _namen(db, v) == ["Jacke"]
def test_feldfilter_wirkt_auch_bei_einzelstuecken(db, haken):
"""Der Wert haengt am Artikel – das Stueck erbt ihn."""
cat, fd = haken
verliehen = _artikel(db, cat, "Jacke", fd, "true")
daheim = _artikel(db, cat, "Hose", fd, "false")
db.add(Item(uid="UIDAAAAA", product_id=verliehen.id))
db.add(Item(uid="UIDBBBBB", product_id=daheim.id))
db.commit()
v = _vorlage(db, ["produkt"], art="items",
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
assert _namen(db, v) == ["Jacke"]
def test_geloeschtes_feld_im_filter_zerreisst_den_export_nicht(db, haken):
"""Wie bei einer Spalte zu einem geloeschten Feld: die Bedingung faellt weg.
Der Export wird dadurch WEITER, nicht leer – die harmlosere der beiden
Fehlrichtungen, und hier festgehalten, damit sie nicht versehentlich kippt.
"""
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, "wert": "ja"}])
db.delete(fd)
db.commit()
assert sorted(_namen(db, v)) == ["Hose", "Jacke"]
def test_kaputter_feldfilter_ergibt_keinen_filter(db):
for nr, roh in enumerate(('{"feldfilter": "quatsch"}',
'{"feldfilter": [{"feld_id": "abc"}]}',
'{"feldfilter": [42]}')):
v = EtikettenVorlage(name=f"Kaputt {nr}", art="items", konfiguration=roh)
db.add(v)
db.commit()
assert konfiguration_lesen(v)["feldfilter"] == []
def test_feldfilter_uebersteht_die_sicherung(db, haken):
"""Feld-IDs sind zwischen zwei Instanzen nicht dieselben.
Deshalb steht in der Sicherung „Kategorie::Label" statt einer ID – und
zwar fuer Spalten UND Filter. Fehlte die Umschreibung beim Filter, zeigte
er nach dem Zurueckspielen auf ein fremdes Feld oder waere still weg, und
der Export gaebe mehr Zeilen aus als das Original.
"""
import json as _json
from app.routers.transfer import _import_json, export_backup_json
from app.seed import ensure_builtin_units
cat, fd = haken
person = User(username="chef", password_hash="x", role=Role.admin)
db.add(person)
_artikel(db, cat, "Jacke", fd, "true")
_artikel(db, cat, "Hose", fd, "false")
v = _vorlage(db, ["name"], art="products",
feldfilter=[{"feld_id": fd.id, "wert": "ja"}])
v.name = "Verliehenes"
db.commit()
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", "op": "=", "wert": "ja"}
]
from app.database import Base
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
motor = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
Base.metadata.create_all(bind=motor)
zweite = sessionmaker(bind=motor)()
ensure_builtin_units(zweite)
zweiter_nutzer = User(username="chef", password_hash="x", role=Role.admin)
zweite.add(zweiter_nutzer)
zweite.commit()
_import_json(zweite, inhalt, zweiter_nutzer, "add")
neu = zweite.query(EtikettenVorlage).filter(
EtikettenVorlage.name == "Verliehenes"
).one()
neues_feld = zweite.query(FieldDefinition).filter(
FieldDefinition.label == "Verliehen"
).one()
konf = konfiguration_lesen(neu)
# Auf die ID der ZWEITEN Instanz umgeschrieben, Wert unveraendert.
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"]
+311
View File
@@ -0,0 +1,311 @@
"""Hausmaße: „2 EL" wiegt bei Mehl anders als bei Zucker.
Die Menge gehört an die Kategorie bzw. den Artikel, nicht an den Namen – ein
globaler Faktor wie bei den verwalteten Einheiten wäre hier zwangsläufig falsch.
"""
import pytest
from app.models import (
BaseUnit,
Category,
CategoryMeasure,
CategoryTracking,
Measure,
Product,
ProductMeasure,
)
from app.routers.measures import category_measures
from app.seed import ensure_builtin_measures
from app.services.conversion import ConversionError, to_base
from app.services.stock import check_in, check_out, current_stock
from app.services.vererbung import (
geerbte_kategorie_frist,
mass_aufloesen,
verfuegbare_masse,
)
@pytest.fixture()
def masse(db):
ensure_builtin_measures(db)
return {m.name: m for m in db.query(Measure).all()}
def _kategorie(db, name, parent=None):
cat = Category(
name=name,
parent_id=parent.id if parent else None,
tracking=CategoryTracking.food.value,
)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000):
product = Product(
name=name, base_unit=base, package_size=package_size,
category_id=cat.id, source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _mass_an_kategorie(db, measure, cat, amount, base=BaseUnit.gram):
db.add(CategoryMeasure(
measure_id=measure.id, category_id=cat.id, amount=amount, base=base.value
))
db.commit()
# --------------------------------------------------------------------------
# Derselbe Löffel, zwei Gewichte
# --------------------------------------------------------------------------
def test_esslöffel_wiegt_je_kategorie_anders(db, masse):
el = masse["Esslöffel"]
mehl_kat = _kategorie(db, "Mehl")
zucker_kat = _kategorie(db, "Zucker")
mehl = _artikel(db, "Weissmehl", mehl_kat)
zucker = _artikel(db, "Kristallzucker", zucker_kat)
_mass_an_kategorie(db, el, mehl_kat, 10)
_mass_an_kategorie(db, el, zucker_kat, 12)
assert to_base(db, mehl, 2, "EL", hausmasse=True) == 20
assert to_base(db, zucker, 2, "EL", hausmasse=True) == 24
def test_kurzform_und_grossschreibung(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
for token in ("EL", "el", "Esslöffel", "esslöffel", " EL "):
assert to_base(db, mehl, 1, token, hausmasse=True) == 10
def test_tasse_in_millilitern(db, masse):
tasse = masse["Tasse"]
kat = _kategorie(db, "Molkerei")
milch = _artikel(db, "Vollmilch", kat, base=BaseUnit.milliliter)
_mass_an_kategorie(db, tasse, kat, 200, base=BaseUnit.milliliter)
assert to_base(db, milch, 1.5, "Tasse", hausmasse=True) == 300
# --------------------------------------------------------------------------
# Vererbung: Artikel > nächste Kategorie > Oberkategorie
# --------------------------------------------------------------------------
def test_unterkategorie_erbt_von_oben(db, masse):
el = masse["Esslöffel"]
trocken = _kategorie(db, "Trockenwaren")
mehl_kat = _kategorie(db, "Mehl", parent=trocken)
mehl = _artikel(db, "Weissmehl", mehl_kat)
_mass_an_kategorie(db, el, trocken, 11)
assert to_base(db, mehl, 1, "EL", hausmasse=True) == 11
angabe = mass_aufloesen(db, mehl, "EL")
assert angabe.herkunft == "Kategorie: Trockenwaren"
def test_naehere_kategorie_schlaegt_die_obere(db, masse):
el = masse["Esslöffel"]
trocken = _kategorie(db, "Trockenwaren")
mehl_kat = _kategorie(db, "Mehl", parent=trocken)
mehl = _artikel(db, "Weissmehl", mehl_kat)
_mass_an_kategorie(db, el, trocken, 11)
_mass_an_kategorie(db, el, mehl_kat, 9)
assert to_base(db, mehl, 1, "EL", hausmasse=True) == 9
def test_artikel_schlaegt_die_kategorie(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Zucker")
puder = _artikel(db, "Puderzucker", kat)
_mass_an_kategorie(db, el, kat, 12)
db.add(ProductMeasure(
measure_id=el.id, product_id=puder.id, amount=8, base=BaseUnit.gram.value
))
db.commit()
assert to_base(db, puder, 1, "EL", hausmasse=True) == 8
assert mass_aufloesen(db, puder, "EL").herkunft == "Artikel"
# --------------------------------------------------------------------------
# Lücken und Fehler
# --------------------------------------------------------------------------
def test_mass_ohne_menge_meldet_sich_deutlich(db, masse):
"""Die Meldung ist die Grundlage für „Maß jetzt anlegen" im Rezeptabgleich."""
kat = _kategorie(db, "Hülsenfrüchte")
linsen = _artikel(db, "Berglinsen", kat)
with pytest.raises(ConversionError) as fehler:
to_base(db, linsen, 1, "Tasse", hausmasse=True)
assert "kein Maß 'Tasse' hinterlegt" in str(fehler.value)
def test_voellig_unbekannte_einheit_bleibt_unbekannte_einheit(db, masse):
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
with pytest.raises(ConversionError) as fehler:
to_base(db, mehl, 1, "furlong", hausmasse=True)
assert "Unbekannte Einheit" in str(fehler.value)
def test_artfremdes_mass_braucht_die_zweiteinheit(db, masse):
"""Ein Gramm-Maß an einem Stück-Artikel geht nur über die Brücke."""
el = masse["Esslöffel"]
kat = _kategorie(db, "Backwaren")
broetchen = _artikel(db, "Brötchen", kat, base=BaseUnit.piece, package_size=6)
_mass_an_kategorie(db, el, kat, 20) # in Gramm
with pytest.raises(ConversionError) as fehler:
to_base(db, broetchen, 1, "EL", hausmasse=True)
assert "Zweiteinheit" in str(fehler.value)
# Mit Brücke „3 Stück ≙ 150 g" sind 20 g dann 0,4 Stück.
broetchen.secondary_base = BaseUnit.gram.value
broetchen.secondary_count = 3
broetchen.secondary_amount = 150
db.commit()
assert to_base(db, broetchen, 1, "EL", hausmasse=True) == pytest.approx(0.4)
def test_packung_schlaegt_das_hausmass(db, masse):
"""„package" wird zuerst geprüft – daran darf ein Hausmaß nichts ändern."""
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat, package_size=1000)
neu = Measure(name="Packung", short="Pck", position=99)
db.add(neu)
db.commit()
_mass_an_kategorie(db, neu, kat, 7)
assert to_base(db, mehl, 1, "package", hausmasse=True) == 1000
def test_ohne_schalter_gilt_das_hausmass_nicht(db, masse):
"""Einlagern und Mindestbestände dürfen keine Schätzwerte annehmen."""
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
with pytest.raises(ConversionError) as fehler:
to_base(db, mehl, 2, "EL") # ohne hausmasse=True
assert "Unbekannte Einheit" in str(fehler.value)
def test_einlagern_nimmt_kein_hausmass(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
with pytest.raises(ConversionError):
check_in(db, mehl, 2, "EL", None, None, None)
def test_auslagern_nimmt_ein_hausmass(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
check_in(db, mehl, 1, "package", None, None, None)
db.commit()
check_out(db, mehl, 2, "EL", None)
db.commit()
assert current_stock(db, mehl.id) == 980
def test_einheit_schlaegt_gleichnamiges_hausmass(db, masse):
"""Ein Maß „Liter" darf die echte Einheit nicht verdecken."""
kat = _kategorie(db, "Getränke")
saft = _artikel(db, "Apfelsaft", kat, base=BaseUnit.milliliter, package_size=1000)
liter_mass = Measure(name="Liter", short="Ltr", position=99)
db.add(liter_mass)
db.commit()
_mass_an_kategorie(db, liter_mass, kat, 5, base=BaseUnit.milliliter)
assert to_base(db, saft, 1, "Liter", hausmasse=True) == 1000
def test_verfuegbare_masse_listet_nur_hinterlegte(db, masse):
el = masse["Esslöffel"]
kat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", kat)
_mass_an_kategorie(db, el, kat, 10)
liste = verfuegbare_masse(db, mehl)
assert [a.measure.name for a in liste] == ["Esslöffel"]
assert liste[0].amount == 10
assert liste[0].herkunft == "Kategorie: Mehl"
def test_artikel_ohne_kategorie_hat_keine_masse(db, masse):
product = Product(name="Irgendwas", base_unit=BaseUnit.gram, source="manual")
db.add(product)
db.commit()
assert verfuegbare_masse(db, product) == []
assert mass_aufloesen(db, product, "EL") is None
# --------------------------------------------------------------------------
# Bearbeitungsansicht: eigene und geerbte Mengen
# --------------------------------------------------------------------------
def test_kategorie_sieht_ihre_eigenen_mengen(db, masse):
"""Ohne Vererbung ist die Liste die reine Bearbeitungsansicht."""
ober = _kategorie(db, "Mehl & Zucker")
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
liste = category_measures(ober.id, db=db, _=None)
assert [(e.name, e.amount, e.herkunft) for e in liste] == [("Esslöffel", 10, "Kategorie")]
def test_unterkategorie_zeigt_das_geerbte_als_solches(db, masse):
"""Der Platzhalter in der Maske: Was gilt, wenn ich das Feld leer lasse?"""
ober = _kategorie(db, "Mehl & Zucker")
unter = _kategorie(db, "Backzutaten", parent=ober)
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
ohne = category_measures(unter.id, db=db, _=None)
mit = category_measures(unter.id, vererbt=True, db=db, _=None)
assert ohne == []
assert [(e.name, e.amount, e.herkunft) for e in mit] == [
("Esslöffel", 10, "Kategorie: Mehl & Zucker"),
]
def test_eigener_wert_verdeckt_den_geerbten_in_der_liste(db, masse):
"""Sonst stünde dieselbe Zeile zweimal da, mit verschiedenen Zahlen."""
ober = _kategorie(db, "Mehl & Zucker")
unter = _kategorie(db, "Backzutaten", parent=ober)
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
_mass_an_kategorie(db, masse["Esslöffel"], unter, 12)
liste = category_measures(unter.id, vererbt=True, db=db, _=None)
assert [(e.amount, e.herkunft) for e in liste] == [(12, "Kategorie")]
def test_geerbte_frist_nennt_die_kategorie_die_sie_setzt(db):
ober = _kategorie(db, "Molkerei")
unter = _kategorie(db, "Frischmilch", parent=ober)
ober.open_days = 5
db.commit()
assert geerbte_kategorie_frist(db, unter) == (5, "Kategorie: Molkerei")
# Die Kategorie, die den Wert selbst setzt, erbt ihn NICHT von sich.
assert geerbte_kategorie_frist(db, ober)[1] == "Einstellungen"
+47 -3
View File
@@ -8,12 +8,15 @@ from app.models import (
Item,
ItemDocument,
Location,
Movement,
MovementType,
Product,
Role,
User,
)
from app.routers.items import _safe_filename, create_items, get_item, list_all_items
from app.schemas import ItemCreate
from app.services.stock import current_stock
@pytest.fixture()
@@ -39,7 +42,7 @@ def _product(db):
def test_item_kaufpreis_round_trip(db, admin):
product = _product(db)
out = create_items(
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, user=admin
)
assert out[0].price_cents == 49900
assert out[0].currency == "CHF"
@@ -47,7 +50,7 @@ def test_item_kaufpreis_round_trip(db, admin):
def test_item_out_listet_belege(db, admin):
product = _product(db)
item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id
item_id = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0].id
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
content_type="application/pdf", data=b"%PDF-1.4 x"))
db.commit()
@@ -68,7 +71,7 @@ def test_list_all_items_angereichert(db, admin):
ort = Location(name="Keller")
db.add(ort)
db.commit()
create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, _=admin)
create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, user=admin)
alle = list_all_items(db=db, _=admin)
assert len(alle) == 2
@@ -84,3 +87,44 @@ def test_safe_filename_entfernt_header_zeichen():
assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf"
assert _safe_filename(" ") == "beleg"
assert _safe_filename(None) == "beleg"
# --------------------------------------------------------------------------
# Verlauf: Anlegen und Entfernen muessen beide auftauchen
# --------------------------------------------------------------------------
def test_anlegen_schreibt_bewegung_je_stueck(db, admin):
"""Frueher kannte der Verlauf nur das Verschwinden, nie das Entstehen."""
product = _product(db)
ort = Location(name="Keller")
db.add(ort)
db.commit()
create_items(product.id, ItemCreate(count=3, location_id=ort.id), db=db, user=admin)
bewegungen = db.query(Movement).filter(Movement.product_id == product.id).all()
assert len(bewegungen) == 3
for b in bewegungen:
assert b.type == MovementType.in_
assert b.quantity == 1
assert b.unit_used == "Stück"
assert b.location_id == ort.id
assert b.user_id == admin.id
# Die UID gehoert in den Eintrag, sonst sagt der Verlauf nicht, welches Stueck.
uids = {i.uid for i in db.query(Item).filter(Item.product_id == product.id)}
assert {b.note.removeprefix("Einzelstück ") for b in bewegungen} == uids
def test_anlegen_uebernimmt_die_notiz_als_grund(db, admin):
"""Eine eigene Notiz ist aussagekraeftiger als die UID - sie gewinnt."""
product = _product(db)
create_items(product.id, ItemCreate(count=1, note="Geschenk von Oma"), db=db, user=admin)
bewegung = db.query(Movement).filter(Movement.product_id == product.id).one()
assert bewegung.note == "Geschenk von Oma"
def test_anlegen_veraendert_den_bestand_nicht_doppelt(db, admin):
"""Der Bestand zaehlt Zeilen in items - die Bewegung darf nichts dazutun."""
product = _product(db)
create_items(product.id, ItemCreate(count=2), db=db, user=admin)
assert current_stock(db, product.id) == 2.0
+182
View File
@@ -0,0 +1,182 @@
"""Zusammenfassen gleichartiger Chargen – und die Ausnahme für angebrochene.
Diese Logik war bisher ungetestet, obwohl vier Stellen sie aufrufen
(`check_in`, `bulk_lot_location`, `split_lot`, `update_lot`). Mit dem Anbrechen
wird sie gefährlich: Ohne die Ausnahme würde eine offene Packung beim nächsten
Einlagern am selben Ort still mit einer ungeöffneten verschmolzen und ihr
kürzeres Verbrauchsdatum verlieren.
"""
from datetime import date, timedelta
import pytest
from app.models import BaseUnit, Location, Lot, Movement, MovementType, Product, Role, User
from app.routers.stock import bulk_lot_location, open_lot
from app.schemas import BulkLotLocation, LotOpen
from app.services.stock import (
check_in,
consolidate_duplicate_lots,
consolidate_lot_group,
current_stock,
)
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def keller(db):
ort = Location(name="Keller")
db.add(ort)
db.commit()
db.refresh(ort)
return ort
@pytest.fixture()
def milch(db):
product = Product(
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _lot(db, product, menge, best_before=None, ort=None):
lot = Lot(
product_id=product.id, quantity=menge, best_before=best_before,
location_id=ort.id if ort else None,
)
db.add(lot)
db.commit()
db.refresh(lot)
return lot
# --------------------------------------------------------------------------
# Das bisherige Verhalten muss bleiben
# --------------------------------------------------------------------------
def test_gleiche_chargen_werden_zusammengefasst(db, milch, keller):
mhd = date.today() + timedelta(days=10)
_lot(db, milch, 1000, mhd, keller)
_lot(db, milch, 2000, mhd, keller)
survivor = consolidate_lot_group(db, milch.id, mhd, "day", keller.id)
db.commit()
assert survivor.quantity == 3000
assert db.query(Lot).count() == 1
def test_verschiedenes_mhd_bleibt_getrennt(db, milch, keller):
_lot(db, milch, 1000, date.today() + timedelta(days=5), keller)
_lot(db, milch, 1000, date.today() + timedelta(days=9), keller)
consolidate_duplicate_lots(db)
assert db.query(Lot).count() == 2
def test_bewegungen_haengen_an_der_verbleibenden_charge(db, milch, keller, user):
mhd = date.today() + timedelta(days=10)
alt = _lot(db, milch, 1000, mhd, keller)
neu = _lot(db, milch, 1000, mhd, keller)
db.add(Movement(product_id=milch.id, lot_id=neu.id, type=MovementType.in_,
quantity=1000, unit_used="ml"))
db.commit()
survivor = consolidate_lot_group(db, milch.id, mhd, "day", keller.id)
db.commit()
assert survivor.id == alt.id
assert db.query(Movement).one().lot_id == alt.id
def test_einlagern_fasst_mit_der_bestehenden_charge_zusammen(db, milch, keller):
mhd = date.today() + timedelta(days=10)
check_in(db, milch, 1, "package", mhd, keller.id, None)
check_in(db, milch, 1, "package", mhd, keller.id, None)
db.commit()
assert db.query(Lot).count() == 1
assert current_stock(db, milch.id) == 2000
# --------------------------------------------------------------------------
# Die Ausnahme: Angebrochenes bleibt für sich
# --------------------------------------------------------------------------
def test_angebrochene_charge_wird_nicht_eingesammelt(db, milch, keller, user):
mhd = date.today() + timedelta(days=20)
lot = _lot(db, milch, 2000, mhd, keller)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
verbrauchsdatum = offen.consume_by
consolidate_lot_group(db, milch.id, mhd, "day", keller.id)
db.commit()
assert db.query(Lot).count() == 2
frisch = db.get(Lot, offen.id)
assert frisch is not None
assert frisch.consume_by == verbrauchsdatum
def test_einlagern_laesst_die_offene_packung_in_ruhe(db, milch, keller, user):
"""Der gefährlichste Pfad: nachkaufen, während eine Packung offen ist."""
mhd = date.today() + timedelta(days=20)
lot = _lot(db, milch, 2000, mhd, keller)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
check_in(db, milch, 1, "package", mhd, keller.id, None)
db.commit()
frisch = db.get(Lot, offen.id)
assert frisch.quantity == 1000
assert frisch.opened_on is not None
# Die zugekaufte Packung verschmilzt mit der verbliebenen ungeöffneten.
zu = db.query(Lot).filter(Lot.opened_on.is_(None)).all()
assert len(zu) == 1
assert zu[0].quantity == 2000
assert current_stock(db, milch.id) == 3000
def test_umlagern_mehrerer_chargen_laesst_offenes_getrennt(db, milch, keller, user):
mhd = date.today() + timedelta(days=20)
lot = _lot(db, milch, 3000, mhd, None)
offen = open_lot(lot.id, LotOpen(), db=db, _=user)
zweite = _lot(db, milch, 1000, mhd, None)
bulk_lot_location(
BulkLotLocation(lot_ids=[offen.id, zweite.id, lot.id], location_id=keller.id),
db=db, _=user,
)
offene = db.query(Lot).filter(Lot.opened_on.isnot(None)).all()
assert len(offene) == 1
assert offene[0].quantity == 1000
assert offene[0].location_id == keller.id
assert current_stock(db, milch.id) == 4000
def test_start_aufraeumer_ruehrt_offenes_nicht_an(db, milch, keller, user):
mhd = date.today() + timedelta(days=20)
lot = _lot(db, milch, 2000, mhd, keller)
open_lot(lot.id, LotOpen(), db=db, _=user)
# Zwei ungeöffnete Dubletten dazu, die zusammengefasst werden sollen.
_lot(db, milch, 500, mhd, keller)
entfernt = consolidate_duplicate_lots(db)
assert entfernt == 1
assert db.query(Lot).filter(Lot.opened_on.isnot(None)).count() == 1
assert db.query(Lot).filter(Lot.opened_on.is_(None)).count() == 1
assert current_stock(db, milch.id) == 2500
+199
View File
@@ -0,0 +1,199 @@
"""Mehrere Chargen auf einmal: komplett ausbuchen, Teilmenge, löschen."""
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from app.models import BaseUnit, Lot, Movement, MovementType, Product, Role, User
from app.routers.stock import bulk_lot_checkout, bulk_lot_delete
from app.schemas import BulkLotCheckout, BulkLotDelete
from app.services.stock import current_stock
@pytest.fixture()
def user(db):
person = User(username="tester", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def milch(db):
product = Product(
name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000,
source="manual",
)
db.add(product)
db.commit()
db.refresh(product)
return product
def _lot(db, product, menge, tage=None):
lot = Lot(
product_id=product.id, quantity=menge,
best_before=(date.today() + timedelta(days=tage)) if tage is not None else None,
)
db.add(lot)
db.commit()
db.refresh(lot)
return lot
# --------------------------------------------------------------------------
# Komplett ausbuchen
# --------------------------------------------------------------------------
def test_mehrere_chargen_komplett_ausbuchen(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
anzahl = bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, b.id]), db=db, user=user)
assert anzahl == 2
assert db.query(Lot).count() == 0
assert current_stock(db, milch.id) == 0
def test_je_charge_eine_bewegung(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, b.id]), db=db, user=user)
bewegungen = db.query(Movement).all()
assert len(bewegungen) == 2
assert {m.quantity for m in bewegungen} == {1000, 2000}
assert all(m.type == MovementType.out for m in bewegungen)
def test_notiz_landet_an_der_bewegung(db, user, milch):
a = _lot(db, milch, 1000, 5)
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id], note="Grossputz"), db=db, user=user
)
assert db.query(Movement).one().note == "Grossputz"
def test_leere_auswahl_tut_nichts(db, user, milch):
_lot(db, milch, 1000, 5)
assert bulk_lot_checkout(BulkLotCheckout(lot_ids=[]), db=db, user=user) == 0
assert current_stock(db, milch.id) == 1000
def test_unbekannte_ids_werden_uebergangen(db, user, milch):
a = _lot(db, milch, 1000, 5)
anzahl = bulk_lot_checkout(BulkLotCheckout(lot_ids=[a.id, 999]), db=db, user=user)
assert anzahl == 1
assert current_stock(db, milch.id) == 0
# --------------------------------------------------------------------------
# Teilmenge je Charge
# --------------------------------------------------------------------------
def test_teilmenge_je_charge(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id, b.id], quantity=200, unit="ml"), db=db, user=user
)
assert db.get(Lot, a.id).quantity == 800
assert db.get(Lot, b.id).quantity == 1800
def test_teilmenge_bei_gemischten_artikeln_wird_abgelehnt(db, user, milch):
"""„1 Packung" bei Milch und Brötchen sind verschiedene Dinge."""
broetchen = Product(name="Brötchen", base_unit=BaseUnit.piece, package_size=6,
source="manual")
db.add(broetchen)
db.commit()
a = _lot(db, milch, 1000, 5)
b = _lot(db, broetchen, 6, 2)
with pytest.raises(HTTPException) as fehler:
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id, b.id], quantity=1, unit="package"),
db=db, user=user,
)
assert fehler.value.status_code == 400
assert db.get(Lot, a.id).quantity == 1000 # nichts gebucht
assert db.get(Lot, b.id).quantity == 6
def test_zu_wenig_bestand_rollt_alles_zurueck(db, user, milch):
"""Ein halb gebuchter Stand wäre schlimmer als gar keiner."""
viel = _lot(db, milch, 1000, 5)
wenig = _lot(db, milch, 100, 9)
with pytest.raises(HTTPException) as fehler:
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[viel.id, wenig.id], quantity=500, unit="ml"),
db=db, user=user,
)
assert fehler.value.status_code == 409
assert current_stock(db, milch.id) == 1100 # unverändert
assert db.query(Movement).count() == 0
def test_unbekannte_einheit_bucht_nichts(db, user, milch):
a = _lot(db, milch, 1000, 5)
with pytest.raises(HTTPException) as fehler:
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id], quantity=1, unit="furlong"), db=db, user=user
)
assert fehler.value.status_code == 400
assert current_stock(db, milch.id) == 1000
def test_teilmenge_leert_und_entfernt_die_charge(db, user, milch):
a = _lot(db, milch, 500, 5)
bulk_lot_checkout(
BulkLotCheckout(lot_ids=[a.id], quantity=500, unit="ml"), db=db, user=user
)
assert db.get(Lot, a.id) is None
# --------------------------------------------------------------------------
# Löschen als Korrektur
# --------------------------------------------------------------------------
def test_mehrere_chargen_loeschen(db, user, milch):
a = _lot(db, milch, 1000, 5)
b = _lot(db, milch, 2000, 9)
anzahl = bulk_lot_delete(BulkLotDelete(lot_ids=[a.id, b.id]), db=db, user=user)
assert anzahl == 2
assert db.query(Lot).count() == 0
def test_loeschen_schreibt_korrektur_nicht_verbrauch(db, user, milch):
a = _lot(db, milch, 1000, 5)
bulk_lot_delete(BulkLotDelete(lot_ids=[a.id]), db=db, user=user)
bewegung = db.query(Movement).one()
assert bewegung.type == MovementType.adjust
assert bewegung.quantity == -1000
assert bewegung.note == "Charge gelöscht"
# Keine Charge mehr, an der die Bewegung haengen koennte.
assert bewegung.lot_id is None
def test_loeschen_mit_leerer_auswahl(db, user, milch):
_lot(db, milch, 1000, 5)
assert bulk_lot_delete(BulkLotDelete(lot_ids=[]), db=db, user=user) == 0
assert current_stock(db, milch.id) == 1000
+325
View File
@@ -0,0 +1,325 @@
"""Mealie-Anbindung: Anmelden, Dauertoken holen, und das Passwort vergessen.
Ohne Netz: ``httpx.MockTransport`` liefert die Antworten, die eine echte
Mealie-Instanz geben würde.
"""
import json
import httpx
import pytest
from fastapi import HTTPException
from app.models import IntegrationSecret, Role, Setting, User
from app.routers.mealie import (
mealie_available,
mealie_konfigurieren,
mealie_status,
mealie_token_setzen,
mealie_trennen,
)
from app.routers.recipes import get_recipe
from app.routers.settings import list_settings
from app.schemas import MealieConfigIn, MealieTokenIn
from app.services import mealie
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture(autouse=True)
def sauber():
"""Caches und Transport zwischen den Tests zuruecksetzen."""
mealie.cache_leeren()
yield
mealie.setze_transport(None)
mealie.cache_leeren()
def _mealie_server(*, passwort_ok=True, token_ok=True, erreichbar=True):
"""Eine Mealie-Instanz nachstellen. Merkt sich, was sie gesehen hat."""
gesehen: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
gesehen.append(request)
if not erreichbar:
raise httpx.ConnectError("keine Verbindung", request=request)
pfad = request.url.path
if pfad == "/api/auth/token":
if not passwort_ok:
return httpx.Response(401, json={"detail": "Unauthorized"})
return httpx.Response(200, json={
"access_token": "kurzlebig", "token_type": "bearer", "expires_in": 3600,
})
if pfad == "/api/users/api-tokens":
if not token_ok:
return httpx.Response(403, json={"detail": "nope"})
return httpx.Response(201, json={"token": "dauertoken-xyz", "name": "Vorrania"})
if pfad == "/api/app/about":
return httpx.Response(200, json={"version": "2.4.1"})
if pfad == "/api/recipes":
return httpx.Response(200, json={"items": [], "total": 0, "page": 1})
if pfad == "/api/households/shopping/lists":
return httpx.Response(200, json={"items": []})
return httpx.Response(404, json={"detail": "Not found"})
mealie.setze_transport(httpx.MockTransport(handler))
return gesehen
# --------------------------------------------------------------------------
# Einrichten
# --------------------------------------------------------------------------
def test_einrichten_holt_ein_dauertoken(db, admin):
_mealie_server()
status = mealie_konfigurieren(
MealieConfigIn(base_url="https://mealie.example.com",
username="guido", password="geheim"),
db=db, _=admin,
)
assert status.configured is True
assert status.token_set is True
assert status.base_url == "https://mealie.example.com"
assert status.reachable is True
assert status.version == "2.4.1"
assert db.get(IntegrationSecret, "mealie").secret == "dauertoken-xyz"
def test_passwort_wird_nirgends_gespeichert(db, admin):
"""Der Kern der Zusage: Das Passwort sieht nur die Anmeldung."""
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://mealie.example.com",
username="guido", password="streng-geheim"),
db=db, _=admin,
)
alle_werte = [s.value for s in db.query(Setting).all()]
alle_werte += [s.secret for s in db.query(IntegrationSecret).all()]
assert all("streng-geheim" not in wert for wert in alle_werte)
def test_passwort_steht_nicht_im_fehlertext(db, admin):
_mealie_server(passwort_ok=False)
with pytest.raises(HTTPException) as fehler:
mealie_konfigurieren(
MealieConfigIn(base_url="https://mealie.example.com",
username="guido", password="streng-geheim"),
db=db, _=admin,
)
assert fehler.value.status_code == 400
assert "streng-geheim" not in str(fehler.value.detail)
def test_adresse_wird_ohne_schraegstrich_abgelegt(db, admin):
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://mealie.example.com/",
username="g", password="p"),
db=db, _=admin,
)
assert db.get(Setting, "mealie_base_url").value == "https://mealie.example.com"
def test_falsches_passwort_wird_als_solches_gemeldet(db, admin):
_mealie_server(passwort_ok=False)
with pytest.raises(HTTPException) as fehler:
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
assert fehler.value.status_code == 400
assert "Passwort" in str(fehler.value.detail)
assert db.get(IntegrationSecret, "mealie") is None
def test_server_weg_ist_kein_passwortfehler(db, admin):
"""Unterscheidung, die der Nutzer braucht: 502 statt 400."""
_mealie_server(erreichbar=False)
with pytest.raises(HTTPException) as fehler:
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
assert fehler.value.status_code == 502
def test_kein_dauertoken_wird_gemeldet(db, admin):
_mealie_server(token_ok=False)
with pytest.raises(HTTPException) as fehler:
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
assert fehler.value.status_code == 502
assert db.get(IntegrationSecret, "mealie") is None
def test_eigenes_token_einsetzen(db, admin):
_mealie_server()
status = mealie_token_setzen(
MealieTokenIn(base_url="https://m.example.com", token="selbst-erzeugt"),
db=db, _=admin,
)
assert status.configured is True
assert db.get(IntegrationSecret, "mealie").secret == "selbst-erzeugt"
# --------------------------------------------------------------------------
# Auskunft – und was sie NICHT verrät
# --------------------------------------------------------------------------
def test_status_gibt_das_token_nie_zurueck(db, admin):
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
status = mealie_status(db=db, _=admin)
assert status.token_set is True
assert "dauertoken-xyz" not in json.dumps(status.model_dump(mode="json"))
def test_einstellungen_enthalten_das_token_nicht(db, admin):
"""GET /settings liest jeder angemeldete Nutzer – deshalb eigene Tabelle."""
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
eintraege = list_settings(db=db, _=admin)
assert all("dauertoken-xyz" not in e.value for e in eintraege)
# Die Adresse darf dort stehen, sie ist kein Geheimnis.
assert any(e.key == "mealie_base_url" for e in eintraege)
def test_ohne_einrichtung_ist_nichts_verfuegbar(db, admin):
assert mealie_available(db=db, _=admin).available is False
status = mealie_status(db=db, _=admin)
assert status.configured is False
assert status.reachable is None # gar nicht erst nachgefragt
def test_nach_einrichtung_ist_es_verfuegbar(db, admin):
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
assert mealie_available(db=db, _=admin).available is True
def test_trennen_entfernt_adresse_und_token(db, admin):
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
mealie_trennen(db=db, _=admin)
assert db.get(IntegrationSecret, "mealie") is None
assert db.get(Setting, "mealie_base_url") is None
assert mealie_available(db=db, _=admin).available is False
def test_abgelaufenes_token_meldet_sich_deutlich(db, admin):
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"detail": "expired"})
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
db.add(IntegrationSecret(service="mealie", secret="alt"))
db.commit()
mealie.setze_transport(httpx.MockTransport(handler))
status = mealie_status(pruefen=True, db=db, _=admin)
assert status.reachable is False
assert "Token" in (status.error or "")
# --------------------------------------------------------------------------
# Fassungsunterschied 1.x / 2.x
# --------------------------------------------------------------------------
def test_erkennt_die_haushalts_pfade(db, admin):
_mealie_server()
mealie_konfigurieren(
MealieConfigIn(base_url="https://m.example.com", username="g", password="p"),
db=db, _=admin,
)
assert mealie.pfadvariante(db) == "households"
def test_faellt_auf_die_gruppen_pfade_zurueck(db, admin):
"""Mealie 1.x kennt /api/households/ nicht."""
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.startswith("/api/households/"):
return httpx.Response(404, json={"detail": "Not Found"})
return httpx.Response(200, json={"items": []})
db.add(Setting(key="mealie_base_url", value="https://alt.example.com"))
db.add(IntegrationSecret(service="mealie", secret="t"))
db.commit()
mealie.setze_transport(httpx.MockTransport(handler))
assert mealie.pfadvariante(db) == "groups"
def test_pfadvariante_wird_gepuffert(db, admin):
gesehen = _mealie_server()
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
db.add(IntegrationSecret(service="mealie", secret="t"))
db.commit()
mealie.pfadvariante(db)
anzahl = len(gesehen)
mealie.pfadvariante(db)
assert len(gesehen) == anzahl # keine zweite Anfrage
def test_unbekanntes_rezept_ist_ein_404_mit_klarem_text(db, admin):
"""Seit das Rezept im Pfad steht, kann ein Link ins Leere zeigen.
Das ist eine gewoehnliche Auskunft und kein Gateway-Fehler - und der Text
landet in der Oberflaeche, darf also nicht nach Innenleben klingen.
"""
db.add(Setting(key="mealie_base_url", value="https://m.example.com"))
db.add(IntegrationSecret(service="mealie", secret="t"))
db.commit()
mealie.setze_transport(httpx.MockTransport(
lambda request: httpx.Response(404, json={"detail": "Not found"}),
))
with pytest.raises(HTTPException) as fehler:
get_recipe("gibtsnicht", db=db, _=admin)
assert fehler.value.status_code == 404
assert "Rezept" in str(fehler.value.detail)
assert "/api/" not in str(fehler.value.detail)
+137
View File
@@ -0,0 +1,137 @@
"""Seitenleiste ausblenden: Vorgabe des Admins, eigene Auswahl, Vereinigung."""
import json
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app.models import NavigationSichtbarkeit, Role, User
from app.routers.navigation import (
eigene_auswahl_setzen,
navigation_lesen,
vorgabe_setzen,
)
from app.schemas import NavigationSetzen
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def mitbewohner(db):
person = User(username="mitbewohner", password_hash="x", role=Role.user)
db.add(person)
db.commit()
db.refresh(person)
return person
def _setze_vorgabe(db, admin, werte):
return vorgabe_setzen(NavigationSetzen(versteckt=werte), db=db, user=admin)
def _setze_eigen(db, user, werte):
return eigene_auswahl_setzen(NavigationSetzen(versteckt=werte), db=db, user=user)
# ---- Vorgabe ----
def test_ohne_eintrag_ist_nichts_versteckt(db, admin):
out = navigation_lesen(db=db, user=admin)
assert out.vorgabe == [] and out.eigen == [] and out.versteckt == []
def test_vorgabe_gilt_fuer_alle(db, admin, mitbewohner):
_setze_vorgabe(db, admin, ["/rezepte"])
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/rezepte"]
assert navigation_lesen(db=db, user=admin).versteckt == ["/rezepte"]
def test_normaler_nutzer_darf_die_vorgabe_nicht_setzen(db, mitbewohner):
"""require_admin greift – geprueft ueber die Rolle, wie in deps.py."""
from app.deps import require_admin
with pytest.raises(HTTPException) as fehler:
require_admin(mitbewohner)
assert fehler.value.status_code == 403
# ---- Eigene Auswahl ----
def test_eigene_auswahl_betrifft_nur_einen(db, admin, mitbewohner):
_setze_eigen(db, mitbewohner, ["/history"])
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/history"]
assert navigation_lesen(db=db, user=admin).versteckt == []
def test_eigene_auswahl_kommt_zur_vorgabe_dazu(db, admin, mitbewohner):
_setze_vorgabe(db, admin, ["/rezepte"])
_setze_eigen(db, mitbewohner, ["/history"])
out = navigation_lesen(db=db, user=mitbewohner)
assert out.vorgabe == ["/rezepte"]
assert out.eigen == ["/history"]
assert out.versteckt == ["/history", "/rezepte"]
def test_nutzer_kann_die_vorgabe_nicht_aufheben(db, admin, mitbewohner):
"""Die eigene Auswahl addiert nur – sie nimmt nichts weg."""
_setze_vorgabe(db, admin, ["/shopping"])
_setze_eigen(db, mitbewohner, [])
assert navigation_lesen(db=db, user=mitbewohner).versteckt == ["/shopping"]
# ---- Speicherung ----
def test_zweimal_setzen_legt_keine_zweite_zeile_an(db, mitbewohner):
_setze_eigen(db, mitbewohner, ["/history"])
_setze_eigen(db, mitbewohner, ["/verliehen"])
zeilen = db.query(NavigationSichtbarkeit).all()
assert len(zeilen) == 1
assert json.loads(zeilen[0].versteckt) == ["/verliehen"]
def test_nur_eine_vorgabezeile(db, admin):
"""Der Teilindex muss eine zweite Vorgabe verhindern."""
_setze_vorgabe(db, admin, ["/rezepte"])
db.add(NavigationSichtbarkeit(user_id=None, versteckt='["/shopping"]'))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_nur_eine_zeile_je_konto(db, mitbewohner):
_setze_eigen(db, mitbewohner, ["/history"])
db.add(NavigationSichtbarkeit(user_id=mitbewohner.id, versteckt="[]"))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_doppelte_und_leere_eintraege_fallen_weg(db, mitbewohner):
out = _setze_eigen(db, mitbewohner, ["/history", "/history", "", " ", "/verliehen"])
assert out.eigen == ["/history", "/verliehen"]
def test_kaputter_inhalt_sperrt_niemanden_aus(db, mitbewohner):
"""Lieber die volle Leiste als eine Fehlermeldung."""
db.add(NavigationSichtbarkeit(user_id=mitbewohner.id, versteckt="kein JSON"))
db.commit()
assert navigation_lesen(db=db, user=mitbewohner).versteckt == []
def test_unbekannter_schluessel_wird_mitgefuehrt_aber_stoert_nicht(db, mitbewohner):
"""Ein umbenannter Eintrag darf die Leiste nicht zerreissen – der Server
kennt die Pfade gar nicht, die Oberflaeche ignoriert Unbekanntes."""
out = _setze_eigen(db, mitbewohner, ["/gibtsnichtmehr"])
assert out.versteckt == ["/gibtsnichtmehr"]
+483
View File
@@ -0,0 +1,483 @@
"""Rezeptzutaten gegen den Bestand: Kann ich das kochen?
Ohne Mealie – die Zutaten stehen hier so im Test, wie Mealie sie liefern würde.
"""
import pytest
from app.models import (
BaseUnit,
Category,
CategoryMeasure,
CategoryTracking,
Group,
Measure,
Product,
)
from app.services.rezepte import faktor_fuer_portionen, kochbar, pruefe_zutaten
from app.services.stock import check_in
def _zutat(name, menge=None, einheit=None, text=None):
"""Eine Zutat im Format von Mealie."""
return {
"quantity": menge,
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
"food": {"name": name},
"display": text or f"{menge or ''} {einheit or ''} {name}".strip(),
}
def _kategorie(db, name):
cat = Category(name=name, tracking=CategoryTracking.food.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000, bestand=0.0):
product = Product(name=name, base_unit=base, package_size=package_size,
category_id=cat.id, source="manual")
db.add(product)
db.commit()
db.refresh(product)
if bestand:
check_in(db, product, bestand, base.value, None, None, None)
db.commit()
return product
# --------------------------------------------------------------------------
# Portionen
# --------------------------------------------------------------------------
def test_portionen_skalieren_die_mengen(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=5000)
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 4}, 6)
assert faktor == 1.5
assert hinweis == ""
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")], faktor=faktor)
assert zutat.menge == 300
assert zutat.benoetigt_base == 300
def test_ohne_portionsangabe_wird_nicht_skaliert(db):
faktor, hinweis = faktor_fuer_portionen({"recipeServings": 0}, 6)
assert faktor == 1.0
assert "keine Portionszahl" in hinweis
def test_ohne_wunsch_bleibt_es_wie_im_rezept(db):
assert faktor_fuer_portionen({"recipeServings": 4}, None) == (1.0, "")
# --------------------------------------------------------------------------
# Reicht der Bestand?
# --------------------------------------------------------------------------
def test_genug_vorhanden(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
assert zutat.status == "vorhanden"
assert zutat.fehlt_base == 0
assert kochbar([zutat]) is True
def test_zu_wenig_meldet_die_fehlmenge(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=150)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
assert zutat.status == "zu_wenig"
assert zutat.fehlt_base == 350
assert kochbar([zutat]) is False
def test_gar_nichts_da(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=0)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 500, "g")])
assert zutat.status == "fehlt"
assert zutat.fehlt_base == 500
def test_unbekannte_zutat_wird_nicht_still_als_vorhanden_gewertet(db):
_kategorie(db, "Trockenwaren")
[zutat] = pruefe_zutaten(db, [_zutat("Safranfäden", 1, "g")])
assert zutat.status == "kein_treffer"
assert kochbar([zutat]) is False
def test_zutat_ohne_menge_ist_unklar(db):
cat = _kategorie(db, "Gewürze")
_artikel(db, "Salz", cat, bestand=500)
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None, text="Salz nach Geschmack")])
assert zutat.status == "menge_unbekannt"
assert kochbar([zutat]) is False
def test_ohne_menge_und_ohne_bestand_fehlt_es(db):
cat = _kategorie(db, "Gewürze")
_artikel(db, "Salz", cat, bestand=0)
[zutat] = pruefe_zutaten(db, [_zutat("Salz", None, None)])
assert zutat.status == "fehlt"
# --------------------------------------------------------------------------
# Zuordnung
# --------------------------------------------------------------------------
def test_aehnlicher_name_wird_gefunden(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl Typ 550", cat, bestand=2000)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 200, "g")])
assert zutat.art == "product"
assert zutat.ziel_name == "Weissmehl Typ 550"
def test_gruppe_zaehlt_marken_zusammen(db):
"""„Milch" ist markenuebergreifend gemeint – dafuer gibt es Gruppen."""
gruppe = Group(name="Milch")
db.add(gruppe)
db.commit()
cat = _kategorie(db, "Molkerei")
a = _artikel(db, "Valflora Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
b = _artikel(db, "Emmi Vollmilch", cat, base=BaseUnit.milliliter, bestand=500)
a.group_id = gruppe.id
b.group_id = gruppe.id
db.commit()
[zutat] = pruefe_zutaten(db, [_zutat("Milch", 1200, "ml")])
assert zutat.art == "group"
assert zutat.bestand_base == 1500 # beide Marken zusammen
assert zutat.status == "vorhanden"
def test_gemerkte_zuordnung_schlaegt_die_suche(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
richtig = _artikel(db, "Dinkelmehl", cat, bestand=2000)
[zutat] = pruefe_zutaten(
db, [_zutat("Mehl", 100, "g")],
zuordnung={"mehl": ("product", richtig.id)},
)
assert zutat.ziel_id == richtig.id
assert zutat.ziel_name == "Dinkelmehl"
def test_voellig_fremde_zutat_liefert_gar_keinen_vorschlag(db):
"""Lieber nichts als Unsinn.
Fruehere Fassung schlug hier „Weissmehl" vor, weil sich Buchstaben
ueberlappen. Ein Vorschlag, den niemand nachvollziehen kann, ist schlimmer
als keiner – er verleitet zum Durchklicken.
"""
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Kurkuma", 1, "g")])
assert zutat.status == "kein_treffer"
assert zutat.ziel_id is None
assert zutat.kandidaten == []
# --------------------------------------------------------------------------
# Was NICHT passen darf – die Faelle aus dem echten Katalog
# --------------------------------------------------------------------------
def test_gegenstaende_kommen_nicht_in_frage(db):
"""Ein Joystick ist kein Zucker und eine Waffelplatte kein Apfel."""
essen = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", essen, bestand=1000)
kram = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(kram)
db.commit()
db.refresh(kram)
for name in ("Joystick für WII", "Snack Collection Platten 4 (Waffeln)",
"Shirt Casual Fit"):
db.add(Product(name=name, base_unit=BaseUnit.piece, category_id=kram.id,
source="manual"))
db.commit()
for zutat_name in ("Zucker", "Apfel", "Salz"):
[zutat] = pruefe_zutaten(db, [_zutat(zutat_name, 1, "g")])
assert zutat.status == "kein_treffer", zutat_name
assert all("Joystick" not in k.name for k in zutat.kandidaten)
def test_mehl_trifft_nicht_das_geschnetzelte(db):
"""Buchstaben-Aehnlichkeit allein reicht nicht – es braucht ein ganzes Wort."""
fleisch = _kategorie(db, "Fleisch & Fisch")
_artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500)
[zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")])
assert zutat.status == "kein_treffer"
def test_eier_treffen_nicht_den_reis(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Reis", cat, bestand=2000)
[zutat] = pruefe_zutaten(db, [_zutat("Eier", 3, None)])
assert zutat.status == "kein_treffer"
def test_kategorie_wird_zuerst_durchsucht(db):
"""Steht die Zutat in einer passenden Kategorie, gilt nur die."""
fleisch = _kategorie(db, "Fleisch & Fisch")
_artikel(db, "Mühlen Geschnetzeltes", fleisch, bestand=500)
backen = _kategorie(db, "Mehl & Zucker")
richtig = _artikel(db, "Weissmehl Typ 405", backen, bestand=2000)
[zutat] = pruefe_zutaten(db, [_zutat("Mehl Typ 405", 125, "g")])
assert zutat.ziel_id == richtig.id
assert zutat.status == "vorhanden"
def test_vegan_allein_begruendet_keinen_treffer(db):
"""„vegan" ist eine Eigenschaft, kein Lebensmittel.
Sonst passt jedes vegane Produkt auf jede vegane Zutat: „vegane Butter"
landete auf „veganes Mühlen Knusper Bleu", „veganer Lime Curd" auf
„Vegane Mühlen-Schnitzel".
"""
fleisch = _kategorie(db, "Fleischersatz")
_artikel(db, "veganes Mühlen Knusper Bleu", fleisch, bestand=400)
_artikel(db, "Vegane Mühlen Nuggets Klassisch", fleisch, bestand=400)
_artikel(db, "Vegane Mühlen-Schnitzel auf Basis von Soja", fleisch, bestand=400)
zutaten = [
_zutat("vegane Butter, Block, ungesalzen", 45, "g"),
_zutat("veganer griechischer Joghurt oder Skyr", 80, "g"),
_zutat("veganer Lime Curd", 120, "g"),
]
for zutat in pruefe_zutaten(db, zutaten):
assert zutat.status == "kein_treffer", zutat.text
assert zutat.ziel_name == ""
def test_vegane_zutat_findet_das_passende_vegane_produkt(db):
"""Die Eigenschaft darf nicht sperren, nur nicht allein tragen."""
molk = _kategorie(db, "Molkereiprodukte")
butter = _artikel(db, "Vegane Butter", molk, bestand=500)
_artikel(db, "Vegane Mühlen-Schnitzel", molk, bestand=400)
[zutat] = pruefe_zutaten(db, [_zutat("vegane Butter, Block, ungesalzen", 45, "g")])
assert zutat.ziel_id == butter.id
assert zutat.status == "vorhanden"
def test_zutat_aus_lauter_eigenschaften_trifft_nichts(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("frisch gemahlen, optional", 1, "g")])
assert zutat.status == "kein_treffer"
def test_beiwoerter_stoeren_den_echten_treffer_nicht(db):
cat = _kategorie(db, "Obst & Gemüse")
aepfel = _artikel(db, "Äpfel", cat, bestand=3000)
[zutat] = pruefe_zutaten(
db, [_zutat("Apfel geschält und in 3 mm dünne Scheiben geschnitten", 900, "g")]
)
assert zutat.ziel_id == aepfel.id
def test_falsche_einheit_spricht_gegen_den_artikel(db):
"""„200 ml Schlagsahne" ist kein „Kräuter Sahne Hähnchen"."""
fleisch = _kategorie(db, "Fleisch & Fisch")
_artikel(db, "Kräuter Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
assert zutat.status == "kein_treffer"
assert "nicht in 'ml' geführt" in zutat.hinweis
def test_passende_einheit_gewinnt_gegen_hoehere_punktzahl(db):
molk = _kategorie(db, "Molkereiprodukte")
fleisch = _kategorie(db, "Fleisch & Fisch")
_artikel(db, "Sahne Hähnchen", fleisch, base=BaseUnit.gram, bestand=800)
richtig = _artikel(db, "Schlagsahne Rahm", molk, base=BaseUnit.milliliter, bestand=500)
[zutat] = pruefe_zutaten(db, [_zutat("Schlagsahne", 200, "ml")])
assert zutat.ziel_id == richtig.id
def test_fehlendes_hausmass_spricht_nicht_gegen_den_artikel(db):
"""Nur eine BEKANNTE Einheit falscher Art ist ein Hinweis.
„1 Tasse Linsen" heisst nicht, dass die Linsen falsch sind – es fehlt bloss
die Maßangabe, und die laesst sich nachtragen.
"""
cat = _kategorie(db, "Hülsenfrüchte")
linsen = _artikel(db, "Berglinsen", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
assert zutat.ziel_id == linsen.id
assert zutat.status == "menge_unbekannt"
def test_zweiteinheit_laesst_die_fremde_einheit_zu(db):
cat = _kategorie(db, "Backwaren")
broetchen = _artikel(db, "Brötchen", cat, base=BaseUnit.piece, package_size=6,
bestand=12)
broetchen.secondary_base = BaseUnit.gram.value
broetchen.secondary_count = 1
broetchen.secondary_amount = 50
db.commit()
[zutat] = pruefe_zutaten(db, [_zutat("Brötchen", 100, "g")])
assert zutat.ziel_id == broetchen.id
def test_umlaut_und_mehrzahl_finden_sich(db):
"""Rezept schreibt „Apfel", im Katalog stehen „Äpfel"."""
cat = _kategorie(db, "Obst & Gemüse")
aepfel = _artikel(db, "Äpfel", cat, base=BaseUnit.gram, bestand=2000)
[zutat] = pruefe_zutaten(db, [_zutat("Apfel", 900, "g")])
assert zutat.ziel_id == aepfel.id
assert zutat.status == "vorhanden"
def test_eigenschaftswort_verschluckt_den_artikelnamen_nicht(db):
'''Der Fund aus der Modellprobe: 'Frischkaese' fing mit 'frisch' an.
Damit galt der ganze Name als blosse Eigenschaft, fiel aus dem Vergleich -
und die Zutat 'Kaese' fand den Frischkaese NIE. Unsichtbar, weil kein
falscher Treffer entstand, sondern ein richtiger ausblieb.
'''
cat = _kategorie(db, "Molkerei")
kaese = _artikel(db, "Frischkäse", cat, bestand=400)
[zutat] = pruefe_zutaten(db, [_zutat("Käse", 100, "g")])
assert zutat.ziel_id == kaese.id
assert zutat.status == "vorhanden"
@pytest.mark.parametrize("artikel,zutat_name", [
("Biomilch", "Milch"),
("Rohrzucker", "Zucker"),
("Kleingebäck", "Gebäck"),
("Ganzkornbrot", "Brot"),
("Grobgrieß", "Grieß"),
])
def test_komposita_mit_eigenschaft_am_anfang_bleiben_auffindbar(db, artikel, zutat_name):
cat = _kategorie(db, "Vorrat")
ziel = _artikel(db, artikel, cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat(zutat_name, 100, "g")])
assert zutat.ziel_id == ziel.id
def test_das_eigenschaftswort_selbst_begruendet_weiter_keinen_treffer(db):
"""Die Gegenprobe – sonst waere die Sperre bloss ausgehebelt."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Frische Kräuter", cat, bestand=100)
[zutat] = pruefe_zutaten(db, [_zutat("frische Hefe", 20, "g")])
assert zutat.status == "kein_treffer"
def test_vollmilch_findet_die_h_vollmilch(db):
cat = _kategorie(db, "Molkerei")
milch = _artikel(db, "H Vollmilch", cat, base=BaseUnit.milliliter, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Vollmilch Zimmertemperatur", 120, "ml")])
assert zutat.ziel_id == milch.id
assert zutat.status == "vorhanden"
# --------------------------------------------------------------------------
# Hausmaße
# --------------------------------------------------------------------------
def test_essloeffel_wird_umgerechnet(db):
cat = _kategorie(db, "Mehl")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
el = db.query(Measure).filter(Measure.name == "Esslöffel").one()
db.add(CategoryMeasure(measure_id=el.id, category_id=cat.id, amount=10,
base=BaseUnit.gram.value))
db.commit()
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 2, "EL")])
assert zutat.benoetigt_base == 20
assert zutat.status == "vorhanden"
def test_fehlendes_mass_wird_zum_anlegen_gemeldet(db):
"""Daraus baut die Oberflaeche „Maß jetzt anlegen"."""
cat = _kategorie(db, "Hülsenfrüchte")
_artikel(db, "Berglinsen", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Berglinsen", 1, "Tasse")])
assert zutat.status == "menge_unbekannt"
assert zutat.fehlendes_mass == "Tasse"
assert zutat.kategorie_id == cat.id
assert "kein Maß" in zutat.hinweis
def test_unbekannte_einheit_wird_benannt(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
[zutat] = pruefe_zutaten(db, [_zutat("Weissmehl", 1, "furlong")])
assert zutat.status == "menge_unbekannt"
assert "furlong" in zutat.hinweis
# --------------------------------------------------------------------------
# Ganzes Rezept
# --------------------------------------------------------------------------
def test_ein_rezept_mit_allem(db):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl", cat, bestand=1000)
_artikel(db, "Zucker", cat, bestand=50)
zutaten = [
_zutat("Weissmehl", 200, "g"), # reicht
_zutat("Zucker", 100, "g"), # zu wenig
_zutat("Safran", 1, "g"), # kennt niemand
]
ergebnis = pruefe_zutaten(db, zutaten)
assert [z.status for z in ergebnis] == ["vorhanden", "zu_wenig", "kein_treffer"]
assert kochbar(ergebnis) is False
+151
View File
@@ -0,0 +1,151 @@
"""Wie lange eine Anmeldung gilt – einstellbar, bis hin zu unbegrenzt.
Vorher war die Frist fest verdrahtet und lief ab dem *Anmelden*: Wer die App
taeglich benutzte, flog trotzdem punktgenau nach Ablauf hinaus. Beides ist
hier geregelt – die Dauer ueber eine Einstellung, das Weiterzaehlen ueber
/auth/refresh.
"""
from datetime import datetime, timedelta, timezone
import pytest
from fastapi import HTTPException
from jose import jwt
from app.config import get_settings
from app.models import ApiToken, Role, Setting, User
from app.routers.auth import refresh
from app.routers.settings import (
SESSION_DAYS_KEY,
SESSION_DAYS_MAX,
get_session_days,
get_session_minutes,
set_setting,
)
from app.security import create_access_token, hash_api_token, hash_password
@pytest.fixture
def eva(db) -> User:
nutzer = User(username="eva", password_hash=hash_password("geheim123"), role=Role.admin)
db.add(nutzer)
db.commit()
db.refresh(nutzer)
return nutzer
def _inhalt(token: str) -> dict:
e = get_settings()
# Ohne Ablaufpruefung, damit auch ein abgelaufenes Token lesbar bleibt.
return jwt.decode(
token, e.jwt_secret, algorithms=[e.jwt_algorithm],
options={"verify_exp": False},
)
# ---------------------------------------------------------------- Einstellung
def test_ohne_eintrag_gilt_der_wert_aus_der_umgebung(db):
erwartet = round(get_settings().jwt_expire_minutes / (60 * 24))
assert get_session_days(db) == erwartet
def test_gesetzte_dauer_landet_im_token(db, eva):
set_setting(key=SESSION_DAYS_KEY, value="60", db=db, _=eva)
assert get_session_days(db) == 60
assert get_session_minutes(db) == 60 * 24 * 60
inhalt = _inhalt(
create_access_token(eva.id, eva.role.value, gueltig_minuten=get_session_minutes(db))
)
tage = (datetime.fromtimestamp(inhalt["exp"], timezone.utc)
- datetime.now(timezone.utc)).days
# 59, weil der angebrochene Tag abgeschnitten wird.
assert tage == 59
def test_null_heisst_unbegrenzt(db, eva):
"""Die Zusage "einfach fuer immer" – ein Token ganz ohne Ablauf."""
set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva)
assert get_session_minutes(db) == 0
inhalt = _inhalt(create_access_token(eva.id, eva.role.value, gueltig_minuten=0))
assert "exp" not in inhalt
def test_unbegrenztes_token_wird_angenommen(db, eva):
"""Ein Token ohne exp darf nicht an der Pruefung scheitern."""
from app.deps import get_current_user
token = create_access_token(eva.id, eva.role.value, gueltig_minuten=0)
assert get_current_user(token=token, db=db).id == eva.id
@pytest.mark.parametrize("wert", ["viel", "-1", str(SESSION_DAYS_MAX + 1), "3.5"])
def test_unsinnige_dauer_wird_abgelehnt(db, eva, wert):
"""Absagen statt still auf die Vorgabe zurueckdrehen – sonst glaubt der
Admin, es sei gesetzt."""
with pytest.raises(HTTPException) as fehler:
set_setting(key=SESSION_DAYS_KEY, value=wert, db=db, _=eva)
assert fehler.value.status_code == 400
assert db.get(Setting, SESSION_DAYS_KEY) is None
def test_beschaedigter_eintrag_faellt_auf_die_vorgabe_zurueck(db):
"""Von Hand in der Datenbank verbogen: lieber die Vorgabe als ein Absturz."""
db.add(Setting(key=SESSION_DAYS_KEY, value="kaputt"))
db.commit()
assert get_session_days(db) == round(get_settings().jwt_expire_minutes / (60 * 24))
def test_dauer_steht_in_der_einstellungsliste(db, eva):
from app.routers.settings import list_settings
schluessel = {s.key for s in list_settings(db=db, _=eva)}
assert SESSION_DAYS_KEY in schluessel
# ---------------------------------------------------------------- Verlaengern
def test_refresh_gibt_ein_frisches_token(db, eva):
set_setting(key=SESSION_DAYS_KEY, value="30", db=db, _=eva)
alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60)
neu = refresh(token=alt, db=db)
assert neu.username == "eva"
assert _inhalt(neu.access_token)["exp"] > _inhalt(alt)["exp"]
def test_refresh_uebernimmt_die_neue_dauer(db, eva):
"""Der Sinn der Sache: eine Aenderung greift, ohne dass man sich abmeldet."""
alt = create_access_token(eva.id, eva.role.value, gueltig_minuten=60)
set_setting(key=SESSION_DAYS_KEY, value="0", db=db, _=eva)
assert "exp" not in _inhalt(refresh(token=alt, db=db).access_token)
def test_refresh_lehnt_ein_abgelaufenes_token_ab(db, eva):
# Von Hand gebaut: ueber create_access_token laesst sich kein abgelaufenes
# Token bestellen – alles <= 0 heisst dort "unbegrenzt".
e = get_settings()
abgelaufen = jwt.encode(
{"sub": str(eva.id), "uid": eva.id, "role": eva.role.value,
"exp": datetime.now(timezone.utc) - timedelta(minutes=1)},
e.jwt_secret, algorithm=e.jwt_algorithm,
)
with pytest.raises(HTTPException) as fehler:
refresh(token=abgelaufen, db=db)
assert fehler.value.status_code == 401
def test_refresh_macht_aus_einem_api_token_kein_jwt(db, eva):
"""Ein API-Token (Home Assistant) ist jederzeit widerrufbar. Ein daraus
gemachtes JWT waere es nicht – deshalb hier eine Absage."""
db.add(ApiToken(name="ha", token_hash=hash_api_token("geheim"), user_id=eva.id))
db.commit()
with pytest.raises(HTTPException) as fehler:
refresh(token="geheim", db=db)
assert fehler.value.status_code == 401
+1 -1
View File
@@ -65,7 +65,7 @@ def _stueck(db, admin, product, ort=None):
return create_items(
product.id,
ItemCreate(count=1, location_id=(ort.id if ort else None)),
db=db, _=admin,
db=db, user=admin,
)[0]
+391
View File
@@ -0,0 +1,391 @@
"""Verwendungsstatus von Einzelstücken: umstellen, Verlauf, Kategorie-Markierung."""
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app.models import (
Category,
CategoryTracking,
Item,
ItemUsage,
Location,
Product,
Role,
UsageStatus,
User,
)
from app.routers.categories import create_category, update_category
from app.routers.items import create_items, delete_item, get_item, list_all_items
from app.routers.verwendung import list_verwendungen, setze_verwendung
from app.schemas import (
CategoryCreate,
CategoryUpdate,
ItemCreate,
VerwendungSetzen,
)
from app.services.stock import current_stock
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def nutzer(db):
person = User(username="mitbewohner", password_hash="x", role=Role.user)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def schrank(db):
ort = Location(name="Schrank")
db.add(ort)
db.commit()
db.refresh(ort)
return ort
@pytest.fixture()
def hemd(db):
"""Ein Einzelstueck in einer Kategorie, die den Status fuehrt."""
cat = Category(
name="Kleidung", tracking=CategoryTracking.object.value, usage_tracking=True
)
db.add(cat)
db.flush()
product = Product(name="Hemd", category_id=cat.id, individual=True, package_size=1)
db.add(product)
db.commit()
db.refresh(product)
return product
@pytest.fixture()
def konserve(db):
"""Ein Einzelstueck in einer Kategorie OHNE Verwendungsstatus."""
cat = Category(name="Vorrat", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Bohrmaschine", category_id=cat.id, individual=True,
package_size=1)
db.add(product)
db.commit()
db.refresh(product)
return product
def _stueck(db, admin, product, ort=None):
return create_items(
product.id,
ItemCreate(count=1, location_id=(ort.id if ort else None)),
db=db, user=admin,
)[0]
def _setze(db, user, item_id, status):
return setze_verwendung(item_id, VerwendungSetzen(status=status), db=db, user=user)
# ---- Umstellen ----
def test_neuer_status_legt_offene_zeile_an(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
out = _setze(db, admin, stueck.id, UsageStatus.in_use)
assert out.verwendung is not None
assert out.verwendung.status == UsageStatus.in_use
assert out.verwendung.ended_at is None
assert out.verwendung.username == "admin"
def test_frisches_stueck_ist_verfuegbar(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
assert get_item(stueck.id, db=db, _=admin).verwendung is None
assert db.query(ItemUsage).count() == 0
def test_wechsel_schliesst_die_vorige_zeile(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
_setze(db, admin, stueck.id, UsageStatus.in_use)
out = _setze(db, admin, stueck.id, UsageStatus.cleaning)
assert out.verwendung.status == UsageStatus.cleaning
zeilen = db.query(ItemUsage).order_by(ItemUsage.id).all()
assert len(zeilen) == 2
assert zeilen[0].status == UsageStatus.in_use.value
assert zeilen[0].ended_at is not None
assert zeilen[1].ended_at is None
def test_nur_eine_offene_zeile_je_stueck(db, admin, hemd):
"""Der Teilindex muss einen zweiten laufenden Zeitraum verhindern."""
stueck = _stueck(db, admin, hemd)
_setze(db, admin, stueck.id, UsageStatus.in_use)
db.add(ItemUsage(item_id=stueck.id, status=UsageStatus.repair.value))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_verfuegbar_schliesst_nur_und_legt_nichts_an(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
_setze(db, admin, stueck.id, UsageStatus.cleaning)
out = _setze(db, admin, stueck.id, None)
assert out.verwendung is None
zeilen = db.query(ItemUsage).all()
assert len(zeilen) == 1
assert zeilen[0].ended_at is not None
def test_verfuegbar_ohne_offene_zeile_ist_kein_fehler(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
out = _setze(db, admin, stueck.id, None)
assert out.verwendung is None
assert db.query(ItemUsage).count() == 0
def test_gleicher_status_zweimal_legt_keine_zweite_zeile_an(db, admin, hemd):
"""Sonst entstuende bei jedem Antippen ein Zeitraum der Laenge 0."""
stueck = _stueck(db, admin, hemd)
_setze(db, admin, stueck.id, UsageStatus.repair)
_setze(db, admin, stueck.id, UsageStatus.repair)
zeilen = db.query(ItemUsage).all()
assert len(zeilen) == 1
assert zeilen[0].ended_at is None
def test_unbekanntes_stueck_ergibt_404(db, admin):
with pytest.raises(HTTPException) as fehler:
_setze(db, admin, 9999, UsageStatus.in_use)
assert fehler.value.status_code == 404
def test_normaler_nutzer_darf_status_setzen(db, admin, nutzer, hemd):
"""Umstellen ist eine Buchung, keine Stammdatenaenderung."""
stueck = _stueck(db, admin, hemd)
out = _setze(db, nutzer, stueck.id, UsageStatus.cleaning)
assert out.verwendung.username == "mitbewohner"
# ---- Bestand ----
def test_status_aendert_den_bestand_nicht(db, admin, hemd, schrank):
"""Ein getragenes Hemd ist weiterhin im Haushalt - anders als ein verliehenes."""
stueck = _stueck(db, admin, hemd, schrank)
vorher = current_stock(db, hemd.id)
_setze(db, admin, stueck.id, UsageStatus.in_use)
assert current_stock(db, hemd.id) == vorher
_setze(db, admin, stueck.id, UsageStatus.cleaning)
assert current_stock(db, hemd.id) == vorher
def test_status_schreibt_keine_bewegung(db, admin, hemd):
"""Der Dashboard-Verlauf rechnet aus Bewegungen - siehe routers/loans.py."""
from app.models import Movement
stueck = _stueck(db, admin, hemd)
vorher = db.query(Movement).count()
_setze(db, admin, stueck.id, UsageStatus.in_use)
assert db.query(Movement).count() == vorher
# ---- Verlauf ----
def test_verlauf_kommt_neueste_zuerst(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
for status in (UsageStatus.in_use, UsageStatus.cleaning, UsageStatus.repair):
_setze(db, admin, stueck.id, status)
verlauf = list_verwendungen(stueck.id, db=db, _=admin)
assert [v.status for v in verlauf] == [
UsageStatus.repair, UsageStatus.cleaning, UsageStatus.in_use
]
assert verlauf[0].ended_at is None
assert all(v.ended_at is not None for v in verlauf[1:])
def test_geloeschtes_stueck_nimmt_den_verlauf_mit(db, admin, hemd):
stueck = _stueck(db, admin, hemd)
_setze(db, admin, stueck.id, UsageStatus.cleaning)
assert db.query(ItemUsage).count() == 1
delete_item(stueck.id, db=db, _=admin)
assert db.query(Item).filter(Item.id == stueck.id).count() == 0
assert db.query(ItemUsage).count() == 0
# ---- Kategorie-Markierung ----
def test_verwendung_moeglich_steht_am_einzelstueck(db, admin, hemd, konserve):
mit = _stueck(db, admin, hemd)
ohne = _stueck(db, admin, konserve)
assert get_item(mit.id, db=db, _=admin).verwendung_moeglich is True
assert get_item(ohne.id, db=db, _=admin).verwendung_moeglich is False
nach_uid = {i.uid: i for i in list_all_items(db=db, _=admin)}
assert nach_uid[mit.uid].verwendung_moeglich is True
assert nach_uid[ohne.uid].verwendung_moeglich is False
def test_markierung_erbt_beim_anlegen(db, admin, hemd):
eltern = db.query(Category).filter(Category.name == "Kleidung").one()
kind = create_category(
CategoryCreate(name="Hemden", parent_id=eltern.id), db=db, _=admin
)
assert kind.usage_tracking is True
def test_neue_oberkategorie_ist_ohne_markierung(db, admin):
neu = create_category(CategoryCreate(name="Werkzeug"), db=db, _=admin)
assert neu.usage_tracking is False
def test_markierung_wandert_in_die_unterkategorien(db, admin):
"""Der Fall aus der Praxis: Die Garderobe ist schon erfasst, erst dann
kommt der Haken an die Oberkategorie."""
oben = create_category(CategoryCreate(name="Kleidung"), db=db, _=admin)
mitte = create_category(
CategoryCreate(name="Oberteile", parent_id=oben.id), db=db, _=admin
)
unten = create_category(
CategoryCreate(name="Hemden", parent_id=mitte.id), db=db, _=admin
)
assert unten.usage_tracking is False
update_category(oben.id, CategoryUpdate(usage_tracking=True), db=db, _=admin)
for cid in (oben.id, mitte.id, unten.id):
assert db.get(Category, cid).usage_tracking is True
def test_markierung_abwaehlen_wandert_auch(db, admin):
oben = create_category(
CategoryCreate(name="Kleidung", usage_tracking=True), db=db, _=admin
)
unten = create_category(
CategoryCreate(name="Hosen", parent_id=oben.id), db=db, _=admin
)
assert unten.usage_tracking is True
update_category(oben.id, CategoryUpdate(usage_tracking=False), db=db, _=admin)
assert db.get(Category, unten.id).usage_tracking is False
def test_andere_aenderung_laesst_die_markierung_in_ruhe(db, admin):
"""Ein Umbenennen darf die Unterkategorien nicht anfassen."""
oben = create_category(CategoryCreate(name="Kleidung"), db=db, _=admin)
unten = create_category(
CategoryCreate(name="Hemden", parent_id=oben.id, usage_tracking=True),
db=db, _=admin,
)
update_category(oben.id, CategoryUpdate(name="Garderobe"), db=db, _=admin)
assert db.get(Category, unten.id).usage_tracking is True
# ---- Sicherung ----
def test_markierung_uebersteht_die_volle_sicherung(db, admin):
"""Die Markierung haengt an der Kategorie und muss im Backup mitfahren.
Ohne die Umschreibung in transfer.py waere sie nach dem Zurueckspielen aus
- und niemand wuerde merken, dass die Garderobe ihren Status verloren hat.
"""
import json
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.routers.transfer import _import_json, export_backup_json
from app.seed import ensure_builtin_units
oben = create_category(
CategoryCreate(name="Kleidung", usage_tracking=True), db=db, _=admin
)
create_category(CategoryCreate(name="Hemden", parent_id=oben.id), db=db, _=admin)
create_category(CategoryCreate(name="Werkzeug"), db=db, _=admin)
inhalt = export_backup_json(db=db, _=admin).body
daten = json.loads(inhalt)
nach_pfad = {c["path"]: c for c in daten["categories"]}
assert nach_pfad["Kleidung"]["usage_tracking"] is True
assert nach_pfad["Kleidung > Hemden"]["usage_tracking"] is True
assert nach_pfad["Werkzeug"]["usage_tracking"] is False
# Zweite, leere Instanz.
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(bind=engine)
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
ensure_builtin_units(zweite)
person = User(username="zwei", password_hash="x", role=Role.admin)
zweite.add(person)
zweite.commit()
_import_json(zweite, inhalt, person, "add")
def markierung(name):
return zweite.query(Category).filter(Category.name == name).one().usage_tracking
assert markierung("Kleidung") is True
assert markierung("Hemden") is True
assert markierung("Werkzeug") is False
zweite.close()
engine.dispose()
def test_markierung_uebersteht_die_stammdaten_sicherung(db, admin):
from app.services.master_data import export_master_data, import_master_data
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
create_category(CategoryCreate(name="Kleidung", usage_tracking=True), db=db, _=admin)
create_category(CategoryCreate(name="Werkzeug"), db=db, _=admin)
daten = export_master_data(db)
engine = create_engine("sqlite://", connect_args={"check_same_thread": False},
poolclass=StaticPool)
Base.metadata.create_all(bind=engine)
zweite = sessionmaker(bind=engine, autoflush=False, autocommit=False)()
import_master_data(zweite, daten, "add")
def markierung(name):
return zweite.query(Category).filter(Category.name == name).one().usage_tracking
assert markierung("Kleidung") is True
assert markierung("Werkzeug") is False
zweite.close()
engine.dispose()
def test_altes_backup_ohne_markierung_bleibt_lesbar(db, admin):
"""Eine Sicherung von vor dieser Aenderung kennt den Schluessel nicht."""
from app.services.master_data import FORMAT_KEY, import_master_data
import_master_data(
db,
{FORMAT_KEY: 1,
"categories": [{"id": 1, "name": "Alt", "parent_id": None, "tracking": "food"}]},
"add",
)
assert db.query(Category).filter(Category.name == "Alt").one().usage_tracking is False
+209
View File
@@ -0,0 +1,209 @@
"""Zubehör: ein Einzelstück gehört zu einem anderen (Netzteil → Laptop).
Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile.
Geprüft werden die Zuordnung selbst, ihre Grenzen (Selbstbezug, Ring) und das
Mitverleihen, das die Oberfläche erfragt und der Server nie von sich aus tut.
"""
import pytest
from fastapi import HTTPException
from app.models import Category, CategoryTracking, Item, Product, Role, User
from app.routers.items import (
create_items,
delete_item,
get_item,
zubehoer_loesen,
zubehoer_zuordnen,
)
from app.routers.loans import lend_item, return_item
from app.schemas import ItemCreate, LoanCreate, LoanReturn, ZubehoerZuordnen
from app.services.loans import offener_verleih
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def laptop(db):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Laptop", category_id=cat.id, individual=True, package_size=1)
db.add(product)
db.commit()
db.refresh(product)
return product
def _stueck(db, admin, product) -> Item:
out = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0]
return db.get(Item, out.id)
def _haenge(db, admin, geraet: Item, teil: Item):
return zubehoer_zuordnen(
geraet.id, ZubehoerZuordnen(child_item_id=teil.id), db=db, _=admin
)
# --------------------------------------------------------------------------
# Zuordnen und lösen
# --------------------------------------------------------------------------
def test_geraet_traegt_mehrere_teile(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
tasche = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
out = _haenge(db, admin, geraet, tasche)
assert {t.uid for t in out.zubehoer} == {netzteil.uid, tasche.uid}
# Und die Gegenrichtung steht am Teil.
assert get_item(netzteil.id, db=db, _=admin).gehoert_zu.uid == geraet.uid
def test_teil_wechselt_das_geraet_ohne_dublette(db, admin, laptop):
"""Ein Teil haengt an genau einem Geraet – Umhaengen ist ein Wechsel."""
a = _stueck(db, admin, laptop)
b = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, a, netzteil)
_haenge(db, admin, b, netzteil)
assert get_item(a.id, db=db, _=admin).zubehoer == []
assert [t.uid for t in get_item(b.id, db=db, _=admin).zubehoer] == [netzteil.uid]
def test_loesen_laesst_das_stueck_bestehen(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
zubehoer_loesen(geraet.id, netzteil.id, db=db, _=admin)
assert get_item(geraet.id, db=db, _=admin).zubehoer == []
assert db.get(Item, netzteil.id) is not None
def test_loesen_eines_fremden_teils_ist_ein_fehler(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
fremdes = _stueck(db, admin, laptop)
with pytest.raises(HTTPException) as fehler:
zubehoer_loesen(geraet.id, fremdes.id, db=db, _=admin)
assert fehler.value.status_code == 404
# --------------------------------------------------------------------------
# Grenzen
# --------------------------------------------------------------------------
def test_selbstbezug_wird_abgelehnt(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
with pytest.raises(HTTPException) as fehler:
_haenge(db, admin, geraet, geraet)
assert fehler.value.status_code == 409
def test_ring_wird_abgelehnt(db, admin, laptop):
"""A trägt B – dann darf B nicht A tragen, sonst läuft jede Kette endlos."""
a = _stueck(db, admin, laptop)
b = _stueck(db, admin, laptop)
_haenge(db, admin, a, b)
with pytest.raises(HTTPException) as fehler:
_haenge(db, admin, b, a)
assert fehler.value.status_code == 409
def test_laengerer_ring_wird_abgelehnt(db, admin, laptop):
a, b, c = (_stueck(db, admin, laptop) for _ in range(3))
_haenge(db, admin, a, b)
_haenge(db, admin, b, c)
with pytest.raises(HTTPException) as fehler:
_haenge(db, admin, c, a)
assert fehler.value.status_code == 409
def test_geraet_loeschen_gibt_das_teil_frei(db, admin, laptop):
"""Das Netzteil ueberlebt den Laptop – es wird wieder eigenstaendig."""
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
delete_item(geraet.id, db=db, _=admin)
uebrig = db.get(Item, netzteil.id)
assert uebrig is not None
assert uebrig.parent_item_id is None
# --------------------------------------------------------------------------
# Verleihen und Zurücknehmen mit Zubehör
# --------------------------------------------------------------------------
def test_ohne_angabe_geht_nur_das_geraet(db, admin, laptop):
"""Der Server nimmt nie ungefragt etwas mit."""
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
assert offener_verleih(db, geraet.id) is not None
assert offener_verleih(db, netzteil.id) is None
def test_mit_zubehoer_verleihen_und_zuruecknehmen(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
tasche = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
_haenge(db, admin, geraet, tasche)
teile = [netzteil.id, tasche.id]
lend_item(
geraet.id,
LoanCreate(borrower="Markus", mit_zubehoer=teile),
db=db, user=admin,
)
assert all(offener_verleih(db, i) is not None for i in [geraet.id, *teile])
return_item(geraet.id, LoanReturn(mit_zubehoer=teile), db=db, _=admin)
# Keines darf offen haengenbleiben – sonst sucht es niemand mehr.
assert all(offener_verleih(db, i) is None for i in [geraet.id, *teile])
def test_fremdes_stueck_laesst_sich_nicht_mitverleihen(db, admin, laptop):
geraet = _stueck(db, admin, laptop)
fremdes = _stueck(db, admin, laptop)
with pytest.raises(HTTPException) as fehler:
lend_item(
geraet.id,
LoanCreate(borrower="Markus", mit_zubehoer=[fremdes.id]),
db=db, user=admin,
)
assert fehler.value.status_code == 400
assert offener_verleih(db, fremdes.id) is None
def test_zuruecknehmen_uebergeht_ein_teil_das_dablieb(db, admin, laptop):
"""Wer das Netzteil behalten hat, verleiht es nicht – die Rückgabe darf
daran trotzdem nicht scheitern."""
geraet = _stueck(db, admin, laptop)
netzteil = _stueck(db, admin, laptop)
_haenge(db, admin, geraet, netzteil)
lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin)
return_item(geraet.id, LoanReturn(mit_zubehoer=[netzteil.id]), db=db, _=admin)
assert offener_verleih(db, geraet.id) is None
+305
View File
@@ -0,0 +1,305 @@
"""Zutaten von Hand zuordnen – und dass sich Vorrania das merkt.
Der unscharfe Abgleich wird immer Zutaten haben, die er nicht erraten kann
(„Lime Curd") oder falsch errät. Hier liegt der Korrekturweg: einmal von Hand
entschieden, gilt es für jedes Rezept.
"""
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Category,
CategoryTracking,
Group,
MealieFoodMap,
Product,
Role,
User,
)
from app.routers.recipes import (
list_zuordnungen,
loesche_zuordnung,
router,
setze_zuordnung,
zuordnung_ziele,
)
from app.schemas import ZuordnungIn
from app.services import zuordnung
from app.services.rezepte import pruefe_zutaten
from app.services.stock import check_in
@pytest.fixture()
def nutzer(db):
person = User(username="koch", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _zutat(name, menge=None, einheit=None):
return {
"quantity": menge,
"unit": {"name": einheit, "abbreviation": einheit} if einheit else None,
"food": {"name": name},
"display": f"{menge or ''} {einheit or ''} {name}".strip(),
}
def _kategorie(db, name, tracking=CategoryTracking.food):
cat = Category(name=name, tracking=tracking.value)
db.add(cat)
db.commit()
db.refresh(cat)
return cat
def _artikel(db, name, cat, base=BaseUnit.gram, bestand=0.0, **extra):
product = Product(name=name, base_unit=base, package_size=1000,
category_id=cat.id, source="manual", **extra)
db.add(product)
db.commit()
db.refresh(product)
if bestand:
check_in(db, product, bestand, base.value, None, None, None)
db.commit()
return product
# --------------------------------------------------------------------------
# Die Zuordnung greift
# --------------------------------------------------------------------------
def test_gemerkte_zuordnung_findet_was_das_raten_nicht_findet(db, nutzer):
"""„Lime Curd" hat mit „Zitronencreme" kein Wort gemeinsam."""
cat = _kategorie(db, "Aufstriche")
creme = _artikel(db, "Zitronencreme", cat, bestand=500)
[vorher] = pruefe_zutaten(db, [_zutat("Lime Curd", 100, "g")])
assert vorher.status == "kein_treffer"
setze_zuordnung(
ZuordnungIn(food="Lime Curd", art="product", ziel_id=creme.id),
db=db, _=nutzer,
)
[nachher] = pruefe_zutaten(
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert nachher.status == "vorhanden"
assert nachher.ziel_name == "Zitronencreme"
assert nachher.manuell is True
def test_zuordnung_schlaegt_einen_falschen_treffer(db, nutzer):
"""Der Kern der Beschwerde: Sahne ist kein Kräuter-Sahne-Hähnchen."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Kräuter Sahne Hähnchen", cat, bestand=2000)
sahne = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
setze_zuordnung(
ZuordnungIn(food="Sahne", art="product", ziel_id=sahne.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat("Sahne", 200, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.ziel_id == sahne.id
assert zutat.status == "vorhanden"
def test_gross_und_kleinschreibung_treffen_dieselbe_zeile(db, nutzer):
cat = _kategorie(db, "Kuehlregal")
rahm = _artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter, bestand=1000)
setze_zuordnung(
ZuordnungIn(food="Schlagsahne", art="product", ziel_id=rahm.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat(" SCHLAGSAHNE ", 200, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.ziel_id == rahm.id
def test_zuordnung_auf_eine_gruppe(db, nutzer):
"""„Milch" ist markenübergreifend gemeint – dafür gibt es Gruppen."""
cat = _kategorie(db, "Kuehlregal")
gruppe = Group(name="Trinkmilch")
db.add(gruppe)
db.commit()
db.refresh(gruppe)
_artikel(db, "H-Milch 3,5%", cat, base=BaseUnit.milliliter,
bestand=2000, group_id=gruppe.id)
setze_zuordnung(
ZuordnungIn(food="Vollmilch", art="group", ziel_id=gruppe.id),
db=db, _=nutzer,
)
[zutat] = pruefe_zutaten(
db, [_zutat("Vollmilch", 500, "ml")], zuordnung=zuordnung.lade(db),
)
assert zutat.art == "group"
assert zutat.ziel_id == gruppe.id
assert zutat.status == "vorhanden"
def test_wechsel_auf_eine_gruppe_laesst_den_artikel_nicht_stehen(db, nutzer):
"""Sonst stünden beide Ziele in der Zeile und eines gewänne zufällig."""
cat = _kategorie(db, "Kuehlregal")
milch = _artikel(db, "H-Milch", cat, base=BaseUnit.milliliter)
gruppe = Group(name="Trinkmilch")
db.add(gruppe)
db.commit()
db.refresh(gruppe)
setze_zuordnung(ZuordnungIn(food="Milch", art="product", ziel_id=milch.id),
db=db, _=nutzer)
setze_zuordnung(ZuordnungIn(food="Milch", art="group", ziel_id=gruppe.id),
db=db, _=nutzer)
zeile = db.get(MealieFoodMap, "milch")
assert zeile.product_id is None
assert zeile.group_id == gruppe.id
assert db.query(MealieFoodMap).count() == 1
# --------------------------------------------------------------------------
# Widerrufen und Aufräumen
# --------------------------------------------------------------------------
def test_loeschen_laesst_wieder_raten(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
setze_zuordnung(ZuordnungIn(food="Reis", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
loesche_zuordnung(food="Reis", db=db, _=nutzer)
assert zuordnung.lade(db) == {}
[zutat] = pruefe_zutaten(
db, [_zutat("Reis", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert zutat.status == "kein_treffer"
def test_loeschen_was_es_nicht_gibt_ist_kein_fehler(db, nutzer):
loesche_zuordnung(food="Gibtsnicht", db=db, _=nutzer)
def test_zuordnung_auf_einen_geloeschten_artikel_wird_uebersprungen(db, nutzer):
"""Auf SQLite greift kein Fremdschlüssel – das darf kein Rezept sprengen."""
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat, bestand=1000)
setze_zuordnung(ZuordnungIn(food="Lime Curd", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
db.delete(mehl)
db.commit()
assert zuordnung.lade(db) == {}
[zutat] = pruefe_zutaten(
db, [_zutat("Lime Curd", 100, "g")], zuordnung=zuordnung.lade(db),
)
assert zutat.status == "kein_treffer"
def test_unbekanntes_ziel_wird_abgelehnt(db, nutzer):
with pytest.raises(HTTPException) as fehler:
setze_zuordnung(ZuordnungIn(food="Mehl", art="product", ziel_id=999),
db=db, _=nutzer)
assert fehler.value.status_code == 400
assert db.query(MealieFoodMap).count() == 0
def test_zutat_ohne_namen_laesst_sich_nicht_merken(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat)
with pytest.raises(zuordnung.ZuordnungFehler):
zuordnung.setze(db, " ", "product", mehl.id)
# --------------------------------------------------------------------------
# Liste und Auswahl
# --------------------------------------------------------------------------
def test_liste_nennt_das_ziel_mit_namen(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
mehl = _artikel(db, "Weissmehl", cat)
setze_zuordnung(ZuordnungIn(food="Flour", art="product", ziel_id=mehl.id),
db=db, _=nutzer)
[eintrag] = list_zuordnungen(db=db, _=nutzer)
assert eintrag.food_name == "Flour"
assert eintrag.food_key == "flour"
assert eintrag.ziel_name == "Weissmehl"
def test_ziele_suchen_ueber_name_und_marke(db, nutzer):
cat = _kategorie(db, "Trockenwaren")
_artikel(db, "Weissmehl Typ 550", cat, brand="Mühle Nord")
_artikel(db, "Zucker", cat)
namen = [z.name for z in zuordnung_ziele(q="mehl", db=db, _=nutzer)]
assert namen == ["Weissmehl Typ 550"]
nach_marke = [z.name for z in zuordnung_ziele(q="mühle nord", db=db, _=nutzer)]
assert nach_marke == ["Weissmehl Typ 550"]
def test_ziele_nennen_die_basiseinheit(db, nutzer):
"""Damit man vor dem Zuordnen sieht, ob ml oder g geführt wird."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Schlagrahm", cat, base=BaseUnit.milliliter)
[ziel] = zuordnung_ziele(q="schlagrahm", db=db, _=nutzer)
assert ziel.basis_einheit == "milliliter"
def test_ziele_enthalten_gegenstaende_aber_keine_einzelstuecke(db, nutzer):
"""Raten darf nur Lebensmittel; von Hand zuordnen darf alles Zählbare.
Backpapier ist kein Lebensmittel und steht trotzdem in Rezepten. Ein
Einzelstück dagegen ist ein benanntes Objekt und nie eine Zutat.
"""
sachen = _kategorie(db, "Haushalt", tracking=CategoryTracking.object)
_artikel(db, "Backpapier", sachen, base=BaseUnit.piece)
_artikel(db, "Backpapier-Spender", sachen, base=BaseUnit.piece, individual=True)
namen = [z.name for z in zuordnung_ziele(q="backpapier", db=db, _=nutzer)]
assert namen == ["Backpapier"]
def test_gruppen_stehen_vor_den_artikeln(db, nutzer):
"""Ohne Suchtext zuerst die Gruppen – sie sind die bessere Zuordnung."""
cat = _kategorie(db, "Kuehlregal")
_artikel(db, "Alpenmilch", cat, base=BaseUnit.milliliter)
db.add(Group(name="Trinkmilch"))
db.commit()
arten = [z.art for z in zuordnung_ziele(db=db, _=nutzer)]
assert arten[0] == "group"
# --------------------------------------------------------------------------
# Routenreihenfolge
# --------------------------------------------------------------------------
def test_zuordnungs_routen_stehen_vor_dem_slug():
"""Sonst liest FastAPI „zuordnungen" als Rezept-Slug und fragt Mealie.
Die Reihenfolge der Deklaration entscheidet das – ein Umsortieren der
Datei würde den Fehler sonst still einbauen.
"""
pfade = [r.path for r in router.routes]
slug = pfade.index("/recipes/{slug}")
assert pfade.index("/recipes/zuordnungen") < slug
assert pfade.index("/recipes/zuordnungen/ziele") < slug
+6
View File
@@ -10,8 +10,14 @@ POSTGRES_PASSWORD=vorrania
POSTGRES_DB=vorrania
# Sicherheit – im Betrieb unbedingt ändern!
# Ein neuer Wert beendet ALLE laufenden Anmeldungen – das ist zugleich der
# einzige Weg, eine unbegrenzt gültige Sitzung wieder loszuwerden.
JWT_SECRET=change-me-please
# Wie lange eine Anmeldung gilt, in Minuten. Nur der Startwert: im Betrieb
# stellt man das unter Einstellungen -> Anmeldung ein (auch "unbegrenzt").
#JWT_EXPIRE_MINUTES=43200
# Erster Admin-Benutzer (wird nur beim allerersten Start angelegt)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
+110
View File
@@ -131,6 +131,21 @@ actor APIClient {
try await send(try makeRequest("/auth/me"), as: MeResponse.self)
}
/// Schiebt die Frist der laufenden Sitzung nach vorn.
///
/// Ohne das zaehlt die am Server eingestellte Dauer ab dem *Anmelden*: Wer
/// die App taeglich benutzt, fliegt trotzdem punktgenau nach Ablauf hinaus.
///
/// Bewusst ohne die Erneuerungsschicht: Ein 401 heisst hier, dass die
/// Sitzung wirklich vorbei ist – dann gehoert sie beendet und nicht ueber
/// einen Umweg gerettet.
func sitzungVerlaengern() async throws -> TokenResponse {
let request = try makeRequest("/auth/refresh", method: "POST")
let (daten, antwort) = try await URLSession.shared.data(for: request)
try check(antwort, data: daten)
return try JSONDecoder().decode(TokenResponse.self, from: daten)
}
/// Am Server eingerichtete Anmeldedienste. Leere Liste = nur Passwort-Login.
func oidcProviders() async throws -> [OidcProviderResponse] {
let (daten, antwort) = try await URLSession.shared.data(
@@ -201,6 +216,12 @@ actor APIClient {
return try await send(request, as: Product.self)
}
/// Artikel löschen. Der Server lehnt mit 409 ab, solange Einzelstücke oder
/// Chargen dranhängen – die Begründung steht dann in der Fehlermeldung.
func deleteProduct(id: Int) async throws {
try await sendNoContent(try makeRequest("/products/\(id)", method: "DELETE"))
}
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
@@ -264,6 +285,12 @@ actor APIClient {
return try await send(request, as: [Item].self)
}
/// Ein Einzelstueck frisch holen - nach dem Zuordnen von Zubehoer ist das
/// urspruenglich uebergebene Objekt veraltet, die Ansicht aber noch offen.
func item(id: Int) async throws -> Item {
try await send(try makeRequest("/items/\(id)"), as: Item.self)
}
/// QR-Auflösung: UID → Einzelstück.
func itemByUid(uid: String) async throws -> Item {
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
@@ -278,6 +305,21 @@ actor APIClient {
// MARK: - Verleih von Einzelstuecken
/// Zubehoer zuordnen. Eigener Endpunkt und nicht ueber updateItem: Dessen
/// Anfrage sendet bewusst alle Felder mit, auch leere - eine Beziehung
/// darueber zu pflegen hiesse, sie bei einem vergessenen Feld zu kappen.
func zubehoerZuordnen(itemId: Int, childItemId: Int) async throws -> Item {
var request = try makeRequest("/items/\(itemId)/zubehoer", method: "POST")
try jsonBody(&request, ZubehoerZuordnenRequest(childItemId: childItemId))
return try await send(request, as: Item.self)
}
func zubehoerLoesen(itemId: Int, childItemId: Int) async throws {
try await sendNoContent(
try makeRequest("/items/\(itemId)/zubehoer/\(childItemId)", method: "DELETE")
)
}
/// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist.
func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan {
var request = try makeRequest("/items/\(id)/loan", method: "POST")
@@ -297,6 +339,25 @@ actor APIClient {
try await send(try makeRequest("/items/\(id)/loans"), as: [Loan].self)
}
// MARK: - Verwendungsstatus
/// Umstellen; `status` nil heisst verfuegbar. Eigener Endpunkt aus
/// demselben Grund wie Zubehoer, und weil updateItem Admins vorbehalten ist
/// - umstellen darf jeder angemeldete Benutzer.
///
/// Antwortet mit dem ganzen Stueck, die Aufrufstelle braucht also kein
/// zweites Laden.
func setzeVerwendung(id: Int, status: VerwendungStatus?) async throws -> Item {
var request = try makeRequest("/items/\(id)/verwendung", method: "PUT")
try jsonBody(&request, VerwendungSetzenRequest(status: status?.rawValue))
return try await send(request, as: Item.self)
}
/// Verwendungs-Verlauf eines Stuecks, neueste zuerst.
func itemVerwendungen(id: Int) async throws -> [Verwendung] {
try await send(try makeRequest("/items/\(id)/verwendungen"), as: [Verwendung].self)
}
/// Offene Verleihe, faelligste zuerst. `dueIn` grenzt auf die ein, deren
/// Rueckgabe in so vielen Tagen ansteht (ueberfaellige sind immer dabei).
func loans(dueIn: Int? = nil) async throws -> [Loan] {
@@ -383,6 +444,22 @@ actor APIClient {
// MARK: - Listen
/// Die ganze Einkaufsliste - gerechnete Bedarfe UND eigene Eintraege.
///
/// Ein Aufruf statt drei, und vor allem der einzige, der die selbst
/// draufgesetzten Posten mitbringt.
func shoppingAll() async throws -> ShoppingListAll {
try await send(try makeRequest("/shopping-list/all"), as: ShoppingListAll.self)
}
/// Einen eigenen Eintrag abhaken oder wieder aufmachen.
@discardableResult
func setShoppingEntryDone(_ id: Int, done: Bool) async throws -> ShoppingEntry {
var request = try makeRequest("/shopping-list/entries/\(id)", method: "PATCH")
try jsonBody(&request, ["done": done])
return try await send(request, as: ShoppingEntry.self)
}
func shoppingList() async throws -> [ShoppingItem] {
try await send(try makeRequest("/shopping-list"), as: [ShoppingItem].self)
}
@@ -425,6 +502,39 @@ actor APIClient {
return try await send(request, as: StockResponse.self)
}
/// Alle Chargen mit Artikelangaben - fuer die uebergreifende Chargenliste.
func allLotRows() async throws -> [LotRow] {
try await send(try makeRequest("/lots/rows"), as: [LotRow].self)
}
/// Eine Packung anbrechen: Sie wird als eigene, offene Charge gefuehrt und
/// bekommt ein "zu verbrauchen bis". Ohne Menge ist es eine Artikeleinheit.
@discardableResult
func openLot(id: Int, quantity: Double? = nil) async throws -> Lot {
var request = try makeRequest("/lots/\(id)/open", method: "POST")
try jsonBody(&request, ["quantity": quantity])
return try await send(request, as: Lot.self)
}
/// Teilmenge aus einer Charge verbrauchen.
///
/// `autoOpen` bricht eine noch geschlossene Charge dabei an - die Rueckfrage
/// dazu stellt die Oberflaeche, nicht der Server.
@discardableResult
func consumeLot(id: Int, quantity: Double, unit: String,
note: String?, autoOpen: Bool) async throws -> ConsumeResult {
var request = try makeRequest("/lots/\(id)/consume", method: "POST")
try jsonBody(&request, ConsumeRequest(quantity: quantity, unit: unit,
note: note, autoOpen: autoOpen))
return try await send(request, as: ConsumeResult.self)
}
/// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft.
func productMeasures(productId: Int) async throws -> [MeasureAmount] {
try await send(try makeRequest("/products/\(productId)/measures"),
as: [MeasureAmount].self)
}
func updateLot(id: Int, _ payload: LotUpdateRequest) async throws -> Lot {
var request = try makeRequest("/lots/\(id)", method: "PATCH")
try jsonBody(&request, payload)
+44
View File
@@ -0,0 +1,44 @@
import Foundation
/// Eine Zeile einer flach ausgelegten Baumliste, mit ihrer Tiefe.
///
/// Lagerorte und Kategorien sind Baeume, kommen vom Server aber als flache
/// Liste nach Namen sortiert. Daraus wird eine Auswahl, in der "Garage → Fach A"
/// und "Keller → Fach A" irgendwo zwischen allem anderen stehen - Geschwister
/// und Kinder finden nie zusammen. Diese Reihenfolge stellt das her.
struct Baumzeile<T: TreeItem>: Identifiable {
let item: T
let tiefe: Int
var id: T.ID { item.id }
/// Name mit Einrueckung je Ebene.
///
/// Eingerueckt wird mit fuehrenden Leerzeichen, weil ein aufklappendes
/// Auswahlmenue ausser Text nichts annimmt - Layout ignoriert es.
/// Bekannte Grenze: Bricht ein langer Name auf eine zweite Zeile um,
/// faengt die wieder ganz links an. Eine Listendarstellung
/// (`.pickerStyle(.navigationLink)`) koennte das, riss aber im Blatt den
/// ganzen Navigationsstapel mit und ist deshalb wieder draussen.
var label: String { String(repeating: " ", count: tiefe) + item.name }
}
/// Bringt eine flache Eltern-Kind-Liste in Baumreihenfolge, mit Tiefe.
///
/// Waisen - deren Elternteil weggefiltert oder geloescht ist - gelten als
/// oberste Ebene. Ohne diese Regel verschwaenden sie lautlos aus der Auswahl.
func baum<T: TreeItem>(_ liste: [T]) -> [Baumzeile<T>] {
let ids = Set(liste.map(\.id))
var ergebnis: [Baumzeile<T>] = []
func gehe(_ knoten: T, _ tiefe: Int) {
ergebnis.append(Baumzeile(item: knoten, tiefe: tiefe))
for kind in liste.filter({ $0.parentId == knoten.id }) {
gehe(kind, tiefe + 1)
}
}
for wurzel in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
gehe(wurzel, 0)
}
return ergebnis
}
+2 -20
View File
@@ -13,9 +13,8 @@ struct CategoryPicker: View {
var body: some View {
Picker("Kategorie", selection: $selection) {
Text("– keine –").tag(Int?.none)
ForEach(flattened(), id: \.item.id) { eintrag in
Text(String(repeating: " ", count: eintrag.depth) + eintrag.item.name)
.tag(Int?.some(eintrag.item.id))
ForEach(baum(gefiltert)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.item.id))
}
}
}
@@ -24,23 +23,6 @@ struct CategoryPicker: View {
guard let tracking else { return categories }
return categories.filter { $0.tracking == tracking }
}
private func flattened() -> [(item: CategoryItem, depth: Int)] {
let quelle = gefiltert
let ids = Set(quelle.map(\.id))
var result: [(CategoryItem, Int)] = []
func walk(_ node: CategoryItem, _ depth: Int) {
result.append((node, depth))
for child in quelle.filter({ $0.parentId == node.id }) {
walk(child, depth + 1)
}
}
for root in quelle.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0)
}
return result
}
}
/// Legt eine Gruppe an Ort und Stelle an, damit man das Formular nicht
+2 -2
View File
@@ -211,8 +211,8 @@ struct CheckInFormView: View {
Section {
Picker("Lagerort", selection: $locationId) {
Text("– keiner –").tag(String?.none)
ForEach(locations) { location in
Text(location.path(in: locations)).tag(String?.some(location.id))
ForEach(baum(locations)) { eintrag in
Text(eintrag.label).tag(String?.some(eintrag.item.id))
}
}
Button {
+21 -1
View File
@@ -56,6 +56,22 @@ struct CheckInView: View {
Label("EAN von Hand eingeben", systemImage: "keyboard")
}
// Eingenaehte Tags an Kleidung lassen sich nicht scannen. Ein
// Tag benennt immer genau ein Einzelstueck, deshalb fuehrt er
// dorthin und nicht in die Artikelsuche.
NFCLesenButton(titel: "NFC-Tag lesen") { uid in
Task {
error = nil; status = nil
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
scannedItem = found
} else {
error = "Kein Einzelstück mit der UID \(uid)."
}
}
} onFehler: { meldung in
error = meldung
}
NavigationLink {
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
product = created
@@ -675,7 +691,11 @@ struct KassenzettelPruefenView: View {
HStack {
Menu {
Button("– ohne –") { z.wrappedValue.ortId = nil }
ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } }
// Voller Pfad statt nur des Namens: In dieser engen Zeile gibt es
// keine Einrueckung, und "Fach A" allein ist nicht eindeutig.
ForEach(locations) { loc in
Button(loc.path(in: locations)) { z.wrappedValue.ortId = loc.id }
}
} label: {
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
}
+24
View File
@@ -13,6 +13,8 @@ struct CheckOutView: View {
@State private var manualCodeShown = false
@State private var manualCode = ""
@State private var searchShown = false
/// Per NFC gelesenes Einzelstueck – Auslagern laeuft dort ueber „Entfernen".
@State private var scannedItem: Item?
var body: some View {
// Kamera als Feld oben, siehe CheckInView.
@@ -48,6 +50,25 @@ struct CheckOutView: View {
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
// Ein Tag benennt immer genau ein Einzelstueck. Auslagern
// heisst dort „entfernen mit Grund", und das steht in der
// Stueck-Ansicht – deshalb fuehrt der Tag dorthin.
NFCLesenButton(titel: "NFC-Tag lesen") { uid in
Task {
error = nil; status = nil
paused = true
if let found = try? await APIClient.shared.itemByUid(uid: uid) {
scannedItem = found
} else {
error = "Kein Einzelstück mit der UID \(uid)."
paused = false
}
}
} onFehler: { meldung in
error = meldung
}
.buttonStyle(.bordered)
}
.padding(.horizontal)
}
@@ -70,6 +91,9 @@ struct CheckOutView: View {
}
Button("Abbrechen", role: .cancel) { paused = false }
}
.sheet(item: $scannedItem, onDismiss: { paused = false }) { it in
NavigationStack { ItemEditView(item: it) }
}
.sheet(isPresented: $searchShown) {
NavigationStack {
ProductSearchView { picked in
+23 -4
View File
@@ -226,18 +226,33 @@ struct ShoppingCard: View {
let stand: Int
@State private var items: [ShoppingItem] = []
/// Selbst draufgesetzte Posten. Ohne sie meldete die Karte, alles sei
/// gedeckt, waehrend auf der Liste fuenf Dinge standen.
@State private var eintraege: [ShoppingEntry] = []
@State private var fehler: String?
private var offene: [ShoppingEntry] { eintraege.filter { !$0.done } }
var body: some View {
Group {
if let fehler {
Text(fehler).font(.caption).foregroundStyle(.red)
} else if items.isEmpty {
} else if items.isEmpty && offene.isEmpty {
Text("Alle Mindestbestände sind gedeckt.")
.font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(items.prefix(5)) { item in
// Eigene Eintraege zuerst: Sie stehen da, weil jemand sie
// bewusst draufgesetzt hat.
ForEach(offene.prefix(5)) { eintrag in
HStack {
Text(eintrag.name).font(.callout)
Spacer()
Text(eintrag.mengeText.isEmpty ? "selbst notiert" : eintrag.mengeText)
.font(.caption).foregroundStyle(.orange)
}
}
ForEach(items.prefix(max(0, 5 - offene.count))) { item in
HStack {
Text(item.name).font(.callout)
Spacer()
@@ -252,7 +267,11 @@ struct ShoppingCard: View {
}
.task(id: stand) {
do {
items = try await APIClient.shared.shoppingList()
// Die gebuendelte Route: Nur sie bringt auch die selbst
// draufgesetzten Posten mit.
let alles = try await APIClient.shared.shoppingAll()
items = alles.products
eintraege = alles.entries
fehler = nil
} catch {
self.fehler = error.localizedDescription
@@ -291,7 +310,7 @@ struct ExpiryCard: View {
HStack {
VStack(alignment: .leading, spacing: 1) {
Text(item.productName).font(.callout)
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision))")
Text("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision))")
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
+2 -2
View File
@@ -217,8 +217,8 @@ struct GroupEditor: View {
HStack {
Picker("Ort", selection: row.locationId) {
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
ForEach(locations) { l in
Text(l.path(in: locations)).tag(String?.some(l.id))
ForEach(baum(locations)) { eintrag in
Text(eintrag.label).tag(String?.some(eintrag.item.id))
}
}
TextField("Menge", text: row.amount)
+97 -2
View File
@@ -11,6 +11,28 @@ struct ItemListView: View {
@State private var locationFilter: String? // Lagerort-ID
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
@State private var verleihFilter: VerleihFilter = .alle
@State private var verwendungFilter: VerwendungFilter = .alle
/// Blickwinkel auf den Verwendungszustand – „was liegt gerade in der
/// Wäsche?" ist die Frage dahinter. „Verfügbar" ist dabei das Fehlen eines
/// laufenden Zeitraums, kein eigener Serverwert.
enum VerwendungFilter: Hashable, CaseIterable {
case alle
case verfuegbar
case status(VerwendungStatus)
static var allCases: [VerwendungFilter] {
[.alle, .verfuegbar] + VerwendungStatus.allCases.map { .status($0) }
}
var label: String {
switch self {
case .alle: return "Alle"
case .verfuegbar: return "Verfügbar"
case let .status(s): return s.label
}
}
}
/// Blickwinkel auf den Verleihzustand – „was ist gerade draußen?" ist die
/// Frage, die man im Alltag an diese Liste stellt.
@@ -28,6 +50,8 @@ struct ItemListView: View {
}
@State private var busy = true
@State private var error: String?
/// Stück, dessen Rückgabe gerade bestätigt werden soll.
@State private var zurueckFuer: Item?
private struct TreeEntry: Identifiable {
let item: CategoryItem
@@ -95,6 +119,16 @@ struct ItemListView: View {
case .ueberfaellig: if !(it.loan?.istUeberfaellig ?? false) { return false }
case .imHaus: if it.loan != nil { return false }
}
switch verwendungFilter {
case .alle: break
// Stuecke ohne Verwendungsstatus fallen hier bewusst raus: Ein
// Schraubenzieher ist nicht „verfuegbar", er fuehrt den Zustand gar
// nicht.
case .verfuegbar:
if !it.zeigtVerwendung || it.verwendungStatus != nil { return false }
case let .status(gesucht):
if it.verwendungStatus != gesucht { return false }
}
if !q.isEmpty {
let heu = [it.productName, it.uid, it.note, it.productBrand]
.compactMap { $0 }.joined(separator: " ").lowercased()
@@ -106,6 +140,7 @@ struct ItemListView: View {
private var hatFilter: Bool {
locationFilter != nil || categoryFilter != nil || verleihFilter != .alle
|| verwendungFilter != .alle
}
var body: some View {
@@ -118,6 +153,23 @@ struct ItemListView: View {
NavigationLink { ItemEditView(item: it) } label: {
ItemRow(item: it, locations: locations)
}
// Zuruecknehmen ohne Umweg: Bisher fuehrte der einzige Weg ueber
// das Bearbeiten-Formular des Stuecks.
.swipeActions(edge: .leading) {
if it.istVerliehen {
Button { zurueckFuer = it } label: {
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
}
.tint(.green)
}
}
.contextMenu {
if it.istVerliehen {
Button { zurueckFuer = it } label: {
Label("Zurücknehmen", systemImage: "arrow.uturn.down")
}
}
}
}
}
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
@@ -128,11 +180,39 @@ struct ItemListView: View {
.refreshable { await load() }
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
.onAppear { Task { await load() } }
.confirmationDialog(
zurueckFuer.map { "„\($0.uid)“ zurücknehmen?" } ?? "",
isPresented: Binding(get: { zurueckFuer != nil },
set: { if !$0 { zurueckFuer = nil } }),
titleVisibility: .visible
) {
Button("Zurücknehmen") { Task { await nimmZurueck() } }
Button("Abbrechen", role: .cancel) { zurueckFuer = nil }
} message: {
if let it = zurueckFuer, let l = it.loan {
let mit = it.teile.filter(\.verliehen)
Text("Von \(l.borrower)." + (mit.isEmpty ? "" :
" Das mitverliehene Zubehör kommt mit zurück: "
+ mit.map(\.uid).joined(separator: ", ") + "."))
}
}
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
}
private func nimmZurueck() async {
guard let it = zurueckFuer else { return }
zurueckFuer = nil
do {
_ = try await APIClient.shared.returnItem(
id: it.id,
LoanReturnRequest(mitZubehoer: it.teile.filter(\.verliehen).map(\.id))
)
await load()
} catch { self.error = error.localizedDescription }
}
private var filterMenu: some View {
Menu {
Menu("Lagerort") {
@@ -146,6 +226,11 @@ struct ItemListView: View {
check(f.label, verleihFilter == f) { verleihFilter = f }
}
}
Menu("Verwendung") {
ForEach(VerwendungFilter.allCases, id: \.self) { f in
check(f.label, verwendungFilter == f) { verwendungFilter = f }
}
}
if !filterKategorien.isEmpty {
Menu("Kategorie") {
check("Alle", categoryFilter == nil) { categoryFilter = nil }
@@ -157,15 +242,18 @@ struct ItemListView: View {
if hatFilter {
Divider()
Button(role: .destructive) {
locationFilter = nil; categoryFilter = nil; verleihFilter = .alle
locationFilter = nil; categoryFilter = nil
verleihFilter = .alle; verwendungFilter = .alle
} label: {
Label("Filter zurücksetzen", systemImage: "xmark.circle")
}
}
} label: {
Image(systemName: hatFilter
Label(hatFilter ? verleihFilter.label : "Filter",
systemImage: hatFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
.labelStyle(.titleAndIcon)
}
}
@@ -214,6 +302,13 @@ struct ItemRow: View {
.foregroundStyle(loan.istUeberfaellig ? Color.red : .secondary)
.lineLimit(2)
}
// Nur, wenn etwas laeuft: „Verfuegbar" unter jeder zweiten
// Zeile waere Rauschen.
if let status = item.verwendungStatus {
Label(status.label, systemImage: status.symbol)
.font(.caption)
.foregroundStyle(.secondary)
}
}
Spacer()
}
+430 -15
View File
@@ -20,6 +20,41 @@ private func anzeigeDatum(_ iso: String?) -> String {
return f.string(from: d)
}
/// ISO-Zeitstempel -> "20.09.2026, 18:42". Anders als bei Daten steht hier ein
/// voller Zeitpunkt: Ein Hemd wechselt mehrmals am Tag den Zustand.
func anzeigeZeitpunkt(_ iso: String?) -> String {
guard let iso else { return "–" }
guard let d = zeitpunktAus(iso) else { return iso }
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy, HH:mm"
return f.string(from: d)
}
/// Zeitstempel des Servers lesen - mit und ohne Zeitzonenangabe.
///
/// Postgres liefert sie mit Zone ("…+00:00"), SQLite laesst sie weg. Ohne
/// Angabe ist es UTC, denn der Server schreibt `datetime.now(UTC)`. Ohne diesen
/// Zweig stuende in der App der rohe ISO-Text.
private func zeitpunktAus(_ iso: String) -> Date? {
let mitZone = ISO8601DateFormatter()
mitZone.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let d = mitZone.date(from: iso) { return d }
mitZone.formatOptions = [.withInternetDateTime]
if let d = mitZone.date(from: iso) { return d }
let ohneZone = DateFormatter()
ohneZone.locale = Locale(identifier: "en_US_POSIX")
ohneZone.timeZone = TimeZone(identifier: "UTC")
for muster in ["yyyy-MM-dd'T'HH:mm:ss.SSSSSS",
"yyyy-MM-dd'T'HH:mm:ss.SSS",
"yyyy-MM-dd'T'HH:mm:ss"] {
ohneZone.dateFormat = muster
if let d = ohneZone.date(from: iso) { return d }
}
return nil
}
/// Verleihzustand in einer Zeile: „Markus · zurueck bis 20.09.2026".
/// Ueberfaelliges kommt beim Aufrufer rot daher.
func verleihZeile(_ loan: Loan) -> String {
@@ -78,9 +113,14 @@ struct ProductItemsSection: View {
var onChanged: () async -> Void
/// Bis zur Liste schließen (für „Anlegen & fertig").
var onFinish: (() -> Void)? = nil
/// Hochzaehlen laedt die Liste neu. Noetig, seit das Anlege-Blatt oben
/// haengt: Von dort kommt kein Rueckruf mehr in diesen Abschnitt.
var nachladen: Int = 0
/// Oeffnet das Anlege-Blatt. Die Bindung gehoert der Elternansicht - das
/// Blatt wird dort gezeigt, nicht hier (siehe unten).
@Binding var anlegen: Bool
@State private var items: [Item] = []
@State private var showAdd = false
var body: some View {
Section("Einzelstücke (\(items.count))") {
@@ -97,17 +137,15 @@ struct ProductItemsSection: View {
if items.isEmpty {
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
}
Button { showAdd = true } label: {
Button { anlegen = true } label: {
Label("Einzelstücke anlegen", systemImage: "plus")
}
}
.task(id: product.id) { await reload() }
.sheet(isPresented: $showAdd) {
NavigationStack {
ItemAddSheet(productId: product.id, onDone: { await refresh() },
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
}
}
.task(id: "\(product.id)-\(nachladen)") { await reload() }
// Kein .sheet hier. Aus einem Abschnitt heraus gezeigt, schloss es sich
// beim ERSTEN Oeffnen sofort wieder und hielt erst beim zweiten Versuch;
// ProductDetailView hat die Mengen-Blaetter aus genau diesem Grund
// laengst auf die Form-Ebene gelegt. Hier stand es nur nie nach.
}
private func untertitel(_ it: Item) -> String {
@@ -223,6 +261,13 @@ struct ItemEditView: View {
@State private var previewURL: URL?
@State private var locScanShown = false
@State private var nfcShown = false
@State private var zurueckFrage = false
// Zubehoer wird nachgeladen statt aus `item` genommen: Nach dem Zuordnen
// ist das uebergebene Stueck veraltet, die Ansicht aber noch offen.
@State private var teile: [ItemKurz] = []
@State private var gehoertZu: ItemKurz?
@State private var zubehoerWaehlen = false
@State private var mitVerleihen: Set<Int> = []
// Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer.
@State private var loan: Loan?
@State private var loanHistory: [Loan] = []
@@ -232,6 +277,17 @@ struct ItemEditView: View {
@State private var hasDue = false
@State private var due = Date()
@State private var busyLoan = false
// Verwendung: der laufende Zustand (nil = verfuegbar), nachgeladen wie der
// Verleih. `zeigtVerwendung` kommt vom uebergebenen Stueck und aendert sich
// waehrend der Ansicht nicht.
@State private var verwendung: VerwendungStatus?
// Was der Server zuletzt gemeldet hat. Ohne diesen Abgleich buchte das
// onChange der Leiste auch dann, wenn `verwendung` nur beim Laden gesetzt
// wurde - also bei jedem Oeffnen einmal ins Leere.
@State private var verwendungServer: VerwendungStatus?
@State private var verwendungSeit: String?
@State private var verwendungAnzahl = 0
@State private var busyVerwendung = false
var body: some View {
Form {
@@ -258,6 +314,39 @@ struct ItemEditView: View {
}
}
// Woran ist es gerade? Steht oben, weil alle Scan-Wege hier landen
// und „gerade aus der Waesche geholt" ein Griff sein soll - also
// eine Buchung wie der Verleih, kein Feld, das auf „Speichern"
// wartet. Vier Woerter waeren auf einem iPhone zu breit, vier
// Symbole passen immer.
if item.zeigtVerwendung {
Section {
Picker("Verwendung", selection: $verwendung) {
ForEach(VerwendungStatus.auswahl, id: \.self) { status in
Image(systemName: VerwendungStatus.symbol(status))
.tag(status)
}
}
.pickerStyle(.segmented)
.disabled(busyVerwendung)
.onChange(of: verwendung) { neu in
Task { await setzeVerwendung(neu) }
}
if verwendungAnzahl > 0 {
NavigationLink("Verwendungs-Verlauf (\(verwendungAnzahl))") {
VerwendungHistorieView(uid: item.uid, itemId: item.id)
}
}
} header: {
Text("Verwendung")
} footer: {
Text(busyVerwendung
? "Moment…"
: VerwendungStatus.label(verwendung)
+ (verwendungSeit.map { " · seit \($0)" } ?? ""))
}
}
// Verleih steht vor allem anderen: Ist das Stueck draussen, ist das
// die Antwort auf „wo ist es?". Alle Scan-Wege der App (QR, NFC,
// vorrania://i/<UID>) landen in dieser Ansicht - deshalb genuegt der
@@ -279,7 +368,7 @@ struct ItemEditView: View {
LabeledContent("Zurück bis",
value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn))
}
Button { Task { await nimmZurueck() } } label: {
Button { zurueckFrage = true } label: {
Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down")
}
.disabled(busyLoan)
@@ -296,10 +385,44 @@ struct ItemEditView: View {
}
}
// Zubehoer: das Netzteil, das zum Laptop gehoert. Ein Teil haengt an
// genau einem Geraet, ein Geraet traegt beliebig viele Teile.
Section("Zubehör") {
if let g = gehoertZu {
LabeledContent("Gehört zu") {
VStack(alignment: .trailing, spacing: 1) {
Text(g.productName ?? "Einzelstück")
Text(g.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
}
}
}
ForEach(teile) { t in
HStack {
VStack(alignment: .leading, spacing: 1) {
Text(t.productName ?? "Einzelstück")
Text(t.uid).font(.caption.monospaced()).foregroundStyle(.secondary)
}
Spacer()
if t.verliehen {
Text("verliehen").font(.caption).foregroundStyle(.orange)
}
}
.swipeActions {
Button("Lösen", role: .destructive) { Task { await loese(t) } }
}
}
if teile.isEmpty && gehoertZu == nil {
Text("Kein Zubehör.").foregroundStyle(.secondary)
}
Button { zubehoerWaehlen = true } label: {
Label("Zubehör hinzufügen", systemImage: "plus")
}
}
Section {
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
// Lagerort per QR setzen – ohne den (evtl. nicht vorhandenen) QR
// des Einzelstücks. Praktisch z.B. bei Kleidung.
@@ -400,6 +523,25 @@ struct ItemEditView: View {
.sheet(isPresented: $nfcShown) {
NavigationStack { NFCSchreibenView(item: item) }
}
.confirmationDialog(
"„\(item.uid)“ zurücknehmen?",
isPresented: $zurueckFrage, titleVisibility: .visible
) {
Button("Zurücknehmen") { Task { await nimmZurueck() } }
Button("Abbrechen", role: .cancel) { }
} message: {
let wer = loan.map { "Von \($0.borrower)." } ?? ""
let mit = mitZurueck.isEmpty ? "" :
" Das mitverliehene Zubehör kommt mit zurück: "
+ mitZurueck.map(\.uid).joined(separator: ", ") + "."
Text(wer + mit)
}
.sheet(isPresented: $zubehoerWaehlen) {
// Dieselbe durchsuchbare Liste wie beim Zuordnen eines Lagerorts.
ItemPickerSheet { gewaehlt in
Task { await haengeAn(gewaehlt) }
}
}
.sheet(isPresented: $showRemove) {
NavigationStack { removeSheet }
}
@@ -445,6 +587,29 @@ struct ItemEditView: View {
Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. "
+ "Als verliehen zählt es aber nicht mehr zum Bestand.")
}
// Zubehoer geht nur mit, wenn es angehakt ist. Schon anderweitig
// verliehene Teile stehen nicht zur Wahl - die sind gar nicht da.
if !teile.filter({ !$0.verliehen }).isEmpty {
Section("Zubehör mitgeben?") {
ForEach(teile.filter { !$0.verliehen }) { t in
Button {
if mitVerleihen.contains(t.id) { mitVerleihen.remove(t.id) }
else { mitVerleihen.insert(t.id) }
} label: {
HStack {
Image(systemName: mitVerleihen.contains(t.id)
? "checkmark.circle.fill" : "circle")
.foregroundStyle(Color.marke)
VStack(alignment: .leading, spacing: 1) {
Text(t.productName ?? "Einzelstück").foregroundStyle(.primary)
Text(t.uid).font(.caption.monospaced())
.foregroundStyle(.secondary)
}
}
}
}
}
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } }
@@ -481,6 +646,9 @@ struct ItemEditView: View {
hasDue = false
due = Date()
error = nil
// Vorausgewaehlt ist, was im Haus ist: Wer ein Geraet weitergibt, gibt
// das Netzteil meist mit. Abwaehlen geht mit einem Tipp.
mitVerleihen = Set(teile.filter { !$0.verliehen }.map(\.id))
showLend = true
}
@@ -490,23 +658,68 @@ struct ItemEditView: View {
loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest(
borrower: borrower.trimmingCharacters(in: .whitespaces),
reason: loanReason.isEmpty ? nil : loanReason,
dueOn: hasDue ? dateToString(due) : nil))
dueOn: hasDue ? dateToString(due) : nil,
mitZubehoer: Array(mitVerleihen)))
showLend = false
await ladeVerleihe()
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
/// Mitverliehenes Zubehoer, das zusammen mit dem Geraet zurueckkommt.
private var mitZurueck: [ItemKurz] { teile.filter { $0.verliehen } }
private func nimmZurueck() async {
busyLoan = true; defer { busyLoan = false }; error = nil
do {
_ = try await APIClient.shared.returnItem(id: item.id)
_ = try await APIClient.shared.returnItem(
id: item.id,
LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id))
)
loan = nil
await ladeVerleihe()
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
/// Verwendungszustand umstellen. Leer = verfuegbar.
///
/// Die Antwort traegt das ganze Stueck, ein zweites Laden entfaellt. Nur
/// der Verlaufszaehler wird nachgeholt.
private func setzeVerwendung(_ neu: VerwendungStatus?) async {
guard neu != verwendungServer, !busyVerwendung else { return }
busyVerwendung = true; defer { busyVerwendung = false }
error = nil
do {
let frisch = try await APIClient.shared.setzeVerwendung(id: item.id, status: neu)
uebernimm(frisch.verwendung)
await ladeVerwendungen()
onChanged?()
} catch {
self.error = error.localizedDescription
// Zurueck auf den Stand des Servers, sonst zeigt die Leiste eine
// Stellung an, die nie gebucht wurde.
await ladeVerwendungen()
}
}
private func uebernimm(_ offen: Verwendung?) {
verwendungServer = offen?.status
verwendung = offen?.status
verwendungSeit = offen.map { anzeigeZeitpunkt($0.startedAt) }
}
/// Verlauf holen - fuer den Zaehler am Verlaufs-Eintrag und um den
/// laufenden Zustand gegen den Server abzugleichen.
private func ladeVerwendungen() async {
guard item.zeigtVerwendung else { return }
let verlauf = (try? await APIClient.shared.itemVerwendungen(id: item.id)) ?? []
verwendungAnzahl = verlauf.count
uebernimm(verlauf.first(where: { $0.laeuft }))
}
/// Offenen Verleih und Historie frisch holen - nach jeder Buchung, damit die
/// Ansicht nicht auf dem Stand des uebergebenen `item` haengen bleibt.
private func ladeVerleihe() async {
@@ -527,7 +740,38 @@ struct ItemEditView: View {
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
loan = item.loan
teile = item.teile
gehoertZu = item.gehoertZu
uebernimm(item.verwendung)
await ladeVerleihe()
await ladeVerwendungen()
}
private func haengeAn(_ gewaehlt: Item) async {
error = nil
do {
_ = try await APIClient.shared.zubehoerZuordnen(
itemId: item.id, childItemId: gewaehlt.id
)
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
private func loese(_ teil: ItemKurz) async {
error = nil
do {
try await APIClient.shared.zubehoerLoesen(itemId: item.id, childItemId: teil.id)
await ladeZubehoer()
onChanged?()
} catch { self.error = error.localizedDescription }
}
private func ladeZubehoer() async {
guard let frisch = try? await APIClient.shared.item(id: item.id) else { return }
teile = frisch.teile
gehoertZu = frisch.gehoertZu
loan = frisch.loan
}
private func save() async {
@@ -641,6 +885,73 @@ struct ItemEditView: View {
// MARK: - Anlegen
extension View {
/// Nach dem Anlegen eines Artikels gleich dessen Einzelstücke erfassen.
///
/// „Artikel anlegen" endete bisher im Nichts: `ProductFormView` liefert den
/// neuen Artikel, aber die Aufrufer warfen ihn weg – und „Einzelstücke
/// anlegen" steckt ausschliesslich in der Artikelansicht, die man von dort
/// aus gar nicht erreicht.
///
/// Die Bindung wird erst im `onDismiss` des Formulars gesetzt: Beides im
/// selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet dann
/// erscheinen soll, waehrend das erste noch verschwindet.
/// Je nach Verwaltungsart geht es zu den Einzelstuecken oder zur Menge je
/// Lagerort. Beides haengt an EINEM sheet-Modifikator, nicht an zweien:
/// Zwei Praesentationen am selben View vertragen sich in SwiftUI nicht
/// zuverlaessig - dann schliesst sich das Blatt beim ersten Mal sofort
/// wieder und erst der zweite Anlauf haelt.
func nachAnlegen(_ artikel: Binding<Product?>) -> some View {
sheet(item: artikel) { p in
NavigationStack {
if fuehrtEinzelstuecke(p) {
ItemAddSheet(productId: p.id,
onDone: {},
onFinish: { artikel.wrappedValue = nil })
} else {
MengeNachAnlegenSheet(product: p) { artikel.wrappedValue = nil }
}
}
}
}
}
/// Hat dieser Artikel ueberhaupt Einzelstuecke? Nur Gegenstaende, die je Stueck
/// eine eigene UID fuehren – Lebensmittel und Mengen-Gegenstaende nicht.
func fuehrtEinzelstuecke(_ p: Product?) -> Bool {
guard let p else { return false }
return p.isObject && p.isIndividual
}
/// Gegenstand, dessen Bestand nur als Menge je Lagerort gezaehlt wird – also
/// weder Einzelstuecke noch Verbrauchsgegenstand (der laeuft als Charge).
func fuehrtMengeJeLagerort(_ p: Product?) -> Bool {
guard let p else { return false }
return p.isObject && !p.isIndividual && !p.isBulk
}
/// Laedt die Lagerorte nach und zeigt dann das vorhandene Mengen-Blatt.
///
/// `ObjectAddSheet` nimmt die Lagerorte als Konstante und holt sie – anders als
/// `ItemAddSheet` – nicht selbst. Direkt nach dem Anlegen hat aber niemand eine
/// Liste zur Hand, deshalb diese duenne Schale.
private struct MengeNachAnlegenSheet: View {
let product: Product
var onFertig: () -> Void
@State private var locations: [StorageLocation] = []
var body: some View {
ObjectAddSheet(
product: product,
locations: locations,
einheit: product.unitName.isEmpty ? "Stück" : product.unitName,
perform: { onFertig() }
)
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
}
}
struct ItemAddSheet: View {
let productId: Int
var onDone: () async -> Void
@@ -671,6 +982,12 @@ struct ItemAddSheet: View {
@State private var busy = false
@State private var error: String?
@State private var locScanShown = false
/// Frisch angelegtes Stueck, dessen Tag gleich beschrieben werden soll.
@State private var nfcItem: Item?
/// Loest den Schub zum Tag-Schritt aus (navigationDestination, iOS 16).
@State private var nfcPush = false
/// Ob nach dem Tag-Schritt bis zur Liste geschlossen wird („Anlegen & fertig").
@State private var nfcFinish = false
var body: some View {
Form {
@@ -678,7 +995,7 @@ struct ItemAddSheet: View {
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
@@ -733,6 +1050,22 @@ struct ItemAddSheet: View {
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
.disabled(busy)
}
// Der Knopf steht hier und nicht weiter oben, weil ein Tag erst
// beschrieben werden kann, wenn das Stueck existiert: Die UID
// vergibt der Server beim Anlegen. Deshalb eine Taste, die
// beides tut - sichtbar an der Stelle, wo man sie sucht.
if count == 1, NFCDienst.verfuegbar {
Button { Task { await create(finish: false, tagDanach: true) } } label: {
Label(busy ? "Anlegen…" : "Anlegen & NFC-Tag beschreiben",
systemImage: "wave.3.right")
}
.disabled(busy)
}
} footer: {
if NFCDienst.verfuegbar && count > 1 {
Text("Ein NFC-Tag gehört zu genau einem Stück. Bei Anzahl 1 lässt "
+ "er sich hier gleich mit beschreiben.")
}
}
}
.navigationTitle("Einzelstücke anlegen")
@@ -766,6 +1099,28 @@ struct ItemAddSheet: View {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
}
// GESCHOBEN und nicht als zweites Blatt aufgelegt. Ein Blatt aus einem
// Blatt heraus ist hier gleich dreifach riskant: Dieses Formular traegt
// schon ein fullScreenCover (Lagerort scannen), und es wird selbst aus
// einer Section heraus gezeigt - wovor ProductDetailView:349 und
// ItemViews:775 jeweils aus eigener Erfahrung warnen. Ein Schub in den
// ohnehin vorhandenen NavigationStack umgeht alle drei Fallen.
.navigationDestination(isPresented: $nfcPush) {
if let stueck = nfcItem {
NFCSchreibenView(item: stueck, onFertig: {
// Jetzt erst auffrischen: vorher raeumte das Neuladen der
// Elternansicht dieses Blatt samt Tag-Schritt ab.
Task {
await onDone()
// Erst den Schub verlassen, dann das Blatt: Solange der
// Tag-Schritt oben liegt, greift dismiss() auf ihn und
// nicht auf das Blatt darunter.
nfcPush = false
if nfcFinish, let onFinish { onFinish() } else { dismiss() }
}
})
}
}
}
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
@@ -797,7 +1152,7 @@ struct ItemAddSheet: View {
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create(finish: Bool) async {
private func create(finish: Bool, tagDanach: Bool = false) async {
busy = true; defer { busy = false }; error = nil
do {
var sid = shopId
@@ -819,12 +1174,72 @@ struct ItemAddSheet: View {
itemId: first.id, data: data,
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
// Weiter zum Tag-Schritt - und zwar BEVOR onDone() laeuft.
//
// onDone() laedt die Elternansicht neu, und dabei wird dieses Blatt
// abgeraeumt: Jeder Zustand, den man danach setzt, laeuft ins Leere.
// Genau daran ist der Schritt vorher unsichtbar gescheitert. Das
// Neuladen holt deshalb „Fertig" im Tag-Schritt nach.
if tagDanach, created.count == 1, NFCDienst.verfuegbar {
nfcFinish = finish
nfcItem = created[0]
nfcPush = true
return
}
await onDone()
if finish, let onFinish { onFinish() } else { dismiss() }
} catch { self.error = error.localizedDescription }
}
}
// MARK: - Verwendungs-Verlauf
/// Alle Verwendungszeitraeume eines Stuecks, neueste zuerst. Abgeschlossene
/// bleiben erhalten - so ist nachvollziehbar, seit wann etwas in der Waesche
/// liegt und wie oft es getragen wurde.
struct VerwendungHistorieView: View {
let uid: String
let itemId: Int
@State private var zeilen: [Verwendung] = []
@State private var geladen = false
var body: some View {
List {
if !geladen {
Text("Wird geladen…").foregroundStyle(.secondary)
} else if zeilen.isEmpty {
Text("War noch nie in Verwendung.").foregroundStyle(.secondary)
}
ForEach(zeilen) { zeile in
VStack(alignment: .leading, spacing: 4) {
HStack {
Label(zeile.status.label, systemImage: zeile.status.symbol)
.font(.body.bold())
Spacer()
if zeile.laeuft {
Text("läuft").font(.caption).foregroundStyle(.secondary)
}
}
Text(anzeigeZeitpunkt(zeile.startedAt)
+ (zeile.endedAt.map { " – " + anzeigeZeitpunkt($0) } ?? ""))
.font(.caption).foregroundStyle(.secondary)
if let von = zeile.username {
Text("von \(von)").font(.caption).foregroundStyle(.secondary)
}
}
.padding(.vertical, 2)
}
}
.navigationTitle("Verwendung \(uid)")
.navigationBarTitleDisplayMode(.inline)
.task {
zeilen = (try? await APIClient.shared.itemVerwendungen(id: itemId)) ?? []
geladen = true
}
}
}
// MARK: - Verleih-Verlauf
/// Alle Verleihe eines Stuecks, neueste zuerst. Zurueckgegebene Vorgaenge bleiben
+498 -9
View File
@@ -51,14 +51,39 @@ struct ShoppingListView: View {
@State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = []
@State private var byLocation: [LocationNeeds] = []
/// Selbst draufgesetzte Posten - von Hand oder aus einem Rezept uebernommen.
@State private var entries: [ShoppingEntry] = []
@State private var busy = true
@State private var error: String?
/// Abgehakte verschwinden: Die Liste ist zum Einkaufen da, nicht zum
/// Nachlesen. Das Aufraeumen ("Erledigte entfernen") macht die
/// Weboberflaeche.
private var offeneEintraege: [ShoppingEntry] { entries.filter { !$0.done } }
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
if !offeneEintraege.isEmpty {
Section("Selbst draufgesetzt") {
ForEach(offeneEintraege) { eintrag in
Button {
Task { await hake(eintrag) }
} label: {
ListRow(
systemImage: eintrag.istNotiz ? "square.and.pencil" : "shippingbox",
title: eintrag.name,
subtitle: eintrag.untertitel
) {
if eintrag.istGruppe { GroupBadge() }
}
}
.buttonStyle(.plain)
}
}
}
if !items.isEmpty {
Section("Artikel unter Mindestbestand") {
ForEach(items) { item in
@@ -103,7 +128,8 @@ struct ShoppingListView: View {
}
}
}
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
if items.isEmpty && groups.isEmpty && byLocation.isEmpty
&& offeneEintraege.isEmpty && !busy {
Text("Nichts einzukaufen – alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary)
}
@@ -114,13 +140,32 @@ struct ShoppingListView: View {
.task { await load() }
}
/// Abhaken. Der Posten verschwindet sofort; geht das Speichern schief,
/// kommt er zurueck - lieber ein Ruecksprung als ein falsches Haekchen.
private func hake(_ eintrag: ShoppingEntry) async {
let vorher = entries
entries.removeAll { $0.id == eintrag.id }
do {
try await APIClient.shared.setShoppingEntryDone(eintrag.id, done: true)
} catch {
entries = vorher
self.error = error.localizedDescription
}
}
private func load() async {
busy = true
defer { busy = false }
do {
items = try await APIClient.shared.shoppingList()
groups = try await APIClient.shared.shoppingGroups()
byLocation = try await APIClient.shared.shoppingByLocation()
// EIN Aufruf statt drei - und der einzige, der die selbst
// draufgesetzten Posten mitbringt. Die drei Einzelrouten liefern
// nur die gerechneten Bedarfe; wer nur sie abfragt, sieht die
// eigenen Eintraege ueberhaupt nicht.
let alles = try await APIClient.shared.shoppingAll()
items = alles.products
groups = alles.groups
byLocation = alles.byLocation
entries = alles.entries
error = nil
} catch {
self.error = error.localizedDescription
@@ -146,7 +191,7 @@ struct ExpiringView: View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(item.productName)
Text("MHD \(display.formatBestBefore(item.bestBefore, precision: item.bestBeforePrecision)) · \(articleAmount(item))")
Text("\(item.fristLabel) \(display.formatBestBefore(item.warnDate, precision: item.fristPrecision)) · \(articleAmount(item))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
@@ -362,6 +407,10 @@ struct ProductListView: View {
@State private var verwaltungFilter: String?
@State private var busy = false
@State private var showNew = false
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
@State private var angelegt: Product?
/// Der gerade angelegte Artikel, bis der Folgeschritt dafuer durch ist.
@State private var nachAnlegenFuer: Product?
enum SortMode: String, CaseIterable, Identifiable {
case nameAsc = "Name A–Z"
@@ -488,12 +537,19 @@ struct ProductListView: View {
}
}
}
.sheet(isPresented: $showNew) {
.sheet(isPresented: $showNew, onDismiss: {
if fuehrtEinzelstuecke(angelegt) || fuehrtMengeJeLagerort(angelegt) {
nachAnlegenFuer = angelegt
}
angelegt = nil
}) {
NavigationStack {
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
// Liste wird aufgefrischt.
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
// vorbelegen. Nach dem Anlegen schließt sich das Formular, die
// Liste wird aufgefrischt – und bei Gegenständen geht es gleich
// weiter: zu den Einzelstücken, oder zur Menge je Lagerort.
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in
angelegt = created
showNew = false
Task { await load() }
}
@@ -504,6 +560,7 @@ struct ProductListView: View {
}
}
}
.nachAnlegen($nachAnlegenFuer)
.task { categories = (try? await APIClient.shared.categories()) ?? [] }
// Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der
// Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte
@@ -578,6 +635,11 @@ struct ProductRow: View {
if let size = product.packageSize, size > 0 {
text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))"
}
// Marke voran: Die Suche findet Artikel ueber sie, ohne sie in der
// Zeile bliebe ein Treffer auf "Jack and Jones" unerklaerlich.
if let marke = product.brand, !marke.isEmpty {
text = "\(marke) · \(text)"
}
return text
}
@@ -595,3 +657,430 @@ struct ProductRow: View {
product.stockLevel == .below ? .red : .orange
}
}
// MARK: - Chargen
/// Alle Chargen über alle Artikel – das Gegenstück zur Chargenliste im Web.
///
/// Hier passiert das Anbrechen und der Teilverbrauch. Beides gab es in der App
/// bisher nirgends: Chargen waren nur im Artikel selbst zu sehen, und dort nur
/// zum Korrigieren.
struct ChargenListView: View {
@EnvironmentObject private var display: DisplaySettings
@State private var zeilen: [LotRow] = []
@State private var locations: [StorageLocation] = []
@State private var suche = ""
@State private var nurAngebrochen = false
@State private var verbrauchen: LotRow?
@State private var anbrechen: LotRow?
@State private var ansehen: LotRow?
@State private var busy = true
@State private var error: String?
@State private var hinweis: String?
private var gefiltert: [LotRow] {
zeilen.filter { zeile in
if nurAngebrochen && !zeile.istAngebrochen { return false }
guard !suche.isEmpty else { return true }
let text = "\(zeile.productName) \(zeile.productBrand ?? "")".lowercased()
return text.contains(suche.lowercased())
}
}
var body: some View {
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
if let hinweis {
Section { Text(hinweis).foregroundStyle(.green).font(.callout) }
}
Section {
Toggle("Nur angebrochene", isOn: $nurAngebrochen)
}
Section("\(gefiltert.count) Chargen") {
ForEach(gefiltert) { zeile in
// Antippen oeffnet die Charge. Das Wischen allein genuegte
// nicht: Man sieht es nicht, und man kam gar nicht an die
// Angaben heran.
Button {
ansehen = zeile
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(zeile.productName)
if zeile.istAngebrochen {
Text("angebrochen")
.font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 2)
.background(Color.accentColor.opacity(0.15))
.clipShape(Capsule())
}
}
Text(untertitel(zeile))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
}
}
.foregroundStyle(.primary)
// Nach links wischen: die beiden Buchungen. Loeschen gibt es
// hier bewusst nicht - eine Charge verschwindet durchs
// Verbrauchen, und das Korrigieren sitzt beim Artikel.
.swipeActions(edge: .trailing) {
Button {
verbrauchen = zeile
} label: { Label("Verbrauchen", systemImage: "minus.circle") }
.tint(.orange)
if !zeile.istAngebrochen {
Button {
anbrechen = zeile
} label: { Label("Anbrechen", systemImage: "arrow.up.bin") }
.tint(.blue)
}
}
}
if gefiltert.isEmpty && !busy {
Text(nurAngebrochen ? "Keine angebrochene Packung."
: "Keine Chargen im Bestand.")
.foregroundStyle(.secondary)
}
}
}
.searchable(text: $suche, prompt: "Artikel suchen")
.navigationTitle("Chargen")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.task { await load() }
.sheet(item: $ansehen) { zeile in
NavigationStack {
ChargeDetailSheet(zeile: zeile, locations: locations) { meldung in
hinweis = meldung
Task { await load() }
}
}
}
.sheet(item: $verbrauchen) { zeile in
NavigationStack {
VerbrauchSheet(zeile: zeile) { meldung in
hinweis = meldung
Task { await load() }
}
}
}
.confirmationDialog("Eine Packung anbrechen?",
isPresented: Binding(get: { anbrechen != nil },
set: { if !$0 { anbrechen = nil } }),
titleVisibility: .visible) {
Button("Anbrechen") {
if let zeile = anbrechen { Task { await brichAn(zeile) } }
anbrechen = nil
}
Button("Abbrechen", role: .cancel) { anbrechen = nil }
} message: {
Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.")
}
}
private func untertitel(_ zeile: LotRow) -> String {
var teile = [zeile.mengeText]
if zeile.istAngebrochen, let start = zeile.openedQuantity {
teile.append("von \(formatAmount(start)) \(zeile.baseKurz)")
}
if let bis = zeile.consumeBy {
teile.append("verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))")
} else if zeile.bestBefore != nil {
teile.append("MHD \(display.formatBestBefore(zeile.bestBefore, precision: zeile.bestBeforePrecision))")
}
if let id = zeile.locationId, let ort = locations.first(where: { $0.id == id }) {
teile.append(ort.path(in: locations))
}
return teile.joined(separator: " · ")
}
private func brichAn(_ zeile: LotRow) async {
do {
let offen = try await APIClient.shared.openLot(id: zeile.id)
if let bis = offen.consumeBy {
hinweis = "Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))."
} else {
hinweis = "Angebrochen."
}
error = nil
await load()
} catch {
self.error = error.localizedDescription
}
}
private func load() async {
busy = true
defer { busy = false }
do {
zeilen = try await APIClient.shared.allLotRows()
locations = (try? await APIClient.shared.locations()) ?? []
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
/// Teilmenge aus einer Charge verbrauchen – „100 ml genommen“, „2 EL Mehl“.
///
/// Eingetippt wird die VERBRAUCHTE Menge, nicht der Rest: Das ist die Zahl, die
/// man kennt. War die Charge noch zu, wird sie dabei angebrochen; der Haken
/// dafür ist abwählbar, falls man nur eine Buchung nachträgt.
struct VerbrauchSheet: View {
let zeile: LotRow
var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var menge = ""
@State private var einheit = ""
@State private var notiz = ""
@State private var alsAngebrochen = true
@State private var masse: [MeasureAmount] = []
@State private var busy = false
@State private var error: String?
/// Alles, was als Einheit gewählt werden kann: Basiseinheit, Gebinde und
/// die Hausmaße der Kategorie.
private var einheiten: [(token: String, label: String)] {
var liste: [(token: String, label: String)] = [(zeile.baseToken, zeile.baseKurz)]
if let p = zeile.packageSize, p > 0 {
let name = zeile.packageLabel ?? "Packung"
liste.append(("package", "\(name) (\(formatAmount(p)) \(zeile.baseKurz))"))
}
for mass in masse {
let kurz = mass.short.map { " (\($0))" } ?? ""
liste.append((mass.token,
"\(mass.name)\(kurz) ≈ \(formatAmount(mass.amount)) \(kurzFuer(mass.base))"))
}
return liste
}
private func kurzFuer(_ base: String) -> String {
switch base {
case "gram": return "g"
case "milliliter": return "ml"
default: return "Stück"
}
}
private var zahl: Double? {
Double(menge.replacingOccurrences(of: ",", with: "."))
}
var body: some View {
Form {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Text(zeile.productName).font(.headline)
Text("aktuell \(zeile.mengeText)").font(.caption).foregroundStyle(.secondary)
}
Section("Verbrauchte Menge") {
QuantityField(label: "Menge", text: $menge)
Picker("Einheit", selection: $einheit) {
ForEach(einheiten, id: \.token) { eintrag in
Text(eintrag.label).tag(eintrag.token)
}
}
}
if masse.isEmpty {
Section {
// Das schließende Anführungszeichen MUSS das typografische
// sein: Ein gerades " würde die Zeichenkette hier beenden.
Text("Für die Kategorie dieses Artikels ist kein Hausmaß hinterlegt – „2 EL“ lässt sich deshalb noch nicht buchen. Mengen dafür stehen in der Weboberfläche unter Kategorien.")
.font(.caption).foregroundStyle(.secondary)
}
}
if !zeile.istAngebrochen {
Section {
Toggle("Packung als angebrochen führen", isOn: $alsAngebrochen)
} footer: {
Text("Eine Packung wird abgespalten und bekommt ein Verbrauchsdatum; der Rest behält sein MHD. Ohne Haken wird nur die Menge abgebucht.")
}
}
Section("Notiz (freiwillig)") {
TextField("z.B. für Pfannkuchen", text: $notiz)
}
Section {
Button(busy ? "Wird gebucht…" : "Verbrauchen") { Task { await buche() } }
.disabled(busy || zahl == nil || (zahl ?? 0) <= 0 || einheit.isEmpty)
}
}
.navigationTitle("Verbrauchen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.task {
einheit = zeile.baseToken
masse = (try? await APIClient.shared.productMeasures(productId: zeile.productId)) ?? []
}
}
private func buche() async {
guard let wert = zahl, wert > 0 else { return }
busy = true
defer { busy = false }
do {
let antwort = try await APIClient.shared.consumeLot(
id: zeile.id, quantity: wert, unit: einheit,
note: notiz.isEmpty ? nil : notiz,
autoOpen: zeile.istAngebrochen ? false : alsAngebrochen)
dismiss()
if antwort.lot == nil {
onDone("Verbraucht – die Charge ist damit leer.")
} else {
onDone(antwort.opened ? "Verbraucht und angebrochen." : "Verbraucht.")
}
} catch {
self.error = error.localizedDescription
}
}
}
/// Eine Charge ansehen – und von hier aus anbrechen oder verbrauchen.
///
/// Das Wischen in der Liste allein genügt nicht: Man sieht es nicht, und man
/// sieht dort auch nicht, was an der Charge dranhängt. Hier stehen alle Angaben
/// und beide Handlungen als sichtbare Knöpfe.
struct ChargeDetailSheet: View {
let zeile: LotRow
let locations: [StorageLocation]
/// Meldung nach einer Buchung – die Liste zeigt sie an und lädt neu.
var onDone: (String) -> Void
@EnvironmentObject private var display: DisplaySettings
@Environment(\.dismiss) private var dismiss
@State private var anbrechenGefragt = false
@State private var busy = false
@State private var error: String?
private var ortText: String {
guard let id = zeile.locationId,
let ort = locations.first(where: { $0.id == id }) else { return "ohne Lagerort" }
return ort.path(in: locations)
}
var body: some View {
Form {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
Section {
Text(zeile.productName).font(.headline)
if let marke = zeile.productBrand, !marke.isEmpty {
Text(marke).font(.caption).foregroundStyle(.secondary)
}
}
Section("Bestand") {
LabeledContent("Menge", value: zeile.mengeText)
if zeile.istAngebrochen, let start = zeile.openedQuantity {
LabeledContent("Beim Öffnen", value: "\(formatAmount(start)) \(zeile.baseKurz)")
}
LabeledContent("Zustand",
value: zeile.istAngebrochen ? "angebrochen" : "ungeöffnet")
LabeledContent("Lagerort", value: ortText)
if let seit = zeile.createdAt {
LabeledContent("Eingelagert",
value: display.formatBestBefore(String(seit.prefix(10)),
precision: "day"))
}
}
Section("Fristen") {
LabeledContent("MHD", value: zeile.bestBefore == nil
? "ohne MHD"
: display.formatBestBefore(zeile.bestBefore,
precision: zeile.bestBeforePrecision))
if let geoeffnet = zeile.openedOn {
LabeledContent("Angebrochen am",
value: display.formatBestBefore(geoeffnet, precision: "day"))
}
if let bis = zeile.consumeBy {
LabeledContent("Verbrauchen bis",
value: display.formatBestBefore(bis, precision: "day"))
}
}
Section {
// Bewusst ein Weiterblaettern und kein zweites Fenster: Ein
// Sheet im Sheet aufzuziehen und beide gleich wieder zu
// schliessen ist in SwiftUI eine zuverlaessige Quelle fuer
// haengende Ansichten.
NavigationLink {
VerbrauchSheet(zeile: zeile) { meldung in
dismiss()
onDone(meldung)
}
} label: {
Label("Verbrauchen", systemImage: "minus.circle")
}
if !zeile.istAngebrochen {
Button {
anbrechenGefragt = true
} label: {
Label("Anbrechen", systemImage: "arrow.up.bin")
}
.disabled(busy)
}
} footer: {
Text(zeile.istAngebrochen
? "Diese Packung ist offen. Was du verbrauchst, geht von ihr ab."
: "Anbrechen spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum; "
+ "der Rest bleibt ungeöffnet mit seinem MHD.")
}
Section {
// Eine einzelne Charge hat keinen eigenen Verlauf im Backend;
// die Bewegungen haengen am Artikel. Das ist hier die naechste
// Frage nach "was ist das" - "was ist damit passiert".
NavigationLink {
HistoryView(productId: zeile.productId)
} label: {
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
}
}
}
.navigationTitle("Charge")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
}
.confirmationDialog("Eine Packung anbrechen?", isPresented: $anbrechenGefragt,
titleVisibility: .visible) {
Button("Anbrechen") { Task { await brichAn() } }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Eine Packung wird als eigene, offene Charge geführt und bekommt ein Verbrauchsdatum. Der Rest bleibt ungeöffnet mit seinem MHD.")
}
}
private func brichAn() async {
busy = true
defer { busy = false }
do {
let offen = try await APIClient.shared.openLot(id: zeile.id)
dismiss()
if let bis = offen.consumeBy {
onDone("Angebrochen – zu verbrauchen bis \(display.formatBestBefore(bis, precision: "day")).")
} else {
onDone("Angebrochen.")
}
} catch {
self.error = error.localizedDescription
}
}
}
+4 -1
View File
@@ -23,7 +23,8 @@ struct LookupScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode, Einzelstück- oder Lagerort-QR scannen – nur ansehen")
Text("Barcode, Einzelstück- oder Lagerort-QR scannen – oder unten "
+ "einen NFC-Tag lesen. Nur ansehen.")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -41,6 +42,8 @@ struct LookupScanView: View {
} onFehler: { meldung in
error = meldung
}
// Unauffaellig unter der Kamerakarte uebersieht man ihn sonst.
.buttonStyle(.bordered)
if let error {
Text(error).font(.callout).foregroundStyle(.red)
+24 -56
View File
@@ -197,33 +197,11 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
@State private var addShown = false
@State private var pendingDeletion: Item?
private struct Node: Identifiable {
let item: Item
let depth: Int
var id: Item.ID { item.id }
}
private var shown: [Item] { items.filter(include) }
/// Baumreihenfolge mit Tiefe. Waisen (Elternteil gefiltert/gelöscht) gelten
/// als oberste Ebene, damit nichts verschwindet.
private var nodes: [Node] {
let list = shown
let ids = Set(list.map(\.id))
var result: [Node] = []
func walk(_ n: Item, _ depth: Int) {
result.append(Node(item: n, depth: depth))
for child in list.filter({ $0.parentId == n.id }) { walk(child, depth + 1) }
}
for root in list.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0)
}
return result
}
private func childCount(_ id: Item.ID) -> Int { shown.filter { $0.parentId == id }.count }
private var visible: [Node] {
private var visible: [Baumzeile<Item>] {
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
func hidden(_ id: Item.ID) -> Bool {
var p = parent[id] ?? nil
@@ -233,7 +211,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
}
return false
}
return nodes.filter { !hidden($0.item.id) }
return baum(shown).filter { !hidden($0.item.id) }
}
var body: some View {
@@ -286,7 +264,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
}
@ViewBuilder
private func row(_ node: Node) -> some View {
private func row(_ node: Baumzeile<Item>) -> some View {
let kinder = childCount(node.item.id)
let zu = collapsed.contains(node.item.id)
HStack(spacing: 8) {
@@ -321,7 +299,7 @@ struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
}
.buttonStyle(.plain)
}
.padding(.leading, CGFloat(node.depth) * 16)
.padding(.leading, CGFloat(node.tiefe) * 16)
}
private func toggle(_ id: Item.ID) {
@@ -468,8 +446,8 @@ struct LocationEditor: View {
Section {
Picker("Übergeordnet", selection: $parentId) {
Text("– oberste Ebene –").tag(String?.none)
ForEach(parentOptions) { loc in
Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
ForEach(baum(parentOptions)) { eintrag in
Text(eintrag.label).tag(String?.some(eintrag.item.id))
}
}
} header: {
@@ -523,19 +501,6 @@ struct LocationEditor: View {
}
return result
}
/// "Keller → Regal" – der Pfad macht gleiche Namen im flachen Picker
/// unterscheidbar.
private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String {
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
var teile = [loc.name]
var pid = loc.parentId
while let cur = pid, let parent = byId[cur] {
teile.insert(parent.name, at: 0)
pid = parent.parentId
}
return teile.joined(separator: " → ")
}
}
struct UnitsView: View {
@@ -792,6 +757,7 @@ struct CategoryEditor: View {
@State private var name: String
@State private var tracking: String
@State private var usageTracking: Bool
@State private var parentId: Int? // nur beim Anlegen
@State private var error: String?
@State private var busy = false
@@ -802,6 +768,7 @@ struct CategoryEditor: View {
self.done = done
_name = State(initialValue: item?.name ?? "")
_tracking = State(initialValue: item?.tracking ?? "object")
_usageTracking = State(initialValue: item?.fuehrtVerwendung ?? false)
_parentId = State(initialValue: nil)
}
@@ -820,8 +787,8 @@ struct CategoryEditor: View {
Section {
Picker("Übergeordnet", selection: $parentId) {
Text("– oberste Ebene –").tag(Int?.none)
ForEach(all) { c in
Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id))
ForEach(baum(all)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.item.id))
}
}
} header: {
@@ -847,6 +814,14 @@ struct CategoryEditor: View {
}
}
if !erbtVonEltern {
Section {
Toggle("Verwendungsstatus führen", isOn: $usageTracking)
} footer: {
Text("Einzelstücke dieser Kategorie führen, woran sie gerade sind – in Verwendung, in Reinigung, in Reparatur. Gedacht für Kleidung und Werkzeug, nicht für Vorräte. Beim Bearbeiten gilt die Einstellung auch für alle Unterkategorien.")
}
}
if let item {
Section {
NavigationLink {
@@ -882,13 +857,17 @@ struct CategoryEditor: View {
do {
if let item {
_ = try await APIClient.shared.updateCategory(
id: item.id, CategoryUpdateRequest(name: n, tracking: tracking))
id: item.id,
CategoryUpdateRequest(name: n, tracking: tracking,
usageTracking: usageTracking))
} else {
// Mit Oberkategorie die Art erben (tracking = nil), sonst die
// gewählte Art.
_ = try await APIClient.shared.createCategory(
NewCategoryRequest(name: n, parentId: parentId,
tracking: parentId == nil ? tracking : nil))
tracking: parentId == nil ? tracking : nil,
// Mit Oberkategorie die Markierung erben.
usageTracking: parentId == nil ? usageTracking : nil))
}
done()
dismiss()
@@ -896,17 +875,6 @@ struct CategoryEditor: View {
self.error = error.localizedDescription
}
}
private static func pfad(_ c: CategoryItem, in all: [CategoryItem]) -> String {
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
var teile = [c.name]
var pid = c.parentId
while let cur = pid, let parent = byId[cur] {
teile.insert(parent.name, at: 0)
pid = parent.parentId
}
return teile.joined(separator: " → ")
}
}
// MARK: - Eigene Felder je Kategorie
+363 -3
View File
@@ -344,6 +344,14 @@ struct Lot: Codable, Identifiable, Hashable {
let bestBefore: String?
let bestBeforePrecision: String?
let locationId: String?
/// Oeffnungstag. nil = die Packung ist noch zu.
let openedOn: String?
/// "Zu verbrauchen bis" - beim Anbrechen aus der Frist gerechnet.
let consumeBy: String?
/// Menge beim Oeffnen, damit "noch 400 von 1000 ml" anzeigbar ist.
let openedQuantity: Double?
var istAngebrochen: Bool { openedOn != nil }
enum CodingKeys: String, CodingKey {
case id, quantity
@@ -351,9 +359,121 @@ struct Lot: Codable, Identifiable, Hashable {
case bestBefore = "best_before"
case bestBeforePrecision = "best_before_precision"
case locationId = "location_id"
case openedOn = "opened_on"
case consumeBy = "consume_by"
case openedQuantity = "opened_quantity"
}
}
/// Eine Charge mit den Artikelangaben dran - fuer die uebergreifende
/// Chargenliste, in der Chargen verschiedener Artikel nebeneinander stehen.
struct LotRow: Codable, Identifiable, Hashable {
let id: Int
let productId: Int
let productName: String
let productBrand: String?
let quantity: Double
let baseUnit: String
let packageSize: Double?
let packageLabel: String?
let unitName: String
let unitFactor: Double
let bestBefore: String?
let bestBeforePrecision: String?
let locationId: String?
let openedOn: String?
let consumeBy: String?
let openedQuantity: Double?
/// Wann die Charge eingelagert wurde - gehoert zu den Angaben, die man an
/// einer Charge sucht ("seit wann liegt das hier?").
let createdAt: String?
var istAngebrochen: Bool { openedOn != nil }
/// Basiseinheiten je Artikeleinheit: das Gebinde, sonst die Anzeigeeinheit.
var artikelFaktor: Double {
if let p = packageSize, p > 0 { return p }
return unitFactor > 0 ? unitFactor : 1
}
/// Wie die Menge dasteht. Bei einer offenen Packung zaehlt die Restmenge in
/// Basiseinheiten - "0,98 Packungen" sagt niemandem etwas, "980 g" schon.
var mengeText: String {
if istAngebrochen { return "\(formatAmount(quantity)) \(baseKurz)" }
let menge = quantity / artikelFaktor
let label = (packageSize ?? 0) > 0 ? (packageLabel ?? "Packung")
: (unitName.isEmpty ? baseKurz : unitName)
return "\(formatAmount(menge)) \(label)"
}
var baseKurz: String {
switch baseUnit {
case "gram": return "g"
case "milliliter": return "ml"
default: return "Stück"
}
}
/// Token fuer die Basiseinheit, wie es das Backend beim Buchen erwartet.
var baseToken: String {
switch baseUnit {
case "gram": return "g"
case "milliliter": return "ml"
default: return "piece"
}
}
enum CodingKeys: String, CodingKey {
case id, quantity
case productId = "product_id"
case productName = "product_name"
case productBrand = "product_brand"
case baseUnit = "base_unit"
case packageSize = "package_size"
case packageLabel = "package_label"
case unitName = "unit_name"
case unitFactor = "unit_factor"
case bestBefore = "best_before"
case bestBeforePrecision = "best_before_precision"
case locationId = "location_id"
case openedOn = "opened_on"
case consumeBy = "consume_by"
case openedQuantity = "opened_quantity"
case createdAt = "created_at"
}
}
/// Ein Hausmass, das fuer einen Artikel wirklich gilt - "1 EL = 10 g".
///
/// Die Mengen haengen an der Kategorie (oder am Artikel), nicht am Mass: Ein
/// Essloeffel Mehl wiegt anders als einer Zucker.
struct MeasureAmount: Codable, Identifiable, Hashable {
let measureId: Int
let name: String
let short: String?
let amount: Double
let base: String
let herkunft: String
var id: Int { measureId }
/// Was beim Buchen als Einheit mitgeschickt wird.
var token: String { name }
enum CodingKeys: String, CodingKey {
case name, short, amount, base, herkunft
case measureId = "measure_id"
}
}
/// Antwort auf einen Teilverbrauch.
struct ConsumeResult: Codable {
/// Wurde die Charge dabei angebrochen?
let opened: Bool
/// Die offene Charge danach - nil, wenn sie dabei leer geworden ist.
let lot: Lot?
}
struct StorageLocation: Codable, Identifiable, Hashable {
let id: String
let name: String
@@ -482,6 +602,59 @@ struct NewProductRequest: Codable {
// MARK: - Listen (Einkaufsliste, bald ablaufend)
/// Ein Posten, den jemand selbst auf die Einkaufsliste gesetzt hat - von Hand
/// oder uebernommen aus einem Rezept.
///
/// Die gerechneten Posten (Artikel und Gruppen unter ihrem Mindestbestand)
/// entstehen bei jedem Aufruf neu; diese hier liegen in der Datenbank und
/// bleiben, bis sie abgehakt sind. Beides zusammen ergibt erst die Liste.
struct ShoppingEntry: Codable, Identifiable {
let id: Int
/// "product" | "group" | "text"
let art: String
let name: String
/// "2 Packungen" bzw. "150 g" - fertig formatiert vom Server, damit App und
/// Weboberflaeche dieselbe Sprache sprechen.
let mengeText: String
let source: String?
let recipeName: String?
let done: Bool
var istNotiz: Bool { art == "text" }
var istGruppe: Bool { art == "group" }
/// Zeile unter dem Namen: woher der Posten kommt und wie viel fehlt.
var untertitel: String {
[recipeName.map { "für \($0)" }, mengeText.isEmpty ? nil : mengeText]
.compactMap { $0 }
.joined(separator: " · ")
}
enum CodingKeys: String, CodingKey {
case id, art, name, source, done
case mengeText = "menge_text"
case recipeName = "recipe_name"
}
}
/// Die komplette Einkaufsliste in einer Antwort: gerechnete Bedarfe UND
/// gespeicherte Eintraege. Die drei Einzelrouten liefern nur die gerechnete
/// Seite - wer nur sie abfragt, sieht die selbst draufgesetzten Posten nicht.
struct ShoppingListAll: Codable {
let products: [ShoppingItem]
let groups: [GroupShoppingItem]
let byLocation: [LocationNeeds]
let entries: [ShoppingEntry]
/// Kennungen der abgehakten GERECHNETEN Posten. Die App zeigt sie noch
/// nicht an, liest sie aber mit, damit das Feld nicht ueberrascht.
let checked: [String]
enum CodingKeys: String, CodingKey {
case products, groups, entries, checked
case byLocation = "by_location"
}
}
struct ShoppingItem: Codable, Identifiable {
let productId: Int
let name: String
@@ -617,8 +790,19 @@ struct ExpiringItem: Codable, Identifiable {
let productName: String
let quantity: Double
let baseUnit: String
let bestBefore: String
/// Darf fehlen! Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein
/// Verbrauchsdatum - und genau die gehoert in diese Liste. Stand hier frueher
/// ein nicht-optionaler String, scheiterte das Entschluesseln der GANZEN
/// Liste an einer einzigen solchen Charge, samt Erinnerungen.
let bestBefore: String?
let bestBeforePrecision: String?
let openedOn: String?
let consumeBy: String?
/// Das Datum, nach dem gewarnt wird: das fruehere von MHD und
/// Verbrauchsdatum. Immer gesetzt - der Server rechnet es aus.
let warnDate: String
/// "best_before" oder "consume_by" - sagt, welches der beiden Daten gewann.
let warnSource: String?
let daysLeft: Int
let packageSize: Double?
let packageLabel: String?
@@ -627,6 +811,17 @@ struct ExpiringItem: Codable, Identifiable {
var id: Int { lotId }
/// Ist die Packung angebrochen und draengt deshalb?
var istAngebrochen: Bool { warnSource == "consume_by" }
/// Beschriftung vor dem Datum. "MHD 05.10." und "verbrauchen bis 05.10."
/// sind zwei verschiedene Aussagen, und die Liste zeigt beide nebeneinander.
var fristLabel: String { istAngebrochen ? "verbrauchen bis" : "MHD" }
/// Genauigkeit des angezeigten Datums. Ein Verbrauchsdatum ist immer
/// taggenau, auch wenn auf der Packung nur ein Monat stand.
var fristPrecision: String? { istAngebrochen ? "day" : bestBeforePrecision }
/// Restlaufzeit als kurzer Text. „0 Tage" ergibt keinen Sinn - am Ablauftag
/// selbst steht „heute". Karte und Liste nutzen dieselbe Formulierung.
var restlaufzeitText: String {
@@ -644,6 +839,10 @@ struct ExpiringItem: Codable, Identifiable {
case baseUnit = "base_unit"
case bestBefore = "best_before"
case bestBeforePrecision = "best_before_precision"
case openedOn = "opened_on"
case consumeBy = "consume_by"
case warnDate = "warn_date"
case warnSource = "warn_source"
case daysLeft = "days_left"
case packageSize = "package_size"
case packageLabel = "package_label"
@@ -742,6 +941,22 @@ struct ProductUpdateRequest: Encodable {
}
}
/// Teilverbrauch aus einer Charge.
///
/// `unit` nimmt dasselbe wie das Auslagern: die Basiseinheit, eine verwaltete
/// Einheit, "package" - und die Hausmasse der Kategorie ("EL", "Tasse").
struct ConsumeRequest: Encodable {
var quantity: Double
var unit: String
var note: String?
var autoOpen: Bool
enum CodingKeys: String, CodingKey {
case quantity, unit, note
case autoOpen = "auto_open"
}
}
struct LotUpdateRequest: Encodable {
var quantity: Double?
var bestBefore: String?
@@ -864,13 +1079,18 @@ struct CategoryItem: Codable, Identifiable, Hashable {
let name: String
let parentId: Int?
let tracking: String?
/// Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren.
/// Optional, weil ein aelterer Server das Feld nicht schickt.
var usageTracking: Bool?
enum CodingKeys: String, CodingKey {
case id, name, tracking
case parentId = "parent_id"
case usageTracking = "usage_tracking"
}
var isObject: Bool { tracking == "object" }
var fuehrtVerwendung: Bool { usageTracking ?? false }
}
// MARK: - Hierarchische Stammdaten
@@ -1051,6 +1271,73 @@ struct ItemDocument: Codable, Identifiable, Hashable {
///
/// `daysOverdue` rechnet der Server, damit App und Web nicht getrennt rechnen -
/// nil heisst: ohne Frist, zurueck, oder noch nicht faellig.
/// Woran ein Einzelstueck gerade ist.
///
/// "Verfuegbar" ist bewusst kein Serverwert, sondern das Fehlen eines
/// laufenden Zeitraums - deshalb traegt der Ruhezustand hier `nil`. Absichtlich
/// allgemein und nicht auf Kleidung zugeschnitten: Eine Bohrmaschine auf der
/// Baustelle ist genauso "in Verwendung".
enum VerwendungStatus: String, Codable, CaseIterable, Identifiable, Hashable {
case inUse = "in_use"
case cleaning = "cleaning"
case repair = "repair"
var id: String { rawValue }
var label: String {
switch self {
case .inUse: return "In Verwendung"
case .cleaning: return "In Reinigung"
case .repair: return "In Reparatur"
}
}
var symbol: String {
switch self {
case .inUse: return "figure.walk"
case .cleaning: return "drop"
case .repair: return "wrench.and.screwdriver"
}
}
/// Die vier Schaltstellungen inklusive des Ruhezustands (nil).
static let auswahl: [VerwendungStatus?] = [nil] + allCases.map { Optional($0) }
static func label(_ status: VerwendungStatus?) -> String {
status?.label ?? "Verfügbar"
}
static func symbol(_ status: VerwendungStatus?) -> String {
status?.symbol ?? "checkmark.circle"
}
}
/// Ein Zeitraum in einem Verwendungszustand - laufend oder abgeschlossen.
struct Verwendung: Codable, Identifiable, Hashable {
let id: Int
let itemId: Int
let status: VerwendungStatus
let startedAt: String
/// nil = laeuft noch.
let endedAt: String?
let username: String?
var laeuft: Bool { endedAt == nil }
enum CodingKeys: String, CodingKey {
case id, status, username
case itemId = "item_id"
case startedAt = "started_at"
case endedAt = "ended_at"
}
}
struct VerwendungSetzenRequest: Encodable {
/// nil = verfuegbar. Muss AUCH dann mitgeschickt werden, sonst liest der
/// Server den Vorgabewert und nicht "verfuegbar".
let status: String?
}
struct Loan: Codable, Identifiable, Hashable {
let id: Int
let itemId: Int
@@ -1094,18 +1381,55 @@ struct LoanCreateRequest: Encodable {
var reason: String? = nil
var lentOn: String? = nil
var dueOn: String? = nil
/// Zubehoerteile, die mitgehen. Leer = nur das Geraet selbst; der Server
/// nimmt nie ungefragt etwas mit.
var mitZubehoer: [Int] = []
enum CodingKeys: String, CodingKey {
case borrower, reason
case lentOn = "lent_on"
case dueOn = "due_on"
case mitZubehoer = "mit_zubehoer"
}
}
struct LoanReturnRequest: Encodable {
var returnedOn: String? = nil
/// Spiegelbild zu LoanCreateRequest: Ohne das kaeme das Geraet zurueck und
/// sein Netzteil bliebe ewig „verliehen".
var mitZubehoer: [Int] = []
enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" }
enum CodingKeys: String, CodingKey {
case returnedOn = "returned_on"
case mitZubehoer = "mit_zubehoer"
}
}
struct ZubehoerZuordnenRequest: Encodable {
let childItemId: Int
enum CodingKeys: String, CodingKey { case childItemId = "child_item_id" }
}
/// Ein verknuepftes Einzelstueck in Kurzform (Zubehoer bzw. das Geraet dazu).
///
/// Bewusst nicht `Item`: sonst zoege das Geraet seine Teile und jedes Teil
/// wieder sein Geraet nach, endlos.
struct ItemKurz: Codable, Identifiable, Hashable {
let id: Int
let uid: String
let productId: Int
let productName: String?
let productBrand: String?
let locationName: String?
let verliehen: Bool
enum CodingKeys: String, CodingKey {
case id, uid, verliehen
case productId = "product_id"
case productName = "product_name"
case productBrand = "product_brand"
case locationName = "location_name"
}
}
struct Item: Codable, Identifiable, Hashable {
@@ -1130,11 +1454,37 @@ struct Item: Codable, Identifiable, Hashable {
/// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil.
/// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck.
let loan: Loan?
/// Zubehoer dieses Geraets und, umgekehrt, das Geraet, zu dem dieses Stueck
/// gehoert.
///
/// Beide optional, und das ist keine Bequemlichkeit: Die App spricht mit
/// mehreren Serverprofilen, und ein aelterer Server schickt die Felder gar
/// nicht. Ein nicht-optionales Feld mit Vorgabewert reicht dafuer NICHT -
/// Swift nimmt den Vorgabewert beim Dekodieren nicht her, es wuerde werfen.
var zubehoer: [ItemKurz]?
var gehoertZu: ItemKurz?
/// Der laufende Verwendungszustand - nil heisst verfuegbar. Anders als beim
/// Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg.
///
/// Beide Felder optional, aus demselben Grund wie `zubehoer` oben: ein
/// aelterer Server schickt sie gar nicht.
var verwendung: Verwendung?
/// Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt.
var verwendungMoeglich: Bool?
/// Zubehoer ohne Optional-Gehampel an der Aufrufstelle.
var teile: [ItemKurz] { zubehoer ?? [] }
var istVerliehen: Bool { loan != nil }
/// Ohne Optional-Gehampel an der Aufrufstelle.
var zeigtVerwendung: Bool { verwendungMoeglich ?? false }
var verwendungStatus: VerwendungStatus? { verwendung?.status }
enum CodingKeys: String, CodingKey {
case id, uid, note, currency, documents, loan
case id, uid, note, currency, documents, loan, zubehoer, verwendung
case gehoertZu = "gehoert_zu"
case verwendungMoeglich = "verwendung_moeglich"
case productId = "product_id"
case locationId = "location_id"
case locationName = "location_name"
@@ -1515,16 +1865,26 @@ struct NewCategoryRequest: Codable {
let name: String
let parentId: Int?
var tracking: String? = nil
/// nil = erbt vom Elternteil bzw. neue Oberkategorie = aus.
var usageTracking: Bool? = nil
enum CodingKeys: String, CodingKey {
case name, tracking
case parentId = "parent_id"
case usageTracking = "usage_tracking"
}
}
struct CategoryUpdateRequest: Codable {
let name: String?
let tracking: String?
/// Wird vom Server in den ganzen Unterbaum uebernommen.
var usageTracking: Bool? = nil
enum CodingKeys: String, CodingKey {
case name, tracking
case usageTracking = "usage_tracking"
}
}
struct NewShopRequest: Codable {
+53 -9
View File
@@ -99,6 +99,48 @@ final class NFCDienst: NSObject {
}
}
/// Kurzfassung eines CoreNFC-Fehlers.
///
/// Apples ``localizedDescription`` ist hier unbrauchbar – sie liefert Dinge
/// wie „Stack Error", die niemandem sagen, was zu tun ist. Fast alle Faelle
/// sind voruebergehend: Tag zu frueh weg, Funkkontakt verloren, Stack belegt.
///
/// Ein unbekannter Code behaelt seine Nummer. Damit steht beim naechsten
/// Auftreten sofort fest, welcher Fall hier noch fehlt.
static func kurz(_ fehler: Error) -> String {
guard let code = (fehler as? NFCReaderError)?.code else {
return "Fehlgeschlagen."
}
switch code {
case .readerTransceiveErrorTagConnectionLost:
return "Tag zu früh entfernt."
case .readerTransceiveErrorRetryExceeded:
return "Keine Antwort vom Tag."
case .readerTransceiveErrorTagResponseError:
return "Tag antwortet fehlerhaft."
case .readerTransceiveErrorTagNotConnected:
return "Kein Tag verbunden."
case .readerSessionInvalidationErrorSessionTimeout:
return "Zeit abgelaufen."
case .readerSessionInvalidationErrorSystemIsBusy:
return "NFC gerade belegt."
case .readerSessionInvalidationErrorSessionTerminatedUnexpectedly:
return "Sitzung abgebrochen."
case .ndefReaderSessionErrorTagUpdateFailure:
return "Schreiben fehlgeschlagen."
case .ndefReaderSessionErrorTagNotWritable:
return "Tag nicht beschreibbar."
case .readerErrorRadioDisabled:
return "NFC ist aus."
case .readerErrorSecurityViolation:
return "NFC nicht erlaubt."
case .readerErrorUnsupportedFeature:
return "Tag-Typ nicht unterstützt."
default:
return "Fehlgeschlagen (Code \(code.rawValue))."
}
}
private enum Auftrag {
case lesen
case schreiben(nutzlast: String, sperren: Bool)
@@ -155,8 +197,10 @@ final class NFCDienst: NSObject {
if let meldung {
session?.alertMessage = meldung
session?.invalidate()
} else if case .failure = ergebnis {
session?.invalidate()
} else if case let .failure(fehler) = ergebnis {
// Mit Text: Ohne ihn schliesst sich Apples Blatt wortlos und die
// Meldung steht erst danach in der App.
session?.invalidate(errorMessage: fehler.localizedDescription)
}
session = nil
fortsetzung.resume(with: ergebnis)
@@ -166,7 +210,7 @@ final class NFCDienst: NSObject {
private func lesen(_ tag: NFCNDEFTag) {
tag.readNDEF { nachricht, fehler in
if let fehler {
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
return
}
guard let text = nachricht?.records.compactMap(Self.text(aus:)).first, !text.isEmpty else {
@@ -187,7 +231,7 @@ final class NFCDienst: NSObject {
tag.queryNDEFStatus { status, kapazitaet, fehler in
if let fehler {
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
return
}
switch status {
@@ -210,7 +254,7 @@ final class NFCDienst: NSObject {
tag.writeNDEF(nachricht) { fehler in
if let fehler {
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
return
}
guard sperren else {
@@ -224,8 +268,8 @@ final class NFCDienst: NSObject {
// Das gehört gesagt, statt den ganzen Vorgang als
// gescheitert auszugeben.
self.fertig(.failure(Fehler.system(
"Tag beschrieben, aber das Sperren ist fehlgeschlagen: "
+ fehler.localizedDescription)))
"Tag beschrieben, Sperren fehlgeschlagen: "
+ Self.kurz(fehler))))
return
}
AudioServicesPlaySystemSound(1057)
@@ -274,7 +318,7 @@ extension NFCDienst: NFCTagReaderSessionDelegate {
}
session.connect(to: ersteser) { fehler in
if let fehler {
self.fertig(.failure(Fehler.system(fehler.localizedDescription)))
self.fertig(.failure(Fehler.system(Self.kurz(fehler))))
return
}
switch self.auftrag {
@@ -293,6 +337,6 @@ extension NFCDienst: NFCTagReaderSessionDelegate {
let abbruch = (error as? NFCReaderError)?.code
== .readerSessionInvalidationErrorUserCanceled
self.session = nil
fertig(.failure(abbruch ? Fehler.abgebrochen : Fehler.system(error.localizedDescription)))
fertig(.failure(abbruch ? Fehler.abgebrochen : Fehler.system(Self.kurz(error))))
}
}
+21 -2
View File
@@ -7,6 +7,11 @@ import SwiftUI
/// steht – `vorrania://i/<UID>`.
struct NFCSchreibenView: View {
let item: Item
/// Was „Fertig" tun soll. Ohne Angabe schliesst die Ansicht sich selbst –
/// das ist der Fall, wenn sie aus der Stueck-Bearbeitung heraus aufgerufen
/// wird. Haengt sie als Schritt an einem Ablauf (gleich nach dem Anlegen),
/// muss stattdessen der ganze Ablauf enden.
var onFertig: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@@ -28,6 +33,9 @@ struct NFCSchreibenView: View {
Text("Auf das Tag wird geschrieben: \(nutzlast)")
}
// Nach dem Erfolg hat das Tag seinen Inhalt. Knopf und Schalter
// stehenzulassen hiesse, es noch einmal beschreiben zu wollen.
if !erfolg {
Section {
Toggle("Tag dauerhaft schreibschützen", isOn: $sperren)
} footer: {
@@ -47,11 +55,18 @@ struct NFCSchreibenView: View {
}
.disabled(laeuft)
}
}
if erfolg {
Section {
Label("Tag beschrieben.", systemImage: "checkmark.circle")
Label(sperren ? "Tag beschrieben und gesperrt." : "Tag beschrieben.",
systemImage: "checkmark.circle")
.foregroundStyle(.green)
// Hier ist nichts mehr zu tun; „Fertig" gehoert deshalb in
// den Inhalt und nicht nur oben in die Leiste. Ohne das
// stuende man im Anlege-Ablauf vor einem fast leeren Blatt.
Button("Fertig") { if let onFertig { onFertig() } else { dismiss() } }
.buttonStyle(.borderedProminent)
}
}
if let fehler {
@@ -62,7 +77,11 @@ struct NFCSchreibenView: View {
}
.navigationTitle("NFC-Tag")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { if let onFertig { onFertig() } else { dismiss() } }
}
}
}
private func schreiben() async {
+3 -1
View File
@@ -75,7 +75,9 @@ enum ExpiryPlanner {
}
for item in snapshot.expiring {
guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue }
// Das Warndatum, nicht das MHD: Eine angebrochene Packung ohne
// aufgedrucktes Datum braucht die Erinnerung am dringendsten.
guard let mhd = parseTag(item.warnDate, calendar: calendar) else { continue }
let mhdTag = calendar.startOfDay(for: mhd)
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
+1 -1
View File
@@ -152,7 +152,7 @@ private struct LocationPicker: View {
var body: some View {
Picker(title, selection: $selection) {
Text("– ohne Lagerort –").tag(String?.none)
ForEach(locations) { loc in Text(loc.path(in: locations)).tag(String?.some(loc.id)) }
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
}
}
+66 -3
View File
@@ -5,10 +5,12 @@ struct ProductDetailView: View {
let product: Product
@EnvironmentObject private var display: DisplaySettings
@EnvironmentObject private var session: Session
@Environment(\.dismiss) private var dismiss
@State private var current: Product
@State private var showLeaveConfirm = false
@State private var pendingDelete = false
@State private var lots: [Lot] = []
@State private var busy = false
@State private var error: String?
@@ -44,6 +46,9 @@ struct ProductDetailView: View {
// Mengen-Bestand (Menge je Lagerort): Sheet + Entnahme-Statistik werden hier
// gehalten und auf Form-Ebene präsentiert, damit das Sheet stabil aufgeht.
@State private var objectSheet: ObjectStockSheet?
@State private var itemsAnlegen = false
/// Hochzaehlen laesst den Einzelstueck-Abschnitt neu laden.
@State private var itemsNachladen = 0
@State private var objectRemovals: RemovalSummary?
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
@@ -96,7 +101,9 @@ struct ProductDetailView: View {
if current.isObject && current.isIndividual {
ProductItemsSection(product: current, onChanged: { await reload() },
onFinish: { dismiss() })
onFinish: { dismiss() },
nachladen: itemsNachladen,
anlegen: $itemsAnlegen)
} else if current.isObject && !current.isBulk {
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
ObjectStockSection(product: current, locations: locations, lots: lots,
@@ -284,7 +291,15 @@ struct ProductDetailView: View {
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
// Eine offene Packung sah hier wie jede
// andere Charge aus - bei zwei Zeilen mit
// gleicher Menge ist das nicht zu erraten.
if lot.istAngebrochen, let bis = lot.consumeBy {
Text("angebrochen · verbrauchen bis \(display.formatBestBefore(bis, precision: "day"))")
.foregroundStyle(.orange)
} else {
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
}
// Ohne den Lagerort sehen zwei Chargen mit
// gleichem MHD identisch aus – er ist hier
// oft das einzige Unterscheidungsmerkmal.
@@ -313,6 +328,14 @@ struct ProductDetailView: View {
Label("Verlauf dieses Artikels", systemImage: "clock.arrow.circlepath")
}
}
// Loeschen verlangt Adminrechte (DELETE /products/{id}). Ohne den
// Riegel bekaeme ein normaler Benutzer nur ein wortkarges 403.
if session.isAdmin {
Section {
Button("Artikel löschen", role: .destructive) { pendingDelete = true }
}
}
}
.navigationTitle(current.name)
.navigationBarTitleDisplayMode(.inline)
@@ -331,6 +354,15 @@ struct ProductDetailView: View {
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
+ "Foto und Lagerort-Bedarf werden sofort gespeichert.")
}
// alert und nicht confirmationDialog: Letzteres faehrt als Blase von
// unten herein. Ein Loeschen will mittig stehen und gelesen werden.
.alert("„\(current.name)“ löschen?", isPresented: $pendingDelete) {
Button("Löschen", role: .destructive) { Task { await loeschen() } }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Barcodes, Bild und der Bewegungsverlauf gehen dabei verloren. "
+ "Hat der Artikel noch Einzelstücke oder Bestand, lehnt der Server ab.")
}
.sheet(item: $editLot) { lot in
NavigationStack {
LotEditView(lot: lot, product: current, locations: locations) {
@@ -338,6 +370,18 @@ struct ProductDetailView: View {
}
}
}
// Aus demselben Grund wie die Mengen-Sheets darunter: aus dem Abschnitt
// heraus gezeigt, schloss sich dieses Blatt beim ersten Oeffnen sofort
// wieder und hielt erst beim zweiten Versuch.
.sheet(isPresented: $itemsAnlegen) {
NavigationStack {
ItemAddSheet(
productId: current.id,
onDone: { itemsNachladen += 1; await reload() },
onFinish: { itemsAnlegen = false; dismiss() }
)
}
}
// Mengen-Sheets auf der stabilen Form-Ebene (nicht im Abschnitt), sonst
// schloss sich das Fenster beim ersten Öffnen sofort wieder.
.sheet(item: $objectSheet) { welche in
@@ -598,6 +642,25 @@ struct ProductDetailView: View {
}
await reload()
}
/// Artikel loeschen und zurueck zur Liste.
///
/// Bewusst `dismiss()` und nicht `attemptLeave()`: Letzteres fragte nach
/// ungespeicherten Aenderungen an einem Artikel, den es nicht mehr gibt.
/// Lehnt der Server ab (409, noch Einzelstuecke oder Bestand), bleibt die
/// Ansicht stehen und seine Begruendung landet unveraendert in der
/// Fehlerzeile – sie nennt genau, was noch dranhaengt.
private func loeschen() async {
error = nil
busy = true
defer { busy = false }
do {
try await APIClient.shared.deleteProduct(id: current.id)
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht –
@@ -680,7 +743,7 @@ struct LotEditView: View {
Section("Lagerort") {
Picker("Lagerort", selection: $locationId) {
Text("– ohne –").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
// Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen.
Button { locScanShown = true } label: {
@@ -792,7 +855,7 @@ struct ProductLocationMinView: View {
HStack {
Picker("Ort", selection: $row.locationId) {
Text(UEBERALL_NAME).tag(String?.some(UEBERALL_ID))
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
ForEach(baum(locations)) { e in Text(e.label).tag(String?.some(e.item.id)) }
}
TextField("Menge", text: $row.amount)
.keyboardType(.decimalPad)
+22 -2
View File
@@ -23,6 +23,9 @@ struct RootView: View {
if session.isLoggedIn {
await display.load()
await session.refreshMe()
// Nach refreshMe, damit eine wirklich abgelaufene Sitzung erst
// sauber endet, statt hier noch verlaengert zu werden.
await session.sitzungVerlaengern()
}
}
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
@@ -117,6 +120,10 @@ struct ScanTabView: View {
@EnvironmentObject private var router: Router
@EnvironmentObject private var session: Session
@State private var showNew = false
// Der gerade angelegte Artikel, bis sich das Formular geschlossen hat.
@State private var angelegt: Product?
/// Der gerade angelegte Artikel, bis der Folgeschritt dafuer durch ist.
@State private var nachAnlegenFuer: Product?
@State private var showKassenzettel = false
var body: some View {
@@ -174,13 +181,20 @@ struct ScanTabView: View {
.padding()
.navigationTitle("Scannen")
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
.sheet(isPresented: $showNew) {
.sheet(isPresented: $showNew, onDismiss: {
if fuehrtEinzelstuecke(angelegt) || fuehrtMengeJeLagerort(angelegt) {
nachAnlegenFuer = angelegt
}
angelegt = nil
}) {
NavigationStack {
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
angelegt = created
showNew = false
}
}
}
.nachAnlegen($nachAnlegenFuer)
}
}
}
@@ -209,6 +223,12 @@ struct ListsTabView: View {
ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare – filterbar nach Lagerort",
systemImage: "tag")
}
NavigationLink {
ChargenListView()
} label: {
ActionTile(title: "Chargen", subtitle: "Anbrechen und Teilmengen verbrauchen",
systemImage: "shippingbox.and.arrow.backward")
}
NavigationLink {
ShoppingListView()
} label: {
+18
View File
@@ -224,6 +224,24 @@ final class Session: ObservableObject {
// kurzer Netzausfall einen nicht aus der App wirft.
}
}
/// Holt beim Start ein frisches Token, damit die am Server eingestellte
/// Sitzungsdauer ab dem letzten Besuch zaehlt und nicht ab dem Anmelden.
///
/// Schluckt jeden Fehler: Ein aelterer Server kennt /auth/refresh noch gar
/// nicht (404), und eine wirklich abgelaufene Sitzung hat refreshMe() einen
/// Schritt vorher schon beendet. Hier deshalb nie abmelden.
@MainActor
func sitzungVerlaengern() async {
guard isLoggedIn, let profil = activeProfile else { return }
guard let neu = try? await APIClient.shared.sitzungVerlaengern() else { return }
// oidcSlug MUSS mitgegeben werden: store() setzt das Feld immer, und
// ohne ihn verloere das Profil seinen Anmeldedienst – die stille
// Erneuerung nach einem 401 fiele damit aus.
store(token: neu.accessToken, username: neu.username,
isAdmin: neu.role == "admin", persist: stayLoggedIn,
oidcSlug: profil.oidcSlug)
}
}
/// Minimaler Keychain-Zugriff für ein einzelnes Token.
+6 -2
View File
@@ -34,6 +34,7 @@
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; };
93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */ = {isa = PBXBuildFile; fileRef = 85F4CDB145DD84793DFC90F1 /* Baum.swift */; };
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */; };
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
@@ -77,6 +78,7 @@
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignScanView.swift; sourceTree = "<group>"; };
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
85F4CDB145DD84793DFC90F1 /* Baum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Baum.swift; sourceTree = "<group>"; };
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
@@ -115,6 +117,7 @@
378F1B2DE567B62169E84426 /* APIClient.swift */,
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */,
85F4CDB145DD84793DFC90F1 /* Baum.swift */,
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
@@ -253,6 +256,7 @@
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */,
93BE44F5C09AA9A9B67ED211 /* Baum.swift in Sources */,
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
@@ -367,7 +371,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
@@ -390,7 +394,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.1;
MARKETING_VERSION = 1.2;
PRODUCT_BUNDLE_IDENTIFIER = com.scarriffle.vorrania;
SDKROOT = iphoneos;
SWIFT_VERSION = 5.9;
+1 -1
View File
@@ -52,7 +52,7 @@ targets:
# MARKETING_VERSION ist die sichtbare Version (App Store),
# CURRENT_PROJECT_VERSION die Build-Nummer: muss bei jedem Upload
# innerhalb derselben Version steigen.
MARKETING_VERSION: "1.1"
MARKETING_VERSION: "1.2"
CURRENT_PROJECT_VERSION: "2"
TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "5.9"
+132 -14
View File
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { createContext, useContext, useEffect, useState } from "react";
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { api } from "./api";
import { useAuth } from "./auth";
import { gruppenPfade } from "./nav";
import Icon from "./components/Icon";
import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark";
@@ -23,17 +24,39 @@ import ItemResolve from "./pages/ItemResolve";
import LocationResolve from "./pages/LocationResolve";
import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes";
import Masse from "./pages/Masse";
import Units from "./pages/Units";
import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList";
import Rezepte from "./pages/Rezepte";
import History from "./pages/History";
import Verliehen from "./pages/Verliehen";
import Transfer from "./pages/Transfer";
import Settings from "./pages/Settings";
import Konto from "./pages/Konto";
const navClass = ({ isActive }) => (isActive ? "nav-link active" : "nav-link");
// Ausgeblendete Eintraege stehen hier drin. Ueber einen Kontext und nicht als
// Eigenschaft an jedem NavItem: So bleiben die zwanzig Zeilen der Leiste
// unveraendert lesbar.
const VersteckteNav = createContext(new Set());
// Die vier Bestandslisten stehen unter einer zuklappbaren Gruppe. Sie sind der
// laengste Block der Leiste und wird im Alltag selten gebraucht – wer einlagert,
// nutzt die Knoepfe darueber.
const LISTEN = [
{ to: "/lebensmittel", icon: "food", label: "Lebensmittel" },
{ to: "/gegenstaende", icon: "package", label: "Gegenstände" },
{ to: "/items", icon: "tag", label: "Einzelstücke" },
{ to: "/chargen", icon: "box", label: "Chargen" },
];
const LISTEN_OFFEN_KEY = "nav:listen-offen";
function NavItem({ to, icon, label, end }) {
// Gar nicht erst rendern statt per CSS verbergen: Unter 768px blendet die
// CSS die Listen-Kinder wieder ein, eine Klasse bisse sich damit.
if (useContext(VersteckteNav).has(to)) return null;
return (
<NavLink to={to} end={end} className={navClass}>
<Icon name={icon} size={17} />
@@ -45,7 +68,34 @@ function NavItem({ to, icon, label, end }) {
function Sidebar() {
const { user, isAdmin, logout } = useAuth();
const navigate = useNavigate();
const { pathname } = useLocation();
const [dashboards, setDashboards] = useState([]);
// Der Rezept-Eintrag erscheint nur, wenn Mealie eingerichtet ist. Ein Fehler
// hier darf die Leiste nicht stoeren, deshalb still abfangen.
const [mealie, setMealie] = useState(false);
// Vorgabe des Administrators plus eigene Auswahl, vom Server schon
// zusammengerechnet. Ein Fehler darf die Leiste nicht leeren.
const [versteckt, setVersteckt] = useState(() => new Set());
// Zugeklappt als Vorgabe; der Zustand bleibt im Browser, wie die
// Spalteneinstellungen der Tabellen.
const [listenOffen, setListenOffen] = useState(
() => localStorage.getItem(LISTEN_OFFEN_KEY) === "1",
);
// Steht man auf einer dieser Seiten, ist die Gruppe offen – ohne den
// gemerkten Zustand zu ueberschreiben. Sonst saehe man nicht, wo man ist.
const sichtbareListen = LISTEN.filter((e) => !versteckt.has(e.to));
const aufEinerListe = sichtbareListen.some((e) => pathname.startsWith(e.to));
// Eine Gruppe ohne Kinder waere ein Knopf, der nichts aufklappt; eine
// Ueberschrift ohne Eintraege darunter genauso sinnlos.
const zeigtGruppe = (gruppe) => gruppenPfade(gruppe).some((t) => !versteckt.has(t));
function klappeListen() {
setListenOffen((alt) => {
const neu = !alt;
localStorage.setItem(LISTEN_OFFEN_KEY, neu ? "1" : "0");
return neu;
});
}
// Die Dashboards des Benutzers stehen als eigene Eintraege in der Leiste.
// Sie aendern sich beim Anlegen und Loeschen, deshalb horcht die Leiste auf
@@ -59,6 +109,24 @@ function Sidebar() {
return () => window.removeEventListener("dashboards-geaendert", laden);
}, []);
// Dasselbe Spiel fuer die ausgeblendeten Eintraege: Einstellungen und
// Kontoseite loesen beim Speichern ein Ereignis aus, statt die Seite neu zu
// laden.
useEffect(() => {
function laden() {
api.navigation()
.then((a) => setVersteckt(new Set(a.versteckt || [])))
.catch(() => {});
}
laden();
window.addEventListener("navigation-geaendert", laden);
return () => window.removeEventListener("navigation-geaendert", laden);
}, []);
useEffect(() => {
api.mealieVerfuegbar().then((a) => setMealie(!!a.available)).catch(() => {});
}, []);
function handleLogout() {
logout();
navigate("/login");
@@ -69,36 +137,64 @@ function Sidebar() {
<div className="sidebar-brand">
<BrandMark />
</div>
<VersteckteNav.Provider value={versteckt}>
<nav className="sidebar-nav">
<NavItem to="/" end icon="dashboard" label="Übersicht" />
{/* Ohne `end`: Der Eintrag soll auch bei /home/edit markiert bleiben.
Bei "/" war `end` noetig, weil sonst JEDER Pfad als Treffer galt. */}
<NavItem to="/home" icon="dashboard" label="Übersicht" />
{/* Ab dem zweiten Dashboard lohnt die Aufzaehlung; bei einem einzigen
waere sie nur eine Verdopplung von "Übersicht". */}
{dashboards.length > 1 && dashboards.map((d, index) => (
<NavLink key={d.id} to={index === 0 ? "/" : `/d/${d.id}`} end
<NavLink key={d.id} to={index === 0 ? "/home" : `/d/${d.id}`}
className={({ isActive }) => (isActive ? "nav-link sub active" : "nav-link sub")}>
<span className="label">{d.name}</span>
</NavLink>
))}
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/lebensmittel" icon="food" label="Lebensmittel" />
<NavItem to="/gegenstaende" icon="package" label="Gegenstände" />
<NavItem to="/items" icon="tag" label="Einzelstücke" />
<NavItem to="/chargen" icon="box" label="Chargen" />
{/* Auf schmalen Bildschirmen wird die Leiste waagerecht; dort gibt es
keine Abschnitte (styles.css) und die Eintraege stehen flach
nebeneinander – zuklappen loest dort kein Platzproblem. */}
{sichtbareListen.length > 0 && (
<button type="button" className="nav-link nav-group" onClick={klappeListen}
aria-expanded={listenOffen || aufEinerListe}>
<Icon name="chevron" size={17}
className={listenOffen || aufEinerListe ? "" : "rot-90"} />
<span className="label">Listen</span>
</button>
)}
{sichtbareListen.map((e) => (
<NavLink key={e.to} to={e.to}
className={({ isActive }) => [
"nav-link nav-child",
isActive ? "active" : "",
// Nicht ausgehaengt, nur verborgen: auf schmalen Bildschirmen
// blendet die CSS sie wieder ein, dort gibt es keine Gruppe.
listenOffen || aufEinerListe ? "" : "nav-zu",
].filter(Boolean).join(" ")}>
<Icon name={e.icon} size={16} />
<span className="label">{e.label}</span>
</NavLink>
))}
{mealie && <NavItem to="/rezepte" icon="food" label="Rezepte" />}
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
<NavItem to="/verliehen" icon="loan" label="Verliehen" />
<NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && (
<>
{/* Stammdaten strukturieren den Katalog und werden nur von Admins
gepflegt – deshalb alle zusammen hier, wie in der iOS-App. */}
<div className="nav-section">Stammdaten</div>
{zeigtGruppe("stammdaten") && <div className="nav-section">Stammdaten</div>}
<NavItem to="/categories" icon="package" label="Kategorien" />
<NavItem to="/groups" icon="tag" label="Gruppen" />
<NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/shops" icon="cart" label="Shops" />
<NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" />
<NavItem to="/masse" icon="split" label="Maße" />
{/* "System" steht immer, weil die Einstellungen darunter nicht
ausblendbar sind – sonst käme man nicht mehr zurück. */}
<div className="nav-section">System</div>
<NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/transfer" icon="history" label="Import / Export" />
@@ -106,6 +202,7 @@ function Sidebar() {
</>
)}
</nav>
</VersteckteNav.Provider>
<div className="sidebar-foot">
{/* Der eigene Name ist der naheliegendste Weg zum eigenen Konto. */}
<NavLink to="/konto" className="sidebar-user" title="Mein Konto">
@@ -147,7 +244,7 @@ function Protected({ children, adminOnly = false, wide = false }) {
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt…</div>;
if (startFehler) return <StartFehler text={startFehler} erneut={erneutVersuchen} />;
if (!user) return <Navigate to="/login" replace />;
if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
if (adminOnly && !isAdmin) return <Navigate to="/home" replace />;
return (
<div className="app-shell">
<Sidebar />
@@ -156,8 +253,14 @@ function Protected({ children, adminOnly = false, wide = false }) {
<div className={wide ? "content content--wide" : "content"}>
{/* Je Seite eine eigene Grenze: stuerzt eine ab, bleibt wenigstens
die Navigation bedienbar. `key` setzt sie beim Seitenwechsel
zurueck, sonst haenge man nach einem Fehler dauerhaft fest. */}
<ErrorBoundary key={pfad} bereich="Diese Seite">{children}</ErrorBoundary>
zurueck, sonst haenge man nach einem Fehler dauerhaft fest.
Nur das ERSTE Pfadsegment: Sonst gilt /home/edit als andere Seite
als /home, der ganze Baum wird neu eingehaengt und laedt beim
Umschalten in den Bearbeitungsmodus alles noch einmal. */}
<ErrorBoundary key={pfad.split("/")[1] || "home"} bereich="Diese Seite">
{children}
</ErrorBoundary>
</div>
</div>
</div>
@@ -171,10 +274,19 @@ export default function App() {
<Routes>
<Route
path="/login"
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
element={loading ? null : user ? <Navigate to="/home" replace /> : <Login />}
/>
<Route path="/" element={<Protected wide><Dashboard /></Protected>} />
{/* Die Startseite hat einen Namen, damit in der Adresszeile steht, wo
man ist; "/" leitet sichtbar dorthin um. Der Bearbeitungsmodus ist
ein eigener Pfad statt eines Zustands im Speicher: So ueberlebt er
ein Neuladen, laesst sich verschicken, und die Zurueck-Taste
verlaesst ihn. Den Modus gibt die Prop vor, damit der Pfad nicht ein
zweites Mal in der Komponente steht. */}
<Route path="/" element={<Navigate to="/home" replace />} />
<Route path="/home" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/home/edit" element={<Protected wide><Dashboard bearbeiten /></Protected>} />
<Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/d/:id/edit" element={<Protected wide><Dashboard bearbeiten /></Protected>} />
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/lebensmittel" element={<Protected wide><Products fixedType="food" /></Protected>} />
@@ -189,7 +301,12 @@ export default function App() {
<Route path="/groups" element={<Protected adminOnly wide><Groups /></Protected>} />
<Route path="/categories" element={<Protected adminOnly wide><Categories /></Protected>} />
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/rezepte" element={<Protected wide><Rezepte /></Protected>} />
<Route path="/rezepte/:slug" element={<Protected wide><Rezepte /></Protected>} />
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
{/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein-
und Auslagern, keine Stammdatenänderung. */}
<Route path="/verliehen" element={<Protected><Verliehen /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} />
{/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */}
<Route path="/konto" element={<Protected><Konto /></Protected>} />
@@ -197,6 +314,7 @@ export default function App() {
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
<Route path="/masse" element={<Protected adminOnly wide><Masse /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
@@ -206,7 +324,7 @@ export default function App() {
<Route path="/auth/callback" element={<OidcCallback />} />
{/* Ebenfalls oeffentlich: hier ist man noch nicht angemeldet. */}
<Route path="/sso-neu" element={<SsoNeu />} />
<Route path="*" element={<Navigate to="/" replace />} />
<Route path="*" element={<Navigate to="/home" replace />} />
</Routes>
);
}
+136
View File
@@ -154,6 +154,10 @@ export const api = {
login: (username, password) =>
request("/auth/login", { method: "POST", form: { username, password } }),
me: () => request("/auth/me"),
// Schiebt die Frist der laufenden Sitzung nach vorn. Wird beim Start
// gerufen, damit die eingestellte Dauer ab dem letzten Besuch zaehlt und
// nicht ab dem Anmelden.
sitzungVerlaengern: () => request("/auth/refresh", { method: "POST" }),
// Welche Single-Sign-on-Anbieter eingerichtet sind. Kommt vom Server, weil die
// Oberflaeche keine Build-Variablen kennt – ohne Anbieter bleibt die Liste leer
// und es taucht kein SSO-Knopf auf.
@@ -232,6 +236,18 @@ export const api = {
lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }),
returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }),
itemLoans: (itemId) => request(`/items/${itemId}/loans`),
// Verwendungsstatus. Leerer Status = verfügbar. Eigener Endpunkt aus
// demselben Grund wie Zubehör (siehe unten) und weil updateItem Admins
// vorbehalten ist – umstellen darf jeder.
setzeVerwendung: (itemId, status) =>
request(`/items/${itemId}/verwendung`, { method: "PUT", body: { status } }),
itemVerwendungen: (itemId) => request(`/items/${itemId}/verwendungen`),
// Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle
// Felder mit, ein vergessenes Feld würde die Beziehung still kappen.
zubehoerZuordnen: (itemId, childItemId) =>
request(`/items/${itemId}/zubehoer`, { method: "POST", body: { child_item_id: childItemId } }),
zubehoerLoesen: (itemId, childItemId) =>
request(`/items/${itemId}/zubehoer/${childItemId}`, { method: "DELETE" }),
// Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe
// in so vielen Tagen ansteht (überfällige sind immer dabei).
loans: ({ openOnly = true, dueIn } = {}) => {
@@ -260,9 +276,47 @@ export const api = {
// Lagerort mehrerer Chargen auf einmal setzen (null = leeren).
bulkLotLocation: (lotIds, locationId) =>
request("/lots/bulk-location", { method: "POST", body: { lot_ids: lotIds, location_id: locationId || null } }),
// Mehrere Chargen ausbuchen: ohne Menge komplett, mit Menge je Charge
// dieselbe (nur bei einheitlichem Artikel erlaubt).
bulkLotCheckout: (lotIds, { quantity = null, unit = null, note = null } = {}) =>
request("/lots/bulk-checkout", {
method: "POST",
body: { lot_ids: lotIds, quantity, unit, note },
}),
// Mehrere Chargen als Korrektur entfernen (nicht als Verbrauch buchen).
bulkLotDelete: (lotIds) =>
request("/lots/bulk-delete", { method: "POST", body: { lot_ids: lotIds } }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
// Charge aufteilen: Teilmenge (Basiseinheiten) an einen anderen Lagerort abzweigen.
splitLot: (id, body) => request(`/lots/${id}/split`, { method: "POST", body }),
// Anbrechen: spaltet eine Packung ab und gibt ihr ein Verbrauchsdatum.
// Ohne Menge ist es eine Artikeleinheit (bzw. die ganze Charge, wenn kleiner).
openLot: (id, quantity = null) =>
request(`/lots/${id}/open`, { method: "POST", body: { quantity } }),
// Teilverbrauch. `auto_open` bricht eine noch geschlossene Charge dabei an.
consumeLot: (id, body) => request(`/lots/${id}/consume`, { method: "POST", body }),
// Die fuer DIESEN Artikel wirksamen Hausmasse samt Herkunft - Grundlage der
// Einheitenauswahl beim Verbrauchen ("2 EL" sind bei Mehl und Zucker
// verschieden viel Gramm).
productMeasures: (id) => request(`/products/${id}/measures`),
// Hausmasse: die NAMEN sind global, die MENGEN haengen an Kategorie bzw.
// Artikel - "1 EL" ist bei Mehl und Zucker verschieden schwer.
listMeasures: () => request("/measures"),
createMeasure: (body) => request("/measures", { method: "POST", body }),
updateMeasure: (id, body) => request(`/measures/${id}`, { method: "PATCH", body }),
deleteMeasure: (id) => request(`/measures/${id}`, { method: "DELETE" }),
// `vererbt` holt zusaetzlich, was die Kategorie von oben erbt - die Maske
// zeigt damit auch, was gelten wuerde, wenn man den eigenen Eintrag loescht.
categoryMeasures: (id, vererbt = false) =>
request(`/categories/${id}/measures${vererbt ? "?vererbt=true" : ""}`),
setCategoryMeasure: (catId, measureId, body) =>
request(`/categories/${catId}/measures/${measureId}`, { method: "PUT", body }),
deleteCategoryMeasure: (catId, measureId) =>
request(`/categories/${catId}/measures/${measureId}`, { method: "DELETE" }),
setProductMeasure: (productId, measureId, body) =>
request(`/products/${productId}/measures/${measureId}`, { method: "PUT", body }),
deleteProductMeasure: (productId, measureId) =>
request(`/products/${productId}/measures/${measureId}`, { method: "DELETE" }),
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
setProductLocationMinStock: (id, list) =>
@@ -277,6 +331,21 @@ export const api = {
// Views
shoppingList: () => request("/shopping-list"),
// Alles in einem Aufruf: die gerechneten Bedarfe, die gespeicherten Eintraege
// und die dauerhaft gesetzten Haken.
shoppingAll: () => request("/shopping-list/all"),
addShoppingEntry: (body) => request("/shopping-list/entries", { method: "POST", body }),
updateShoppingEntry: (id, body) =>
request(`/shopping-list/entries/${id}`, { method: "PATCH", body }),
deleteShoppingEntry: (id) =>
request(`/shopping-list/entries/${id}`, { method: "DELETE" }),
clearDoneShopping: () => request("/shopping-list/clear-done", { method: "POST" }),
// Haken an einem GERECHNETEN Posten. Der Schluessel kommt von hier ("p12",
// "g3", "lKUECHEp12") - der Server haengt ihn nur daran.
setShoppingCheck: (key) =>
request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "PUT" }),
clearShoppingCheck: (key) =>
request(`/shopping-list/checks/${encodeURIComponent(key)}`, { method: "DELETE" }),
groupShoppingList: () => request("/shopping-list/groups"),
shoppingByLocation: () => request("/shopping-list/by-location"),
expiring: (days) => request(`/expiring${days != null ? `?days=${days}` : ""}`),
@@ -353,6 +422,44 @@ export const api = {
exportJson: () =>
downloadFile("/export/backup.json", `vorrania-backup_${zeitstempel()}.json`),
// Zeilen für den QR-Etiketten-Export (P-touch); QR-Inhalt setzt das Web dazu.
// ---- Etiketten-Vorlagen ----
etikettenVorlagen: () => request("/etiketten/vorlagen"),
etikettenSpalten: (art, categoryIds) => {
const p = new URLSearchParams({ art });
if (categoryIds && categoryIds.length) p.set("kategorie_ids", categoryIds.join(","));
return request(`/etiketten/spalten?${p}`);
},
etikettenVorlageAnlegen: (body) =>
request("/etiketten/vorlagen", { method: "POST", body }),
etikettenVorlageAendern: (id, body) =>
request(`/etiketten/vorlagen/${id}`, { method: "PATCH", body }),
etikettenVorlageLoeschen: (id) =>
request(`/etiketten/vorlagen/${id}`, { method: "DELETE" }),
// Die CSV baut der Server. basis_url braucht nur der Lagerort-QR (…/l/<id>);
// nur der Browser weiss, unter welcher Adresse er gerade erreichbar ist.
// Seitenleiste: Vorgabe des Admins, eigene Auswahl und die fertige
// Vereinigung – alles in einem Zug, das Zusammenrechnen macht der Server.
navigation: () => request("/navigation"),
setzeEigeneNavigation: (versteckt) =>
request("/navigation", { method: "PUT", body: { versteckt } }),
setzeNavigationsVorgabe: (versteckt) =>
request("/navigation/vorgabe", { method: "PUT", body: { versteckt } }),
etikettenCsv: (id, name) =>
downloadFile(
`/etiketten/vorlagen/${id}/csv?basis_url=${encodeURIComponent(window.location.origin)}`,
`${name || "etiketten"}.csv`,
),
etikettenDateiHochladen: (id, file) => {
const fd = new FormData();
fd.append("file", file);
return request(`/etiketten/vorlagen/${id}/datei`, { method: "POST", formData: fd });
},
etikettenDatei: (id, name) =>
downloadFile(`/etiketten/vorlagen/${id}/datei`, name || "vorlage.lbx"),
etikettenDateiEntfernen: (id) =>
request(`/etiketten/vorlagen/${id}/datei`, { method: "DELETE" }),
labelRows: (categoryIds) =>
request(`/export/labels${categoryIds && categoryIds.length ? `?category_ids=${categoryIds.join(",")}` : ""}`),
importStock: (file, mode = "add") => {
@@ -394,6 +501,35 @@ export const api = {
resetAll: () => request("/maintenance/reset/all", { method: "POST" }),
// Einstellungen
// Mealie: Adresse und Token nimmt der Server entgegen, gibt sie aber nie
// zurueck – die Oberflaeche erfaehrt nur „eingerichtet ja/nein".
mealieStatus: (pruefen = false) => request(`/mealie/status${pruefen ? "?pruefen=true" : ""}`),
mealieVerfuegbar: () => request("/mealie/available"),
mealieKonfigurieren: (body) => request("/mealie/config", { method: "PUT", body }),
mealieToken: (body) => request("/mealie/token", { method: "PUT", body }),
mealieTrennen: () => request("/mealie/config", { method: "DELETE" }),
// Rezepte aus Mealie, gegen den Bestand geprueft.
recipes: ({ q, page = 1, perPage = 50 } = {}) => {
const s = new URLSearchParams({ page: String(page), per_page: String(perPage) });
if (q) s.set("q", q);
return request(`/recipes?${s.toString()}`);
},
recipe: (slug, servings) =>
request(`/recipes/${encodeURIComponent(slug)}${servings ? `?servings=${servings}` : ""}`),
// Zutaten von Hand zuordnen. Der Zutatenname ist der Schluessel - er reist
// als Abfrageparameter, weil Lebensmittelnamen Schraegstriche enthalten.
zuordnungen: () => request("/recipes/zuordnungen"),
zuordnungsZiele: (q) =>
request(`/recipes/zuordnungen/ziele${q ? `?q=${encodeURIComponent(q)}` : ""}`),
setzeZuordnung: (body) => request("/recipes/zuordnungen", { method: "PUT", body }),
// Fehlende Zutaten uebernehmen - mit der Portionszahl, die auf dem Schirm steht.
rezeptAufEinkaufsliste: (slug, servings) =>
request(
`/recipes/${encodeURIComponent(slug)}/to-shopping-list${servings ? `?servings=${servings}` : ""}`,
{ method: "POST" },
),
loescheZuordnung: (food) =>
request(`/recipes/zuordnungen?food=${encodeURIComponent(food)}`, { method: "DELETE" }),
listSettings: () => request("/settings"),
setSetting: (key, value) =>
request(`/settings/${key}?value=${encodeURIComponent(value)}`, { method: "PUT" }),
+17
View File
@@ -13,6 +13,22 @@ export function AuthProvider({ children }) {
// meldet einen ab.
const [startFehler, setStartFehler] = useState(null);
// Holt ein frisches Token, damit die eingestellte Dauer ab dem letzten
// Besuch zaehlt statt ab dem Anmelden.
//
// Der Fehler wird geschluckt, weil ein Server- oder Netzfehler hier nichts
// kostet: das bisherige Token gilt ja weiter. Ein 401 waere etwas anderes –
// um den kuemmert sich bereits die zentrale Abmeldung in api.js, und er kann
// direkt nach einem geglueckten me() ohnehin nicht auftreten.
async function sitzungVerlaengern() {
try {
const res = await api.sitzungVerlaengern();
setToken(res.access_token);
} catch {
/* siehe oben */
}
}
async function pruefen() {
setStartFehler(null);
if (!getToken()) {
@@ -21,6 +37,7 @@ export function AuthProvider({ children }) {
}
try {
setUser(await api.me());
await sitzungVerlaengern();
} catch (err) {
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
+7
View File
@@ -20,7 +20,11 @@ export default function CategorySelect({
// Zusätzliche Einträge über dem Baum, z.B. { value: 0, label: "Ohne Kategorie" }
// für Filter. Ihr Wert wird unverändert an onChange gereicht.
extraOptions = [],
// Aus, wo „nichts gewaehlt" gar kein gueltiger Wert ist (Mindestbestaende:
// dort heisst die oberste Zeile „Ueberall" und steht in extraOptions).
showRoot = true,
disabled = false,
title,
}) {
const [open, setOpen] = useState(false);
const [collapsed, setCollapsed] = useState(() => new Set());
@@ -123,6 +127,7 @@ export default function CategorySelect({
ref={btnRef}
className="tree-select-btn"
disabled={disabled}
title={title}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
@@ -138,6 +143,7 @@ export default function CategorySelect({
className="tree-pop"
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 220) }}
>
{showRoot && (
<button
type="button"
className={`tree-pop-opt ${isRoot ? "sel" : ""}`}
@@ -146,6 +152,7 @@ export default function CategorySelect({
>
{rootLabel}
</button>
)}
{extraOptions.map((o) => (
<button
key={String(o.value)}
+151 -22
View File
@@ -1,7 +1,9 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useSearchParams } from "react-router-dom";
import Icon from "./Icon";
import { Skeleton } from "./Skeleton";
import { leseTabelle, schreibeTabelle } from "../urlState";
/**
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
@@ -11,7 +13,8 @@ import { Skeleton } from "./Skeleton";
*
* Spalten-Def: { key, header, render(row)→JSX, filterText(row)? (aktiviert Filter),
* sortValue(row)? (Sortierung), align:"num", grow:true (füllt Restbreite),
* fixed:true (kein Reorder/Resize), width, min }.
* fixed:true (kein Reorder/Resize), width, min,
* defaultHidden:true (erscheint erst, wenn man sie im Spalten-Menü einschaltet) }.
*
* `tree` (optional): { column, idOf(row), parentIdOf(row) } – zeigt die Zeilen als
* ein-/ausklappbaren Baum in der Spalte `column`. Sobald gefiltert oder sortiert
@@ -34,6 +37,16 @@ export default function DataTable({
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
const [params, setParams] = useSearchParams();
/**
* Was beim Aufruf in der Adresse stand - einmal beim Mounten festgehalten.
*
* Die Regel dahinter: Der Browserspeicher ist die persoenliche Vorgabe, die
* URL schlaegt sie. Ein verschickter Link zeigt damit beim Empfaenger
* dasselbe, auch wenn der sich etwas anderes eingestellt hat.
*/
const [vonUrl] = useState(() => leseTabelle(params, (k) => !!colByKey[k]));
const [order, setOrder] = useState(() => {
const p = loadPersisted(id);
const saved = Array.isArray(p.order) ? p.order.filter((k) => colByKey[k]) : [];
@@ -48,11 +61,25 @@ export default function DataTable({
// Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst
// gewaehlte Reihenfolge soll nicht umspringen.
const schluessel = columns.map((c) => c.key).join("|");
// Spiegel der Reihenfolge, um sie im Effekt zu lesen, ohne sie in die
// Abhaengigkeiten zu nehmen (das liefe im Kreis). Gleiche Technik wie bei
// onVisibleRef weiter unten.
const ordnungRef = useRef(order);
ordnungRef.current = order;
useEffect(() => {
setOrder((alt) => {
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
return fehlend.length ? [...alt, ...fehlend] : alt;
});
const fehlend = columns.map((c) => c.key).filter((k) => !ordnungRef.current.includes(k));
if (!fehlend.length) return;
setOrder((alt) => [...alt, ...fehlend.filter((k) => !alt.includes(k))]);
// Spalten, die sich als „erst auf Wunsch" melden (defaultHidden), starten
// verborgen - aber nur beim ERSTEN Auftauchen. Wer sie einschaltet, steht
// danach in `order` und taucht hier nie wieder auf.
//
// setzeHidden und nicht setHidden: Nur der merkt den Stand ueberhaupt
// (siehe Speicher-Effekt). Mit setHidden stuende die Spalte zwar in der
// gemerkten Reihenfolge, aber nicht unter den versteckten - beim zweiten
// Besuch waere sie sichtbar.
const verbergen = fehlend.filter((k) => colByKey[k]?.defaultHidden);
if (verbergen.length) setzeHidden((s) => new Set([...s, ...verbergen]));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schluessel]);
@@ -61,6 +88,7 @@ export default function DataTable({
// „Filter löschen" drückt oder anders sortiert – auch nach Navigieren/Neuladen.
// Beim Speichern werden Sets zu Arrays; beim Laden zurück zu Sets.
const [filters, setFilters] = useState(() => {
if (vonUrl) return vonUrl.filters;
const saved = loadPersisted(id).filters || {};
const out = {};
for (const [k, v] of Object.entries(saved)) {
@@ -70,28 +98,84 @@ export default function DataTable({
return out;
}); // { key: { text, values:Set|null } }
const [sort, setSort] = useState(() => {
if (vonUrl) return vonUrl.sort;
const s = loadPersisted(id).sort;
return s && s.key && colByKey[s.key] ? s : null;
}); // { key, dir }
const [filterShown, setFilterShown] = useState(true);
const [openFilter, setOpenFilter] = useState(null);
const [collapsed, setCollapsed] = useState(() => new Set());
const [hidden, setHidden] = useState(
() => new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k])),
);
const [hidden, setHidden] = useState(() => (vonUrl
? vonUrl.hidden
: new Set((loadPersisted(id).hidden || []).filter((k) => colByKey[k]))));
const [chooserOpen, setChooserOpen] = useState(false);
/**
* Kam die letzte Aenderung an Filter, Sortierung oder Spaltenauswahl von
* einer Bedienhandlung?
*
* Nur dann wird die persoenliche Vorgabe im Browserspeicher fortgeschrieben.
* Wer einen fremden gefilterten Link oeffnet, soll seine eigenen
* Einstellungen nicht verlieren, bloss weil er geschaut hat.
*
* Bewusst ein Merker an den Settern und kein "ueberspring den ersten
* Durchlauf": React fuehrt Effekte im Entwicklungsmodus doppelt aus, ein
* Zaehler am Mounten wird dadurch wertlos.
*/
const vomBenutzer = useRef(false);
const setzeFilters = (wert) => { vomBenutzer.current = true; setFilters(wert); };
const setzeSort = (wert) => { vomBenutzer.current = true; setSort(wert); };
const setzeHidden = (wert) => { vomBenutzer.current = true; setHidden(wert); };
/**
* Die persoenliche Vorgabe fortschreiben.
*
* Spaltenreihenfolge und -breiten sind reines Layout und entstehen immer
* durch Ziehen - die werden ohne Wenn und Aber gemerkt. Filter, Sortierung
* und ausgeblendete Spalten koennen dagegen aus einer fremden URL stammen;
* dann bleibt stehen, was vorher gespeichert war.
*/
useEffect(() => {
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein Filter);
// Sets werden zu Arrays serialisiert.
const bisher = loadPersisted(id);
const neu = { order, widths };
if (vomBenutzer.current) {
vomBenutzer.current = false;
// Nur echte Einschränkungen sichern (leerer Text + values=null = kein
// Filter); Sets werden zu Arrays serialisiert.
const filtersSer = {};
for (const [k, v] of Object.entries(filters)) {
if (!v || (!v.text && v.values == null)) continue;
filtersSer[k] = { text: v.text || "", values: v.values ? [...v.values] : null };
}
savePersisted(id, { order, widths, hidden: [...hidden], filters: filtersSer, sort });
Object.assign(neu, { hidden: [...hidden], filters: filtersSer, sort });
} else {
Object.assign(neu, {
hidden: bisher.hidden || [],
filters: bisher.filters || {},
sort: bisher.sort ?? null,
});
}
savePersisted(id, neu);
}, [id, order, widths, hidden, filters, sort]);
/**
* Was man sieht, steht in der Adresse - auch wenn es aus der gemerkten
* Vorgabe stammt. Erst damit laesst sich ein gefilterter Stand verschicken.
*
* `setParams` bekommt eine Funktion, damit fremde Parameter derselben Seite
* (auf der Kategorienseite etwa `kat` und `panel`) aus dem AKTUELLEN Stand
* kommen und nicht aus einem veralteten Abschluss. Immer `replace`: Ein
* Filter ist kein Ort, zu dem man zurueckblaettern will, und jeder
* Tastendruck im Filterfeld wuerde sonst einen Verlaufseintrag anlegen.
*/
useEffect(() => {
setParams((alt) => {
const neu = schreibeTabelle(alt, { filters, sort, hidden });
return neu.toString() === alt.toString() ? alt : neu;
}, { replace: true });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters, sort, hidden]);
// Containerbreite messen, damit die grow-Spalte den Rest füllt.
const wrapRef = useRef(null);
const [containerWidth, setContainerWidth] = useState(0);
@@ -135,7 +219,7 @@ export default function DataTable({
}, [chooserOpen]);
function toggleHidden(key) {
setHidden((s) => {
setzeHidden((s) => {
const n = new Set(s);
if (n.has(key)) { n.delete(key); return n; }
// Mindestens eine Spalte sichtbar lassen.
@@ -254,7 +338,7 @@ export default function DataTable({
function toggleSort(c) {
if (!c.sortValue && !c.filterText) return;
setSort((s) => {
setzeSort((s) => {
if (!s || s.key !== c.key) return { key: c.key, dir: "asc" };
if (s.dir === "asc") return { key: c.key, dir: "desc" };
return null;
@@ -265,34 +349,77 @@ export default function DataTable({
}
// --- Resize (Pointer) ---
//
// Gerechnet wird mit der BEWEGUNG seit dem letzten Schritt, nicht mit dem
// Abstand zum Startpunkt. Nur so laesst sich die letzte Spalte verbreitern:
// Ihr Anfasser sitzt am rechten Rand, und dort ist fuer den Zeiger Schluss -
// nach rechts gibt es kein Fenster mehr. Steht er in der Randzone, waechst die
// Spalte deshalb von selbst weiter, Schritt fuer Schritt, solange die Taste
// haelt. Ein Taktgeber treibt das an, denn ein stehender Zeiger meldet keine
// Bewegung mehr.
const resizing = useRef(null);
const rollTimer = useRef(null);
const RAND = 36; // Breite der Randzone
const SCHRITT = 14; // Pixel je Takt in der Randzone
function startResize(e, c) {
e.preventDefault(); e.stopPropagation();
const startW = widthOf(c);
// Die flexible (grow-)Spalte auf ihre aktuelle Pixelbreite einfrieren. Sonst
// "klaut" das Breiterziehen einer Spalte der grow-Spalte den Platz – dann
// "klaut" das Breiterziehen einer Spalte der grow-Spalte den Platz - dann
// schrumpft die Nachbarspalte und alles wandert nach links. Danach verhalten
// sich die Spalten wie in Excel (unabhängig, notfalls scrollt die Tabelle).
// sich die Spalten wie in Excel (unabhaengig, notfalls scrollt die Tabelle).
setWidths((old) => {
const next = { ...old };
for (const gc of growCols) if (next[gc.key] == null) next[gc.key] = Math.round(widthOf(gc));
return next;
});
resizing.current = { key: c.key, startX: e.clientX, startW };
resizing.current = { key: c.key, breite: widthOf(c), zeigerX: e.clientX, letzterX: e.clientX };
rollTimer.current = window.setInterval(anwenden, 30);
window.addEventListener("pointermove", onResizeMove);
window.addEventListener("pointerup", endResize);
}
function anwenden() {
const r = resizing.current; if (!r) return;
let delta = r.zeigerX - r.letzterX;
r.letzterX = r.zeigerX;
const el = wrapRef.current;
let amRand = false;
if (el) {
const kasten = el.getBoundingClientRect();
if (r.zeigerX > kasten.right - RAND) { delta += SCHRITT; amRand = true; }
else if (r.zeigerX < kasten.left + RAND) delta -= SCHRITT;
}
if (!delta) return;
const neu = Math.max(60, r.breite + delta);
if (neu === r.breite) return;
r.breite = neu;
setWidths((old) => ({ ...old, [r.key]: Math.round(neu) }));
// Mitlaufen, damit der Anfasser unter dem Zeiger bleibt, waehrend die
// Tabelle nach rechts waechst.
if (amRand && el) requestAnimationFrame(() => { el.scrollLeft = el.scrollWidth; });
}
function onResizeMove(e) {
const r = resizing.current; if (!r) return;
const w = Math.max(60, Math.round(r.startW + (e.clientX - r.startX)));
setWidths((old) => ({ ...old, [r.key]: w }));
r.zeigerX = e.clientX;
anwenden();
}
function endResize() {
resizing.current = null;
if (rollTimer.current) { window.clearInterval(rollTimer.current); rollTimer.current = null; }
window.removeEventListener("pointermove", onResizeMove);
window.removeEventListener("pointerup", endResize);
}
// Beim Verlassen der Seite mitten im Ziehen bliebe sonst ein Taktgeber laufen.
useEffect(() => () => {
if (rollTimer.current) window.clearInterval(rollTimer.current);
}, []);
// --- Reorder (HTML5 Drag) ---
const dragKey = useRef(null);
const [dropKey, setDropKey] = useState(null);
@@ -319,7 +446,9 @@ export default function DataTable({
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
setOrder(columns.map((c) => c.key));
setWidths({});
setHidden(new Set());
// „Zuruecksetzen" heisst Auslieferungszustand, nicht „alles einblenden":
// Spalten mit defaultHidden verschwinden wieder.
setzeHidden(new Set(columns.filter((c) => c.defaultHidden).map((c) => c.key)));
}
return (
@@ -329,7 +458,7 @@ export default function DataTable({
onClick={() => setFilterShown((v) => !v)}>
<Icon name="search" size={14} />Filter
</button>
<button type="button" className="btn sm ghost" onClick={() => setFilters({})}>Filter löschen</button>
<button type="button" className="btn sm ghost" onClick={() => setzeFilters({})}>Filter löschen</button>
<button type="button" ref={chooserBtn} className={`btn sm ${chooserOpen ? "" : "ghost"}`}
onClick={() => setChooserOpen((o) => !o)}>
<Icon name="settings" size={14} />Spalten
@@ -385,7 +514,7 @@ export default function DataTable({
hasText={Boolean(c.filterText)}
open={openFilter === c.key}
onToggleOpen={setOpenFilter}
onChange={(f) => setFilters((old) => ({ ...old, [c.key]: f }))}
onChange={(f) => setzeFilters((old) => ({ ...old, [c.key]: f }))}
/>
) : null}
</td>
+72 -45
View File
@@ -4,8 +4,10 @@ import { api, authorizedObjectUrl } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import Zubehoer from "./Zubehoer";
import { REASONS } from "./ObjektBestand";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "./LocationSelect";
import { itemDeepLink } from "../qr";
import {
VerleihAktion,
@@ -14,6 +16,10 @@ import {
VerleihText,
verleihRowClass,
} from "./Verleih";
import {
VerwendungAktion,
VerwendungHistorie,
} from "./Verwendung";
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
@@ -48,6 +54,9 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const [addForm, setAddForm] = useState(EMPTY_ADD);
const [remove, setRemove] = useState(null); // { item, reason, note } | null
const [lend, setLend] = useState(null); // Stück, das gerade verliehen wird
// Aufgeklappte Stücke. Die Zeile zeigt nur, was man im Alltag braucht
// (wo liegt es, ist es verliehen) – Kaufdaten und Belege auf Wunsch.
const [offen, setOffen] = useState(() => new Set());
const [showAdd, setShowAdd] = useState(false); // Anlege-Maske ein-/ausklappen
const [suggestion, setSuggestion] = useState(null); // { itemId, date } aus PDF-Analyse
const [docFile, setDocFile] = useState(null); // Beleg, der beim Anlegen mitkommt
@@ -254,6 +263,14 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
const ortName = (id) => locationPathById(id, locations);
function klappe(id) {
setOffen((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id); else neu.add(id);
return neu;
});
}
return (
<section className="card">
<div className="card-head">
@@ -271,9 +288,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<table className="table">
<thead>
<tr>
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th>
<th>Gekauft am</th><th>Garantie bis</th><th>Gekauft bei</th>
<th>Notiz</th><th></th>
<th>QR</th><th>UID</th><th>Lagerort</th><th>Verliehen an</th><th></th>
</tr>
</thead>
<tbody>
@@ -284,44 +299,15 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
<td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort">
{isAdmin ? (
<select value={it.location_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { location_id: e.target.value === "" ? null : e.target.value })}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={it.location_id ?? ""} locations={locations}
onChange={(id) => patchItem(it, { location_id: id === "" ? null : id })} />
) : <span>{ortName(it.location_id) || "–"}</span>}
</td>
<td data-label="Verliehen an"><VerleihText loan={it.loan} /></td>
<td data-label="Gekauft am">
{isAdmin ? (
<input type="date" defaultValue={it.acquired_on || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
) : <span>{it.acquired_on || "–"}</span>}
</td>
<td data-label="Garantie bis">
{isAdmin ? (
<input key={it.warranty_until || "none"} type="date"
defaultValue={it.warranty_until || ""} style={{ marginTop: 0 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
) : <span>{it.warranty_until || "–"}</span>}
</td>
<td data-label="Gekauft bei">
{isAdmin ? (
<select value={it.shop_id ?? ""} style={{ marginTop: 0, minWidth: 120 }}
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">– unbekannt –</option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
) : <span>{it.shop_name || "–"}</span>}
</td>
<td data-label="Notiz">
{isAdmin ? (
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" style={{ marginTop: 0, minWidth: 120 }}
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
) : <span>{it.note || "–"}</span>}
</td>
{/* cell-row statt field-inline: letzteres bricht in Tabellen um
(styles.css) und stellte die Symbole uebereinander. */}
<td className="num">
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<div className="cell-row" style={{ justifyContent: "flex-end" }}>
{/* Verleihen und Zurücknehmen ist eine Buchung – das darf jeder. */}
<VerleihAktion item={it} onLend={setLend} onChanged={nachAktion}
onError={onError} />
@@ -337,19 +323,62 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</button>
</>
)}
<button type="button" className="btn-icon"
title={offen.has(it.id) ? "Details zuklappen" : "Kaufdaten, Preis, Belege, Verleih, Verwendung"}
aria-expanded={offen.has(it.id)}
onClick={() => klappe(it.id)}>
<Icon name="chevron" size={18} className={offen.has(it.id) ? "" : "rot-90"} />
</button>
</div>
</td>
</tr>
{offen.has(it.id) && (
<tr className="einzel-detailrow">
<td colSpan={9}>
<td colSpan={5}>
<div style={{ display: "grid", gap: "var(--sp-4)",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))" }}>
<div className="row" style={{ margin: 0 }}>
<label className="grow" style={{ margin: 0 }}>Gekauft am
<input type="date" defaultValue={it.acquired_on || ""} disabled={!isAdmin}
onBlur={(e) => { if ((e.target.value || "") !== (it.acquired_on || "")) patchItem(it, { acquired_on: e.target.value || null }); }} />
</label>
<label className="grow" style={{ margin: 0 }}>Garantie bis
<input key={it.warranty_until || "none"} type="date" disabled={!isAdmin}
defaultValue={it.warranty_until || ""}
onBlur={(e) => { if ((e.target.value || "") !== (it.warranty_until || "")) patchItem(it, { warranty_until: e.target.value || null }); }} />
</label>
</div>
<label style={{ margin: 0 }}>Gekauft bei
<select value={it.shop_id ?? ""} disabled={!isAdmin}
onChange={(e) => patchItem(it, { shop_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value="">– unbekannt –</option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label style={{ margin: 0 }}>Notiz
<input defaultValue={it.note || ""} placeholder="z.B. Zustand" disabled={!isAdmin}
onBlur={(e) => { if ((e.target.value || "") !== (it.note || "")) patchItem(it, { note: e.target.value || null }); }} />
</label>
<div>
<span className="muted small">Verleih</span>
<div style={{ marginTop: "var(--sp-2)" }}>
<VerleihHistorie item={it} onError={onError} />
</div>
</div>
{it.verwendung_moeglich && (
<div>
<span className="muted small">Verwendung</span>
<div style={{ marginTop: "var(--sp-2)" }}>
<VerwendungAktion item={it} onChanged={nachAktion}
onError={onError} />
<div style={{ marginTop: "var(--sp-2)" }}>
<VerwendungHistorie item={it} onError={onError} />
</div>
</div>
</div>
)}
<Zubehoer item={it} isAdmin={isAdmin}
onChanged={nachAktion} onError={onError} />
<label style={{ margin: 0 }}>Kaufpreis
<div className="field-inline">
<input key={it.price_cents ?? "none"} type="number" step="0.01" min="0"
@@ -428,9 +457,10 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
</div>
</td>
</tr>
)}
</Fragment>
))}
{items.length === 0 && <tr><td colSpan={9} className="empty">Noch keine Einzelstücke.</td></tr>}
{items.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Einzelstücke.</td></tr>}
</tbody>
</table>
</div>
@@ -456,11 +486,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
onChange={(e) => setAddForm({ ...addForm, count: e.target.value })} />
</label>
<label className="grow">Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value="">– ohne –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={addForm.location_id} locations={locations}
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
</label>
<label className="grow">Gekauft bei
<select value={addForm.shop_id}
+436
View File
@@ -0,0 +1,436 @@
import { useCallback, useEffect, useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import { asTree } from "../categoryTree";
/**
* Gespeicherte Zusammenstellungen für den Etikettenexport.
*
* Ersetzt die beiden früheren Abschnitte mit festen Spalten. Der Gewinn sind
* nicht die Vorlagen an sich, sondern die frei wählbaren Spalten: Erst dadurch
* kommen eigene Felder wie eine Kabellänge oder die Kapazität einer Powerbank
* überhaupt aufs Etikett.
*
* Die CSV baut der Server – dort liegen die Feldwerte, und dort lässt sich das
* Ergebnis prüfen.
*/
const ARTEN = [
{ wert: "items", label: "Einzelstücke", hinweis: "Ein Etikett je Stück, mit UID und QR." },
{ wert: "products", label: "Artikel", hinweis: "Ein Etikett je Artikel statt je Stück." },
{ wert: "locations", label: "Lagerorte", hinweis: "Ein Etikett je Lagerort, mit dessen QR." },
];
const TRENNZEICHEN = [
{ wert: ",", label: "Komma" },
{ wert: ";", label: "Semikolon" },
{ wert: "\t", label: "Tabulator" },
];
export default function EtikettenVorlagen({ onError }) {
const confirm = useConfirm();
const toast = useToast();
const [vorlagen, setVorlagen] = useState(null);
const [offen, setOffen] = useState(null); // id der bearbeiteten Vorlage
const [busy, setBusy] = useState(false);
const laden = useCallback(async () => {
try { setVorlagen(await api.etikettenVorlagen()); }
catch (err) { onError(err.message); setVorlagen([]); }
}, [onError]);
useEffect(() => { laden(); }, [laden]);
async function anlegen() {
const name = await confirm({
title: "Neue Vorlage",
message: "Wie soll sie heißen? Zum Beispiel „Kabelexport“.",
confirmLabel: "Anlegen",
prompt: true,
});
if (!name || typeof name !== "string") return;
setBusy(true);
try {
const neu = await api.etikettenVorlageAnlegen({
name: name.trim(),
art: "items",
spalten: ["qr", "uid", "produkt"],
kategorie_ids: [],
trennzeichen: ",",
});
await laden();
setOffen(neu.id);
toast(`Vorlage „${neu.name}“ angelegt.`);
} catch (err) { onError(err.message); } finally { setBusy(false); }
}
async function loeschen(v) {
const ok = await confirm({
title: `Vorlage „${v.name}“ löschen?`,
message: "Die hinterlegte P-touch-Datei geht mit verloren.",
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.etikettenVorlageLoeschen(v.id);
if (offen === v.id) setOffen(null);
await laden();
} catch (err) { onError(err.message); }
}
async function csv(v) {
setBusy(true);
try { await api.etikettenCsv(v.id, v.name); }
catch (err) { onError(err.message); } finally { setBusy(false); }
}
return (
<section className="card">
<div className="card-head">
<Icon name="download" /><h2>Etiketten-Vorlagen</h2>
<button type="button" className="btn sm" style={{ marginLeft: "auto" }}
disabled={busy} onClick={anlegen}>
<Icon name="plus" size={14} />Neue Vorlage
</button>
</div>
<p className="muted small">
Eine Vorlage hält fest, worauf sie sich bezieht, welche Spalten in welcher
Reihenfolge exportiert werden und wie gefiltert wird – samt eigener Felder.
Dazu lässt sich die P-touch-Datei (.lbx) hinterlegen.
</p>
{vorlagen == null && <div className="muted">Lädt…</div>}
{vorlagen && vorlagen.length === 0 && (
<p className="muted small">Noch keine Vorlage.</p>
)}
<ul className="clean-list" style={{ marginTop: "var(--sp-3)" }}>
{(vorlagen || []).map((v) => (
<li key={v.id} style={{ flexDirection: "column", alignItems: "stretch" }}>
<div className="cell-row" style={{ gap: "var(--sp-2)" }}>
{/* Die ganze Zeile klappt auf und zu, nicht nur ein kleines
Symbol am Rand – und derselbe Griff schliesst wieder. */}
<button type="button" className="zeilen-schalter"
style={{ flex: 1, minWidth: 0, textAlign: "left" }}
aria-expanded={offen === v.id}
onClick={() => setOffen(offen === v.id ? null : v.id)}>
<Icon name="chevron" size={16}
className={offen === v.id ? "" : "rot-90"} />{" "}
<span className="strong">{v.name}</span>{" "}
<span className="muted small">
{ARTEN.find((a) => a.wert === v.art)?.label || v.art}
{" · "}{v.spalten.length} Spalten
{/* Dass eine Vorlage nur einen Teil ausgibt, soll man sehen,
ohne sie aufzuklappen. */}
{v.feldfilter?.length ? " · gefiltert" : ""}
{v.datei_name ? " · mit Vorlage" : ""}
</span>
</button>
<button type="button" className="btn sm" disabled={busy}
onClick={() => csv(v)}>
<Icon name="download" size={14} />CSV
</button>
{v.datei_name && (
<button type="button" className="btn sm ghost"
onClick={() => api.etikettenDatei(v.id, v.datei_name).catch((e) => onError(e.message))}>
<Icon name="download" size={14} />.lbx
</button>
)}
<button type="button" className="btn-icon danger" title="Löschen"
onClick={() => loeschen(v)}>
<Icon name="trash" size={16} />
</button>
</div>
{offen === v.id && (
<VorlageEditor vorlage={v} onGespeichert={laden} onError={onError} />
)}
</li>
))}
</ul>
</section>
);
}
/** 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);
const [spalten, setSpalten] = useState(vorlage.spalten);
const [katIds, setKatIds] = useState(vorlage.kategorie_ids);
const [trenn, setTrenn] = useState(vorlage.trennzeichen);
const [feldfilter, setFeldfilter] = useState(vorlage.feldfilter || []);
const [moeglich, setMoeglich] = useState([]);
const [kategorien, setKategorien] = useState([]);
const [busy, setBusy] = useState(false);
useEffect(() => {
api.listCategories().then(setKategorien).catch(() => {});
}, []);
// Welche Spalten es gibt, hängt von Bezugsart UND Kategorien ab – die eigenen
// Felder kommen schließlich von dort.
useEffect(() => {
let aktiv = true;
api.etikettenSpalten(art, katIds)
.then((s) => { if (aktiv) setMoeglich(s); })
.catch((err) => onError(err.message));
return () => { aktiv = false; };
}, [art, katIds, onError]);
const titelVon = (schluessel) =>
moeglich.find((m) => m.schluessel === schluessel)?.titel || schluessel;
const offen = moeglich.filter((m) => !spalten.includes(m.schluessel));
function verschieben(index, richtung) {
const ziel = index + richtung;
if (ziel < 0 || ziel >= spalten.length) return;
const kopie = [...spalten];
[kopie[index], kopie[ziel]] = [kopie[ziel], kopie[index]];
setSpalten(kopie);
}
async function speichern() {
setBusy(true);
try {
await api.etikettenVorlageAendern(vorlage.id, {
art, spalten, kategorie_ids: katIds, trennzeichen: trenn, feldfilter,
});
await onGespeichert();
toast("Vorlage gespeichert.");
} catch (err) { onError(err.message); } finally { setBusy(false); }
}
async function dateiWaehlen(e) {
const datei = e.target.files?.[0];
e.target.value = ""; // damit dieselbe Datei erneut gewählt werden kann
if (!datei) return;
setBusy(true);
try {
await api.etikettenDateiHochladen(vorlage.id, datei);
await onGespeichert();
toast("P-touch-Vorlage hinterlegt.");
} catch (err) { onError(err.message); } finally { setBusy(false); }
}
// Einzelstuecke gibt es nur bei Gegenstaenden; Artikel kann es in beiden
// Welten geben, dort also nicht einschraenken.
const baum = asTree(kategorien).filter(
(c) => art === "products" || c.tracking === "object",
);
return (
<div className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="row">
<label className="grow" style={{ maxWidth: 260 }}>
Bezugsart
<select value={art} onChange={(e) => { setArt(e.target.value); setSpalten([]); }}>
{ARTEN.map((a) => <option key={a.wert} value={a.wert}>{a.label}</option>)}
</select>
</label>
<label style={{ maxWidth: 200 }}>
Trennzeichen
<select value={trenn} onChange={(e) => setTrenn(e.target.value)}>
{TRENNZEICHEN.map((t) => (
<option key={t.wert} value={t.wert}>{t.label}</option>
))}
</select>
</label>
</div>
<p className="muted small mt-0">{ARTEN.find((a) => a.wert === art)?.hinweis}</p>
{art !== "locations" && (
<>
<span className="muted small">Kategorien (leer = alle)</span>
<div className="kat-check-liste" style={{ marginTop: "var(--sp-1)" }}>
{baum.map((c) => (
<label key={c.id} className="check-inline"
style={{ margin: 0, paddingLeft: (c.depth || 0) * 16 }}>
<input type="checkbox" checked={katIds.includes(c.id)}
onChange={(e) => setKatIds(e.target.checked
? [...katIds, c.id]
: katIds.filter((k) => k !== c.id))} />
<span>{c.name}</span>
</label>
))}
</div>
<p className="muted small">
Eigene Felder hängen am Artikel, nicht am einzelnen Stück – alle Stücke
eines Artikels tragen denselben Wert.
</p>
<span className="muted small" style={{ display: "block", marginTop: "var(--sp-3)" }}>
Nur Zeilen mit bestimmten Feldwerten (alle Bedingungen müssen zutreffen)
</span>
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{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) => {
const id = Number(e.target.value.slice("feld:".length));
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, op: "=", wert: start }]);
}}>
<option value="">+ Bedingung auf einem eigenen Feld …</option>
{moeglich.filter((m) => m.schluessel.startsWith("feld:")).map((m) => (
<option key={m.schluessel} value={m.schluessel}>{m.titel}</option>
))}
</select>
<p className="muted small">
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>
</>
)}
<div className="grid-2" style={{ marginTop: "var(--sp-3)" }}>
<div>
<span className="muted small">Spalten (in dieser Reihenfolge)</span>
{spalten.length === 0 && (
<p className="muted small">Noch keine gewählt – es greifen dann die Standardspalten.</p>
)}
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{spalten.map((s, i) => (
<li key={s}>
<span style={{ flex: 1, minWidth: 0 }}>{titelVon(s)}</span>
<button type="button" className="btn-icon" title="Nach oben"
disabled={i === 0} onClick={() => verschieben(i, -1)}>↑</button>
<button type="button" className="btn-icon" title="Nach unten"
disabled={i === spalten.length - 1} onClick={() => verschieben(i, 1)}>↓</button>
<button type="button" className="btn-icon danger" title="Entfernen"
onClick={() => setSpalten(spalten.filter((x) => x !== s))}>
<Icon name="close" size={14} />
</button>
</li>
))}
</ul>
</div>
<div>
<span className="muted small">Verfügbar</span>
{offen.length === 0 && <p className="muted small">Alle Spalten sind gewählt.</p>}
<ul className="clean-list" style={{ marginTop: "var(--sp-2)" }}>
{offen.map((m) => (
<li key={m.schluessel}>
<button type="button" className="btn sm ghost"
onClick={() => setSpalten([...spalten, m.schluessel])}>
<Icon name="plus" size={14} />{m.titel}
</button>
</li>
))}
</ul>
</div>
</div>
<div className="row" style={{ marginTop: "var(--sp-3)", alignItems: "center" }}>
<button type="button" className="btn primary" disabled={busy} onClick={speichern}>
{busy ? "Speichern…" : "Speichern"}
</button>
<label style={{ margin: 0 }}>
<span className="muted small">P-touch-Vorlage (.lbx)</span>
<input type="file" accept=".lbx" onChange={dateiWaehlen} disabled={busy} />
</label>
{vorlage.datei_name && (
<button type="button" className="btn sm ghost"
onClick={async () => {
try {
await api.etikettenDateiEntfernen(vorlage.id);
await onGespeichert();
} catch (err) { onError(err.message); }
}}>
<Icon name="trash" size={14} />{vorlage.datei_name} entfernen
</button>
)}
</div>
<p className="muted small">
Eine .lbx merkt sich den Pfad zu ihrer Datenquelle. Auf einem fremden
Rechner muss die CSV in P-touch einmal neu verknüpft werden – die Datei
dabeizuhaben erspart trotzdem das Suchen.
</p>
</div>
);
}
+2
View File
@@ -119,6 +119,8 @@ const PATHS = {
</>
),
plus: <path d="M5 12h14M12 5v14" />,
// Aufklappen. Zeigt nach unten; zugeklappt dreht die Zelle es nach rechts.
chevron: <path d="m6 9 6 6 6-6" />,
trash: (
<>
<path d="M3 6h18" />
+4 -3
View File
@@ -1,6 +1,7 @@
import { useState } from "react";
import Icon from "./Icon";
import { locationOptions } from "../locationPath";
import LocationSelect from "./LocationSelect";
// „Überall" ist ein Ort wie jeder andere – nur eben der oberste. Im Datenmodell
// ist er location_id = null; im <select> braucht es einen Wert, deshalb hier ein
@@ -70,9 +71,9 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
{rows.map((r, i) => (
<div className="field-inline" key={i} style={{ marginBottom: 0 }}>
<label className="grow" style={{ flex: "1 1 auto", minWidth: 0, margin: 0 }}>
<select value={r.location_id} onChange={(e) => setRow(i, { location_id: e.target.value })}>
{alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={r.location_id} locations={locations}
showRoot={false} extraOptions={[{ value: UEBERALL, label: "Überall (egal wo)" }]}
onChange={(id) => setRow(i, { location_id: id })} />
</label>
<label style={{ margin: 0, width: 130 }}>
<input type="number" min="0" step="0.01" placeholder="Menge" value={r.min_stock}
+41
View File
@@ -0,0 +1,41 @@
import CategorySelect from "./CategorySelect";
import { asTree } from "../categoryTree";
/**
* Lagerort-Auswahl als aufklappbarer Baum – dieselbe Darstellung wie bei den
* Kategorien, denn Lagerorte sind genauso verschachtelt (Hedingen → Kallax →
* Fach Oben Links → Oben). Vorher stand überall ein flaches <select>, in dem
* jede Zeile den ganzen Pfad ausschrieb; Geschwister standen dadurch nur
* zufällig beieinander.
*
* `CategorySelect` kann beides – seine IDs dürfen Zeichenketten sein, und
* Lagerort-Codes sind welche. Hier kommt nur der Baumaufbau dazu und die
* Umrechnung "" ↔ null, damit die Aufrufstellen weiter mit dem leeren String
* für „kein Ort" arbeiten wie beim <select> zuvor.
*/
export default function LocationSelect({
value,
onChange,
locations,
rootLabel = "– ohne –",
extraOptions = [],
showRoot = true,
disabled = false,
title,
}) {
return (
<CategorySelect
value={value === "" || value == null ? null : value}
onChange={(id) => onChange(id == null ? "" : id)}
// Nach Namen sortieren, bevor der Baum entsteht: Der Server sortiert
// zeichenweise, dort stuende „Fach oben Rechts" hinter „Fach Unten
// Rechts". asTree uebernimmt die Reihenfolge der Geschwister unveraendert.
nodes={asTree([...(locations || [])].sort((a, b) => a.name.localeCompare(b.name, "de")))}
rootLabel={rootLabel}
extraOptions={extraOptions}
showRoot={showRoot}
disabled={disabled}
title={title}
/>
);
}
+48
View File
@@ -0,0 +1,48 @@
import { NAV_AUSBLENDBAR } from "../nav";
import { useAuth } from "../auth";
/**
* Häkchenliste für die Seitenleiste – an zwei Stellen benutzt: in den
* Einstellungen für die Vorgabe des Administrators, auf der Kontoseite für die
* eigene Auswahl.
*
* Angehakt heisst **sichtbar**. Das ist herum, wie man es erwartet, auch wenn
* der Server das Gegenteil speichert (die Liste der versteckten Pfade).
*
* `gesperrt` sind Einträge, die der Administrator schon ausgeblendet hat: Sie
* stehen abgehakt und unveränderlich da, denn die eigene Auswahl addiert nur –
* sie nimmt nichts weg.
*/
export default function NavigationAuswahl({ versteckt, gesperrt = [], onChange }) {
const { isAdmin } = useAuth();
const aus = new Set(versteckt);
const fest = new Set(gesperrt);
// Stammdaten und System sieht ohnehin nur ein Administrator.
const eintraege = NAV_AUSBLENDBAR.filter(
(e) => isAdmin || !["stammdaten", "system"].includes(e.gruppe),
);
function umschalten(to, sichtbar) {
const neu = new Set(aus);
if (sichtbar) neu.delete(to);
else neu.add(to);
onChange([...neu]);
}
return (
<div className="nav-auswahl">
{eintraege.map((e) => {
const blockiert = fest.has(e.to);
return (
<label key={e.to} className="check-inline"
title={blockiert ? "Vom Administrator ausgeblendet" : undefined}>
<input type="checkbox" disabled={blockiert}
checked={!blockiert && !aus.has(e.to)}
onChange={(ev) => umschalten(e.to, ev.target.checked)} />
<span className={blockiert ? "muted" : ""}>{e.label}</span>
</label>
);
})}
</div>
);
}
+14 -19
View File
@@ -4,7 +4,8 @@ import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import { fmt } from "../units";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "./LocationSelect";
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
export const REASONS = [
@@ -182,11 +183,9 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
<label className="grow">
Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={addForm.location_id} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setAddForm({ ...addForm, location_id: id })} />
</label>
<label style={{ width: 120 }}>
Menge
@@ -208,16 +207,14 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
</div>
<div className="row">
<label className="grow">Von
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={move.from} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setMove({ ...move, from: id })} />
</label>
<label className="grow">Nach
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={move.to} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setMove({ ...move, to: id })} />
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={move.quantity}
@@ -237,11 +234,9 @@ export default function ObjektBestand({ product, lots, locations, isAdmin, onCha
</div>
<div className="row">
<label className="grow">Lagerort
<select value={remove.location_id}
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={remove.location_id} locations={locations}
rootLabel="– ohne Lagerort –"
onChange={(id) => setRemove({ ...remove, location_id: id })} />
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={remove.quantity}
+4 -5
View File
@@ -2,7 +2,8 @@ import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { fmt } from "../units";
import { locationOptions, locationPathById } from "../locationPath";
import { locationPathById } from "../locationPath";
import LocationSelect from "./LocationSelect";
/**
* Charge aufteilen: einen Teil der Menge (in Artikeleinheiten – Packungen/Stück/g)
@@ -55,10 +56,8 @@ export default function SplitLotDialog({ lot, factor, labelFor, locations, onClo
</label>
<label>
nach Lagerort
<select value={locId} onChange={(e) => setLocId(e.target.value)}>
<option value="">– ohne Lagerort –</option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<LocationSelect value={locId} locations={locations}
rootLabel="– ohne Lagerort –" onChange={setLocId} />
</label>
{rest != null && !zielGleich && (
<p className="muted small mt-0">
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { buildUnitOptions, fmt, gebinde, unitShort } from "../units";
/**
* Teilmenge aus einer Charge verbrauchen – „ich habe 100 ml von dieser Milch
* genommen", „2 von 6 Brötchen gegessen", „2 EL Mehl".
*
* Eingetippt wird die **verbrauchte** Menge, nicht der Rest: Das ist die Zahl,
* die man kennt. War die Charge noch zu, wird sie dabei angebrochen — eine
* Packung spaltet sich ab, bekommt ihr „zu verbrauchen bis" und der Rest bleibt
* unangetastet mit seinem eigenen MHD. Die Rückfrage dazu steht unten und ist
* abwählbar, falls man nur eine Buchung nachtragen will.
*
* Die Einheitenauswahl holt neben den verwalteten Einheiten auch die Hausmaße
* der Kategorie — „EL" taucht nur auf, wenn dafür eine Menge hinterlegt ist,
* denn ein Esslöffel Mehl und ein Esslöffel Zucker sind verschieden schwer.
*/
export default function VerbrauchDialog({ lot, onClose, onDone, onError }) {
const [produkt, setProdukt] = useState(null);
const [einheiten, setEinheiten] = useState([]);
const [masse, setMasse] = useState([]);
const [menge, setMenge] = useState("");
const [einheit, setEinheit] = useState("");
const [notiz, setNotiz] = useState("");
const [anbrechen, setAnbrechen] = useState(true);
const [busy, setBusy] = useState(false);
const [laedt, setLaedt] = useState(true);
useEffect(() => {
let abgebrochen = false;
(async () => {
try {
const [p, us, ms] = await Promise.all([
api.getProduct(lot.product_id),
api.listUnits(),
api.productMeasures(lot.product_id),
]);
if (abgebrochen) return;
setProdukt(p);
setEinheiten(buildUnitOptions(p, us));
setMasse(ms);
// Vorbelegung: die Basiseinheit. Beim Verbrauchen ist das die
// naheliegende Angabe („100 ml"), nicht die ganze Packung.
setEinheit(unitStandard(p, us));
} catch (err) {
onError?.(err.message);
onClose();
} finally {
if (!abgebrochen) setLaedt(false);
}
})();
return () => { abgebrochen = true; };
}, [lot.product_id]);
const zahl = Number(String(menge).trim().replace(",", "."));
const gueltig = menge !== "" && Number.isFinite(zahl) && zahl > 0;
const offen = !!lot.opened_on;
async function submit() {
if (!gueltig || busy || !einheit) return;
setBusy(true);
try {
const antwort = await api.consumeLot(lot.id, {
quantity: zahl,
unit: einheit,
note: notiz.trim() || null,
auto_open: offen ? false : anbrechen,
});
onClose();
await onDone(antwort);
} catch (err) {
onError?.(err.message);
} finally {
setBusy(false);
}
}
const basis = unitShort(lot.base_unit);
const restText = lot.package_size > 0
? `${fmt(lot.quantity / lot.package_size)} ${gebinde(lot.quantity / lot.package_size, lot.package_label)} (${fmt(lot.quantity)} ${basis})`
: `${fmt(lot.quantity)} ${basis}`;
return (
<div className="modal-backdrop" onClick={(e) => e.target === e.currentTarget && onClose()}>
<div className="modal" role="dialog" aria-modal="true">
<div className="modal-head"><Icon name="checkout" size={18} /><h2>Verbrauchen</h2></div>
<p className="modal-body mt-0">
{lot.product_name}: aktuell {restText}
{offen && lot.opened_quantity
? ` — angebrochen, ursprünglich ${fmt(lot.opened_quantity)} ${basis}.`
: "."}
</p>
{laedt && <p className="modal-body muted small">Einheiten werden geladen…</p>}
{!laedt && (
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
<div className="field-inline" style={{ marginBottom: 0 }}>
<label style={{ margin: 0 }}>
Verbrauchte Menge
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input type="number" step="any" min="0" value={menge} autoFocus
onChange={(e) => setMenge(e.target.value)} placeholder="z.B. 100" />
</label>
<label style={{ margin: 0 }}>
Einheit
<select value={einheit} onChange={(e) => setEinheit(e.target.value)}>
{einheiten.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
{masse.length > 0 && (
<optgroup label="Hausmaße">
{masse.map((m) => (
<option key={`m${m.measure_id}`} value={m.name}>
{m.short ? `${m.name} (${m.short})` : m.name}
{` ≈ ${fmt(m.amount)} ${unitShort(m.base)}`}
</option>
))}
</optgroup>
)}
</select>
</label>
</div>
{masse.length === 0 && produkt && (
<p className="muted small mt-0">
Für die Kategorie dieses Artikels sind keine Hausmaße hinterlegt –
„2 EL" lässt sich deshalb noch nicht buchen. Mengen dafür stehen
unter Stammdaten ▸ Maße.
</p>
)}
{!offen && (
<label className="check-inline" style={{ margin: 0 }}>
<input type="checkbox" checked={anbrechen}
onChange={(e) => setAnbrechen(e.target.checked)} />
<span>
Packung als angebrochen führen
<span className="muted small" style={{ display: "block" }}>
Eine Packung wird abgespalten und bekommt ein „zu verbrauchen
bis"; der Rest behält sein MHD. Ohne Haken wird nur die Menge
abgebucht.
</span>
</span>
</label>
)}
<label style={{ margin: 0 }}>
Notiz (freiwillig)
<input value={notiz} maxLength={255} placeholder="z.B. für Pfannkuchen"
onChange={(e) => setNotiz(e.target.value)} />
</label>
</div>
)}
<div className="btn-pair">
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
<button type="button" className="btn primary" disabled={!gueltig || busy || laedt}
onClick={submit}>
<Icon name="checkout" size={16} />Verbrauchen
</button>
</div>
</div>
</div>
);
}
// Basiseinheit des Artikels als Vorauswahl – beim Verbrauchen zählt die kleine
// Menge, nicht das Gebinde.
function unitStandard(produkt, units) {
const basis = (units || []).find(
(u) => u.factor === 1 && u.kind === produkt.kind,
);
return basis ? basis.name : (produkt.package_size ? "package" : "");
}
+49 -8
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { useToast } from "../toast";
import { useConfirm } from "../confirm";
import { daysUntil, relativeExpiry } from "../units";
/**
@@ -18,8 +19,10 @@ export function FristBadge({ loan, warnTage = 7 }) {
if (!loan || !loan.due_on) return null;
const tage = daysUntil(loan.due_on);
const art = loan.days_overdue ? "danger" : (tage != null && tage <= warnTage ? "warn" : null);
// Bei Ueberfaelligkeit die Tage ausschreiben: „vor 1 Woche" (relativeExpiry)
// liest sich als Zeitpunkt, gemeint ist aber eine Dauer.
const text = loan.days_overdue
? `überfällig (${relativeExpiry(-loan.days_overdue)})`
? `${loan.days_overdue} ${loan.days_overdue === 1 ? "Tag" : "Tage"} überfällig`
: `zurück ${relativeExpiry(tage)}`;
return art
? <span className={`badge ${art}`}>{text}</span>
@@ -56,6 +59,14 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
const toast = useToast();
const [form, setForm] = useState(LEER);
const [busy, setBusy] = useState(false);
// Zubehör geht nur mit, wenn es angehakt ist. Vorausgewählt sind die Teile,
// die gerade im Haus sind – ein schon anderweitig verliehenes kann nicht mit.
const verfuegbaresZubehoer = (item.zubehoer || []).filter((t) => !t.verliehen);
const [mit, setMit] = useState(() => verfuegbaresZubehoer.map((t) => t.id));
function umschalten(id) {
setMit((alt) => (alt.includes(id) ? alt.filter((x) => x !== id) : [...alt, id]));
}
async function submit(e) {
e.preventDefault();
@@ -66,6 +77,7 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
borrower: form.borrower.trim(),
reason: form.reason.trim() || null,
due_on: form.due_on || null,
mit_zubehoer: mit,
});
setForm(LEER);
toast(`${item.uid} an ${form.borrower.trim()} verliehen.`);
@@ -98,6 +110,19 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
onChange={(e) => setForm({ ...form, due_on: e.target.value })} />
</label>
</div>
{verfuegbaresZubehoer.length > 0 && (
<fieldset className="card-sub" style={{ marginTop: "var(--sp-2)" }}>
<legend className="small strong">Zubehör mitgeben?</legend>
{verfuegbaresZubehoer.map((t) => (
<label key={t.id} className="cell-row" style={{ gap: "var(--sp-2)" }}>
<input type="checkbox" checked={mit.includes(t.id)}
onChange={() => umschalten(t.id)} />
<span>{t.product_name || "Einzelstück"}</span>
<span className="muted small">{t.uid}</span>
</label>
))}
</fieldset>
)}
<p className="muted small">
Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. Als
verliehen zählt es aber nicht mehr zum Bestand.
@@ -115,29 +140,45 @@ export function VerleihForm({ item, onDone, onCancel, onError }) {
*/
export function VerleihAktion({ item, onLend, onChanged, onError, size = 16 }) {
const toast = useToast();
const confirm = useConfirm();
const [busy, setBusy] = useState(false);
async function zurueck() {
// Mitverliehenes Zubehör kommt zusammen mit dem Gerät zurück – sonst bliebe
// das Netzteil ewig „verliehen“, weil niemand mehr danach sucht.
const teile = (item.zubehoer || []).filter((t) => t.verliehen);
const ok = await confirm({
title: `„${item.uid}“ zurücknehmen?`,
message: teile.length
? `Von ${item.loan.borrower}. Das mitverliehene Zubehör kommt mit zurück: `
+ teile.map((t) => t.uid).join(", ") + "."
: `Von ${item.loan.borrower}.`,
confirmLabel: "Zurücknehmen",
});
if (!ok) return;
setBusy(true);
try {
await api.returnItem(item.id);
await api.returnItem(item.id, { mit_zubehoer: teile.map((t) => t.id) });
toast(`${item.uid} ist zurück.`);
if (onChanged) await onChanged();
} catch (err) { onError(err.message); } finally { setBusy(false); }
}
// Bewusst mit Text statt als nacktes Symbol: In einer breiten Tabelle war der
// Knopf sonst ein unbeschriftetes Bildchen ganz rechts, und das title-Attribut
// erreicht auf einem Touchgerät niemanden.
if (item.loan) {
return (
<button type="button" className="btn-icon" disabled={busy}
title={`Zurücknehmen (von ${item.loan.borrower})`} onClick={zurueck}>
<Icon name="checkin" size={size} />
<button type="button" className="btn sm" disabled={busy} onClick={zurueck}
aria-label={`„${item.uid}“ von ${item.loan.borrower} zurücknehmen`}>
<Icon name="checkin" size={size} />{busy ? "Moment…" : "Zurücknehmen"}
</button>
);
}
return (
<button type="button" className="btn-icon" title="Verleihen"
onClick={() => onLend(item)}>
<Icon name="loan" size={size} />
<button type="button" className="btn sm" onClick={() => onLend(item)}
aria-label={`„${item.uid}“ verleihen`}>
<Icon name="loan" size={size} />Verleihen
</button>
);
}
+142
View File
@@ -0,0 +1,142 @@
import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { useToast } from "../toast";
/**
* Verwendungsstatus eines Einzelstücks – Anzeige und Umstellen, an drei Stellen
* benutzt: in der Einzelstück-Tabelle eines Artikels, in der Liste aller
* Einzelstücke und auf der Scan-Zielseite (/i/<uid>).
*
* Woran ist das Stück gerade? Der Lagerort sagt nur, wohin es gehört. Gedacht
* für eine Garderobe mit eingenähten NFC-Tags, aber bewusst allgemein: ein Auto
* in der Waschanlage ist genauso „in Reinigung".
*
* Erscheint nur bei Kategorien, die den Status führen (`verwendung_moeglich`).
* „Verfügbar" ist kein gespeicherter Wert, sondern das Fehlen eines laufenden
* Zeitraums – deshalb ist der Status hier überall `null`.
*/
/** Die vier Zustände in der Reihenfolge, in der sie als Knöpfe erscheinen. */
export const ZUSTAENDE = [
{ status: null, label: "Verfügbar" },
{ status: "in_use", label: "In Verwendung" },
{ status: "cleaning", label: "In Reinigung" },
{ status: "repair", label: "In Reparatur" },
];
const LABEL = { in_use: "In Verwendung", cleaning: "In Reinigung", repair: "In Reparatur" };
// Rot = da muss jemand ran, Gelb = ist unterwegs, schlicht = in Benutzung.
const ART = { cleaning: "warn", repair: "danger" };
export function verwendungLabel(status) {
return LABEL[status] || "Verfügbar";
}
/** Zeitpunkt kurz: „14.03.2026, 18:42". Anders als bei Daten steht hier ein
* vollständiger Zeitstempel – ein Hemd wechselt mehrmals am Tag. */
function zeitpunkt(iso) {
if (!iso) return "";
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", year: "numeric",
hour: "2-digit", minute: "2-digit",
});
}
/** „seit heute" / „seit 3 Tagen" – die Frage im Alltag ist die Dauer. */
function seit(iso) {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "";
const tage = Math.floor((Date.now() - d.getTime()) / 86400000);
if (tage <= 0) return "seit heute";
return `seit ${tage} ${tage === 1 ? "Tag" : "Tagen"}`;
}
/** Der aktuelle Zustand als Plakette – für Tabellenspalten. */
export function VerwendungBadge({ item }) {
if (!item?.verwendung_moeglich) return <span className="muted">–</span>;
const status = item.verwendung?.status || null;
if (!status) return <span className="muted">Verfügbar</span>;
const art = ART[status];
return (
<span className={art ? `badge ${art}` : "badge"} title={seit(item.verwendung.started_at)}>
{verwendungLabel(status)}
</span>
);
}
/**
* Die vier Knöpfe, der aktuelle hervorgehoben. Ein Griff genügt – gedacht für
* „gerade aus der Wäsche geholt, ist wieder im Schrank" direkt nach dem Scannen.
*/
export function VerwendungAktion({ item, onChanged, onError, size = "sm" }) {
const toast = useToast();
const [busy, setBusy] = useState(false);
if (!item?.verwendung_moeglich) return null;
const aktuell = item.verwendung?.status || null;
async function setze(status) {
if (status === aktuell) return;
setBusy(true);
try {
await api.setzeVerwendung(item.id, status);
toast(`${item.uid}: ${verwendungLabel(status)}`);
await onChanged?.();
} catch (err) { onError?.(err.message); } finally { setBusy(false); }
}
return (
<div className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
{ZUSTAENDE.map(({ status, label }) => (
<button key={status || "frei"} type="button" disabled={busy}
className={`btn ${size} ${status === aktuell ? "primary" : "ghost"}`}
aria-pressed={status === aktuell}
onClick={() => setze(status)}>
{label}
</button>
))}
</div>
);
}
/** Verwendungs-Verlauf eines Stücks (neueste zuerst), bei Bedarf nachgeladen. */
export function VerwendungHistorie({ item, onError }) {
const [zeilen, setZeilen] = useState(null);
const [offen, setOffen] = useState(false);
if (!item?.verwendung_moeglich) return null;
async function laden() {
setOffen(true);
try { setZeilen(await api.itemVerwendungen(item.id)); }
catch (err) { onError?.(err.message); }
}
if (!offen) {
return (
<button type="button" className="btn sm ghost" onClick={laden}>
<Icon name="history" size={14} />Verwendungs-Verlauf
</button>
);
}
if (zeilen == null) return <span className="muted small">Wird geladen…</span>;
if (zeilen.length === 0) {
return <span className="muted small">War noch nie in Verwendung.</span>;
}
return (
<ul className="clean-list">
{zeilen.map((v) => (
<li key={v.id} className="cell-row" style={{ gap: "var(--sp-2)", flexWrap: "wrap" }}>
<span className="strong">{verwendungLabel(v.status)}</span>
<span className="muted small">
{zeitpunkt(v.started_at)}
{v.ended_at ? ` – ${zeitpunkt(v.ended_at)}` : " – läuft"}
{v.username ? ` · ${v.username}` : ""}
</span>
</li>
))}
</ul>
);
}

Some files were not shown because too many files have changed in this diff Show More