Compare commits

..

121 Commits

Author SHA1 Message Date
Scarriffle
4e2dd3e3eb DataTable: neue Spalten ruecken in die gespeicherte Reihenfolge nach
Die Gruppen-Spalte fehlte im Spalten-Menue - aus zwei Gruenden, und der zweite
ist der ernstere.

1) order wurde EINMAL beim Mounten berechnet: gespeicherte Reihenfolge plus
alles, was zu dem Zeitpunkt in columns stand. Aendert eine Seite ihre Spalten
danach - die Artikelliste zeigt "Verwaltung" nur bei Gegenstaenden -, fehlt die
neue Spalte in order und faellt damit aus allOrdered heraus. Sie ist dann nicht
bloss aus dem Menue verschwunden, sondern aus der TABELLE. Ein useEffect zieht
fehlende Schluessel jetzt nach; angehaengt, nicht einsortiert, damit eine selbst
gewaehlte Reihenfolge nicht umspringt.

2) Die Liste im Menue war fest auf 240px begrenzt. Bei zehn Spalten stand der
letzte Eintrag ausserhalb, und weil macOS die Bildlaufleiste ausblendet, sah es
aus, als gaebe es ihn nicht. Die Hoehe richtet sich jetzt nach dem freien Platz
bis zum unteren Bildrand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:31:40 +02:00
Scarriffle
68feb43b3e Icons: Auslagern-Pfeil zeigt jetzt WEG vom Lager
Ein- und Auslagern sind ein Paar und duerfen sich nur in EINEM Merkmal
unterscheiden - der Pfeilrichtung. Tatsaechlich sprang auch die Linie: beim
Einlagern lag sie unten und der Pfeil zeigte auf sie zu (richtig), beim
Auslagern lag sie oben und der Pfeil zeigte ebenfalls auf sie zu. Das las sich
wie "hinein, nur nach oben" statt wie "heraus".

Jetzt liegt die Linie bei beiden unten - sie ist das Lager - und nur der Pfeil
dreht sich: hinein beim Einlagern, weg beim Auslagern. Das ist auch die
Konvention der Vorlage (Feather download/upload), an der sich die uebrigen
Icons orientieren.

Wirkt ueberall, wo das Icon steht: Navigation, Ein-/Auslagern-Knoepfe,
Schnellbuchung, Umlagern, Export und die Verlaufsansicht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:52:02 +02:00
Scarriffle
ad3100462b Verlauf: klarere Bewegungs-Icons, Spalte nach vorn
Die beiden Kisten unterschieden sich nur in einem kleinen Pfeil. Bei 18 px war
das nicht zu trennen - erkannt hat man sie allein an der Farbe, und die war
gerade wegen der Wertung (gruen rein, orange raus) rausgeflogen. Ohne Farbe
blieb nichts uebrig, woran man sie auseinanderhaelt.

Jetzt die Strich-Icons checkin/checkout/edit: grosser Pfeil auf eine Linie zu
bzw. von ihr weg, Stift fuer die Korrektur. Dieselben Zeichen wie auf den
Ein-/Auslagern-Knoepfen, also schon vertraut, und auch klein eindeutig - die
Richtung liegt im Zeichen statt in der Farbe.

Auf der Verlaufsseite steht die Spalte jetzt an erster Stelle; sie sagt in
einem Blick worum es geht und braucht als Zeichen kaum Breite.

Der gefuellte Icon-Zweig in Icon.jsx wird damit nicht mehr gebraucht und ist
raus. Die SVG-Quellen bleiben unter brand/icons/ liegen - das README sagt jetzt
ausdruecklich, dass sie ungenutzt sind, warum, und wie man sie zurueckholt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:48:25 +02:00
Scarriffle
e9738e28e7 Verlauf: keine Wertung mehr in der Farbe der Bewegungsart
Gruen fuer Einlagern und Orange fuer Auslagern las sich wie gut und schlecht -
dabei ist Auslagern schlicht der Normalfall, kein Missstand. Die Farbe kam noch
aus der Badge-Zeit, wo sie neben dem Wort stand; als alleiniges Signal wertet
sie.

Alle drei Arten tragen jetzt dieselbe neutrale Kachel. Unterschieden wird ueber
die Pfeilrichtung im Icon, und die Bedeutung steht weiterhin in title und
aria-label.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:42:44 +02:00
Scarriffle
e33c01ec37 Icon-Quellen nach brand/icons/ verschieben und benennen
Die drei SVGs lagen in der Repo-Wurzel und hiessen noch wie beim Download
("open-box (1).svg" – inklusive Leerzeichen und Klammer). Durch mein git add -A
im letzten Commit sind sie dort auch gelandet.

Jetzt liegen sie in brand/icons/ und heissen wie die Icons im Code:
einlagern.svg, auslagern.svg, korrektur.svg. Ein README daneben haelt fest,
wofuer jede da ist - die beiden Kisten unterscheiden sich nur in der
Pfeilrichtung, wer sie verwechselt dreht Ein- und Auslagern um, ohne dass etwas
kaputtgeht - und wie man die Pfaddaten nach einer Aenderung neu uebernimmt.

Zur Laufzeit laedt niemand diese Dateien: die Pfade stehen inline in Icon.jsx,
damit die Icons ueber currentColor die Textfarbe annehmen. Die Originale tragen
festes Schwarz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:41:21 +02:00
Scarriffle
9a2dc3a482 Verlauf: Bewegungsart als Sinnbild statt als Wort
"Eingelagert"/"Ausgelagert"/"Korrektur" standen als Textbadge in einer eigenen
Spalte - in der schmalen Ansicht als dritte beschriftete Zeile je Eintrag. Jetzt
eine getoente Kachel mit Icon: Kiste mit Pfeil hinein, Kiste mit Pfeil heraus,
Stapel mit Stift (aus open-box (1).svg, open-box.svg, edit-server.svg). Die
Farbe traegt dieselbe Bedeutung wie vorher das Badge - gruen rein, orange raus,
neutral Korrektur; Titel und aria-label behalten das Wort.

Die Dashboard-Karte bekommt dabei das Layout aus der Skizze: Kachel links,
Zeitpunkt ueber Produkt, ohne Tabelle. Die Verlaufs-SEITE bleibt eine Tabelle -
dort stehen noch Menge und Benutzer -, dort weicht nur das Wort der Kachel.

Die Icon-Komponente kann jetzt auch GEFUELLTE Grafiken mit eigenem viewBox; die
vorhandenen sind Strichzeichnungen (fill:none, stroke:currentColor) und
vertragen sich damit nicht. Die Pfaddaten sind per Skript aus den SVGs
uebernommen, nicht abgetippt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 12:38:05 +02:00
Scarriffle
e9a2b70728 Web: Fehlergrenzen - weisse Seiten zeigen jetzt, was schiefging
Im ganzen Frontend gab es keine einzige Error Boundary. In React 18 haengt eine
Ausnahme beim Rendern damit den KOMPLETTEN Baum aus: die Seite wird schlagartig
weiss und nur ein Neuladen hilft. Genau das gemeldete Bild - "keine Karten",
"gar nichts", "muss F5 druecken". Schlimmer noch: man sah nicht, WAS passiert
ist, und konnte es folglich auch nicht melden.

Zwei Ebenen: eine aeussere in main.jsx als letzte Instanz (faengt auch Fehler in
den Providern) und eine je Seite in Protected, damit bei einem Absturz
wenigstens die Navigation bedienbar bleibt. Die innere haengt am Pfad, sonst
haenge man nach einem Fehler dauerhaft in der Fehleranzeige fest.

Angezeigt wird die Meldung selbst, nicht nur ein "Ups", plus die technischen
Angaben zum Aufklappen. Damit ist der naechste Vorfall in einer Minute
eingeordnet statt in einer Stunde.

Das erklaert die Haeufigkeit, die meine vorherige Erklaerung (abgelaufene
Anmeldung) nicht hergab: ein Token gilt sieben Tage, nicht zwanzig Minuten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:12:15 +02:00
Scarriffle
eddbb3710e Web: abgelaufene Anmeldung erkennen statt leere Seiten zeigen
Zwei Defekte, die zusammen das gemeldete Muster ergeben.

1) Es gab keine zentrale Behandlung von HTTP 401. AuthProvider prueft die
Anmeldung EINMAL beim Start; laeuft das Token danach ab, bleibt der Zustand
"angemeldet" stehen, waehrend jede Abfrage scheitert. Seiten mit
.catch(() => {}) (18 Stellen) zeigen dann schlicht nichts, andere eine
englische Servermeldung - genau das "mal gar nichts, mal die Haelfte".
Jetzt raeumt der API-Client bei 401 die Sitzung auf und der Login sagt, dass
sie abgelaufen ist. Beim Anmelden selbst greift das nicht, dort heisst 401
"Passwort falsch".

2) Der Start hatte ein blankes `catch`, das das Token bei JEDEM Fehler loeschte -
auch bei 502 oder weggebrochenem Netz. Wer waehrend eines Server-Neustarts F5
drueckte, war deshalb abgemeldet, obwohl die Sitzung voellig in Ordnung war.
Das duerfte der haeufigere Ausloeser gewesen sein, weil in letzter Zeit oft neu
deployt wurde. Jetzt wird nur bei 401/403 abgemeldet; bei allem anderen bleibt
die Anmeldung liegen und es erscheint "Server nicht erreichbar" mit einem
Knopf zum erneuten Versuchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 11:07:55 +02:00
Scarriffle
01c93d8b2c Web: Mindestbestaende in festen Spalten, Detail-Spalten verschiebbar
Die Eingabefelder auf der Mindestbestaende-Seite standen je Zeile woanders. Die
Zeile war ein .field-inline, und dort waechst sowohl der Name als auch das
Eingabefeld (.field-inline > input { flex: 1 1 auto }) - beide teilten sich den
Restplatz, also verschob ein laengerer Name das Feld nach rechts und machte es
schmaler. Jetzt ist die Zeile ein Raster mit festen Spalten (Name, Menge,
Einheit, Bestand, Ort, Loeschen); ohne Adminrechte bleiben die letzten beiden
Zellen leer, damit die Zeilen trotzdem fluchten. Schmale Fenster stellen den
Namen ueber die volle Breite.

Die "Details"-Spalte liess sich nicht verschieben: sie war fixed und hatte
zusaetzlich einen leeren Kopf - es gab also nichts zu greifen. Sie ist jetzt
frei und traegt einen sichtbaren Titel. Dasselbe fuer die gleichartigen
Link-Spalten in der Einzelstueck- und der Chargenliste ("Artikel",
"Aufteilen"). Sortieren bleibt dort aus, weil toggleSort ohne sortValue
ohnehin aussteigt. Bild- und Auswahlspalten bleiben fest - die haben keinen
sinnvollen Titel und gehoeren an den Anfang.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 10:36:48 +02:00
Scarriffle
e00eef275c Gruppen hierarchisch: in allen Auswahlfeldern und in der Artikelliste
Die Gruppen-Dropdowns waren flache, alphabetische Listen - man sah nicht, dass
"Grillwurst" unter "Wurst" haengt. Ein gemeinsamer Helfer (gruppenOptionen)
rollt den Graphen zum Baum aus und rueckt ein; eine Gruppe mit mehreren
Obergruppen erscheint unter jeder, denn genau das ist ihr Sinn, und ausgewaehlt
wird ohnehin dieselbe ID.

Eingerueckt wird mit GESCHUETZTEN Leerzeichen - normale fasst der Browser in
einem <option> zusammen, die Einrueckung waere sonst wirkungslos.

Betroffen: Gruppen-Auswahl im Artikelformular (beide Zweige), beim Einlagern,
die Ziel-Auswahl der Mindestbestaende und die Obergruppen-Mehrfachauswahl (die
beim Suchen bewusst abflacht, weil eine Einrueckung ohne die Eltern in die Irre
fuehrt). In der App dasselbe in beiden Gruppen-Pickern.

Die Gruppen-Spalte der Artikelliste zeigt jetzt den Weg von oben statt eines
Etiketts - "Wurst → Grillwurst", mit gedaempften Vorfahren wie bei der
Kategorie. Gefiltert wird ueber ALLE Wege: wer auf "Wurst" filtert, sieht auch
die Artikel aus den Untergruppen. Dafuer gibt es gruppenInfoMap als Gegenstueck
zu categoryInfoMap, das mit mehreren Wegen je Gruppe umgehen kann.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:59:32 +02:00
Scarriffle
302e7625e5 Web: Gruppentabelle auf volle Breite, Karten darunter
Die Seite hatte die Tabelle links und eine feste rechte Spalte (24% der
Breite). Seit die Tabelle Obergruppen, Bestand mit Einheit und die
Gebinde-Erlaeuterung traegt, war die rechte Spalte teurer als sie half - und sie
stand auch dann da, wenn gar keine Gruppe ausgewaehlt war.

Jetzt bekommt die Tabelle die volle Breite. "Neue Gruppe" steht darunter, die
Karten zur ausgewaehlten Gruppe (EAN-Codes, Mindestbestand) erscheinen beim
"verwalten" ebenfalls darunter, nebeneinander. Kein Springen des Layouts beim
Auswaehlen, weil die Tabelle ihre Breite nie aendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:44:29 +02:00
Scarriffle
3de168b598 Gruppen: Bestand in der richtigen Einheit, Baumansicht, Gruppen-Spalte
Anzeigefehler: die Gruppenliste klebte immer min_stock_unit_name hinter den
Bestand - auch wenn die Gruppe laengst im Gruppen-Gebinde zaehlt. Aus 790 g bei
einem 190-g-Glas wurde so "4,16 Gramm" statt "4,16 Glaeser". Gerechnet war
richtig, beschriftet falsch.

Ursache war doppelte Logik: MinStock.jsx hatte grpPkgMode/grpFactor/grpUnit
lokal, Groups.jsx gar nicht. Die drei stehen jetzt in units.js und werden von
beiden benutzt. Die Einheiten-Spalte sagt ausserdem, wenn eine Gruppe im
Gebinde zaehlt ("zaehlt in Glaeser a 190 g") und bietet den Weg zurueck an.

Gruppenliste als Baum: eine Gruppe mit zwei Obergruppen erscheint unter beiden -
das ist die ehrliche Darstellung eines Graphen. Jede Zeile traegt ihren PFAD als
Schluessel, damit die vorhandene Baumlogik der DataTable unveraendert zurecht
kommt; die kennt nur einen Elternteil je Zeile, und ein Pfad hat genau einen.
Beim Sortieren/Filtern flacht sie wie gewohnt ab.

"1 Untergruppen" heisst jetzt "1 Untergruppe" - an allen vier Stellen ueber
einen gemeinsamen Helfer (anzahlWort), Web und App.

Lebensmittelliste bekommt eine Spalte "Gruppe" mit Filter. Dafuer liefert
ProductOut jetzt group_name, symmetrisch zu category_name, mit joinedload gegen
N+1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:40:09 +02:00
Scarriffle
158175f693 App: Einheiten-Felder als zusammenhaengender Block
Dieselbe Umstellung wie im Web. Der Abschnitt "Artikel" traegt nur noch Name
und Marke; alles zur Einheit steht unter "Einheit und Menge" und liest sich als
Satz ("1 Packung sind 3 Stueck"). Das Gebinde heisst jetzt auch so statt
"Bezeichnung" und steht vor der Packungsgroesse, weil die Zeile darunter es
beim Namen nennt.

Die Umrechnung heisst nach dem, was sie liefert (Gewicht / Volumen /
Stueckzahl), beginnt mit einer einzigen Auswahl und zeigt den Satz erst danach -
mit passendem Verb und Ein-/Mehrzahl. Die linke Zahl wird beim Waehlen der
Zielart einmalig mit der Packungsgroesse vorbelegt.

Das Anlege-Formular kannte die Umrechnung bisher gar nicht; wer in der App
einen Artikel anlegte, musste ihn erst speichern und wieder oeffnen. Es hat
jetzt denselben Abschnitt und uebernimmt den Vorschlag aus dem Barcode-Lookup
("3 x 80 g").

Reine Oberflaeche - 226 Backend-Tests unveraendert gruen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:21:42 +02:00
Scarriffle
37bc7663af Web: Einheiten-Felder als zusammenhaengender Block
Im Grillwurst-Fall stand dieselbe Zahl zweimal im Formular, mit zwei
Bedeutungen und in identischer Aufmachung: "Packungsgroesse 3" (1 Packung = 3
Stueck) und "Zweiteinheit 3 Stk ≙ 250 g" (3 Stueck wiegen 250 g). Dazu sass der
Zweiteinheit-Block zwischen Gebinde/MHD und Mindestbestand/Gruppe, hiess nach
einem Fachbegriff und benutzte ≙ statt eines Satzes.

Jetzt steht alles zur Einheit unter einer Ueberschrift und liest sich als Satz:
"Gezaehlt wird in Stueck", "Gebinde Packung", "1 Packung sind 3 Stueck". Das
Gebinde steht dafuer vor der Packungsgroesse - die Zeile darunter nennt es beim
Namen.

Die Umrechnung heisst nach dem, was sie liefert (Gewicht / Volumen / Stueckzahl)
und beginnt mit einer einzigen Auswahl; erst danach erscheint der Satz "3 Stk
wiegen 250 g" mit passendem Verb und Ein-/Mehrzahl. Die linke Zahl wird beim
Waehlen der Zielart EINMALIG mit der Packungsgroesse vorbelegt - zu tippen
bleibt nur die 250. Bewusst keine dauerhafte Kopplung: wer die Packung spaeter
auf 4 aendert, hat dieselbe Wurst, und ein mitgezogener Wert wuerde
stillschweigend behaupten, sie sei leichter geworden.

MHD-Angabe wandert aus dem Einheiten-Block zur Gruppen-Zeile; sie stand nur
zufaellig neben dem Gebinde.

Reine Oberflaeche - Datenmodell, API und Backend-Tests unveraendert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 09:19:08 +02:00
Scarriffle
71d52917e4 App: Zweiteinheit erfassen und anzeigen
Eigener Abschnitt im Artikel mit "Menge [Stk] entspricht [Gramm] das sind
[250]" und der ausgerechneten Kontrolle in der Fusszeile. Nur Charge-Artikel,
und nur die anderen Arten stehen zur Wahl - eine Bruecke auf die eigene Art
haette nichts umzurechnen.

ProductUpdateRequest bekommt sendSecondary nach dem Vorbild von sendMinStock:
ein PATCH aus einer anderen Maske soll die Bruecke nicht unbeabsichtigt
loeschen. Die Einheitenauswahl beim Ein- und Auslagern nimmt bei hinterlegter
Bruecke die Gegenart mit auf, der Bestand steht in beiden Lesarten
("6 Stueck · 500 g").

Alle neuen Product-Felder sind Optionals, damit die App auch gegen einen
aelteren Server laeuft.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:42:52 +02:00
Scarriffle
788ef2caf2 Web: Zweiteinheit erfassen und anzeigen
Im Artikel-Formular eine Zeile "3 [Stk] ≙ 250 [Gramm]" mit ausgerechneter
Kontrolle darunter ("1 Stk ≈ 83,33 g"). Links steht die Basiseinheit des
Artikels fest, rechts stehen nur die anderen Arten zur Wahl - eine Bruecke auf
die eigene Art haette nichts umzurechnen. Nur vollstaendig ausgefuellt wird sie
gespeichert; Gegenstaende ohne Charge tragen keine.

buildUnitOptions laesst bei hinterlegter Bruecke auch die Einheiten der Gegenart
zu - damit erben Ein- und Auslagern die Faehigkeit ohne eigene Logik. Beim
Auslagern steht der Bestand jetzt in beiden Lesarten ("6 Stueck · 500 g").

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:40:33 +02:00
Scarriffle
9f3225113e Zweiteinheit am Artikel: Bruecke zwischen Stueck, Gramm und Milliliter
Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet
count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor
dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten
beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte
Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der
Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht.

Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert
wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim
naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund:
250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und
liefe damit gegen die Bestandspruefung beim Auslagern.

Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine
Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit
(GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet -
weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit
geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck,
und Runden wuerde stumm etwas anderes buchen als angegeben.

WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in
_gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein
Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren.
Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer
Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in
Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit
verrechnet.

Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den
ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND
Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen
schmalen Vertrag.

18 neue Tests. Dass test_wrong_kind_rejected und
test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist
selbst der Beleg: ohne Bruecke aendert sich nichts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:38:13 +02:00
Scarriffle
f33054c534 App: Obergruppen zuordnen, Mindestbestaende je Ort bearbeiten
Die App konnte bei Gruppen bisher nur umbenennen und Mindestbestaende lesen.
Jetzt gibt es einen vollen Gruppen-Editor (Sources/GroupViews.swift): Name,
Obergruppen als Mehrfachauswahl, Einheit und Mindestbestaende je Ort. Bewusst
eine flache Liste mit "unter: ..." statt TreeMasterView - das Protokoll TreeItem
kennt genau einen Elternteil und wuerde eine Gruppe mit zwei Obergruppen doppelt
zeigen. Die eigene Gruppe und ihre Untergruppen sind in der Auswahl
ausgeschlossen, damit kein Ring entsteht.

Mindestbestaende-Uebersicht ist nach Lagerort gegliedert ("Ueberall" zuerst) und
nicht mehr nur lesend - Gruppenzeilen oeffnen den neuen Editor. Am Artikel
entfaellt das separate Feld "Gesamt": der Bedarf haengt immer an einem Ort, und
"Ueberall" ist einer davon. Das Stammdaten-Speichern schickt min_stock nicht
mehr mit, sonst wuerde es die dort gesetzte Ueberall-Zeile ueberschreiben.

APIClient kann jetzt updateGroup und setGroupLocationMinStock. GroupUpdateRequest
bekommt bewusst KEINEN eigenen Encoder: der synthetisierte laesst nil-Felder weg,
ein handgeschriebener wuerde "name": null senden und den Namen loeschen.

Neue Datei -> xcodegen generate; Projektdatei ist mit committet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 00:02:43 +02:00
Scarriffle
0537a2b53f Web: Obergruppen zuordnen, Mindestbestaende nach Lagerort
Mindestbestaende-Seite ist jetzt nach Lagerort gegliedert statt nach Artikel -
"Ueberall (egal wo)" zuerst, danach die Lagerorte. Es gibt keine automatische
Leerzeile je Artikel mehr; angelegt wird ueber "+ hinzufuegen" im jeweiligen
Abschnitt, dann steht der Ort schon fest und es fehlen nur Ziel und Menge.
Das Zeilenmodell faellt damit von vier Faellen auf zwei, und weil der Server
einheitlich Basiseinheiten liefert, bleibt genau eine Umrechnung uebrig statt
der drei gegenlaeufigen von vorher.

Gruppen: neue Spalte "Obergruppen" mit Mehrfachauswahl (GroupParentSelect).
Bewusst kein Baum - CategorySelect und die tree-Prop der DataTable koennen nur
einen Elternteil und wuerden eine Gruppe mit zwei Obergruppen doppelt anzeigen.
Die eigene Gruppe und ihre Untergruppen sind in der Auswahl gesperrt, damit die
Ringregel sichtbar wird, bevor der Server sie ablehnt. Produktzahl zeigt
transitiv und direkt ("12 (3 direkt)"), dazu Badges fuer Untergruppen und eine
Warnung, wenn eine Gruppe mit Untergruppen keine Einheit hat - sonst summiert
sie Gramm und Stueck zu einer sinnlosen Zahl.

Einkaufsliste: "Gesamt" heisst "Ueberall", Gruppen zeigen ihre mitgezaehlten
Untergruppen, und eine Fussnote benennt die eine Stelle, die sich nicht
verrechnen laesst (zwei Obergruppen mit gemeinsamer Untergruppe).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 23:56:18 +02:00
Scarriffle
54261b98c0 Sicherung: Obergruppen und Mindestbestaende je Ort mitsichern
Das JSON-Backup schrieb bei Gruppen nur Name, Mindestbestand und Einheit -
Gebinde und Ort-Bedarfe fehlten schon vorher, ein Restore verlor sie also
stillschweigend. Mit den Obergruppen waere die komplette Hierarchie dazu
gekommen.

Format v3: Gruppen bringen jetzt parents (als NAMEN, nicht IDs - die sind
zwischen zwei Instanzen nicht gleich), Gebinde-Felder und ihre Mindestbestaende
je Ort mit; Artikel ebenso. Ort null = "Ueberall". Der Import laeuft dafuer
zweiphasig, weil Kanten erst gesetzt werden koennen, wenn alle Gruppen und
Lagerorte existieren, und weist Ringe ab - eine beschaedigte Datei darf keinen
einschleusen, der danach jede Auswertung im Kreis laufen liesse.

v2-Sicherungen bleiben lesbar; ihnen fehlen die neuen Listen einfach. Die
CSV-Spalte "mindestbestand" meint weiterhin den Bedarf ohne Ortsangabe und
landet in der Ueberall-Zeile.

maintenance.py raeumt die n:m-Zeilen jetzt ausdruecklich ab: das Core-DELETE
nimmt sie nicht mit, und SQLite erzwingt keine Fremdschluessel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 23:48:47 +02:00
Scarriffle
eaacfd03e5 Gruppen: Obergruppen + Mindestbestaende nur noch je Lagerort (Backend)
Zwei zusammenhaengende Umbauten, weil sie dieselben Stellen betreffen.

Obergruppen: Gruppen bilden jetzt einen gerichteten azyklischen Graphen statt
einer flachen Liste. Eine Gruppe darf unter MEHREREN Obergruppen haengen -
"Grillwurst" unter "Wurst" UND unter "Grillgut"; mit einem einzelnen parent_id
waere genau das nicht abbildbar. Bestand und Mindestbestand einer Gruppe zaehlen
den gesamten Untergraphen, wobei eine ueber zwei Wege erreichbare Untergruppe
nur einmal zaehlt (services/gruppen.py arbeitet durchgaengig mit Mengen).
Product.group_id bleibt unveraendert - ein Artikel haengt weiter an genau einer
Gruppe.

Mindestbestaende: der separate Gesamt-Mindestbestand entfaellt. Er wird zur
Zeile mit location_id NULL ("Ueberall") und ist damit die Wurzel ueber allen
Lagerorten - dieselbe Verrechnung wie bei verschachtelten Orten greift jetzt
auch zwischen Ueberall und Kueche, wodurch derselbe Artikel nicht mehr doppelt
in der Einkaufsliste steht. Alle Werte liegen einheitlich in Basiseinheiten
statt in drei verschiedenen Einheiten nebeneinander; das Umrechnen beim
Umschalten der Erfassungseinheit entfaellt dadurch ersatzlos.

_netted_topups nimmt die Hierarchie jetzt als Parameter und faltet damit
Lagerort-Baum und Gruppen-Graph. Verrechnet wird zwischen zwei Gruppen nur,
wenn die zaehlenden Artikel der Untergruppe eine Teilmenge der Obergruppe sind -
zaehlt die Obergruppe in Kilogramm und die Untergruppe in Stueck, kommt ein Kauf
dort oben nicht an.

Die vierfach kopierte Bestandssumme wandert in Sammelabfragen
(summe_bestand_base), sonst vervielfacht der transitive Teilgraph die Abfragen.

Einmalige Datenwanderung beim Start (Merker in den Einstellungen), 18 neue
Tests - darunter Doppelzaehlung ueber zwei Wege, Ringschutz und die bewusst
offene Grenze bei zwei Obergruppen mit gemeinsamer Untergruppe.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 21:48:14 +02:00
Scarriffle
df65d9583c App: Lagerort bei Chargen anzeigen und aendern
In der Chargenliste eines Artikels standen nur MHD und Menge. Zwei Chargen
mit gleichem MHD, die sich nur im Lagerort unterscheiden, sahen dadurch
identisch aus. Die Zeile nennt jetzt zusaetzlich den Lagerort-Pfad (bzw.
"ohne Ort").

Im Sheet "Charge aendern" kommt ein Lagerort-Picker samt QR-Scan dazu -
wie in der Weboberflaeche. Das Backend konnte das ueber PATCH /lots/{id}
schon; LotUpdateRequest sendet location_id jetzt immer mit (auch null),
damit sich ein gesetzter Ort wieder entfernen laesst.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 14:54:44 +02:00
Scarriffle
86e0edace8 Mindestbestände: Gruppen in Packungen erfassen (Gruppen-Gebinde)
Gruppen mit verwalteter Einheit lassen sich in der Spalte 'Einheit' auf ein
Gruppen-Gebinde umschalten. Über 'Gebinde festlegen…' definiert man einmalig
'1 Packung ≈ X g/ml' (Richtwert, da Produkte unterschiedlich große Packungen
haben können). Danach werden Mindestbestand UND Bestand der Gruppe in
Packungen angezeigt/erfasst; das Backend rechnet vorhandene Werte beim
Umschalten um.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:42:15 +02:00
Scarriffle
ecc1570100 Gruppen-Gebinde: Mindestbestand/Bestand in Packungen (Backend)
Gruppen bekommen ein eigenes Richt-Gebinde (package_size/label) plus
min_stock_in_packages. Weil die Produkte einer Gruppe unterschiedlich
große Packungen haben können (Pesto 99 g vs. 160 g), legt die Gruppe einen
gemeinsamen Richtwert fest (1 Glas ≈ X g).

- Neuer Helfer group_min_context() zentralisiert, in welcher Einheit der
  Gruppen-Mindestbestand zaehlt (Gebinde ODER verwaltete Einheit).
- Einkaufsliste (gesamt + je Ort), Dashboard-Bedarf und GroupOut nutzen ihn;
  need.text kommt so als 'x Glaeser (y g)'.
- update_group rechnet bestehende Werte beim Umschalten der Einheit um, damit
  der physische Bedarf gleich bleibt.
- Migration: groups.package_size/package_label/min_stock_in_packages.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:38:12 +02:00
Scarriffle
28785b0a10 Mindestbestände: Einheit je Produkt umschaltbar (g/ml <-> Gebinde)
Produkte mit Packungsgröße lassen sich auf der Mindestbestandsseite nun
wahlweise in ihrer Gebinde-Einheit (Glas/Dose) statt nur in g/ml erfassen
- Auswahl in der Spalte 'Einheit'. Mindestbestand UND Bestand werden dann
in Packungen angezeigt; der Hinzufügen-Dialog hat den Umschalter ebenfalls.
Gespeicherte Werte bleiben physisch gleich (nur Anzeige/Erfassung wechselt),
das Backend-Flag min_stock_in_packages steuert die Umrechnung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:31:30 +02:00
Scarriffle
f2c657ff31 Einkaufsliste: Fehlmenge als Gebinde (Glaeser/Dosen), Basiseinheit klein
Statt 'fehlt 500 Gramm' jetzt die Produktgroesse als Leitangabe:
'fehlt 3 Glaeser (500 g)'. Auf ganze Gebinde aufgerundet, weil man nur
ganze Glaeser/Dosen kauft; Basiseinheit als kleiner Hinweis.

Backend: neues ShoppingNeed-Objekt an jeder Einkaufslisten-Zeile (Produkte
+ Gruppen, gesamt + je Ort) - damit die Angabe auch direkt ueber die API
kommt ('x Glaeser (y g)'), inkl. Pluralisierung aus der Gebinde-Tabelle.
Gruppen leiten das Gebinde ab, wenn alle passenden Produkte dasselbe haben,
sonst bleibt es bei der Basiseinheit. Bestehende Felder unveraendert.

Frontend: gemeinsame Komponente ShoppingNeedText fuer Karte und volle Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 18:03:37 +02:00
Scarriffle
e214bb76a9 Web: Skeleton-Loading (schimmernde Ladeplatzhalter)
Statt nacktem 'Lädt…'/'Wird geladen…' jetzt geformte, schimmernde
Platzhalter an den zentralen Stellen:
- Neuer Baustein components/Skeleton.jsx (Skeleton/SkeletonLines/CardSkeleton),
  Sweep-Animation, Farben aus CSS-Tokens (Dark-Mode automatisch), respektiert
  prefers-reduced-motion.
- DataTable: bei Erstladung Platzhalterzeilen in Spaltenform (deckt alle
  8 Tabellenseiten). Nachladen mit vorhandenen Zeilen laesst Daten stehen.
- Dashboard-Karten: Zustand-Wrapper + Inline-Diagramm-Lader zeigen
  KPI-/Listen-/Diagramm-Skelette passend zur Kartenform.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-04 00:30:25 +02:00
Scarriffle
da5934948e iOS: Code-Review-Korrekturen an Icon/Info.plist/Projekt
- hexagon.svg (neues App-Icon) mit einchecken; alte Icon-SVGs entfernt
- LSApplicationCategoryType ins Info.plist: bei GENERATE_INFOPLIST_FILE=NO
  wird die INFOPLIST_KEY_*-Build-Einstellung NICHT ins gebaute Plist
  uebernommen -> Kategorie 'utilities' landete bisher nirgends
- Info.plist wieder handgepflegt (deutsche Kommentare zurueck), von Xcode
  weggeschriebene Doku wiederhergestellt
- iPhone bewusst nur Hochkant; iPad-Unterstuetzung wiederhergestellt
  (TARGETED_DEVICE_FAMILY zurueck auf 1,2), iPad behaelt alle Ausrichtungen
- ItemListView.swift im Projekt (von Xcode ergaenzt)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 18:19:53 +02:00
Scarriffle
63ee9608af iOS: Am Ablauftag 'heute' statt '0 Tage'
Die Uebersichtskarte 'Bald ablaufend' zeigte am Ablauftag '0 Tage'.
Jetzt gemeinsame Formulierung auf ExpiringItem.restlaufzeitText:
abgelaufen / heute / morgen / X Tage - Karte und volle Liste identisch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-02 16:40:29 +02:00
Scarriffle
6ddf6024f7 iOS: 'Bearbeiten'-Leiste sichtbar machen (nicht mehr in versteckter .bottomBar)
Der Start-Tab liegt in einer TabView; deren Tab-Balken verdeckte die
.bottomBar-Werkzeugleiste, deshalb war 'Bearbeiten' nicht auffindbar.
Jetzt eine eigene Leiste per safeAreaInset direkt ueber dem Tab-Balken.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 22:53:42 +02:00
Scarriffle
80aa857bb0 iOS: Diagramm-Einstellungen im lokalen App-Editor
Der lokale Uebersicht-Editor kann jetzt pro Diagramm-Karte einstellen,
wie im Web-Editor:
- Ablauf-Ring: 'Ohne MHD einbeziehen'
- Kategorien-Ring: 'Detailansicht' (alle einzeln) + 'Kategorie-Tiefe'

AppCard speichert die Optionen (optional -> alte Uebersichten dekodieren
weiter) und reicht sie an DashboardCardView durch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 22:45:09 +02:00
Scarriffle
d680614526 iOS: Diagramm-Einstellungen der Web-Karten beim Spiegeln uebernehmen
Die gespiegelte Web-Uebersicht ignorierte bisher die Karten-Props der
Diagramme. Jetzt werden uebernommen:
- Ablauf-Ring: 'Ohne MHD einbeziehen' (mit_ohne_mhd)
- Kategorien-Ring: 'Detailansicht' (alle einzeln statt 'Uebrige') (detail)
- Kategorien-Ring: 'Kategorie-Tiefe' (tiefe -> /by-category?depth=N)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 22:41:36 +02:00
Scarriffle
ade3c86747 Web: nur Tabelleninhalte markierbar, Titel/UI nicht (kein Text-Caret mehr)
cursor:default nimmt nur den Maus-I-Beam weg; bei markierbarem Text poppt beim
Klick auf einen Titel trotzdem ein blinkendes Caret auf. Jetzt: user-select:none
auf body, wieder markierbar nur .table td, Eingabefelder, textarea, code/pre und
.selectable. So bleiben Artikel-/Markennamen aus Tabellen kopierbar, Titel/Labels
zeigen kein Caret/I-Beam mehr.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 11:10:56 +02:00
Scarriffle
749848360b Web: Text wieder markierbar/kopierbar, nur der I-Beam-Cursor bleibt weg
user-select:none war zu viel - es hat das Kopieren von Artikel-/Markennamen aus
Tabellen verhindert. Cursor und Markierbarkeit sind unabhaengig: cursor:default
(aus 6f4f887) nimmt den I-Beam weg, Text bleibt dank Standard-user-select aber
markierbar. Also Pfeil-Cursor UND kopierbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:04:52 +02:00
Scarriffle
69adc6d910 Web/nginx: index.html nicht cachen, gehashte Assets dauerhaft
Ohne Cache-Header cachte der Browser die alte index.html -> neue Deploys (neue
Asset-Hashes) kamen nie an, obwohl der Code laengst stimmte. Jetzt: index.html
Cache-Control:no-cache (immer revalidieren), gehashte JS/CSS/Bilder immutable +
1 Jahr. Deploys sind damit sofort sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:59:32 +02:00
Scarriffle
6895d8ec1a Web: reiner UI-Text nicht mehr markierbar (fuehlt sich nicht mehr wie Eingabefeld an)
Cursor-Fix allein reichte nicht: das Blau-Hinterlegen beim Markieren wirkte weiter
wie ein Eingabefeld. Jetzt user-select:none auf body; Eingabefelder, textarea,
contenteditable sowie Code/Token (und .selectable) bleiben markierbar/kopierbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:56:15 +02:00
Scarriffle
6f4f8871c3 Web: Text-Cursor (I-Beam) nur noch auf echten Eingabefeldern
body bekommt cursor:default (vererbt) -> ueber Text erscheint der Pfeil statt des
I-Beams, damit nicht-editierbarer Text nicht wie ein Eingabefeld aussieht. I-Beam
nur noch auf Text-/Zahlenfeldern & textarea/contenteditable; Hand fuer Links,
Selects, Checkbox/Radio und deren Labels. Klickbares mit eigener Regel (Buttons,
Sortier-Header, Kacheln, klickbare Zeilen) bleibt unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:48:07 +02:00
Scarriffle
7696f21eaa iOS: Charts schreiben 'Artikeleinheiten' aus statt 'AE'
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 20:08:28 +02:00
Scarriffle
1648d1b917 iOS: Kassenzettel in den Scannen-Tab, Markenblau statt System-Blau, weisse Kacheln, luftigere Pruefen-Liste
- Kassenzettel-Kachel vom Listen- in den Scannen-Tab verschoben (passt thematisch).
- App-Akzent auf Markenblau (#3F51B5/#7C8AE8, wie Web/App-Icon) via .tint; alle
  Color.accentColor -> Color.marke. System-Blau verschwindet.
- Kachel-Buttons/Links auf .buttonStyle(.plain): weisse Schrift/Symbole statt
  getoentem Blau.
- Kassenzettel-Pruefen-Liste: je Artikel eine eigene Section (insetGrouped) mit
  Artikel/Menge/Lagerort in eigenen Zeilen - klar gruppiert, nicht mehr gequetscht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 19:05:03 +02:00
Scarriffle
648b8294b8 iOS: Kassenzettel scannen -> Lebensmittel erkennen & einlagern
Neuer Flow (Kachel im Listen-Tab): Foto aufnehmen/waehlen, Artikelbereich per
Rechteck markieren, on-device OCR (Vision, deutsch; Bild bleibt auf dem Geraet),
Zeilen an /products/match schicken. Pruefen-Liste je Zeile: Artikel-Picker
(Auto-Treffer ueber Schwellwert + eigene Lebensmittel-Suche), Menge (aus Zeile
geparst, korrigierbar), Lagerort-Dropdown + QR-Scan. Einlagern per checkInBatch,
unbekannte Zeilen werden uebersprungen; keine Neuanlage.

Backend: MatchCandidate traegt package_size/base_unit mit, damit die App die
Einheit ohne Extra-Abruf kennt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:40:10 +02:00
Scarriffle
9cc3e325bb Web: Kassenzettel-Trefferschwelle in den Einstellungen
Prozent-Feld fuer receipt_match_threshold (Default 45) analog zur Ablaufwarnung;
speichert ueber den generischen Settings-Endpunkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:26:20 +02:00
Scarriffle
91adee5333 Backend: Kassenzettel-Abgleich (POST /products/match) + Schwellwert-Einstellung
Neuer Endpunkt ordnet OCR-Zeilen den aehnlichsten LEBENSMITTELN zu (Score 0-100,
difflib + Token-/Praefix-Abgleich fuer abgekuerzte Kassennamen). Gegenstaende
inkl. Verbrauchsgegenstaende bleiben aussen vor. Je Zeile die besten Treffer ueber
dem Schwellwert (Request oder Einstellung receipt_match_threshold, Default 45).
3 Tests, gesamt 182 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:24:19 +02:00
Scarriffle
d05727779b Web: Ring-Karten – Legende in eigenem Scroll-Bereich (Ring bleibt stehen)
align-self:stretch an der <ul> war zu unzuverlaessig (u.a. wegen flex-wrap). Jetzt
kanonisches Muster: Ring (feste Groesse, mittig) + eigener .chart-legend-scroll
(flex:1 1 0; min-height:0; overflow-y:auto) in einem align-items:stretch-Container
ohne Umbruch. Nur die Legende scrollt, der Ring bleibt stehen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:17:10 +02:00
Scarriffle
30e570095f Web: Dashboard-Karten fuellen die Rasterzelle (.dash-card height:100%)
Ohne definierte Kartenhoehe reichte die feste Rasterzellen-Hoehe nie an Body/
Chart/Legende durch - die Flex-Kette (min-height:0/overflow) hatte nichts zum
Andocken, weshalb die ganze Karte scrollte statt nur der Legende. Mit height:100%
greift die Legenden-Scroll-Regel: der Anteils-Ring bleibt stehen, nur die Legende
scrollt. Betrifft auch die internen Tabellen-Scrolls der anderen Karten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 18:10:58 +02:00
Scarriffle
2f6c88ce08 Web: Anteils-Ringe – Ring bleibt stehen, nur Legende scrollt; Ohne-MHD-Schalter in die Konfig
Bei vielen Kategorien scrollte die ganze Karte (Ring wanderte weg). Jetzt fuellt
die Legende die Hoehe des Ring-Bereichs und scrollt in sich; der Ring bleibt
mittig fixiert.

Ablauf-Anteile: der Ohne-MHD-Schalter sass als Checkbox auf der Karte (localStorage).
Jetzt als boolean-Konfig im Zahnrad-Dialog (Vorgabe an), wie die anderen Optionen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:47:46 +02:00
Scarriffle
9b0771ae0e Kategorien-Anteile: einstellbare Kategorie-Tiefe (Unterkategorien zusammenfassen)
/dashboard/by-category bekommt depth: rollt jede Artikel-Kategorie auf ihren
Vorfahren der gewuenschten Stufe hoch (1 = oberste Ebene), ohne depth zaehlt
jede zugeordnete Kategorie einzeln (feinste). Neue Karten-Konfig select
Kategorie-Tiefe (Feinste / oberste / bis 2. / bis 3. Stufe). KartenConfig zeigt
jetzt auch bei select den Hinweis.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:39:30 +02:00
Scarriffle
1cddb4432c Web: Kategorien-Anteile-Karte umschaltbar auf Detailansicht (keine Gruppe Andere)
Neue Karten-Konfig-Option Detailansicht: mitFarben(..., { alle:true }) faerbt
jede Kategorie einzeln (Festpalette vorne, danach erzeugte Farbtoene) statt den
Rest zu Andere zu buendeln. Umschaltbar ueber das Zahnrad der Karte.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 17:34:27 +02:00
Scarriffle
55db1d1363 Web: Einkaufsliste-Karte zum Abhaken (wie die volle Seite)
Die Karte zeigte die Bedarfe als Tabelle ohne Haekchen. Jetzt als abhakbare
Checklist (Gesamt + je Lagerort) mit Durchstreichen beim Abhaken - dieselbe
Bedienung wie die Einkaufslisten-Seite. Abhaken ist sitzungslokal (wie dort
auch, noch nicht dauerhaft gespeichert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:36:04 +02:00
Scarriffle
3e10e254c8 Web: Dashboard-Karten wie HomeAssistant konfigurieren + Einkaufsliste je Lagerort
Karten-Optionen (Artikel, Lagerort, Zeitraum) sassen als einzelne Dropdowns im
Kartenkopf. Jetzt: deklaratives config-Schema je Kartenart und ein Zahnrad-Dialog
(KartenConfig) im Anordnen-Modus - der Kopf zeigt sonst nur noch Titel/Papierkorb.
Felder: product, location, zeitraum (erweiterbar: select, boolean). Werte bleiben
in props/tage, keine Migration.

Einkaufsliste-Karte laedt zusaetzlich shoppingByLocation und zeigt Gesamt + Bedarfe
je Lagerort. Ueber die Karten-Konfig waehlbarer Lagerort: leer = alle Orte, gesetzt
= Gesamt + nur dieser Ort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 14:29:09 +02:00
Scarriffle
f3bab15362 Mindestbestaende: Bestand je Lagerort anzeigen (nicht nur Gesamt)
Per-Lagerort-Zeilen (Produkt und Gruppe) zeigten '-' beim Bestand, weil die Seite
nur den Gesamtbestand kannte. Jetzt liefert LocationMinStockOut.stock den Bestand
AN DEM Ort (inkl. Unterorte) mit - bei Produkten in Basiseinheiten, bei Gruppen in
der Gruppen-Einheit (analog zu ProductOut.stock/GroupOut.stock). Berechnet ueber
location_subtree_stock_base; die Weboberflaeche zeigt ihn wie beim Gesamt-Bestand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:57:40 +02:00
Scarriffle
4ed33e221f Web: Chargen-Seite faerbt Zeilen nach MHD (gelb = bald ablaufend, rot = abgelaufen)
Wiederverwendung von expiryRowClass + .row-warn/.row-danger wie auf der
Artikelseite. Warnfrist kommt aus der Einstellung expiry_warning_days (Default 7).
Die MHD-Faerbung bleibt auch bei ausgewaehlter/gehoverter Zeile sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:45:19 +02:00
Scarriffle
f981f41d94 Einzelstueck-QR ueber Custom-Scheme statt Universal Link
Universal Links (Associated Domains) sind an eine feste Domain gebunden - fuer
einen selbstgehosteten Dienst untauglich (jede Instanz hat eine andere URL) und
die Domain stand im offenen Quellcode (Leak). Jetzt tragen Einzelstueck-QRs das
Custom-Scheme vorrania://i/<UID>: Die normale Kamera-App oeffnet die App weiterhin,
aber ohne Domain-Bindung und ohne Server im Code. applinks-Eintrag entfernt.

Web: itemDeepLink()-Helfer, genutzt in Einzelstuecke-Liste/-Etiketten und im
P-touch-Export. Lagerort-QRs bleiben unveraendert (https).
iOS: Router erkennt vorrania://i/<UID> und oeffnet das Einzelstueck am aktiven Server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 11:34:37 +02:00
Scarriffle
48d47f1d39 Web: Mindestbestand-Ort per Dropdown verschieben + OFF-Panel-Regression behoben
MinStock: die ORT-Spalte ist jetzt ein Dropdown (Gesamt/Lagerort). Auswahl
verschiebt den Bedarf (moveRow) statt ihn zu duplizieren - mit Rueckfrage, falls
der Zielort schon belegt ist. Produktwerte werden dabei umgerechnet, Gruppen nicht.

OFF-Panel: meine schmalere Formularspalte hatte den Vergleichs-Button abgeschnitten
und die OFF-Tabelle aus der Karte laufen lassen. Tabelle jetzt in .table-wrap
(scrollt/stapelt), Button-Text darf umbrechen, Formularspalte etwas breiter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 09:32:45 +02:00
Scarriffle
f78ae924de Backend: verschachtelte Ort-Mindestbestaende hierarchisch verrechnen
shopping_list_by_location zaehlte Ober- und Unterort unabhaengig, obwohl der
Ober-Subtree den Unterort schon enthaelt - Bedarf wurde doppelt gemeldet. Jetzt
werden Bedarfe je Produkt/Gruppe von unten nach oben verrechnet (_netted_topups):
was in einen Unterort gekauft wird, deckt den Oberort mit. Im Mehl-Beispiel (Lemgo
5, Kueche 2, je 1 fehlend) meldet der Server nur noch 1 (Kueche) statt 2.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 09:32:45 +02:00
Scarriffle
39803dfb82 Backend: auch Einlagern bucht auf bestehende gleiche Charge dazu
Damit gilt die Regel ueberall gleich: gleicher Artikel + MHD + Genauigkeit +
Lagerort = eine Charge. Einlagern legt keine zweite Charge mehr an, sondern
erhoeht die vorhandene; die Einlager-Bewegung bleibt als eigener Verlaufseintrag
erhalten. Alle 177 Tests gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 09:01:36 +02:00
Scarriffle
05b54dc7ac Backend: gleiche Chargen (Artikel+MHD+Ort) werden zusammengefasst
Nach dem Umlagern/Aufteilen entstanden Dubletten: zwei Chargen desselben Artikels
mit gleichem MHD am gleichen Lagerort, obwohl das fuer den Nutzer eine Charge ist.
Neu: consolidate_lot_group fasst solche Chargen zusammen (Mengen addieren,
Bewegungen auf die aeltere Charge umhaengen, Rest loeschen) - aufgerufen nach
split, bulk-location und update_lot. Bestand und Historie bleiben unveraendert.
Beim Start raeumt consolidate_duplicate_lots einmalig bestehende Dubletten auf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 08:53:12 +02:00
Scarriffle
231d86895f Charge aufteilen: Teilmenge mit gleichem MHD an anderen Lagerort umlagern
Bisher liess sich nur die ganze Charge umlagern. Neu: POST /lots/{id}/split zweigt
eine Teilmenge (Basiseinheiten) als neue Charge mit gleichem MHD an einen anderen
Lagerort ab; der Rest bleibt. Reine Umbuchung ohne Bewegungseintrag, Gesamtbestand
unveraendert. Schutz gegen zu grosse Menge und gleichen Zielort.

Web: gemeinsamer SplitLotDialog (Menge in Artikeleinheit + Zielort, zeigt Rest),
Aufteilen-Knopf auf der Chargen-Seite und in der Chargen-Tabelle der Artikelseite.
Neues Split-Icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 08:43:16 +02:00
Scarriffle
8cf7cef6e1 Web: Artikelseite nutzt volle Breite, Chargen-Tabelle bekommt mehr Platz
Die Bearbeiten-Seite war auf 1400px gedeckelt (viel Rand links/rechts) und teilte
Formular und Chargen 50/50. Jetzt nutzt sie die volle Breite (wie die Listen) und
das Grid ist gespiegelt zu .wide-aside: links das schmale Formular (clamp bis
640px), rechts die Chargen-/Bestandstabelle mit dem Restplatz - so passen
Menge/MHD/Lagerort ohne Seitwaerts-Scrollen. Neuanlage bleibt einspaltig.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 18:52:30 +02:00
Scarriffle
e28b6a5e38 Web: Mindestbestaende per Loesch-Knopf entfernbar
Loeschen ging bisher nur uebers Leeren des Zahlenfeldes - das war weder
auffindbar noch (bei Gesamt-Werten, deren Zeile bestehen bleibt) sichtbar.
Jetzt hat jede gesetzte Zeile einen Papierkorb-Knopf: Gesamt-Wert -> NULL,
je-Lagerort-Eintrag faellt raus. Mit Rueckfrage ueber den App-Dialog.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:12:57 +02:00
Scarriffle
bfc52997dc Web: Mindestbestand je Lagerort laesst sich anlegen (mergeLoc ergaenzt neue Orte)
Der Endpunkt ersetzt die komplette Lagerort-Liste; mergeLoc iterierte aber nur
ueber bestehende Eintraege und liess neue Orte weg. Dadurch entstand nie ein
neuer je-Lagerort-Mindestbestand, wenn nur ein Gesamt-Wert (leere Ortsliste)
oder ein Eintrag an einem anderen Ort existierte. Jetzt wird ein fehlender Ort
ergaenzt und der Abgleich per String() unabhaengig vom id-Typ gemacht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 17:04:14 +02:00
Scarriffle
f29bbced8d iOS: Mindestbestaende-Uebersicht zeigt Gruppen und je-Lagerort
Bisher nur globale Produkt-Mindestbestaende - wer (wie im Screenshot) nur Gruppen-
oder Lagerort-Bedarfe hatte, sah "keine gesetzt". Jetzt Produkte UND Gruppen,
jeweils Gesamt und je Lagerort, mit Soll (Anzeige-/Gruppeneinheit) und "niedrig"-
Hinweis. Produktzeilen oeffnen den Artikel zum Bearbeiten. GroupItem traegt dafuer
die Mindestbestand-Felder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:50:11 +02:00
Scarriffle
a04315db21 Backend: kombinierter Einkaufslisten-Endpunkt /shopping-list/all
Liefert Produkte, Gruppen und Bedarfe je Lagerort in einem Aufruf - dieselben
drei Quellen, die die Oberflaeche ohnehin zusammenfuehrt. Erspart API-Nutzern
drei getrennte Requests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 16:08:12 +02:00
Scarriffle
66a52bfecb Web: Scrollleisten ans Theme angepasst
Scrollbare Karten/Tabellen zeigten die helle System-Scrollleiste. Jetzt eine
dezente, theme-abhaengige Scrollleiste (Variablen border-strong/muted, hell wie
dunkel) - schlanker Daumen mit runden Enden, transparente Spur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:20:22 +02:00
Scarriffle
8250b5dc84 iOS: Benachrichtigungs-Kategorien laden + Ein-/Auslager-Meldung huebscher
1) In den Benachrichtigungs-Regeln liess sich keine Kategorie waehlen: der Abruf
   holte den ganzen Snapshot (Produkte + Ablauf + Kategorien) - schlug ein Teil
   fehl, kamen auch die Kategorien leer. snapshot ist jetzt widerstandsfaehig
   (Teilausfaelle egal), und die Regel-Bearbeitung holt nur noch die Kategorien.

2) Die gruene Erfolgsmeldung ist jetzt eine saubere, eingerueckte Pille mit Titel
   ("Eingelagert"/"Ausgelagert") und Detailzeile ("Neuer Bestand: 2 Packungen").
   Die Einheit steht in der Mehrzahl (Gebinde-Plural vom Server; Basiseinheiten
   wie Gramm bleiben unveraendert).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 15:04:25 +02:00
Scarriffle
a0979a0780 Docs: README auf aktuellen Stand (Gegenstaende, iOS, alle Features)
Root-README beschrieb noch "Schritt 1" mit iOS "in Vorbereitung". Jetzt
tatsaechlicher Umfang: Lebensmittel + Gegenstaende (3 Arten), verschachtelte
Lagerorte mit Inhaltsansicht, Kategorien/Gruppen, Mindestbestaende, Chargen-/
Einzelstuecklisten, Dashboards, Web-UI + native iOS-App. iOS-README-Stand,
Dateiuebersicht und "offene Punkte" ebenfalls aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:47:18 +02:00
Scarriffle
7a7b40ff99 iOS: Text-Scan zerlegt Bloecke in einzelne Zeilen
Ein erkannter Textblock konnte mehrere Zeilen enthalten; die Auswahl uebernahm
dann mehrzeiligen Text. Jetzt wird strikt in einzelne Zeilen zerlegt, sodass
jede Auswahl genau eine Zeile ist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:41:50 +02:00
Scarriffle
e42e11bf23 iOS: Name/Marke per Text scannen + Frischedatum ohne Jahr erkennen
- Neuer TextScanView: beim Anlegen Name und Marke per Texterkennung einlesen
  (erkannte Zeilen antippen, wie beim MHD-Scan) - kein Abtippen mehr, wenn ein
  Produkt nicht in Open Food/Products Facts steht.
- BestBeforeText erkennt jetzt auch Tag.Monat ohne Jahr (z.B. "05.01" bei
  Frischware / "zu verbrauchen bis") und ergaenzt das aktuelle Jahr (bzw. das
  naechste, falls schon vergangen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:35:46 +02:00
Scarriffle
18c07eaf3a Web: Chargenseite - Spalte "Art" (Lebensmittel/Gegenstand)
Neue sortier-/filterbare Spalte zeigt je Charge, ob es ein Lebensmittel oder ein
Gegenstand (Verbrauchsgegenstand) ist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:13:22 +02:00
Scarriffle
012e3461ae Backend: Chargenliste - Art (food/object) + nur Charge-Artikel
/lots/rows liefert jetzt die Art (Lebensmittel/Gegenstand) mit und listet nur
Charge-Artikel (Lebensmittel + Verbrauchsgegenstand). Lots von "Menge je
Lagerort" (und etwaige Einzelstueck-Altlasten) fallen raus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:12:35 +02:00
Scarriffle
5550897319 Web: Chargen per Klick auf die ganze Zeile auswaehlen
Nicht nur die kleine Checkbox schaltet die Auswahl, sondern ein Klick irgendwo
in die Zeile (ausser auf Bedienelemente wie Lagerort-Dropdown/Link). Ausgewaehlte
Zeilen werden hervorgehoben. Neuer DataTable-Prop onRowClick.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:08:12 +02:00
Scarriffle
51c02091ed Web: Chargen auswaehlen und Lagerort der Auswahl setzen
Chargenliste bekommt Auswahl-Checkboxen. Das obere Panel wirkt jetzt auf die
Auswahl: "N ausgewaehlt -> Lagerort setzen" (leer = Ort entfernen, z.B. beim
Aufloesen eines Orts). Schnellwahl "alle sichtbaren" (respektiert Filter) und
"alle ohne Lagerort". DataTable meldet dafuer die sichtbaren Zeilen
(onVisibleRows).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 14:04:19 +02:00
Scarriffle
826f4df4bd Web: Chargenliste (mit Sammel-Lagerort) + Ladezustand auf allen Tabellen
Neue Seite "Chargen": alle Chargen ueber alle Artikel (Artikel, Menge, MHD,
Lagerort, eingelagert). Lagerort je Zeile direkt aenderbar; alle Chargen ohne
Ort per Klick zuweisbar (bulk-location) - gut, wenn der Lagerort erst spaeter
angelegt wurde.

DataTable bekommt einen loading-Prop: waehrend des Ladens steht "Wird geladen..."
statt der Leer-Meldung. Auf ALLEN Tabellen angewendet (Produkte, Mindestbestaende,
Kategorien, Gruppen, Einheiten, Gebinde, Einzelstuecke, Chargen).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:58:07 +02:00
Scarriffle
1c88206838 Backend: alle Chargen auflisten + Lagerort mehrerer Chargen auf einmal setzen
GET /lots/rows liefert alle Chargen mit Artikel-Infos (Name, Menge, Einheit,
Lagerort) fuer eine uebergreifende Chargenliste. POST /lots/bulk-location setzt
den Lagerort vieler Chargen in einem Rutsch (reine Ortsangabe, ohne Bewegung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:48:19 +02:00
Scarriffle
227143d13f Web: Chargen zeigen ihren Lagerort (und lassen ihn aendern)
Die Chargen-Tabelle hatte keine Lagerort-Spalte - dadurch wirkte es, als koenne
man Chargen keinem Ort zuordnen (geht laengst ueber den Lagerort beim Einlagern).
Neue Spalte "Lagerort" zeigt den Ort je Charge; beim Bearbeiten laesst er sich
per Dropdown aendern (z.B. 2 Glaeser Pesto in Schrank A, 2 in Schrank B).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:41:19 +02:00
Scarriffle
d448754d2a Web: Lagerort-Inhalt ansehen + von dort einlagern
Der Lagerort-QR (/l/<code>) zeigt jetzt, was am Ort (inkl. Unterorte) liegt -
Artikel mit Bestand, jeweils verlinkt - statt nur den Namen. "Hier einlagern"
springt ins Einlagern mit vorbelegtem Lagerort (?location=). In der Lagerorte-
Verwaltung fuehrt ein neues Lupensymbol zur Inhaltsansicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 13:37:34 +02:00
Scarriffle
85ab62359a Backend: Bild beim Anlegen im Hintergrund holen (Anlegen viel schneller)
create_product holte ein per URL vorgeschlagenes Bild synchron waehrend des
Anlegens (httpx.get) - das blockierte die Antwort um mehrere Sekunden. Der
Abruf laeuft jetzt als BackgroundTask mit eigener Session nach der Antwort; das
Anlegen kehrt sofort zurueck, das Bild erscheint kurz danach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 12:26:43 +02:00
Scarriffle
4728a839ea Backend: Produktliste gebuendelt laden (N+1 weg, deutlich schneller)
list_products rief product_to_out je Artikel auf - pro Artikel mehrere Abfragen
(Bestand, abgelaufene Chargen, Codes, Bild, Lazy-Relationen). Bei vielen
Artikeln = hunderte Queries und mehrere Sekunden. Neu: products_to_out_bulk holt
Bestand/Abgelaufen/Codes/Bild je einmal fuer alle und laedt Relationen eager
(joinedload/selectinload). Ergebnis identisch (Test), aber nur noch wenige Queries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 12:23:12 +02:00
Scarriffle
4034b97cf3 Web: Produktlisten zeigen "wird geladen" statt "keine Produkte" beim Laden
Bisher stand sofort "Keine Produkte gefunden." da, obwohl noch geladen wurde.
Jetzt ein Ladezustand: Kopf zeigt "Wird geladen...", Tabelle "Produkte werden
geladen...", und die Leer-Meldung kommt erst, wenn der Server wirklich nichts
liefert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 12:07:12 +02:00
Scarriffle
620e77dff1 iOS: Mengen-Sheet auf Form-Ebene praesentieren (erstes Oeffnen bleibt offen)
Das Menge-hinzufuegen/umlagern/entfernen-Sheet ging beim ersten Antippen sofort
wieder zu: das spaet fertige Laden der Entnahme-Statistik (.task im Abschnitt)
riss das gerade praesentierte Sheet weg. ObjectStockSection ist jetzt zustands-
los (bekommt removals + Sheet-Binding); Sheet und Statistik-Laden liegen in der
Detailansicht auf der stabilen Form-Ebene bzw. im reload().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 12:03:19 +02:00
Scarriffle
4ab8d11f92 iOS: Menge-je-Lagerort-Sheet an den Abschnitt haengen (Flackern behoben)
Das Hinzufuegen/Umlagern/Entfernen-Sheet hing an der umschliessenden Group; bei
manchen iOS-Versionen ging es dadurch kurz auf und sofort wieder zu. Jetzt am
Abschnitt selbst (wie bei den Einzelstuecken), was stabil praesentiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:43:31 +02:00
Scarriffle
f53d5bd921 iOS: Verwaltungsart eines Gegenstands aendern (Menge/Einzelstueck/Verbrauch)
Neue "Verwaltung"-Auswahl in der Detailansicht: Menge je Lagerort <->
Einzelstuecke <-> Verbrauchsgegenstand umstellbar. Wird beim Speichern per
sendMode uebertragen (individual/bulk); vorhandener Bestand wird nicht
automatisch umgerechnet (Hinweis im Footer).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:33:30 +02:00
Scarriffle
2c1efba83d iOS: Mindestbestaende-Uebersicht im Listen-Tab
Neue "Mindestbestaende"-Ansicht: alle Artikel mit gesetztem Mindestbestand mit
Soll vs. Bestand (Anzeigeeinheit) und "niedrig"-Hinweis; Tippen oeffnet den
Artikel zum Bearbeiten. Kachel im Listen-Tab ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:28:50 +02:00
Scarriffle
120dfea66a iOS: Menge je Lagerort - Lagerort scannen + Einlagern ohne MHD
- Lagerort per QR scannen in Menge-hinzufuegen/entfernen/umlagern (Ziel), nicht
  nur ueber das Dropdown suchen.
- Beim Einlagern laufen "Menge je Lagerort"-Gegenstaende jetzt ueber die Mengen-
  Eingabe (ObjectAddSheet) statt durch den Charge-Weg mit MHD - Socken & Co.
  fragen kein MHD mehr ab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-29 11:22:40 +02:00
Scarriffle
7c359a8f5d iOS: Eingabefelder linksbuendig - getippte Leerzeichen sind sofort sichtbar
LabeledField war rechtsbuendig; iOS rendert bei rechtsbuendigen Feldern ein
getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen folgt (wirkt, als
reagiere die Eingabe nicht). Linksbuendig behebt das und liest sich bei Name/
Marke ohnehin natuerlicher.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 17:44:22 +02:00
Scarriffle
e981cd8cd4 iOS: Einzelstuecke beim Einlagern als Exemplare anlegen (Bestand zaehlt jetzt)
Beim Einlagern lief auch ein Einzelstueck-Produkt durch den Charge-Weg
(CheckInFormView -> Lot). Einzelstuecke zaehlen aber Items, nicht Lots - der
Bestand blieb 0. Im Einlagern-Ablauf werden Einzelstueck-Produkte jetzt ueber
ItemAddSheet als physische Exemplare (UID/QR) angelegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 17:37:58 +02:00
Scarriffle
1d173bb9c0 iOS: Listen beim Zurueckkommen neu laden (Bestand nicht mehr veraltet)
Nach dem Anlegen eines Einzelstuecks im Detail zeigte die Produktliste weiter
"Bestand 0", weil sie nicht aktualisiert wurde (das Stueck war aber gespeichert).
Produkt- und Einzelstueckliste laden jetzt bei jedem Erscheinen neu (onAppear),
auch beim Zurueckkommen aus der Detailansicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:31:32 +02:00
Scarriffle
902b597091 iOS: Mindestbestand auch bei Lebensmitteln verwalten
Das Gesamt-Mindestbestand-Feld erschien nur beim Verbrauchsgegenstand; bei
Lebensmitteln fehlte es und save() schickte ihn nicht. Jetzt fuer alle foodLike
(Lebensmittel + Verbrauchsgegenstand) - in der Anzeigeeinheit, robust aus den
Basiseinheiten umgerechnet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:26:16 +02:00
Scarriffle
53492ad4ab iOS: Gegenstaende-Liste - Filter nach Verwaltungsart
Neuer "Verwaltung"-Filter (nur in der Gegenstaende-Liste): Menge je Lagerort /
Einzelstuecke / Verbrauchsgegenstand.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 16:23:19 +02:00
Scarriffle
f96286b3fa Web: Gegenstaende-Liste - Spalte/Filter "Verwaltung"
Neue Spalte "Verwaltung" (nur in der Gegenstaende-Liste): Menge je Lagerort /
Einzelstuecke / Verbrauchsgegenstand - sortier- und filterbar wie die anderen
Spalten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 15:40:21 +02:00
Scarriffle
53d8f18497 iOS: "Ort zuordnen" auch ohne Stueck-QR (Einzelstueck aus Liste waehlen)
Bisher musste man im "Ort zuordnen"-Ablauf zuerst den QR des Einzelstuecks
scannen. Fuer Stuecke ohne eigenen Code (z.B. Kleidung) gibt es jetzt
"Einzelstueck aus Liste waehlen" (durchsuchbare Liste) als Alternative; danach
wie gehabt den Lagerort-QR scannen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:47:42 +02:00
Scarriffle
532695c2ee iOS: Einzelstueck-Kategoriefilter hierarchisch + mit Oberkategorien
Der Kategoriefilter der Einzelstueckliste zeigt jetzt einen eingerueckten Baum
und enthaelt auch die Oberkategorien (z.B. "Klamotten"), nicht nur die Blaetter
mit Exemplaren. Nach einer Oberkategorie zu filtern schliesst alle Unter-
kategorien ein (ueber category_id statt Namensgleichheit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:45:41 +02:00
Scarriffle
4f8e6e3185 iOS: Produktliste - Kategoriefilter nach Typ + Sortierung
Kategoriefilter zeigt nur noch Kategorien des Listentyps (Lebensmittel-Liste nur
Food-Kategorien, Gegenstaende-Liste nur Objekt-Kategorien) - vorher konnte man
sinnlos ueberkreuz filtern. Neue Sortierung (Name A-Z/Z-A, Zuletzt geaendert)
per Menue-Picker; Product traegt dafuer updated_at.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:43:44 +02:00
Scarriffle
d029c882f1 iOS: Lagerort im Einzelstueck per QR scannen (ohne dessen eigenen QR)
Im Einzelstueck-Formular (bearbeiten und anlegen) gibt es jetzt neben dem
Lagerort-Dropdown einen "Lagerort scannen"-Knopf: den QR am Regal/Fach scannen
setzt den Ort direkt. So kann man z.B. Kleidung ohne eigenen Stueck-QR einem Ort
zuordnen. Neue wiederverwendbare LocationScannerView (…/l/<Code>).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 13:22:38 +02:00
Scarriffle
03c0f4043d Web: Filter und Sortierung der Tabellen bleiben erhalten
Bisher gingen Filter + Sortierung beim Navigieren (Produkt -> zurueck) verloren.
Jetzt werden sie wie Spaltenreihenfolge/-breiten je Tabelle im Browser
gespeichert (Sets als Arrays serialisiert) und bleiben bis "Filter loeschen"
bzw. bis man anders sortiert. Gilt automatisch fuer alle DataTable-Tabellen.
"Spalten zuruecksetzen" laesst Filter/Sortierung unangetastet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:45:49 +02:00
Scarriffle
31137cd884 Web: Spalte "Zuletzt geaendert" in den Produktlisten
Neue sortier- und filterbare Spalte (Datum + Uhrzeit) in Lebensmittel- und
Gegenstaende-Liste, basierend auf Product.updated_at. Nuetzlich, um zuletzt
angelegte/geaenderte Artikel zu finden (z.B. mit fehlender Kategorie). Erscheint
automatisch, laesst sich per Spaltenauswahl aus-/einblenden und umsortieren.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:36:47 +02:00
Scarriffle
45031df1f4 Backend: Product.updated_at (Zuletzt geaendert)
Neue Spalte updated_at, die bei jeder Stammdaten-Aenderung automatisch nachzieht
(onupdate). Migration legt sie nullable an und setzt Altbestaende auf created_at.
In ProductOut ausgegeben - Basis fuer eine "Zuletzt geaendert"-Spalte im Web.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 11:12:03 +02:00
Scarriffle
9ca7291c68 iOS: eigene Kategorie-Felder schon beim Anlegen ausfuellen
Beim Neuanlegen eines Gegenstands (z.B. T-Shirt) werden jetzt die eigenen Felder
der gewaehlten Kategorie (Groesse, Farbe, ...) geladen und angezeigt - vorher
tauchten sie erst in der Detailansicht nach dem Anlegen auf. NewProductRequest
traegt die Werte via field_values mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:56:37 +02:00
Scarriffle
c67998d78b iOS: Foto schon beim Anlegen eines Produkts hinterlegen
Anlege-Formular bekommt eine Foto-Sektion (Kamera/Galerie) mit Vorschau. Das
Bild wird nach dem Anlegen hochgeladen (dann gibt es eine Produkt-ID). Die
JPEG-Aufbereitung ist jetzt eine freie Funktion (productImageJPEG), von der
Detail- und Anlege-Ansicht gemeinsam genutzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:32:49 +02:00
Scarriffle
4141781ffb Web: Foto schon beim Anlegen eines Produkts hinterlegen
Bisher ging ein Foto erst nach dem Speichern (Produkt-ID noetig). Jetzt kann man
beim Neuanlegen direkt "Foto machen"/"Galerie" nutzen - das Bild wird vorgemerkt,
als Vorschau gezeigt und nach dem Anlegen hochgeladen. Nuetzlich, wenn Open Food/
Products Facts kein Bild liefert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 10:28:56 +02:00
Scarriffle
27f84003f3 iOS: Mindestbestand nur fuer Verbrauchsgegenstand, in echter Einheit
Mindestbestand (global + je Lagerort) nur noch bei Charge-Artikeln: das Feld
verschwindet bei Menge je Lagerort und Einzelstuecken, Alt-Werte werden beim
Speichern geleert. Angezeigt/gespeichert wird in der Anzeigeeinheit
(Milliliter/Gramm/Stueck) statt in Packungen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 09:02:10 +02:00
Scarriffle
3ff7d2a35a Web: Mindestbestand je Lagerort im Formular in echter Einheit
Der je-Lagerort-Editor (foodLike) zeigt und speichert jetzt in der Anzeigeeinheit
(Gramm/Milliliter/Stueck) statt in Packungen - konsistent zur Mindestbestaende-
Seite. Umrechnung Artikeleinheit<->Anzeigeeinheit an der Formular-Grenze.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 09:02:10 +02:00
Scarriffle
49a96d7fdb Web: Mindestbestand nur fuer Charge-Artikel (Lebensmittel + Verbrauchsgegenstand)
Menge je Lagerort und Einzelstuecke haben keinen Mindestbestand mehr: die
"Mindestbestand (Stueck)"-Felder und der je-Lagerort-Block fallen im Formular
weg (Gruppe fuer Menge bleibt), Alt-Werte werden beim Speichern geleert. Die
Mindestbestaende-Seite und der "+ hinzufuegen"-Dialog zeigen nur noch
Charge-Artikel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:54:50 +02:00
Scarriffle
275611160b Web: Mindestbestaende in echter Einheit statt Packung
"1 Packung" ist nichtssagend (kann 500 g, 1 kg oder 25 ml sein). Die Seite zeigt
und bearbeitet Produkt-Mindestbestaende jetzt in der Anzeigeeinheit des Artikels
(Gramm/Milliliter/Stueck): global (Basiseinheiten) und je Lagerort (Artikel-
einheiten) werden dafuer umgerechnet. Rigatoni zeigt jetzt z.B. "500 Gramm".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:48:32 +02:00
Scarriffle
f0c64466e2 Web: Mindestbestaende-Bestand zeigt richtige Einheit ("1 Packung" statt "1 Gramm")
articleLabel fiel bei leerem Gebinde-Namen auf die Basiseinheit zurueck, waehrend
articleUnit durch die Packungsgroesse teilt. Dadurch stand z.B. bei Rigatoni
"1 Gramm" statt "1 Packung" (500 g). Label ist jetzt an die Packungsgroesse
gekoppelt und passt zur Zahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 08:39:03 +02:00
Scarriffle
0ea188374a iOS: getrennte Lebensmittel/Gegenstaende-Listen + anklickbarer Verlauf
Listen-Tab: zwei halbe Kacheln "Lebensmittel" und "Gegenstaende" (ProductListView
mit fixedType, clientseitig ueber isObject gefiltert wie im Web); Neuanlage
uebernimmt den Typ. Einzelstuecke bleiben unveraendert (reine Gegenstands-Welt).

Verlauf und "Letzte Bewegungen" auf der Startseite: Tippen auf eine Bewegung
oeffnet ueber den neuen Lader ProductByIdView das jeweilige Produkt. Der
artikel-eigene Verlauf bleibt ohne Link.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-28 07:29:49 +02:00
Scarriffle
c28ac7d7dd Web: Kategorie-Auswahlfeld zeigt Oberkategorien der gewaehlten Kategorie
Im geschlossenen Feld stand nur das Blatt (z.B. "Fruechte") - bei mehreren
gleichnamigen Kategorien war unklar, welche gemeint ist. Jetzt steht der volle
Pfad drin, Oberkategorien klein/subtil davor (z.B. "TK -> Fruechte"), das Blatt
normal - ohne das Dropdown oeffnen zu muessen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:38:16 +02:00
Scarriffle
10a948df15 Backend: Verbrauchsgegenstand/Einzelstueck sind immer Gegenstaende
product_tracking leitet die Art bisher nur aus der Kategorie ab. Beim Umstellen
Lebensmittel -> Verbrauchsgegenstand faellt die (Lebensmittel-)Kategorie weg,
sodass der Artikel faelschlich "food" blieb und in der Lebensmittelliste hing.
individual/bulk implizieren jetzt "object", unabhaengig von der Kategorie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:30:09 +02:00
Scarriffle
24ea55c396 Web: Barcode-Feld nicht mehr von den Knoepfen zusammengequetscht
Barcode-Eingabe behaelt eine Mindestbreite; die Knoepfe (Nachschlagen /
Vergleichen) brechen bei Platznot in die naechste Zeile um.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:27:16 +02:00
Scarriffle
46f9c69a39 Web: "Neue Kategorie"-Button auf Hoehe des Auswahlfelds
Eigene unsichtbare Beschriftungszeile ueber dem Knopf, damit er auf gleicher
Hoehe wie das Kategorie-Auswahlfeld beginnt - statt am oberen oder unteren
Zeilenrand zu kleben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:23:38 +02:00
Scarriffle
8d34e2e8d8 Web: "Neue Kategorie"-Button an das Kategorie-Feld ausrichten
Button per align-self unten buendig zum Auswahlfeld (stand vorher am oberen
Zeilenrand). Gruppen-Tooltip auf "mehrerer Artikel" verallgemeinert, da jetzt
auch Gegenstaende gruppierbar sind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 21:19:18 +02:00
Scarriffle
72fa2ce37d iOS: Verbrauchsgegenstand als dritte Gegenstands-Art (wie ein Lebensmittel)
Neuer Artikel: 3-Wege-Verwaltung fuer Gegenstaende (Menge je Lagerort /
Einzelstuecke / Verbrauchsgegenstand). Verbrauchsgegenstaende laufen wie
Lebensmittel als Charge (Einheit, Packung, MHD, Gruppe, Chargen-Ansicht,
Mindestbestand mit Einheit) - foodLike = kein Gegenstand ODER bulk. Menge-
Gegenstaende koennen jetzt ebenfalls einer Gruppe zugeordnet werden.
ProductDetailView entsprechend auf foodLike umgestellt; NewProductRequest
und ProductUpdateRequest um bulk/individual/base_unit erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:51:07 +02:00
Scarriffle
f287f8f768 Web: zentrale Mindestbestaende per "+ hinzufuegen" anlegen
Neuer Dialog auf der Mindestbestaende-Seite: Ziel (Produkt/Gegenstand oder
Gruppe) -> Geltung (Gesamt oder Lagerort) -> Menge in der Einheit des Ziels.
Speichert ueber die vorhandenen Endpunkte (updateProduct/updateGroup bzw.
set*LocationMinStock).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:41:57 +02:00
Scarriffle
4087277bb0 Web: Verbrauchsgegenstand als dritte Gegenstands-Art + Typ-Position + Thumbnail
ProductForm: Typ/Kategorie an feste Position (nach Marke, vor den typ-
spezifischen Feldern) - der Umschalter springt nicht mehr je nach Auswahl.
Gegenstaende bekommen eine 3-Wege-Verwaltung: Menge je Lagerort /
Einzelstuecke / Verbrauchsgegenstand. Verbrauchsgegenstaende werden wie
Lebensmittel als Charge (Einheit, Packung, MHD, Mindestbestand mit Einheit,
Gruppe, Chargen-Ansicht) gefuehrt (foodLike = kein Gegenstand ODER bulk).
Menge-Gegenstaende koennen jetzt ebenfalls einer Gruppe zugeordnet werden.

Thumbnail: .thumb display:block, damit die Zeilenhoehe beim Nachladen nicht
mehr minimal springt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:39:15 +02:00
Scarriffle
6c870584dd Backend: Verbrauchsgegenstand (Product.bulk) - Gegenstand wie ein Lebensmittel
Neues Flag bulk auf Product: Gegenstand als Charge mit Menge/Einheit/MHD/
Mindestbestand fuehren (z.B. Sonnencreme in ml), ohne eindeutigen Code.
Migration (ADD COLUMN), Schemas und Anlege-Pfad ergaenzt. Kernlogik nutzt
foodLike = kein Gegenstand ODER bulk; Bestand/Einkaufsliste unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:29:01 +02:00
Scarriffle
1dc249eb79 Mindestbestand auch fuer Gegenstaende (Web-Formular + iOS)
Gegenstaende (Verbrauchsmaterial wie Sonnencreme) koennen jetzt einen Mindestbestand haben - im Web-Produktformular ein Gesamt-Feld (Stueck) plus Mindestbestand je Lagerort, auf iOS ein Gesamt-Feld in der Detailansicht. Umrechnung wie bei Lebensmitteln (fuer Stueck-Artikel = Stueckzahl); iOS sendet min_stock nur bei Gegenstaenden, damit Lebensmittel-Modi unberuehrt bleiben. Backend/Einkaufsliste unterstuetzen das bereits. Namen (Lebensmittel/Gegenstaende) bleiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:48:41 +02:00
Scarriffle
9551751732 iOS: 'Anlegen & fertig' fuers Einzelstueck + Speichern-Nachfrage beim Zurueck
1) Im Einzelstueck-Anlegen gibt es jetzt 'Anlegen & fertig' - das schliesst in einem Tap bis zur Produktliste zurueck (statt Sheet schliessen + nochmal zurueck). 'Anlegen' bleibt fuer mehrere Stuecke nacheinander. 2) Beim Zurueck aus der Produkt-Detailansicht wird bei ungespeicherten Stammdaten-Aenderungen nachgefragt (Speichern/Verwerfen); ohne Aenderung direkt zurueck. Fussnote stellt klar: Foto und Lagerort-Bedarf speichern sofort.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:42:43 +02:00
Scarriffle
c52e88f856 Web: Thumbnails cachen (kein Neuladen) + feste Zeilenhoehe
Artikelbilder werden modulweit gecacht (Produkt-ID+Version -> Objekt-URL, auch 'kein Bild'); beim erneuten Oeffnen einer Liste werden sie nicht mehr jedes Mal neu geholt. Der Platzhalter (.thumb-fallback) hat jetzt dieselbe feste Groesse (34x34) wie das Bild, damit die Zeilenhoehe mit/ohne Bild gleich ist und beim Nachladen nicht mehr springt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:32:59 +02:00
Scarriffle
189b1f8e6b Web: zentrale Mindestbestand-Liste (Produkte + Gruppen, Gesamt + je Lagerort)
Neue Seite 'Mindestbestaende' (Sidebar): filterbare Tabelle mit je einer Zeile pro Mindestbestand - Gesamt je Produkt/Gruppe und je Lagerort - inline editierbar. Produkt-Gesamtwert wird nicht-destruktiv umgerechnet (Faktor aus dem bestehenden Wert abgeleitet, sonst Artikeleinheit); Lagerort-Bedarfe und Gruppenwerte ueber die vorhandenen Endpunkte. Nur-Lesen fuer Nicht-Admins.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 19:00:33 +02:00
Scarriffle
912f3dac6f Web: Lebensmittel-Icon (Apfel) + mehr Abstand vor 'Typ' im Produktformular
Eigenes food-Icon fuer den Lebensmittel-Sidebar-Eintrag (statt des Sechsecks). Der Block 'Mindestbestand je Lagerort' bekommt Abstand nach unten, damit er nicht am Typ-Umschalter klebt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:42:12 +02:00
Scarriffle
01a3528227 Web: Produkte in Lebensmittel/Gegenstaende getrennt; Spalten-Labels; Produktformular aufgeraeumt
1) Zwei getrennte Sidebar-Eintraege 'Lebensmittel' und 'Gegenstaende' (Products mit fixedType) statt einem 'Produkte' mit Umschalter - so vermischt sich nichts. 2) Spalten-Waehler zeigt sprechende Labels ('Bild' statt 'thumb', 'Aktionen', ...). 3) Produktformular: leerer Grow-Platzhalter unter 'Typ' entfernt und 'Neue Kategorie' direkt neben das Kategorie-Dropdown gruppiert; Formular wieder auf Lesebreite (kein content--wide), da das Layout-Problem intern war.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:26:41 +02:00
Scarriffle
4aa6d9597b iOS: Einzelstueck-Daten einheitlich als dd.MM.yyyy
Die nativen compact-DatePicker zeigten das Jahr mal zwei-, mal vierstellig (Gekauft am vs. Garantie bis). Neue DateRow: fester Formatierer dd.MM.yyyy, Antippen klappt einen grafischen Kalender inline auf - konsistent und ohne iOS-16.4-Abhaengigkeit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 18:26:41 +02:00
115 changed files with 9362 additions and 1188 deletions

103
README.md
View File

@@ -1,44 +1,78 @@
# Vorrania Selfhostbare Lebensmittel-Lagerverwaltung
# Vorrania Selfhostbare Vorrats- und Gegenstandsverwaltung
Ein selbst gehosteter Dienst zur Verwaltung deines Lebensmittelvorrats:
Ein-/Auslagern per Barcode, MHD-/Ablaufverwaltung mit Chargen, Mindestbestände,
automatische Einkaufsliste mit **Web-UI** und (in Vorbereitung) **nativer iOS-App**.
Ein selbst gehosteter Dienst, um sowohl deinen **Lebensmittelvorrat** als auch
allgemeine **Gegenstände** (Haushalt, Elektronik, Kleidung, Verbrauchsmaterial …)
zu verwalten: Ein-/Auslagern per Barcode, Chargen mit MHD, Einzelstücke mit
eigener UID/QR, Mindestbestände, automatische Einkaufsliste, verschachtelte
Lagerorte, Gruppen, Kategorien und Dashboards mit **Web-UI** und **nativer
iOS-App**, die beide dieselbe REST-API nutzen.
> Dies ist **Schritt 1** (Fundament). Der komplette Fahrplan steht in
> [docs/ROADMAP.md](docs/ROADMAP.md).
## Was Vorrania verwaltet
## Features (Schritt 1)
- **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) mit
lokalem Fallback (unbekannte Produkte selbst anlegen).
- **Chargen mit MHD:** Jedes Einlagern erzeugt eine Charge mit eigenem
Mindesthaltbarkeitsdatum. Beim Auslagern wird automatisch die zuerst ablaufende
Charge zuerst entnommen (**FEFO** First Expired, First Out).
- **Einheiten:** Basiseinheit Stück / Gramm / Milliliter plus optionale
Packungsgröße → Ein-/Auslagern in Packungen **oder** Teilmengen (z. B. 200 g).
- **Mindestbestände** pro Produkt → automatische **Einkaufsliste**.
- **Ablaufwarnung** für bald ablaufende Chargen (Frist konfigurierbar).
- **Lagerorte** (flach; Unterlagerorte folgen in Schritt 3).
- **Mehrbenutzer mit Rollen:**
- **Admin** verwaltet Produkte, Lagerorte, Benutzer, Mindestbestände.
- **Nutzer** nur ein-/auslagern und ansehen.
- Bewegungen werden protokolliert (wer hat wann was ein-/ausgelagert).
**Lebensmittel** laufen als **Chargen** mit Mindesthaltbarkeitsdatum. Beim
Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
**Gegenstände** kennen drei Arten:
- **Menge je Lagerort** reines Zählen (z. B. Unterhosen, Batterien).
- **Einzelstücke** jedes Exemplar mit eigener **UID/QR**, Kaufdatum, Garantie,
Bezugsquelle und Beleg (z. B. Powerbank, Kamera).
- **Verbrauchsgegenstand** wie ein Lebensmittel als Charge mit Menge und
Einheit geführt (z. B. Sonnencreme in ml), nur ohne eindeutigen Code.
## Features
- **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) und
Open Products Facts mit lokalem Fallback; unbekannte Produkte selbst anlegen,
Bild wird automatisch geholt.
- **Chargen mit MHD & FEFO**, Mengen in Packungen **oder** Teilmengen
(Basiseinheit Stück / Gramm / Milliliter plus Packungsgröße/Gebinde).
- **Lagerorte** beliebig verschachtelt (Keller → Regal 2 → Fach A), mit
**QR-Etiketten**, **Inhaltsansicht** („was liegt hier?“), Umlagern und
Entfernen mit Grund. Chargen und Einzelstücke sind einem Lagerort zugeordnet.
- **Kategorien** verschachtelt und pro Typ (Lebensmittel/Gegenstand); für
Gegenstände mit **eigenen Feldern** (z. B. Kapazität, Größe, Farbe).
- **Gruppen** rechnen Bestände mehrerer Marken/Artikel zusammen (z. B. „5 kg
Mehl“) mit eigenem Mindestbestand und Einheit.
- **Mindestbestände** je Produkt und Gruppe gesamt **und je Lagerort** mit
zentraler Übersichtsseite, daraus die automatische **Einkaufsliste** und
**Ablaufwarnung** für bald ablaufende Chargen.
- **Übergreifende Listen** für Produkte (getrennt Lebensmittel/Gegenstände),
Einzelstücke und Chargen filter-, sortier- und spaltenkonfigurierbar; bei
Chargen lässt sich der Lagerort mehrerer auf einmal setzen.
- **Dashboards** mit konfigurierbaren Karten und Diagrammen, **Verlauf** (wer hat
wann was ein-/ausgelagert), **Stammdaten** (Einheiten, Gebinde, Shops) und
**Import/Export**.
- **Mehrbenutzer mit Rollen:** *Admin* verwaltet Stammdaten und Benutzer,
*Nutzer* lagert nur ein/aus und sieht Bestände; alle Bewegungen werden
protokolliert.
### In der iOS-App zusätzlich
- **Scannen** von Barcode, **MHD/Datum per Texterkennung** (auch nur Tag+Monat
ohne Jahr bei Frischware), **Lagerort-QR** und **Name/Marke per Text-Scan**.
- **Foto direkt beim Anlegen** (Kamera/Galerie), **Push-Benachrichtigungen** bei
ablaufenden Produkten, lokale Dashboards und Home-Screen-Schnellaktionen.
Details zur App: [ios/README.md](ios/README.md).
## Architektur
| Teil | Technik |
|---------|--------------------------------------|
| Backend | Python · FastAPI · SQLAlchemy |
| DB | PostgreSQL |
| Web-UI | React · Vite (via nginx ausgeliefert)|
| iOS | SwiftUI (Schritt 2) |
| Deploy | Docker Compose · `install.sh` |
| Teil | Technik |
|---------|----------------------------------------|
| Backend | Python · FastAPI · SQLAlchemy |
| DB | PostgreSQL (SQLite für Tests/lokal) |
| Web-UI | React · Vite (via nginx ausgeliefert) |
| iOS | Native SwiftUI-App (VisionKit-Scanner) |
| Deploy | Docker Compose · `install.sh` |
```
backend/ FastAPI-App + Tests
web/ React/Vite SPA
deploy/ docker-compose.yml, install.sh, .env.example
ios/ Native SwiftUI-App (XcodeGen-Projekt)
deploy/ docker-compose.yml, install.sh, update.sh, .env.example
docs/ Roadmap
```
Das Schema wird beim Start per idempotenter `ALTER TABLE`-Migration in
`backend/app/main.py` nachgezogen (kein Alembic).
## Installation (selfhosted, Linux)
Voraussetzung: eine Linux-Maschine (Server, NAS, Raspberry Pi …). Docker wird bei
Bedarf automatisch installiert.
@@ -59,6 +93,8 @@ Nach dem Start:
- Web-UI: `http://<server-ip>:8080` (Port konfigurierbar)
- Anmeldung mit dem im Installer angezeigten Admin-Benutzer/Passwort.
Die iOS-App wird mit derselben Adresse verbunden (`http://<server-ip>:8080`).
## Aktualisieren
```bash
cd vorrania/deploy
@@ -99,13 +135,14 @@ npm install
npm run dev # http://localhost:5173, proxyt /api → localhost:8000
```
**iOS:** siehe [ios/README.md](ios/README.md) (XcodeGen-Projekt, `xcodegen generate`).
**Tests:**
```bash
cd backend
pip install -r requirements.txt
pytest # prüft FEFO-Abbuchung und Einheiten-Umrechnung
DATABASE_URL="sqlite://" pytest
```
## Nächste Schritte
Native iOS-App (Schritt 2) und fortgeschrittene Features (Gruppen-Intelligenz,
Unterlagerorte, Push-Benachrichtigungen …) siehe [docs/ROADMAP.md](docs/ROADMAP.md).
## Weiteres
Fahrplan und Ideen: [docs/ROADMAP.md](docs/ROADMAP.md).

View File

@@ -2,15 +2,65 @@
from __future__ import annotations
from collections import defaultdict
from datetime import date
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from .models import Barcode, Category, CategoryTracking, Lot, Product, ProductImage
from .models import (
Barcode,
BaseUnit,
Category,
CategoryTracking,
Item,
Lot,
Product,
ProductImage,
)
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock
from .services.conversion import (
BASE_OF_KIND,
KIND_OF_BASE,
display_unit_info,
zweit_faktor,
)
from .services.min_stock import UEBERALL_NAME, lies_ueberall
from .services.stock import current_stock, location_subtree_stock_base
def _min_anzeige(
product: Product, min_base: float, disp_name: str, disp_factor: float
) -> tuple[float, str]:
"""Mindestbestand (Basiseinheiten) in der ERFASSTEN Einheit + deren Name.
Die Erfassungseinheit darf seit der Zweiteinheit artfremd sein („500 g" an
einem stueckweise gefuehrten Artikel). ``zweit_faktor`` ist 1,0, solange sie
zur eigenen Art gehoert dieselbe Formel deckt also beide Faelle ab.
"""
if product.min_stock_in_packages and product.package_size:
return min_base / product.package_size, "Pkg"
einheit = product.min_stock_unit
if einheit is not None:
bruecke = zweit_faktor(product, BASE_OF_KIND[einheit.kind])
if bruecke is not None:
return min_base * bruecke / einheit.factor, einheit.name
# Bruecke nachtraeglich entfernt: lieber die Artikeleinheit zeigen als
# eine Zahl in einer Einheit, die es fuer diesen Artikel nicht mehr gibt.
return min_base / disp_factor, disp_name
def _zweit_felder(out, product: Product) -> None:
"""Abgeleitete Zweiteinheit-Felder setzen, damit die Oberflaechen nicht
selbst dividieren muessen."""
if not product.secondary_base:
return
try:
ziel = BaseUnit(product.secondary_base)
except ValueError:
return # unbekannter Altwert dann eben keine Bruecke
out.secondary_factor = zweit_faktor(product, ziel)
def product_tracking(db: Session, product: Product) -> str:
@@ -19,7 +69,14 @@ def product_tracking(db: Session, product: Product) -> str:
Ohne Kategorie gilt "food" so bleibt das Verhalten bestehender
(reiner Lebensmittel-)Installationen unverändert. Ein Artikel in einer
Gegenstands-Kategorie wird als "object" geführt.
Einzelstücke und Verbrauchsgegenstände sind immer Gegenstände: Diese Arten
gibt es nur bei Gegenständen, sie werden aber (anders als "Menge je Lagerort")
über ein Flag am Artikel geführt. So bleibt ein als Verbrauchsgegenstand
umgestellter Artikel auch ohne (Gegenstands-)Kategorie ein Gegenstand.
"""
if product.individual or product.bulk:
return CategoryTracking.object.value
if product.category_id is None:
return CategoryTracking.food.value
cat = product.category or db.get(Category, product.category_id)
@@ -44,6 +101,7 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
]
out.category_name = product.category.name if product.category else None
out.group_name = product.group.name if product.group else None
# Bild-Version = Epoch der letzten Bildänderung (identisch zum ETag der
# Bild-Route), damit der Client nur geänderte Bilder neu lädt. None = kein Bild.
bild_ts = (
@@ -59,29 +117,124 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
out.unit_name = name
out.unit_factor = factor
# Mindestbestand in der Einheit anzeigen, in der er erfasst wurde.
if product.min_stock is not None:
if product.min_stock_in_packages and product.package_size:
out.min_stock_display = product.min_stock / product.package_size
out.min_stock_unit_label = "Pkg"
elif product.min_stock_unit is not None:
out.min_stock_display = product.min_stock / product.min_stock_unit.factor
out.min_stock_unit_label = product.min_stock_unit.name
else:
out.min_stock_display = product.min_stock / factor
out.min_stock_unit_label = name
# „Ueberall" (Ort NULL) ist der frueher separate Gesamt-Mindestbestand.
# ``out.min_stock`` bleibt im Vertrag Dashboards, Einkaufsliste und App
# lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle.
out.min_stock = lies_ueberall(product.location_min_stocks)
if out.min_stock is not None:
out.min_stock_display, out.min_stock_unit_label = _min_anzeige(
product, out.min_stock, name, factor
)
_zweit_felder(out, product)
out.location_min_stocks = [
LocationMinStockOut(
location_id=e.location_id,
location_name=e.location.name if e.location else None,
location_name=UEBERALL_NAME if e.location_id is None else (
e.location.name if e.location else None
),
min_stock=e.min_stock,
stock=(
out.stock
if e.location_id is None
else location_subtree_stock_base(db, product, e.location_id)
),
)
# „Ueberall" zuerst, danach nach Anlagereihenfolge.
for e in sorted(
product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)
)
for e in sorted(product.location_min_stocks, key=lambda x: x.id)
]
return out
def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOut]:
"""Wie ``product_to_out``, aber für viele Artikel mit **gebündelten** Abfragen
statt N+1 für die Listen-Ansichten. Bestand, abgelaufene Chargen, Codes und
Bild-Version werden je einmal für alle Artikel geholt; Relationen (Kategorie,
Shop, Einheiten, Bedarfe, Feldwerte) sollten vom Aufrufer eager-geladen sein.
"""
if not products:
return []
ids = [p.id for p in products]
# Bestand: Lot-Summen (Nicht-Einzelstücke) bzw. Item-Anzahl (Einzelstücke).
lot_sums = dict(
db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
.filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all()
)
item_counts = dict(
db.query(Item.product_id, func.count(Item.id))
.filter(Item.product_id.in_(ids)).group_by(Item.product_id).all()
)
# Abgelaufene Chargen je Artikel.
heute = date.today()
expired = dict(
db.query(Lot.product_id, func.count(Lot.id))
.filter(
Lot.product_id.in_(ids),
Lot.best_before.isnot(None),
Lot.best_before < heute,
Lot.quantity > 0,
)
.group_by(Lot.product_id).all()
)
# Zusätzliche EAN-Codes je Artikel.
codes: dict[int, list[Barcode]] = defaultdict(list)
for b in db.query(Barcode).filter(Barcode.product_id.in_(ids)).order_by(Barcode.id).all():
codes[b.product_id].append(b)
# Bild-Version (Epoch der letzten Bildänderung) je Artikel.
imgs = dict(
db.query(ProductImage.product_id, ProductImage.updated_at)
.filter(ProductImage.product_id.in_(ids)).all()
)
out: list[ProductOut] = []
for product in products:
o = ProductOut.model_validate(product)
o.stock = (
float(item_counts.get(product.id, 0)) if product.individual
else float(lot_sums.get(product.id, 0.0))
)
o.expired_count = expired.get(product.id, 0)
o.barcodes = [BarcodeOut.model_validate(b) for b in codes.get(product.id, [])]
o.category_name = product.category.name if product.category else None
o.group_name = product.group.name if product.group else None
ts = imgs.get(product.id)
o.image_version = int(ts.timestamp()) if ts is not None else None
o.tracking = CategoryTracking(product_tracking(db, product))
o.shop_name = product.shop.name if product.shop else None
o.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product)
o.unit_name = name
o.unit_factor = factor
o.min_stock = lies_ueberall(product.location_min_stocks)
if o.min_stock is not None:
o.min_stock_display, o.min_stock_unit_label = _min_anzeige(
product, o.min_stock, name, factor
)
_zweit_felder(o, product)
o.location_min_stocks = [
LocationMinStockOut(
location_id=e.location_id,
location_name=UEBERALL_NAME if e.location_id is None else (
e.location.name if e.location else None
),
min_stock=e.min_stock,
stock=(
o.stock
if e.location_id is None
else location_subtree_stock_base(db, product, e.location_id)
),
)
for e in sorted(
product.location_min_stocks, key=lambda x: (x.location_id is not None, x.id)
)
]
out.append(o)
return out
def resolve_product(
db: Session, product_id: int | None, barcode: str | None
) -> Product:

View File

@@ -35,6 +35,8 @@ from .seed import (
ensure_first_admin,
)
from .services.group_codes import backfill as backfill_group_codes
from .services.min_stock import migriere_mindestbestaende
from .services.stock import consolidate_duplicate_lots
settings = get_settings()
@@ -143,6 +145,10 @@ def _ensure_schema() -> None:
"REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_size DOUBLE PRECISION",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS package_label VARCHAR(32)",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
"NOT NULL DEFAULT FALSE",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
@@ -176,9 +182,33 @@ def _ensure_schema() -> None:
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
"NOT NULL DEFAULT FALSE",
# Verbrauchsgegenstand: Gegenstand wie ein Lebensmittel führen (Chargen).
"ALTER TABLE products ADD COLUMN IF NOT EXISTS bulk BOOLEAN "
"NOT NULL DEFAULT FALSE",
# Zeitpunkt der letzten Änderung an den Stammdaten (für „Zuletzt geändert").
# Nullable anlegen und Altbestände auf created_at zurücksetzen; neue Zeilen
# füllt die ORM-Spalte (default/onupdate).
"ALTER TABLE products ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ",
"UPDATE products SET updated_at = created_at WHERE updated_at IS NULL",
# Kaufpreis (in Rappen/Cent) und Währung am Einzelstück.
"ALTER TABLE items ADD COLUMN IF NOT EXISTS price_cents INTEGER",
"ALTER TABLE items ADD COLUMN IF NOT EXISTS currency VARCHAR(3)",
# Mindestbestände hängen nur noch an Lagerorten; location_id NULL ist
# „Überall". DROP NOT NULL ist auf einer bereits nullable Spalte ein
# No-Op, der Aufruf also wiederholbar.
"ALTER TABLE product_location_min_stock ALTER COLUMN location_id DROP NOT NULL",
"ALTER TABLE group_location_min_stock ALTER COLUMN location_id DROP NOT NULL",
# Postgres zählt NULLs in UNIQUE als verschieden die vorhandene
# Beschränkung verhindert also keine zwei „Überall"-Zeilen. Teilindex.
"CREATE UNIQUE INDEX IF NOT EXISTS uq_prod_ueberall_min "
"ON product_location_min_stock (product_id) WHERE location_id IS NULL",
"CREATE UNIQUE INDEX IF NOT EXISTS uq_group_ueberall_min "
"ON group_location_min_stock (group_id) WHERE location_id IS NULL",
# Zweiteinheit am Artikel („3 Stück ≙ 250 g") Brücke zwischen den
# Einheiten-Arten. Leer = wie bisher, strikt getrennt.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_base VARCHAR(16)",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_count DOUBLE PRECISION",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS secondary_amount DOUBLE PRECISION",
]
with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
@@ -213,6 +243,12 @@ async def lifespan(app: FastAPI):
ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db)
# Gesamt-Mindestbestände zu „Überall"-Zeilen machen und alle Werte auf
# Basiseinheiten umstellen (einmalig, mit Merker in den Einstellungen).
migriere_mindestbestaende(db)
# Bereits vorhandene Dubletten (gleicher Artikel + MHD + Lagerort)
# einmalig zusammenfassen ab jetzt geschieht das beim Umlagern selbst.
consolidate_duplicate_lots(db)
finally:
db.close()
yield

View File

@@ -6,16 +6,20 @@ from datetime import date, datetime, timezone
from sqlalchemy import (
Boolean,
Column,
Date,
DateTime,
Enum,
Float,
ForeignKey,
Index,
Integer,
LargeBinary,
String,
Table,
Text,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -119,6 +123,27 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# Ober-/Untergruppen: eine Gruppe darf zu MEHREREN Obergruppen gehoeren, nicht
# nur zu einer. „Grillwurst" haengt unter „Wurst" UND unter „Grillgut" mit
# einem Baum (ein Elternteil je Gruppe) waere genau das nicht abbildbar. Der
# Graph darf deshalb Rauten haben, aber keine Ringe (Pruefung in routers/groups).
group_parents = Table(
"group_parents",
Base.metadata,
Column(
"child_id", ForeignKey("groups.id", ondelete="CASCADE"), primary_key=True
),
# Eigener Index: der zusammengesetzte Primaerschluessel deckt nur die Suche
# nach child_id ab, gefragt wird aber genauso oft „wer haengt unter X?".
Column(
"parent_id",
ForeignKey("groups.id", ondelete="CASCADE"),
primary_key=True,
index=True,
),
)
class Group(Base):
__tablename__ = "groups"
@@ -129,6 +154,17 @@ class Group(Base):
min_stock_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
)
# Gruppen-Gebinde als Richtwert: wie viele Basiseinheiten „1 Packung/Glas"
# der Gruppe zählt. Nötig, weil die Produkte einer Gruppe unterschiedlich
# große Packungen haben können (Pesto 99 g vs. 160 g) die Gruppe legt einen
# gemeinsamen Richtwert fest. NULL = keiner definiert.
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
# true = ``min_stock`` (und die je-Ort-Werte) sind in diesem Gebinde erfasst,
# sonst in ``min_stock_unit`` (bzw. Basiseinheit).
min_stock_in_packages: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False
)
products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship()
@@ -136,6 +172,28 @@ class Group(Base):
cascade="all, delete-orphan"
)
# Obergruppen dieser Gruppe (mehrere moeglich) bzw. die Gruppen, die
# umgekehrt unter dieser haengen. Bestand und Mindestbestand einer Gruppe
# zaehlen immer den ganzen Untergraphen mit siehe services/groups.py.
# ``lazy="selectin"``: /groups laedt alle Gruppen auf einmal so faellt je
# Ebene EINE Nachladeabfrage an statt einer je Gruppe.
parents: Mapped[list["Group"]] = relationship(
"Group",
secondary=group_parents,
primaryjoin=lambda: Group.id == group_parents.c.child_id,
secondaryjoin=lambda: Group.id == group_parents.c.parent_id,
back_populates="children",
lazy="selectin",
)
children: Mapped[list["Group"]] = relationship(
"Group",
secondary=group_parents,
primaryjoin=lambda: Group.id == group_parents.c.parent_id,
secondaryjoin=lambda: Group.id == group_parents.c.child_id,
back_populates="parents",
lazy="selectin",
)
# Lagerorte tragen einen zufälligen 10-Zeichen-Code als ID statt einer
# fortlaufenden Zahl. So kollidieren Sicherung/Import zwischen zwei Instanzen
@@ -214,6 +272,18 @@ class Product(Base):
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True)
# Zweiteinheit: Bruecke zwischen den Einheiten-ARTEN fuer DIESEN Artikel.
# „3 Stueck ≙ 250 g" gespeichert als das eingegebene PAAR, damit beim
# naechsten Oeffnen genau das wieder dasteht und nicht „83,333 g je Stueck".
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
# ``secondary_amount`` in ``secondary_base``. NULL = keine Bruecke, dann
# bleibt es bei der strikten Trennung der Arten (services/conversion.py).
#
# String statt Enum wie bei ``Category.tracking``: ein natives Postgres-Enum
# in der handgeschriebenen Migration nachzuziehen waere unnoetig heikel.
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)
# 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(
@@ -251,8 +321,18 @@ class Product(Base):
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Gegenstände: als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen,
# Einheit, Packungsgröße, MHD optional, Mindestbestand), aber ohne eindeutigen
# Code z.B. Sonnencreme in ml. Schließt sich mit ``individual`` aus. Für die
# Kernlogik gilt: foodLike = kein Gegenstand ODER bulk.
bulk: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
# Zeitpunkt der letzten Änderung an den Stammdaten des Artikels (nicht am
# Bestand). Wird bei jedem ORM-UPDATE der Zeile automatisch nachgezogen.
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=_now, onupdate=_now
)
group: Mapped[Group | None] = relationship(back_populates="products")
category: Mapped[Category | None] = relationship()
@@ -586,10 +666,18 @@ class ItemDocument(Base):
class ProductLocationMinStock(Base):
"""Mindestbestand eines Produkts an EINEM Lagerort (in Artikeleinheiten).
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
Zusätzlich zum globalen ``Product.min_stock``: so laesst sich derselbe Artikel
an mehreren Orten getrennt fuehren (z.B. 5 zuhause, 3 im Ferienhaus).
Der einzige Ort, an dem Artikel-Mindestbestaende stehen. ``location_id``
NULL heisst „Ueberall" egal wo, Hauptsache die Menge ist im Haus; das war
frueher das separate Feld ``Product.min_stock``. Damit ist „Ueberall" die
Wurzel ueber allen Lagerorten und wird von derselben Verrechnung erfasst wie
verschachtelte Orte (siehe routers/views.py).
Mengen stehen in BASISEINHEITEN (g/ml/Stueck), nicht in Artikeleinheiten:
sonst bedeutet ein gespeicherter Wert etwas anderes, sobald sich die
Packungsgroesse aendert. In welcher Einheit er angezeigt und eingetippt
wird, sagen ``Product.min_stock_unit_id`` und ``min_stock_in_packages``.
"""
__tablename__ = "product_location_min_stock"
@@ -598,19 +686,35 @@ class ProductLocationMinStock(Base):
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
location_id: Mapped[str] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
)
# In Artikeleinheiten (Packungen/Stueck), wie in der Produktliste gezaehlt.
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
location: Mapped[Location] = relationship()
location: Mapped[Location | None] = relationship()
__table_args__ = (UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),)
__table_args__ = (
UniqueConstraint("product_id", "location_id", name="uq_prod_loc_min"),
# Postgres zaehlt NULLs in UNIQUE als verschieden der Constraint oben
# verhindert also KEINE zwei „Ueberall"-Zeilen. Dafuer ein Teilindex.
Index(
"uq_prod_ueberall_min",
"product_id",
unique=True,
postgresql_where=text("location_id IS NULL"),
sqlite_where=text("location_id IS NULL"),
),
)
class GroupLocationMinStock(Base):
"""Mindestbestand einer Gruppe an EINEM Lagerort (in der Gruppen-Einheit)."""
"""Mindestbestand einer Gruppe an EINEM Lagerort (in Basiseinheiten).
``location_id`` NULL = „Ueberall", genau wie bei
:class:`ProductLocationMinStock`. Auch hier Basiseinheiten nur so lassen
sich Ober- und Untergruppe gegeneinander verrechnen, wenn die eine in
Kilogramm und die andere in Glaesern erfasst ist.
"""
__tablename__ = "group_location_min_stock"
@@ -618,11 +722,20 @@ class GroupLocationMinStock(Base):
group_id: Mapped[int] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
location_id: Mapped[str] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=False
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="CASCADE"), nullable=True
)
min_stock: Mapped[float] = mapped_column(Float, nullable=False)
location: Mapped[Location] = relationship()
location: Mapped[Location | None] = relationship()
__table_args__ = (UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),)
__table_args__ = (
UniqueConstraint("group_id", "location_id", name="uq_group_loc_min"),
Index(
"uq_group_ueberall_min",
"group_id",
unique=True,
postgresql_where=text("location_id IS NULL"),
sqlite_where=text("location_id IS NULL"),
),
)

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import json
import re
from typing import NamedTuple
import httpx
@@ -24,22 +25,67 @@ _UNIT_TO_BASE: dict[str, tuple[str, float]] = {
_QUANTITY_RE = re.compile(r"([\d]+(?:[.,]\d+)?)\s*(kg|mg|g|dl|cl|ml|l)\b", re.IGNORECASE)
#: „3 x 80 g", „6 × 1,5 l", „4 Stück à 125 g" der Multiplikator steht vorn,
#: dazwischen darf ein Zählwort stehen. Bewusst eng gefasst: ein zu weites
#: Muster macht aus „12 Eier à 53 g" schnell Unsinn, und eine nicht erkannte
#: Stückzahl ist besser als eine falsch geratene.
_MULTI_RE = re.compile(
r"(?P<anzahl>\d+)\s*(?:st(?:ü|ue)ck|stk\.?|pcs?|st\.?)?\s*"
r"(?:[x×*]|à|a)\s*"
r"(?P<menge>\d+(?:[.,]\d+)?)\s*(?P<einheit>kg|mg|g|dl|cl|ml|l)\b",
re.IGNORECASE,
)
class OffMenge(NamedTuple):
"""Was aus dem OFF-Feld ``quantity`` herauszulesen ist."""
base_unit: str # "gram" | "milliliter" | "piece"
package_size: float | None # Gesamtfuellmenge des Gebindes, in Basiseinheiten
stueck: float | None # zaehlbare Teile im Gebinde (nur bei „3 x 80 g")
einzelmenge: float | None # Fuellmenge EINES Teils (die 80 g)
def parse_gebinde(quantity: str | None) -> OffMenge:
"""Gesamtmenge UND einen etwaigen Multiplikator aus ``quantity`` lesen.
„3 x 80 g" ist nicht dasselbe wie „240 g": die Packung enthaelt drei
zaehlbare Riegel. Dieser Multiplikator ging bisher verloren, weil das alte
Muster den ERSTEN Zahl-Einheit-Treffer nahm also die 80.
"""
if not quantity:
return OffMenge("piece", None, None, None)
multi = _MULTI_RE.search(quantity)
if multi is not None:
anzahl = float(multi.group("anzahl"))
menge = float(multi.group("menge").replace(",", "."))
base_unit, factor = _UNIT_TO_BASE[multi.group("einheit").lower()]
einzel = round(menge * factor, 3)
gesamt = round(anzahl * einzel, 3)
if anzahl > 0 and gesamt > 0:
return OffMenge(base_unit, gesamt, anzahl, einzel)
return OffMenge("piece", None, None, None)
einfach = _QUANTITY_RE.search(quantity)
if einfach is None:
return OffMenge("piece", None, None, None)
amount = float(einfach.group(1).replace(",", "."))
base_unit, factor = _UNIT_TO_BASE[einfach.group(2).lower()]
gesamt = round(amount * factor, 3)
return OffMenge(base_unit, gesamt if gesamt > 0 else None, None, None)
def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None).
Der schmale Vertrag von frueher; die volle Auskunft (mit Multiplikator)
liefert ``parse_gebinde``.
"""
if not quantity:
return "piece", None
match = _QUANTITY_RE.search(quantity)
if not match:
return "piece", None
amount = float(match.group(1).replace(",", "."))
base_unit, factor = _UNIT_TO_BASE[match.group(2).lower()]
package_size = round(amount * factor, 3)
return base_unit, (package_size if package_size > 0 else None)
menge = parse_gebinde(quantity)
return menge.base_unit, menge.package_size
def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
@@ -85,16 +131,24 @@ def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
categories = product.get("categories") or ""
category_tags = product.get("categories_tags") or []
base_unit, package_size = parse_quantity(product.get("quantity"))
menge = parse_gebinde(product.get("quantity"))
return {
"barcode": barcode,
"name": name,
"brand": (product.get("brands") or "").strip() or None,
"image_url": product.get("image_front_url") or product.get("image_url") or None,
"base_unit": base_unit,
"package_size": package_size,
"base_unit": menge.base_unit,
"package_size": menge.package_size,
"quantity_text": product.get("quantity"),
# „3 x 80 g": die Packung enthaelt drei zaehlbare Teile. Als VORSCHLAG
# fuer die Zweiteinheit mitgegeben („240 g ≙ 3 Stueck"), nicht gesetzt
# bestaetigen soll es der Nutzer, OFF-Angaben sind nicht immer sauber.
"secondary_base": "piece" if menge.stueck else None,
"secondary_count": menge.package_size if menge.stueck else None,
"secondary_amount": menge.stueck,
# Nur fuer den Hinweistext „3 × 80 g".
"unit_amount": menge.einzelmenge,
"category_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags,
"source": source,

View File

@@ -47,6 +47,7 @@ from ..schemas import (
from ..services.conversion import article_unit
from ..services.stock import current_stock
from .settings import get_expiry_warning_days
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -348,19 +349,10 @@ def stats(
elif zustand == "soon":
bald += 1
# Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand.
bedarf = 0
for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0):
if current_stock(db, product.id) < product.min_stock:
bedarf += 1
for group in db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0):
unit = group.min_stock_unit
produkte = list(group.products)
summe = float(sum(current_stock(db, p.id) for p in produkte))
if unit is not None:
summe /= unit.factor or 1.0
if summe < group.min_stock:
bedarf += 1
# 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))
return DashboardStats(
products_in_stock=len(bestand_produkte),
@@ -386,17 +378,45 @@ def expiry_split(
@router.get("/by-category", response_model=list[CategoryShare])
def by_category(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
depth: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[CategoryShare]:
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt."""
"""Artikeleinheiten je Kategorie, zusätzlich nach Ablaufzustand aufgeteilt.
``depth`` fasst tiefe Unterkategorien zu ihrer Ober­kategorie auf der
gewünschten Stufe zusammen (1 = oberste Ebene). Ohne ``depth`` zählt jede
Kategorie so, wie sie dem Artikel zugeordnet ist (feinste Auflösung).
"""
heute = date.today()
warnfrist = get_expiry_warning_days(db)
namen = {c.id: c.name for c in db.query(Category).all()}
kategorien = db.query(Category).all()
namen = {c.id: c.name for c in kategorien}
eltern = {c.id: c.parent_id for c in kategorien}
# Kategorie auf die gewünschte Tiefe hochrollen (Vorfahr auf Stufe ``depth``).
rollup_cache: dict[int | None, int | None] = {}
def rollup(cid: int | None) -> int | None:
if depth is None or cid is None:
return cid
if cid in rollup_cache:
return rollup_cache[cid]
pfad: list[int] = []
cur, gesehen = cid, set()
while cur is not None and cur not in gesehen:
pfad.append(cur)
gesehen.add(cur)
cur = eltern.get(cur)
pfad.reverse() # Wurzel zuerst
ziel = pfad[min(max(depth, 1) - 1, len(pfad) - 1)]
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}
eimer: dict[int | None, dict] = defaultdict(leer)
for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
topf = eimer[product.category_id]
topf = eimer[rollup(product.category_id)]
topf["article_units"] += menge
topf[zustand] += menge

View File

@@ -15,16 +15,53 @@ from ..schemas import (
LocationMinStockOut,
ProductBarcodeOut,
)
from ..services.conversion import BASE_OF_KIND
from ..services.stock import current_stock
from ..services import gruppen as gruppen_graph
from ..services.conversion import group_min_context
from ..services.min_stock import UEBERALL_NAME, lies_ueberall, schreibe_ueberall
from ..services.stock import (
summe_bestand_gewichtet,
summe_bestand_im_subtree_gewichtet,
)
router = APIRouter(prefix="/groups", tags=["groups"])
def _obergruppen_setzen(db: Session, group: Group, parent_ids: list[int]) -> None:
"""Obergruppen einer Gruppe ersetzen mit Ringschutz.
Ohne die Pruefung entstuende ein Ring, und jede Bestands- oder
Bedarfsrechnung liefe im Kreis. Gleiche Absicherung wie beim Umhaengen einer
Kategorie (routers/categories.py), nur ueber eine Nachfahren-MENGE, weil
Gruppen ein Graph und kein Baum sind.
"""
# Die Nachfahren aendern sich durch das Setzen von OBERgruppen nicht
# deshalb einmal vor der Schleife bestimmen.
verboten = gruppen_graph.nachfahren_ids(group) | {group.id}
gewuenscht: list[Group] = []
for pid in dict.fromkeys(parent_ids): # Reihenfolge halten, Dubletten raus
if pid in verboten:
raise HTTPException(
status.HTTP_409_CONFLICT,
"Eine Gruppe kann nicht sich selbst oder einer ihrer "
"Untergruppen untergeordnet werden.",
)
eltern = db.get(Group, pid)
if eltern is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Obergruppe nicht gefunden")
gewuenscht.append(eltern)
group.parents = gewuenscht
def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group)
# ``min_stock`` bleibt nach aussen in der Erfassungseinheit der Gruppe,
# gespeichert ist die „Ueberall"-Zeile aber in Basiseinheiten.
ueberall = lies_ueberall(group.location_min_stocks)
products = db.query(Product).filter(Product.group_id == group.id).all()
out.product_count = len(products)
out.parent_ids = [p.id for p in group.parents]
out.child_ids = sorted(c.id for c in group.children)
out.direct_product_count = len(products)
out.product_count = len(gruppen_graph.produkte(group))
# Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
artikel_zu_code: dict[str, Product] = {}
@@ -67,25 +104,43 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
)
out.product_barcodes = product_codes
# Bestand in derselben Einheit, in der auch der Mindestbestand erfasst ist
# (Gruppen-Gebinde ODER verwaltete Einheit) so ist beides vergleichbar.
ctx = group_min_context(group)
# Gewichtet: artfremde Artikel zaehlen ueber ihre Zweiteinheit mit.
stock_base = summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
out.stock = round(stock_base / ctx.divisor, 3)
out.min_stock = None if ueberall is None else round(ueberall / ctx.divisor, 3)
unit = group.min_stock_unit
if unit is not None:
# Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt.
base = BASE_OF_KIND[unit.kind]
matching = [p for p in products if p.base_unit == base]
stock_base = float(sum(current_stock(db, p.id) for p in matching))
out.stock = stock_base / unit.factor
out.min_stock_unit_name = unit.name
out.min_stock_unit_factor = unit.factor
out.kind = unit.kind.value
else:
out.stock = float(sum(current_stock(db, p.id) for p in products))
# Bestand je Lagerort (inkl. Unterorte) in derselben Einheit wie out.stock.
# Bestand je Lagerort in BASISEINHEITEN wie ``min_stock`` in denselben
# Zeilen. Nur ``out.stock`` oben rechnet in der Erfassungseinheit.
def _loc_stock(loc_id: str | None) -> float:
# „Ueberall" (Ort NULL) zaehlt den Gesamtbestand inkl. Chargen, die
# (noch) an keinem Lagerort liegen.
total = (
stock_base
if loc_id is None
else summe_bestand_im_subtree_gewichtet(db, ctx.matching, loc_id, ctx.faktoren)
)
return round(total, 3)
out.location_min_stocks = [
LocationMinStockOut(
location_id=e.location_id,
location_name=e.location.name if e.location else None,
location_name=UEBERALL_NAME if e.location_id is None else (
e.location.name if e.location else None
),
min_stock=e.min_stock,
stock=_loc_stock(e.location_id),
)
for e in sorted(group.location_min_stocks, key=lambda x: x.id)
# „Ueberall" zuerst, danach nach Anlagereihenfolge.
for e in sorted(group.location_min_stocks, key=lambda x: (x.location_id is not None, x.id))
]
return out
@@ -105,7 +160,11 @@ def set_group_location_min_stock(
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt)."""
"""Gruppen-Mindestbestände je Lagerort ersetzen (Menge 0 = Eintrag entfällt).
``location_id = null`` ist „Überall" und damit ein Ort wie jeder andere
er ersetzt den frueheren Gesamt-Mindestbestand. Mengen in Basiseinheiten.
"""
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
@@ -113,11 +172,12 @@ def set_group_location_min_stock(
db.query(GroupLocationMinStock).filter(
GroupLocationMinStock.group_id == group_id
).delete()
gesehen: set[int] = set()
# None (= Überall) ist ein eigener Schluessel in der Dublettenpruefung.
gesehen: set[str | None] = set()
for eintrag in payload:
if eintrag.min_stock <= 0 or eintrag.location_id in gesehen:
continue
if db.get(Location, eintrag.location_id) is None:
if eintrag.location_id is not None and db.get(Location, eintrag.location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
gesehen.add(eintrag.location_id)
db.add(GroupLocationMinStock(
@@ -140,10 +200,21 @@ def create_group(
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group(
name=payload.name,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id,
package_size=payload.package_size,
package_label=payload.package_label,
min_stock_in_packages=payload.min_stock_in_packages,
)
db.add(group)
# Erst flushen: die Ringpruefung in _obergruppen_setzen braucht group.id.
db.flush()
_obergruppen_setzen(db, group, payload.parent_ids)
# ``min_stock`` kommt in der Erfassungseinheit der Gruppe, gespeichert wird
# in Basiseinheiten deshalb ueber den Divisor.
if payload.min_stock is not None:
schreibe_ueberall(
db, group, payload.min_stock * (group_min_context(group).divisor or 1.0)
)
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@@ -160,6 +231,12 @@ def update_group(
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
data = payload.model_dump(exclude_unset=True)
# Muss RAUS, bevor unten stumpf jedes Feld per setattr gesetzt wird
# ``parent_ids`` ist kein Modellattribut, sondern eine Beziehung.
obergruppen = data.pop("parent_ids", None)
# Ebenfalls kein Modellfeld mehr: der Mindestbestand ist die „Ueberall"-Zeile.
min_gesetzt = "min_stock" in data
min_wert = data.pop("min_stock", None)
if "name" in data and data["name"]:
clash = (
db.query(Group)
@@ -168,8 +245,17 @@ def update_group(
)
if clash:
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
for field, value in data.items():
setattr(group, field, value)
if obergruppen is not None:
_obergruppen_setzen(db, group, obergruppen)
# Die Erfassungseinheit umzustellen (Gebinde <-> verwaltete Einheit) braucht
# keine Umrechnung mehr: gespeichert wird in Basiseinheiten, der physische
# Bedarf bleibt dadurch von selbst gleich.
if min_gesetzt:
divisor = group_min_context(group).divisor or 1.0
schreibe_ueberall(db, group, None if min_wert is None else min_wert * divisor)
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@@ -290,5 +376,13 @@ def delete_group(
group = db.get(Group, group_id)
if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
# Untergruppen bleiben bestehen und ruecken NICHT nach oben: im Graphen
# waere unklar, an welchen der moeglicherweise mehreren Grosseltern sie
# sollten ein automatisches Umhaengen wuerde stillschweigend neue
# Bestandssummen erzeugen. Sie verlieren nur die Verbindung. Kanten
# ausdruecklich loesen, weil SQLite Fremdschluessel nicht erzwingt.
group.parents.clear()
group.children.clear()
db.flush()
db.delete(group)
db.commit()

View File

@@ -24,6 +24,7 @@ from ..models import (
Barcode,
Category,
Group,
group_parents,
Location,
Lot,
Movement,
@@ -108,6 +109,10 @@ def reset_all(
_delete_products(db)
# Restliche EAN-Codes (die an Gruppen hängen) mitnehmen.
db.query(Barcode).delete(synchronize_session=False)
# Ober-/Untergruppen-Kanten ausdrücklich: ein Core-DELETE räumt die n:m-
# Zeilen nicht mit ab, und SQLite erzwingt keine Fremdschlüssel sonst
# blieben verwaiste Kanten stehen und hefteten sich an neu vergebene IDs.
db.execute(group_parents.delete())
db.query(Group).delete(synchronize_session=False)
db.query(Category).delete(synchronize_session=False)
db.query(Location).delete(synchronize_session=False)

View File

@@ -1,14 +1,18 @@
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session
import difflib
import re
from ..crud import product_to_out, product_tracking
from ..database import get_db
from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload, selectinload
from ..crud import product_to_out, product_tracking, products_to_out_bulk
from ..database import SessionLocal, get_db
from ..deps import get_current_user, require_admin
from ..models import (
Barcode,
BaseUnit,
Category,
CategoryTracking,
Group,
Location,
Movement,
@@ -25,6 +29,9 @@ from ..schemas import (
BarcodeCreate,
LocationMinStockIn,
LookupResult,
MatchCandidate,
MatchLine,
MatchRequest,
ProductCreate,
ProductOut,
ProductUpdate,
@@ -35,13 +42,88 @@ from ..schemas import (
from ..services import images
from ..services.categories import suggest_category
from .categories import descendant_ids
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.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 0100 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))
@router.post("/match", response_model=list[MatchLine])
def match_receipt(
payload: MatchRequest,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MatchLine]:
"""Kassenzettel-Zeilen den ähnlichsten LEBENSMITTELN zuordnen (Score 0100).
Gegenstände (auch Verbrauchsgegenstände) bleiben außen vor; je Zeile die besten
Treffer über dem Schwellwert (aus Request oder Einstellung)."""
schwelle = payload.threshold if payload.threshold is not None else get_receipt_match_threshold(db)
schwelle = max(0, min(100, schwelle))
lebensmittel = [
p
for p in db.query(Product).options(joinedload(Product.category)).all()
if product_tracking(db, p) != CategoryTracking.object.value
]
ergebnis: list[MatchLine] = []
for zeile in payload.lines:
text = (zeile or "").strip()
treffer = [(p, _match_score(text, p.name)) for p in lebensmittel]
treffer = sorted(
(ps for ps in treffer if ps[1] >= schwelle),
key=lambda ps: ps[1],
reverse=True,
)
ergebnis.append(MatchLine(
text=text,
candidates=[
MatchCandidate(
product_id=p.id, name=p.name, brand=p.brand, score=s,
package_size=p.package_size, base_unit=p.base_unit.value,
)
for p, s in treffer[:5]
],
))
return ergebnis
@router.get("", response_model=list[ProductOut])
def list_products(
@@ -56,7 +138,16 @@ def list_products(
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
auch "Schokolade" sehen.
"""
query = db.query(Product)
query = db.query(Product).options(
# Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst.
joinedload(Product.category),
joinedload(Product.group),
joinedload(Product.shop),
joinedload(Product.display_unit),
joinedload(Product.min_stock_unit),
selectinload(Product.field_values),
selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location),
)
if q:
like = f"%{q}%"
query = query.filter(Product.name.ilike(like))
@@ -65,7 +156,7 @@ def list_products(
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()
return [product_to_out(db, p) for p in products]
return products_to_out_bulk(db, products)
@router.get("/lookup", response_model=LookupResult)
@@ -331,9 +422,45 @@ def delete_product_image(
db.commit()
def _store_product_image_bg(product_id: int, url: str) -> None:
"""Bild nach dem Anlegen im Hintergrund holen (eigene Session), damit das
Anlegen nicht auf den (langsamen) Netzwerk-Abruf wartet."""
db = SessionLocal()
try:
product = db.get(Product, product_id)
if product is not None:
images.store(db, product, url)
db.commit()
finally:
db.close()
def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None:
"""Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen.
Eine halbe Bruecke waere schlimmer als keine: sie saehe im Formular richtig
aus, wuerde aber nirgends greifen (``zweit_faktor`` liefert dann None).
"""
gesetzt = [x for x in (basis, anzahl, menge) if x is not None]
if not gesetzt:
return
if len(gesetzt) != 3:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Zweiteinheit unvollständig: Art, Anzahl und Menge gehören zusammen.",
)
if basis == base_unit:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Die Zweiteinheit muss eine ANDERE Art sein als die Einheit des "
"Artikels sonst gibt es nichts umzurechnen.",
)
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product(
payload: ProductCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
@@ -352,6 +479,9 @@ def create_product(
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
_pruefe_zweiteinheit(
base_unit, payload.secondary_base, payload.secondary_count, payload.secondary_amount
)
product = Product(
barcode=payload.barcode or None,
name=payload.name,
@@ -361,30 +491,36 @@ def create_product(
display_unit_id=display_unit_id,
package_size=payload.package_size,
package_label=payload.package_label or None,
secondary_base=payload.secondary_base.value if payload.secondary_base else None,
secondary_count=payload.secondary_count,
secondary_amount=payload.secondary_amount,
date_precision=payload.date_precision.value,
group_id=payload.group_id,
category_id=payload.category_id,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id,
min_stock_in_packages=bool(payload.min_stock_in_packages),
shop_id=payload.shop_id,
product_url=payload.product_url or None,
individual=bool(payload.individual),
bulk=bool(payload.bulk),
source="manual",
)
db.add(product)
db.flush() # product.id fuer den Gruppen-Code
# Der Mindestbestand ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
schreibe_ueberall(db, product, payload.min_stock)
sync_group_code(db, product)
try:
apply_field_values(db, product, payload.field_values)
except FieldError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if product.image_url:
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler:
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern.
images.store(db, product, product.image_url)
db.commit()
db.refresh(product)
if product.image_url:
# Bild erst NACH dem Anlegen im Hintergrund holen der Netzwerk-Abruf
# soll das Anlegen nicht mehrere Sekunden blockieren. Schlägt er fehl,
# bleibt der Artikel trotzdem angelegt (nur ohne Bild).
background_tasks.add_task(_store_product_image_bg, product.id, product.image_url)
return product_to_out(db, product)
@@ -425,6 +561,27 @@ def update_product(
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
# ``min_stock`` ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile.
# Der Vertrag nach aussen bleibt aber gleich (Basiseinheiten), damit
# aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen.
ueberall_gesetzt = "min_stock" in data
ueberall_wert = data.pop("min_stock", None)
# Zweiteinheit: Enum -> String, und nur vollstaendig oder gar nicht.
if "secondary_base" in data:
_pruefe_zweiteinheit(
data.get("base_unit") or product.base_unit,
data["secondary_base"],
data.get("secondary_count", product.secondary_count),
data.get("secondary_amount", product.secondary_amount),
)
data["secondary_base"] = (
data["secondary_base"].value if data["secondary_base"] else None
)
# Bruecke entfernt: die beiden Zahlen muessen mit, sonst bliebe eine
# halbe Angabe stehen, die nirgends greift.
if data["secondary_base"] is None:
data["secondary_count"] = None
data["secondary_amount"] = None
if data.get("min_stock_in_packages") is None:
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
if data.get("date_precision") is None:
@@ -433,6 +590,8 @@ def update_product(
data["date_precision"] = data["date_precision"].value
for field, value in data.items():
setattr(product, field, value)
if ueberall_gesetzt:
schreibe_ueberall(db, product, ueberall_wert)
if field_values is not None:
try:
apply_field_values(db, product, field_values)

View File

@@ -10,6 +10,10 @@ from ..schemas import SettingOut
router = APIRouter(prefix="/settings", tags=["settings"])
EXPIRY_WARNING_KEY = "expiry_warning_days"
# Ab wie viel Prozent Übereinstimmung ein Artikel beim Kassenzettel-Scan als
# Treffer vorgeschlagen wird.
RECEIPT_THRESHOLD_KEY = "receipt_match_threshold"
RECEIPT_THRESHOLD_DEFAULT = 45
def get_expiry_warning_days(db: Session) -> int:
@@ -22,6 +26,17 @@ def get_expiry_warning_days(db: Session) -> int:
return get_settings().expiry_warning_days_default
def get_receipt_match_threshold(db: Session) -> int:
"""Schwellwert (0100) für Kassenzettel-Treffer; auf sinnvollen Bereich geklemmt."""
row = db.get(Setting, RECEIPT_THRESHOLD_KEY)
if row is None:
return RECEIPT_THRESHOLD_DEFAULT
try:
return max(0, min(100, int(row.value)))
except ValueError:
return RECEIPT_THRESHOLD_DEFAULT
@router.get("", response_model=list[SettingOut])
def list_settings(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -29,6 +44,7 @@ def list_settings(
rows = db.query(Setting).all()
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)))
return [SettingOut(key=k, value=v) for k, v in known.items()]

View File

@@ -1,30 +1,34 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from sqlalchemy.orm import Session, joinedload
from ..crud import product_tracking, resolve_product
from ..database import get_db
from ..deps import get_current_user
from ..models import CategoryTracking, Lot, Movement, MovementType, User
from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Product, User
from ..schemas import (
BatchCheckInRequest,
BatchCheckInResponse,
BulkLotLocation,
CheckInRequest,
CheckInResponse,
CheckOutRequest,
CheckOutResponse,
LotOut,
LotRow,
LotSplit,
LotUpdate,
RelocateRequest,
RemoveRequest,
StockActionResponse,
)
from ..services.conversion import ConversionError
from ..services.conversion import ConversionError, display_unit_info
from ..services.dates import clean_precision, normalize_best_before
from ..services.stock import (
StockError,
check_in,
check_out,
check_out_lot,
consolidate_lot_group,
current_stock,
object_add,
object_relocate,
@@ -236,6 +240,122 @@ def list_lots(
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
@router.get("/lots/rows", response_model=list[LotRow])
def list_all_lots(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[LotRow]:
"""Chargen der Charge-Artikel (Lebensmittel + Verbrauchsgegenstand) über alle
Artikel für die übergreifende Chargenliste. „Menge je Lagerort" und
Einzelstücke gehören nicht hierher und werden übersprungen. Der Artikel wird
eager geladen (kein N+1)."""
lots = (
db.query(Lot)
.options(joinedload(Lot.product).joinedload(Product.category))
.order_by(Lot.created_at.desc())
.all()
)
rows: list[LotRow] = []
for lot in lots:
p = lot.product
tracking = product_tracking(db, p)
# Nur Charge-Artikel: Lebensmittel (food) oder Verbrauchsgegenstand (bulk).
food_like = tracking != CategoryTracking.object.value or bool(p.bulk)
if not food_like:
continue
name, factor = display_unit_info(p)
rows.append(LotRow(
id=lot.id, product_id=p.id, product_name=p.name, product_brand=p.brand,
tracking=CategoryTracking(tracking),
quantity=lot.quantity, base_unit=p.base_unit,
package_size=p.package_size, package_label=p.package_label,
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,
))
return rows
@router.post("/lots/bulk-location", response_model=int)
def bulk_lot_location(
payload: BulkLotLocation,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> int:
"""Lagerort mehrerer Chargen auf einmal setzen (oder mit null leeren). Reine
Ortsangabe kein Umbuchen mit Bewegung. Gibt die Anzahl geänderter Zeilen."""
if not payload.lot_ids:
return 0
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")
n = (
db.query(Lot)
.filter(Lot.id.in_(payload.lot_ids))
.update({Lot.location_id: payload.location_id}, synchronize_session=False)
)
db.flush()
# Am Zielort entstandene Dubletten (gleicher Artikel + MHD) zusammenfassen.
gruppen = (
db.query(Lot.product_id, Lot.best_before, Lot.best_before_precision)
.filter(Lot.id.in_(payload.lot_ids))
.distinct()
.all()
)
for product_id, best_before, precision in gruppen:
consolidate_lot_group(db, product_id, best_before, precision, payload.location_id)
db.commit()
return n
@router.post("/lots/{lot_id}/split", response_model=LotOut)
def split_lot(
lot_id: int,
payload: LotSplit,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Lot:
"""Charge aufteilen: `quantity` (Basiseinheiten) abzweigen und als neue Charge
mit gleichem MHD an einen anderen Lagerort legen. Der Rest bleibt in der alten
Charge. Reine Umbuchung wie das Setzen des Lagerorts kein Bewegungseintrag,
der Gesamtbestand ändert sich nicht. Gibt die neu entstandene Charge zurück."""
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()
# 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
)
db.commit()
db.refresh(survivor)
return survivor
@router.patch("/lots/{lot_id}", response_model=LotOut)
def update_lot(
lot_id: int,
@@ -279,9 +399,15 @@ def update_lot(
note="Charge korrigiert",
)
)
db.flush()
# Ist die Charge durch die Änderung (Lagerort/MHD) zur Dublette einer anderen
# geworden, werden beide zusammengefasst.
survivor = consolidate_lot_group(
db, lot.product_id, lot.best_before, lot.best_before_precision, lot.location_id
)
db.commit()
db.refresh(lot)
return lot
db.refresh(survivor)
return survivor
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -19,7 +19,9 @@ from ..database import get_db
from ..deps import get_current_user, require_admin
from ..services.master_data import export_master_data, import_master_data
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 ..models import (
BaseUnit,
Category,
@@ -27,12 +29,14 @@ from ..models import (
DatePrecision,
FieldDefinition,
Group,
GroupLocationMinStock,
Item,
Location,
Lot,
Movement,
MovementType,
Product,
ProductLocationMinStock,
Shop,
Unit,
UnitKind,
@@ -116,7 +120,10 @@ def export_stock_csv(
product.group.name if product.group else "",
_category_path(db, product.category),
_art_label(product),
product.min_stock if product.min_stock is not None else "",
# „Ueberall"-Bedarf (frueher der Gesamt-Mindestbestand am Artikel).
# Die CSV kennt nur diese eine Spalte; Ort-Bedarfe stehen im
# JSON-Backup.
_ueberall_csv(product),
]
lots = (
db.query(Lot)
@@ -159,7 +166,10 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations}
data = {
"version": 2,
# v3: Gruppen mit Obergruppen und Gebinde, Mindestbestaende je Ort
# (Ort null = „Ueberall") in Basiseinheiten. v2-Sicherungen bleiben
# lesbar ihnen fehlen diese Listen einfach.
"version": 3,
"exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [
@@ -169,8 +179,13 @@ def export_backup_json(
"groups": [
{
"name": g.name,
"min_stock": g.min_stock,
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None,
"package_size": g.package_size,
"package_label": g.package_label,
"min_stock_in_packages": g.min_stock_in_packages,
# Namen statt IDs: die sind zwischen zwei Instanzen nicht gleich.
"parents": sorted(p.name for p in g.parents),
"min_stocks": _min_stock_liste(g, loc_name),
}
for g in db.query(Group).order_by(Group.id).all()
],
@@ -222,9 +237,13 @@ def export_backup_json(
"date_precision": p.date_precision,
"group": p.group.name if p.group else None,
"category": _category_path(db, p.category),
"min_stock": p.min_stock,
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
"min_stock_in_packages": bool(p.min_stock_in_packages),
# Zweiteinheit-Brücke („3 Stück ≙ 250 g"), in Basiseinheiten.
"secondary_base": p.secondary_base,
"secondary_count": p.secondary_count,
"secondary_amount": p.secondary_amount,
"min_stocks": _min_stock_liste(p, loc_name),
# Gegenstands-Felder:
"shop": p.shop.name if p.shop else None,
"product_url": p.product_url,
@@ -479,6 +498,55 @@ def _get_or_create_shop(db: Session, name: str | None, website: str | None = Non
return shop
def _ueberall_csv(besitzer) -> float | str:
"""Der „Ueberall"-Mindestbestand fuer die CSV-Spalte (leer, wenn keiner)."""
wert = lies_ueberall(besitzer.location_min_stocks)
return "" if wert is None else wert
def _min_stock_liste(besitzer, loc_name: dict[str, str]) -> list[dict]:
"""Mindestbestaende eines Artikels/einer Gruppe je Ort, fuer die Sicherung.
Mengen in Basiseinheiten, Orte als Name; ``location = null`` ist „Ueberall".
"""
return [
{
"location": None if e.location_id is None else loc_name.get(e.location_id),
"min_stock": e.min_stock,
}
for e in besitzer.location_min_stocks
]
def _min_stocks_einspielen(db: Session, besitzer, eintraege: list[dict] | None) -> None:
"""Mindestbestaende aus der Sicherung setzen nur, wenn noch keine da sind.
Wie der ganze Import additiv: Vorhandenes wird nie ueberschrieben.
"""
if not eintraege or besitzer.location_min_stocks:
return
for eintrag in eintraege:
menge = eintrag.get("min_stock")
if menge is None or float(menge) <= 0:
continue
ort_name = eintrag.get("location")
ort = None
if ort_name:
ort = db.query(Location).filter(Location.name == ort_name).first()
if ort is None:
continue # Ort fehlt in dieser Instanz Eintrag entfaellt
# Beziehung mitsetzen, nicht nur die ID: sonst liefert ``.location`` bis
# zum naechsten Commit None, obwohl der Ort feststeht.
if isinstance(besitzer, Product):
besitzer.location_min_stocks.append(ProductLocationMinStock(
product_id=besitzer.id, location=ort,
location_id=ort.id if ort else None, min_stock=float(menge)))
else:
besitzer.location_min_stocks.append(GroupLocationMinStock(
group_id=besitzer.id, location=ort,
location_id=ort.id if ort else None, min_stock=float(menge)))
def _get_or_create_group(db: Session, name: str | None) -> Group | None:
name = (name or "").strip()
if not name:
@@ -542,11 +610,13 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
group_id=group.id if group else None,
category_id=category.id if category else None,
min_stock=_num(row.get("mindestbestand")),
source="import",
)
db.add(product)
db.flush()
# Die CSV-Spalte „mindestbestand" (und das v2-Backup) meinen den Bedarf
# ohne Ortsangabe das ist jetzt die „Ueberall"-Zeile. Basiseinheiten.
schreibe_ueberall(db, product, _num(row.get("mindestbestand")))
# Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
# ueber die Oberflaeche.
sync_group_code(db, product)
@@ -664,7 +734,19 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
db.flush()
for entry in data.get("groups", []):
_get_or_create_group(db, entry.get("name"))
gruppe = _get_or_create_group(db, entry.get("name"))
if gruppe is None:
continue
# Gebinde und Einheit nur nachtragen, wenn die Gruppe noch nackt ist
# der Import ueberschreibt grundsaetzlich nichts Vorhandenes.
if gruppe.package_size is None and entry.get("package_size"):
gruppe.package_size = float(entry["package_size"])
gruppe.package_label = entry.get("package_label")
gruppe.min_stock_in_packages = bool(entry.get("min_stock_in_packages"))
if gruppe.min_stock_unit_id is None and entry.get("min_stock_unit"):
einheit = db.query(Unit).filter(Unit.name == entry["min_stock_unit"]).first()
if einheit is not None:
gruppe.min_stock_unit_id = einheit.id
for entry in data.get("locations", []):
_get_or_create_location(db, entry.get("name"))
db.flush()
@@ -678,6 +760,27 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
if child and parent and child.parent_id is None and child.id != parent.id:
child.parent_id = parent.id
# Zweiter Durchgang für die Gruppen: Ober-/Untergruppen und Mindestbestände
# lassen sich erst setzen, wenn alle Gruppen und Lagerorte existieren.
for entry in data.get("groups", []):
gruppe = db.query(Group).filter(Group.name == entry.get("name")).first()
if gruppe is None:
continue
if not gruppe.parents:
for eltern_name in entry.get("parents") or []:
eltern = db.query(Group).filter(Group.name == eltern_name).first()
# Selbstkante und Ringe abweisen: eine beschädigte oder
# manipulierte Datei darf keinen einschleusen, der danach jede
# Auswertung im Kreis laufen liesse.
if eltern is None or eltern.id == gruppe.id:
continue
if eltern.id in gruppen_graph.nachfahren_ids(gruppe):
errors.append(f"Gruppe {gruppe.name!r}: {eltern_name!r} wäre ein Ring")
continue
gruppe.parents.append(eltern)
_min_stocks_einspielen(db, gruppe, entry.get("min_stocks"))
db.flush()
# Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
for entry in data.get("categories", []):
@@ -733,6 +836,15 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
}
product = _get_or_create_product(db, row, created_products)
# Mindestbestände je Ort (v3). Ältere Sicherungen haben nur den
# Gesamtwert, der oben schon über „mindestbestand" gesetzt wurde.
_min_stocks_einspielen(db, product, entry.get("min_stocks"))
# Zweiteinheit nur nachtragen, wenn der Artikel noch keine hat
# der Import ueberschreibt grundsaetzlich nichts Vorhandenes.
if not product.secondary_base and entry.get("secondary_base"):
product.secondary_base = entry["secondary_base"]
product.secondary_count = entry.get("secondary_count")
product.secondary_amount = entry.get("secondary_amount")
# Gegenstands-Zusatzfelder nur ergänzend, nichts überschreiben.
shop_name = (entry.get("shop") or "").strip()
if shop_name and product.shop_id is None:

View File

@@ -1,5 +1,8 @@
import math
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, timedelta
from typing import Callable, TypeVar
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
@@ -13,11 +16,13 @@ from ..models import (
Location,
Lot,
Movement,
PackageType,
Product,
ProductLocationMinStock,
User,
)
from ..schemas import (
BaseUnit,
ExpiringItem,
GroupShoppingItem,
LocationContentEntry,
@@ -27,43 +32,283 @@ from ..schemas import (
LocationNeeds,
MovementOut,
ShoppingItem,
ShoppingListAll,
ShoppingNeed,
)
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info
from ..services.conversion import (
BASE_OF_KIND,
article_unit,
display_unit_info,
group_min_context,
)
from ..services import gruppen as gruppen_graph
from ..services.stock import (
current_stock,
descendant_location_ids,
location_subtree_stock_base,
summe_bestand_gewichtet,
summe_bestand_im_subtree_gewichtet,
)
from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"])
#: Schluesseltyp der Bedarfs-Verrechnung: ein Lagerort (bzw. None = „Ueberall")
#: oder ein Paar aus Gruppe und Ort.
K = TypeVar("K")
# ---- Bedarf lesbar aufbereiten (Gebinde als Leitangabe) --------------------
_UNIT_SHORT: dict[BaseUnit, str] = {
BaseUnit.piece: "Stk",
BaseUnit.gram: "g",
BaseUnit.milliliter: "ml",
}
def _de_num(x: float) -> str:
"""Deutsche Kurzzahl ohne unnoetige Nullen: 500, 1, 1,4."""
r = round(float(x), 2)
if r == int(r):
return str(int(r))
return f"{r:.2f}".rstrip("0").rstrip(".").replace(".", ",")
def _package_plural(db: Session) -> dict[str, str]:
"""Einzahl -> Mehrzahl der Gebinde (Glas -> Gläser), aus der Gebinde-Tabelle."""
return {pt.singular: pt.plural for pt in db.query(PackageType).all()}
def _build_need(
*,
deficit_base: float,
stock_base: float,
min_base: float,
factor: float,
singular: str,
is_package: bool,
base_unit: BaseUnit | None,
plural: dict[str, str],
) -> ShoppingNeed:
"""Rechnet Basiseinheiten in Gebinde/Artikeleinheiten um und baut die Texte.
``factor`` = Basiseinheiten je Gebinde (Packungsgröße bzw. Einheitenfaktor).
Bei zählbaren Packungen wird die Kaufmenge auf ganze Gebinde aufgerundet."""
f = factor or 1.0
count = math.ceil(deficit_base / f - 1e-9) if is_package else round(deficit_base / f, 3)
label = singular if abs(count) == 1 else plural.get(singular, singular)
text = f"{_de_num(count)} {label}".strip()
hint = ""
base_amount: float | None = None
if (is_package or f != 1.0) and base_unit is not None:
base_amount = round(deficit_base, 3)
hint = f"{_de_num(deficit_base)} {_UNIT_SHORT.get(base_unit, base_unit.value)}"
return ShoppingNeed(
text=text,
hint=hint,
count=count,
label=label,
singular=singular,
is_package=is_package,
base_amount=base_amount,
base_unit=base_unit if base_amount is not None else None,
stock=round(stock_base / f, 3),
min_stock=round(min_base / f, 3),
)
def _artikel_bedarfe(
db: Session, ort_desc: dict[str, set[str]]
) -> dict[int, tuple[Product, dict, dict, dict]]:
"""Je Artikel Mindestbestand, Bestand und verrechneter Bedarf je Ort.
Schluessel ``None`` ist „Ueberall" und liegt ueber allen Lagerorten. Alle
Mengen in Basiseinheiten.
"""
nach_artikel: dict[int, list[ProductLocationMinStock]] = defaultdict(list)
for e in db.query(ProductLocationMinStock).all():
if e.min_stock > 0:
nach_artikel[e.product_id].append(e)
ergebnis: dict[int, tuple[Product, dict, dict, dict]] = {}
for product_id, rows in nach_artikel.items():
product = db.get(Product, product_id)
if product is None:
continue
mins = {e.location_id: e.min_stock for e in rows}
bestand = {
loc: (
current_stock(db, product.id)
if loc is None
else location_subtree_stock_base(db, product, loc)
)
for loc in mins
}
needs = _netted_topups(mins, _ort_nachfahren(mins, ort_desc), bestand.__getitem__)
ergebnis[product_id] = (product, mins, bestand, needs)
return ergebnis
def einkaufsliste_artikel(db: Session) -> list[ShoppingItem]:
"""Artikel, bei denen „Überall" etwas fehlt egal wo im Haus.
Käufe für einzelne Lagerorte sind bereits abgezogen: derselbe Artikel steht
dadurch nicht mehr doppelt in der Liste (einmal „Gesamt", einmal je Ort).
"""
plural = _package_plural(db)
ort_desc = _ort_nachfahren_tabelle(db)
items: list[ShoppingItem] = []
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
if None not in mins:
continue # nur Ort-Bedarfe, kein „Überall"
fehlt = needs.get(None, 0.0)
if fehlt <= 1e-9:
continue
factor, singular = article_unit(product)
items.append(
ShoppingItem(
product_id=product.id,
name=product.name,
base_unit=product.base_unit,
package_size=product.package_size,
stock=round(bestand[None], 3),
min_stock=mins[None],
deficit=round(fehlt, 3),
need=_build_need(
deficit_base=fehlt,
stock_base=bestand[None],
min_base=mins[None],
factor=factor,
singular=singular,
is_package=bool(product.package_size and product.package_size > 0),
base_unit=product.base_unit,
plural=plural,
),
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
@router.get("/shopping-list", response_model=list[ShoppingItem])
def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]:
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
items: list[ShoppingItem] = []
products = (
db.query(Product)
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
.all()
)
for product in products:
stock = current_stock(db, product.id)
if stock < product.min_stock:
items.append(
ShoppingItem(
product_id=product.id,
name=product.name,
base_unit=product.base_unit,
package_size=product.package_size,
stock=stock,
min_stock=product.min_stock,
deficit=product.min_stock - stock,
)
return einkaufsliste_artikel(db)
def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, dict, dict, dict, dict]:
"""Je (Gruppe, Ort) Mindestbestand, Bestand und verrechneter Bedarf.
Hier wirken ZWEI Hierarchien zusammen: der Gruppen-Graph und der
Lagerort-Baum. „1 kg Grillwurst in die Küche" deckt auch „Wurst in Lemgo".
Deshalb ist ein Schlüssel ein Paar, und ``(h, m)`` gilt als Nachfahre von
``(g, l)``, wenn h unter g und m unter l liegt (oder gleich ist).
Verrechnet wird nur, wo die zählenden Artikel der Untergruppe eine Teilmenge
der Obergruppe sind: zählt „Wurst" in Kilogramm, ihre Untergruppe aber in
Stück, kommt ein Kauf dort oben gar nicht an (siehe die Einheiten-Filterung
in ``group_min_context``).
Alle Mengen in Basiseinheiten nur darin lassen sich Gruppen mit
verschiedenen Erfassungseinheiten überhaupt gegeneinander verrechnen.
"""
eintraege = [e for e in db.query(GroupLocationMinStock).all() if e.min_stock > 0]
gruppen: dict[int, Group] = {}
for e in eintraege:
if e.group_id not in gruppen:
g = db.get(Group, e.group_id)
if g is not None:
gruppen[e.group_id] = g
ctxs = {gid: group_min_context(g) for gid, g in gruppen.items()}
artikel = {gid: {p.id for p in c.matching} for gid, c in ctxs.items()}
gruppen_desc = {
gid: {
h.id
for h in gruppen_graph.teilgraph(g)
if h.id != gid
and h.id in artikel
# Nur zwischen Gruppen verrechnen, die in DERSELBEN Basiseinheit
# zaehlen. Frueher folgte das nebenbei aus der Teilmengen-Bedingung:
# Artikel verschiedener Arten waren zwangslaeufig disjunkt. Seit die
# Zweiteinheit einen Stueck-Artikel auch in einer Gramm-Gruppe
# mitzaehlen laesst, gilt das nicht mehr und ein Bedarf in Stueck
# duerfte keinen Bedarf in Gramm decken (siehe _netted_topups:
# „Alle Mengen muessen in DERSELBEN Einheit vorliegen").
and ctxs[h.id].base_unit == ctxs[gid].base_unit
and artikel[h.id] <= artikel[gid]
}
for gid, g in gruppen.items()
}
minima: dict[tuple[int, str | None], float] = {}
bestand: dict[tuple[int, str | None], float] = {}
for e in eintraege:
ctx = ctxs.get(e.group_id)
if ctx is None:
continue
schluessel = (e.group_id, e.location_id)
minima[schluessel] = e.min_stock
bestand[schluessel] = (
summe_bestand_gewichtet(db, ctx.matching, ctx.faktoren)
if e.location_id is None
else summe_bestand_im_subtree_gewichtet(
db, ctx.matching, e.location_id, ctx.faktoren)
)
alle_orte = {loc for (_, loc) in minima if loc is not None}
def nachfahren(k: tuple[int, str | None]) -> set[tuple[int, str | None]]:
gid, loc = k
unten = gruppen_desc.get(gid, set()) | {gid}
# „Überall" (None) liegt über allen Lagerorten.
orte = (alle_orte if loc is None else ort_desc.get(loc, set())) | {loc}
return {(h, m) for h in unten for m in orte if (h, m) in minima} - {k}
needs = _netted_topups(minima, nachfahren, bestand.__getitem__)
return gruppen, ctxs, minima, bestand, needs
def einkaufsliste_gruppen(db: Session) -> list[GroupShoppingItem]:
"""Gruppen, bei denen „Überall" etwas fehlt.
Gruppen-Bestand = Summe der Artikelbestände der Gruppe UND ihrer
Untergruppen (Basiseinheiten). Käufe für Untergruppen und für einzelne
Lagerorte sind bereits abgezogen.
"""
plural = _package_plural(db)
ort_desc = _ort_nachfahren_tabelle(db)
gruppen, ctxs, minima, bestand, needs = _gruppen_bedarfe(db, ort_desc)
items: list[GroupShoppingItem] = []
for (gid, loc), fehlt in needs.items():
if loc is not None or fehlt <= 1e-9:
continue
ctx = ctxs[gid]
schluessel = (gid, None)
items.append(
GroupShoppingItem(
group_id=gid,
name=gruppen[gid].name,
stock=round(bestand[schluessel] / ctx.divisor, 3),
min_stock=round(minima[schluessel] / ctx.divisor, 3),
deficit=round(fehlt / ctx.divisor, 3),
unit_name=ctx.label,
product_count=len(ctx.matching),
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
need=_build_need(
deficit_base=fehlt,
stock_base=bestand[schluessel],
min_base=minima[schluessel],
factor=ctx.divisor,
singular=ctx.label,
is_package=ctx.is_package,
base_unit=ctx.base_unit,
plural=plural,
),
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
@@ -72,83 +317,136 @@ def shopping_list(
def group_shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[GroupShoppingItem]:
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt.
return einkaufsliste_gruppen(db)
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
def _netted_topups(
minima: dict[K, float],
nachfahren: Callable[[K], set[K]],
stock_of: Callable[[K], float],
) -> dict[K, float]:
"""Bedarfe entlang ihrer Hierarchie verrechnen.
Was fuer einen Nachfahren gekauft wird, liegt auch bei dessen Vorfahren und
deckt deren Bedarf mit. ``topup(x)`` ist die ZUSAETZLICH noetige Menge
ueber die Kaeufe fuer die Nachfahren hinaus. So kostet „Lemgo braucht 5,
Kueche braucht 2" bei je 1 fehlend nur 1 (in die Kueche), nicht 2.
``nachfahren`` liefert die TRANSITIVE Nachfahren-MENGE, nicht die direkten
Kinder. Beim Gruppen-Graphen ist eine Untergruppe ueber mehrere Wege
erreichbar als Menge zaehlt sie in ``committed`` trotzdem nur einmal.
Alle Mengen muessen in DERSELBEN Einheit vorliegen (hier: Basiseinheiten),
sonst wird Aepfel mit Birnen verrechnet.
"""
items: list[GroupShoppingItem] = []
groups = (
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
)
for group in groups:
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
products = [p for p in group.products if p.base_unit == base]
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
unit_name = unit.name
else:
products = list(group.products)
stock = float(sum(current_stock(db, p.id) for p in products))
unit_name = ""
if stock < group.min_stock:
items.append(
GroupShoppingItem(
group_id=group.id,
name=group.name,
stock=stock,
min_stock=group.min_stock,
deficit=group.min_stock - stock,
unit_name=unit_name,
product_count=len(products),
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
schluessel = list(minima)
desc = {k: [d for d in schluessel if d != k and d in nachfahren(k)] for k in schluessel}
memo: dict[K, float] = {}
def topup(k: K) -> float:
if k not in memo:
# Vorbelegen schuetzt vor einem Ring in den Daten: der wuerde sonst
# endlos rekursieren (die API laesst keinen zu, ein Import schon).
memo[k] = 0.0
committed = sum(topup(d) for d in desc[k])
memo[k] = max(0.0, minima[k] - (stock_of(k) + committed))
return memo[k]
return {k: topup(k) for k in schluessel}
def _ort_nachfahren(
schluessel: Iterable[str | None], ort_desc: dict[str, set[str]]
) -> Callable[[str | None], set[str | None]]:
"""Nachfahren-Funktion fuer die Ort-Hierarchie eines Bedarfssatzes.
„Ueberall" (``None``) liegt ueber ALLEN Lagerorten. Dadurch verrechnet
dieselbe Faltung auch Ueberall gegen die einzelnen Orte der frueher
getrennte Gesamt-Mindestbestand erzeugt keinen zweiten Eintrag mehr.
"""
orte = list(schluessel)
def nachfahren(k: str | None) -> set[str | None]:
if k is None:
return {d for d in orte if d is not None}
return {d for d in orte if d is not None and d in ort_desc.get(k, set())}
return nachfahren
def _ort_nachfahren_tabelle(db: Session) -> dict[str, set[str]]:
"""Unterorte je Lagerort, einmal je Anfrage statt je Artikel und Gruppe.
``descendant_location_ids`` geht je Ebene an die Datenbank; frueher wurde es
fuer jeden Bedarfssatz neu aufgerufen.
"""
return {loc.id: descendant_location_ids(db, loc.id) for loc in db.query(Location).all()}
@router.get("/shopping-list/by-location", response_model=list[LocationNeeds])
def shopping_list_by_location(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[LocationNeeds]:
"""Bedarfe je Lagerort: Produkte und Gruppen, deren Bestand AN DIESEM ORT
unter dem dort hinterlegten Mindestbestand liegt."""
prod_needs: dict[int, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[int, list[LocationNeedGroup]] = defaultdict(list)
"""Bedarfe je Lagerort: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt.
for e in db.query(ProductLocationMinStock).all():
product = db.get(Product, e.product_id)
if product is None:
continue
„Überall" gehört nicht hierher das liefern ``/shopping-list`` und
``/shopping-list/groups``. Verrechnet wird über beide Listen hinweg, ein
Artikel steht also nur einmal drin.
"""
plural = _package_plural(db)
ort_desc = _ort_nachfahren_tabelle(db)
prod_needs: dict[str, list[LocationNeedProduct]] = defaultdict(list)
group_needs: dict[str, list[LocationNeedGroup]] = defaultdict(list)
for product, mins, bestand, needs in _artikel_bedarfe(db, ort_desc).values():
faktor, label = article_unit(product)
stock = location_subtree_stock_base(db, product, e.location_id) / (faktor or 1.0)
if stock < e.min_stock:
prod_needs[e.location_id].append(LocationNeedProduct(
faktor = faktor or 1.0
ist_gebinde = bool(product.package_size and product.package_size > 0)
for loc, need in needs.items():
if loc is None or need <= 1e-9:
continue
prod_needs[loc].append(LocationNeedProduct(
product_id=product.id, name=product.name, unit_label=label,
stock=round(stock, 3), min_stock=e.min_stock,
deficit=round(e.min_stock - stock, 3),
# Nach aussen weiterhin in Artikeleinheiten gespeichert und
# gerechnet wird intern in Basiseinheiten.
stock=round(bestand[loc] / faktor, 3),
min_stock=round(mins[loc] / faktor, 3),
deficit=round(need / faktor, 3),
need=_build_need(
deficit_base=need,
stock_base=bestand[loc],
min_base=mins[loc],
factor=faktor,
singular=label,
is_package=ist_gebinde,
base_unit=product.base_unit,
plural=plural,
),
))
for e in db.query(GroupLocationMinStock).all():
group = db.get(Group, e.group_id)
if group is None:
gruppen, ctxs, minima, bestand_g, needs_g = _gruppen_bedarfe(db, ort_desc)
for (gid, loc), need in needs_g.items():
if loc is None or need <= 1e-9:
continue
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
matching = [p for p in group.products if p.base_unit == base]
stock = sum(location_subtree_stock_base(db, p, e.location_id) for p in matching) / unit.factor
unit_name = unit.name
else:
stock = float(sum(location_subtree_stock_base(db, p, e.location_id) for p in group.products))
unit_name = ""
if stock < e.min_stock:
group_needs[e.location_id].append(LocationNeedGroup(
group_id=group.id, name=group.name, unit_name=unit_name,
stock=round(stock, 3), min_stock=e.min_stock,
deficit=round(e.min_stock - stock, 3),
))
ctx = ctxs[gid]
schluessel = (gid, loc)
group_needs[loc].append(LocationNeedGroup(
group_id=gid, name=gruppen[gid].name, unit_name=ctx.label,
stock=round(bestand_g[schluessel] / ctx.divisor, 3),
min_stock=round(minima[schluessel] / ctx.divisor, 3),
deficit=round(need / ctx.divisor, 3),
subgroup_count=len(gruppen_graph.nachfahren_ids(gruppen[gid])),
need=_build_need(
deficit_base=need,
stock_base=bestand_g[schluessel],
min_base=minima[schluessel],
factor=ctx.divisor,
singular=ctx.label,
is_package=ctx.is_package,
base_unit=ctx.base_unit,
plural=plural,
),
))
loc_ids = set(prod_needs) | set(group_needs)
namen = {
@@ -166,6 +464,20 @@ def shopping_list_by_location(
return result
@router.get("/shopping-list/all", response_model=ShoppingListAll)
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."""
return ShoppingListAll(
products=shopping_list(db, user),
groups=group_shopping_list(db, user),
by_location=shopping_list_by_location(db, user),
)
@router.get("/location/{code}", response_model=LocationContents)
def location_contents(
code: str,

View File

@@ -110,28 +110,49 @@ class UserUpdate(BaseModel):
# ---- Groups ----
class LocationMinStockIn(BaseModel):
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Artikeleinheiten)."""
location_id: str
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
``location_id = null`` heisst „Überall" egal wo, Hauptsache die Menge ist
im Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand.
"""
location_id: str | None = None
min_stock: float = Field(ge=0)
class LocationMinStockOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
location_id: str
location_name: str | None = None
location_id: str | None = None
location_name: str | None = None # bei location_id = null: „Überall"
min_stock: float
# Bestand AN DIESEM Ort (inkl. Unterorte), ebenfalls in Basiseinheiten
# bei Artikeln wie bei Gruppen. Bei „Überall" der Gesamtbestand.
stock: float | None = None
class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
min_stock: float | None = None
min_stock: float | None = None # in der aktuellen Erfassungseinheit (s.u.)
min_stock_unit_id: int | None = None
# Gruppen-Gebinde (Richtwert) + ob der Mindestbestand darin erfasst ist:
package_size: float | None = None
package_label: str | None = None
min_stock_in_packages: bool = False
# Obergruppen (n:m eine Gruppe darf unter mehreren haengen) und die
# direkten Untergruppen. Beide als IDs; ``_group_to_out`` setzt sie, weil
# ``Group.parents``/``children`` Objekte sind.
parent_ids: list[int] = []
child_ids: list[int] = []
# angereichert:
# Artikel im gesamten Untergruppen-Graphen passt zu ``stock``, das
# ebenfalls transitiv rechnet. ``direct_product_count`` sind die, die
# unmittelbar an dieser Gruppe haengen.
product_count: int = 0
stock: float = 0.0 # Bestand in Basiseinheiten
min_stock_unit_name: str | None = None
direct_product_count: int = 0
stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit
min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …)
min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit
kind: str | None = None # Art der Mindestbestand-Einheit
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
@@ -144,12 +165,21 @@ class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None
package_size: float | None = Field(default=None, ge=0)
package_label: str | None = Field(default=None, max_length=32)
min_stock_in_packages: bool = False
parent_ids: list[int] = []
class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None
package_size: float | None = Field(default=None, ge=0)
package_label: str | None = Field(default=None, max_length=32)
min_stock_in_packages: bool | None = None
# Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen.
parent_ids: list[int] | None = None
# ---- Categories ----
@@ -285,6 +315,12 @@ class ProductBase(BaseModel):
package_size: float | None = Field(default=None, gt=0)
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
package_label: str | None = Field(default=None, max_length=32)
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
secondary_base: BaseUnit | None = None
secondary_count: float | None = Field(default=None, gt=0)
secondary_amount: float | None = Field(default=None, gt=0)
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
date_precision: DatePrecision = DatePrecision.day
group_id: int | None = None
@@ -299,6 +335,8 @@ class ProductBase(BaseModel):
product_url: str | None = Field(default=None, max_length=1024)
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
individual: bool = False
# Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen).
bulk: bool = False
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
field_values: dict[int, str | None] | None = None
@@ -316,6 +354,12 @@ class ProductUpdate(BaseModel):
unit_id: int | None = None
package_size: float | None = Field(default=None, gt=0)
package_label: str | None = Field(default=None, max_length=32)
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
secondary_base: BaseUnit | None = None
secondary_count: float | None = Field(default=None, gt=0)
secondary_amount: float | None = Field(default=None, gt=0)
date_precision: DatePrecision | None = None
group_id: int | None = None
category_id: int | None = None
@@ -325,6 +369,7 @@ class ProductUpdate(BaseModel):
shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024)
individual: bool | None = None
bulk: bool | None = None
field_values: dict[int, str | None] | None = None
@@ -339,8 +384,18 @@ class ProductOut(BaseModel):
display_unit_id: int | None = None
package_size: float | None
package_label: str | None = None
# Zweiteinheit-Brücke, so wie eingegeben („3 Stück ≙ 250 g").
secondary_base: BaseUnit | None = None
secondary_count: float | None = None
secondary_amount: float | None = None
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels
# damit die Oberflächen nicht selbst dividieren müssen.
secondary_factor: float | None = None
date_precision: DatePrecision = DatePrecision.day
group_id: int | None
# Name der Gruppe wie category_name, damit die Listen danach filtern
# koennen, ohne die Gruppen zusaetzlich zu laden.
group_name: str | None = None
category_id: int | None = None
category_name: str | None = None
min_stock: float | None
@@ -350,7 +405,9 @@ class ProductOut(BaseModel):
shop_id: int | None = None
product_url: str | None = None
individual: bool = False
bulk: bool = False
created_at: datetime
updated_at: datetime | None = None
# angereichert:
stock: float = 0.0
expired_count: int = 0
@@ -445,6 +502,61 @@ class LotUpdate(BaseModel):
location_id: str | None = None
class LotRow(BaseModel):
"""Eine Charge mit Artikel-Infos für die übergreifende Chargenliste."""
id: int
product_id: int
product_name: str
product_brand: str | None = None
tracking: CategoryTracking = CategoryTracking.food # food / object
quantity: float # in Basiseinheiten
base_unit: BaseUnit
package_size: float | None = None
package_label: str | None = None
unit_name: str = ""
unit_factor: float = 1.0
best_before: date | None = None
best_before_precision: DatePrecision = DatePrecision.day
location_id: str | None = None
created_at: datetime
class BulkLotLocation(BaseModel):
"""Lagerort für mehrere Chargen auf einmal setzen (oder leeren)."""
lot_ids: list[int]
location_id: str | None = None
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."""
quantity: float = Field(gt=0)
location_id: str | None = None
# ---- Kassenzettel-Abgleich ----
class MatchRequest(BaseModel):
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
lines: list[str]
threshold: int | None = None # 0100; ohne Angabe gilt die Einstellung
class MatchCandidate(BaseModel):
product_id: int
name: str
brand: str | None = None
score: int # 0100, Ähnlichkeit zur Kassenzeile
# Für die Einheit beim Einlagern (ganze Gebinde bzw. Basiseinheit) so muss
# die App den Artikel nicht noch einmal einzeln laden.
package_size: float | None = None
base_unit: str
class MatchLine(BaseModel):
text: str
candidates: list[MatchCandidate] = []
class CheckInResponse(BaseModel):
lot: LotOut
product_stock: float
@@ -600,11 +712,29 @@ class ItemOut(BaseModel):
# ---- Views ----
class ShoppingNeed(BaseModel):
"""Fehlmenge als Gebinde-Leitangabe bei zaehlbaren Packungen (Glas/Dose)
auf ganze aufgerundet, weil man nur ganze Gebinde kauft mit der
Basiseinheit als kleinem Hinweis. So bekommen auch API-Konsumenten direkt
„1 Glas (500 g)", ohne selbst Packungsgroessen umzurechnen."""
text: str # Leitangabe: "1 Glas", "4 Dosen", "500 g"
hint: str = "" # Basiseinheit klein: "500 g" / "2 l" leer wenn identisch
count: float # aufgerundete Gebindezahl (zum Weiterrechnen)
label: str # zur count passendes, pluralisiertes Label
singular: str # Einzahl-Label ("Glas", "Gramm", …)
is_package: bool = False # zaehlbares Gebinde (Glas/Dose) vs. Basis-/Anzeigeeinheit
base_amount: float | None = None
base_unit: BaseUnit | None = None
stock: float # Bestand in derselben Leitangabe
min_stock: float # Mindestbestand in derselben Leitangabe
class ShoppingItem(BaseModel):
product_id: int
name: str
base_unit: BaseUnit
package_size: float | None = None
need: ShoppingNeed | None = None
stock: float
min_stock: float
deficit: float
@@ -634,6 +764,9 @@ class GroupShoppingItem(BaseModel):
deficit: float
unit_name: str = ""
product_count: int
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
subgroup_count: int = 0
need: ShoppingNeed | None = None
class LocationNeedProduct(BaseModel):
@@ -644,6 +777,7 @@ class LocationNeedProduct(BaseModel):
stock: float
min_stock: float
deficit: float
need: ShoppingNeed | None = None
class LocationNeedGroup(BaseModel):
@@ -653,6 +787,9 @@ class LocationNeedGroup(BaseModel):
stock: float
min_stock: float
deficit: float
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
subgroup_count: int = 0
need: ShoppingNeed | None = None
class LocationNeeds(BaseModel):
@@ -663,6 +800,14 @@ class LocationNeeds(BaseModel):
groups: list[LocationNeedGroup] = []
class ShoppingListAll(BaseModel):
"""Komplette Einkaufsliste in einem Aufruf dieselben drei Quellen, die auch
die Oberfläche zusammenführt."""
products: list[ShoppingItem] = []
groups: list[GroupShoppingItem] = []
by_location: list[LocationNeeds] = []
class LocationContentEntry(BaseModel):
"""Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten)."""
product_id: int

View File

@@ -7,10 +7,13 @@ Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
from __future__ import annotations
from typing import NamedTuple
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import BaseUnit, Product, Unit, UnitKind
from ..models import BaseUnit, Group, Product, Unit, UnitKind
from . import gruppen
BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
UnitKind.count: BaseUnit.piece,
@@ -51,6 +54,50 @@ def find_unit(db: Session, token: str) -> Unit | None:
return db.query(Unit).filter(func.lower(Unit.name) == t).first()
def zweit_faktor(product: Product, ziel: BaseUnit) -> float | None:
"""Wie viele ``ziel``-Basiseinheiten EINE Basiseinheit des Artikels wert ist.
Die Zweiteinheit am Artikel („3 Stück ≙ 250 g") ist die einzige Brücke
zwischen den sonst strikt getrennten Arten. Sie gilt in BEIDE Richtungen:
derselbe Eintrag macht aus einem Stück-Artikel 83,333 g je Stück und aus
einem Gramm-Artikel 0,012 Stück je Gramm.
Gleiche Art → 1.0. Ohne (oder mit unbrauchbarer) Brücke → ``None``; dann
bleibt es bei der strikten Trennung, und der Aufrufer entscheidet, was das
heisst ablehnen beim Einlagern, aussortieren beim Gruppenbestand.
"""
if product.base_unit == ziel:
return 1.0
if product.secondary_base != ziel.value:
return None # keine Brücke in DIESE Art
anzahl = product.secondary_count # in der Basiseinheit des Artikels
menge = product.secondary_amount # in secondary_base
if not anzahl or not menge or anzahl <= 0 or menge <= 0:
return None
# „3 Stück ≙ 250 g" -> 1 Stück = 250/3 g. Bei einem Gramm-Artikel steht
# dieselbe Zeile als „250 g ≙ 3 Stück" da und liefert 3/250 Stück je Gramm.
return menge / anzahl
def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> float | None:
"""Gegenrichtung: eine Menge in ``quell_base`` in die Artikel-Basiseinheit.
Bewusst erst multiplizieren, dann teilen. ``250 * 3 / 250`` ist exakt 3;
der Umweg ueber den Faktor (``250 / (250/3)``) ergibt 3,0000000000000004
und daran scheitert sonst „den ganzen Bestand auslagern" an der Pruefung in
``check_out``.
"""
if quell_base == product.base_unit:
return menge
if product.secondary_base != quell_base.value:
return None
anzahl = product.secondary_count
zweit = product.secondary_amount
if not anzahl or not zweit or anzahl <= 0 or zweit <= 0:
return None
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."""
if quantity <= 0:
@@ -68,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
if unit is None:
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product):
raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]})."
)
# Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
# „250 g" bei einem in Stück geführten Artikel wird zu 3 Stück.
# Bewusst OHNE Runden krumme Mengen sind erlaubt, sonst driftete
# der gebuchte Bestand von der tatsächlichen Entnahme weg.
ergebnis = in_artikel_basis(product, quantity * unit.factor, BASE_OF_KIND[unit.kind])
if ergebnis is None:
raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]}). Mit einer Zweiteinheit am "
f"Artikel (z.B. „3 Stück ≙ 250 g\") ginge es."
)
return ergebnis
return quantity * unit.factor
@@ -108,3 +163,68 @@ def article_units(product: Product, quantity_base: float) -> float:
"""Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
factor, _ = article_unit(product)
return quantity_base / factor if factor else quantity_base
class GroupMinContext(NamedTuple):
divisor: float # Basiseinheiten je Mindestbestand-Einheit
label: str # Einzahl-Label ("Glas", "Gramm", …)
is_package: bool # zaehlbares Gebinde (aufrunden) vs. Anzeige-/Basiseinheit
base_unit: BaseUnit | None
matching: list[Product] # Produkte, die zur Gruppen-Einheit zaehlen
# Artikel-ID -> Gruppen-Basiseinheiten je EINER Basiseinheit des Artikels.
# Fuer gleichartige Artikel 1,0; fuer artfremde der Zweiteinheit-Faktor.
# Bestandssummen muessen damit GEWICHTET werden (services/stock.py).
faktoren: dict[int, float]
def group_min_context(group: Group) -> GroupMinContext:
"""In welcher Einheit der Mindestbestand einer Gruppe zu lesen ist.
Eine Gruppe zaehlt in ihrer verwalteten Einheit (``min_stock_unit``) oder wenn
ein Gruppen-Gebinde definiert und aktiv ist in ganzen Packungen. Die
Packungsgroesse ist ein **Richtwert der Gruppe** (nicht die der einzelnen
Produkte), weil deren Packungen unterschiedlich gross sein koennen.
``matching`` umfasst die Artikel der Gruppe UND ihrer Untergruppen
(transitiv): „Wurst" zaehlt Wurst, Grillwurst, Salami und alles darunter.
Die Einheiten-Filterung der OBERgruppe gilt dabei fuer den ganzen
Teilgraphen.
Artfremde Artikel zaehlen mit, WENN sie eine Zweiteinheit in die Art der
Gruppe tragen: ein in Stueck gefuehrter Artikel mit „3 Stueck ≙ 250 g"
zaehlt in einer Kilogramm-Gruppe mit 83,333 g je Stueck. Ohne Bruecke bleibt
er wie bisher aussen vor. Die Umrechnung steht in ``faktoren``.
"""
alle = gruppen.produkte(group) # inkl. Untergruppen, ohne Dubletten
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
matching = []
faktoren: dict[int, float] = {}
for p in alle:
f = zweit_faktor(p, base)
if f is not None:
matching.append(p)
faktoren[p.id] = f
base_unit: BaseUnit | None = base
else:
# Ohne verwaltete Einheit zaehlt die Gruppe roh weiter dann werden
# Gramm und Stueck weiterhin ungefiltert addiert (unveraendert).
matching = alle
faktoren = {p.id: 1.0 for p in alle}
base_unit = None
if group.min_stock_in_packages and group.package_size and group.package_size > 0:
return GroupMinContext(
divisor=float(group.package_size),
label=group.package_label or "Packung",
is_package=True, base_unit=base_unit, matching=matching, faktoren=faktoren,
)
if unit is not None:
return GroupMinContext(
divisor=unit.factor or 1.0, label=unit.name, is_package=False,
base_unit=base_unit, matching=matching, faktoren=faktoren,
)
return GroupMinContext(
divisor=1.0, label="", is_package=False,
base_unit=None, matching=matching, faktoren=faktoren,
)

View File

@@ -0,0 +1,71 @@
"""Der Gruppen-Graph: Ober- und Untergruppen.
Anders als Kategorien und Lagerorte bilden Gruppen KEINEN Baum: „Grillwurst"
haengt unter „Wurst" UND unter „Grillgut" mit einem einzelnen ``parent_id``
waere genau das nicht abbildbar. Die Struktur ist deshalb ein gerichteter
azyklischer Graph.
Daraus folgt die eine Regel, an der hier alles haengt: Auswertungen laufen ueber
MENGEN, nicht ueber Baum-Walks. Eine ueber zwei Wege erreichbare Untergruppe
wuerde sonst doppelt zaehlen und den Bestand ihrer Obergruppe verfaelschen.
Jede Funktion schuetzt sich zusaetzlich mit einem ``gesehen``-Set gegen Ringe.
Ueber die API kann keiner entstehen (Pruefung in routers/groups.py), eine
eingespielte Sicherung kann aber einen mitbringen und ein Ring wuerde jede
Auswertung endlos laufen lassen. Gleiche Vorsichtsmassnahme wie in
routers/categories.py.
"""
from __future__ import annotations
from ..models import Group, Product
def teilgraph(group: Group) -> list[Group]:
"""Die Gruppe samt ALLEN Untergruppen (transitiv), jede genau einmal."""
gesehen: set[int] = set()
ergebnis: list[Group] = []
offen = [group]
while offen:
aktuell = offen.pop()
if aktuell.id in gesehen:
continue # ueber zwei Wege erreichbar ODER Ring in den Daten
gesehen.add(aktuell.id)
ergebnis.append(aktuell)
offen.extend(aktuell.children)
return ergebnis
def nachfahren_ids(group: Group) -> set[int]:
"""IDs aller Untergruppen (transitiv, ohne die Gruppe selbst)."""
return {g.id for g in teilgraph(group)} - {group.id}
def vorfahren_ids(group: Group) -> set[int]:
"""IDs aller Obergruppen (transitiv, ohne die Gruppe selbst)."""
gesehen: set[int] = set()
offen = list(group.parents)
while offen:
aktuell = offen.pop()
if aktuell.id in gesehen:
continue
gesehen.add(aktuell.id)
offen.extend(aktuell.parents)
return gesehen
def produkte(group: Group) -> list[Product]:
"""Alle Artikel der Gruppe UND ihrer Untergruppen jeder genau einmal.
Ein Artikel haengt an genau einer Gruppe (``Product.group_id``), die
Entdopplung greift also schon auf Gruppenebene. Der Artikel-Check bleibt
trotzdem stehen: er kostet nichts und macht die Absicht sichtbar.
"""
gesehen: set[int] = set()
ergebnis: list[Product] = []
for g in teilgraph(group):
for p in g.products:
if p.id not in gesehen:
gesehen.add(p.id)
ergebnis.append(p)
return ergebnis

View File

@@ -0,0 +1,151 @@
"""Mindestbestaende: „Ueberall" als Ort und die einmalige Datenwanderung.
Mindestbestaende haengen ausschliesslich an Lagerorten. Der frueher separate
Gesamt-Mindestbestand (``Product.min_stock`` / ``Group.min_stock``) ist jetzt
eine Zeile mit ``location_id = NULL`` „Ueberall": egal wo, Hauptsache die
Menge ist im Haus. Damit ist Ueberall die Wurzel ueber allen Lagerorten und
wird von derselben Verrechnung erfasst wie verschachtelte Orte.
Alle Mengen stehen in BASISEINHEITEN. Vorher waren es drei verschiedene
Einheiten nebeneinander (Gesamt in Basiseinheiten, je Ort in Artikeleinheiten,
Gruppen in der Gruppen-Erfassungseinheit) das machte jede Umrechnung zur
Fehlerquelle und liess gespeicherte Werte ihre Bedeutung aendern, sobald sich
eine Packungsgroesse aenderte.
"""
from __future__ import annotations
from collections.abc import Iterable
from sqlalchemy.orm import Session
from ..models import (
Group,
GroupLocationMinStock,
Product,
ProductLocationMinStock,
Setting,
)
from .conversion import article_unit, group_min_context
#: Anzeigename des Ortes NULL hier und in der Oberflaeche derselbe Begriff.
UEBERALL_NAME = "Überall"
#: Merker, dass die Datenwanderung gelaufen ist. Schritt 1 (Umrechnen der
#: vorhandenen Werte) waere sonst nicht wiederholungsfest.
MIGRATION_KEY = "min_stock_basis_v2"
def lies_ueberall(eintraege: Iterable) -> float | None:
"""Der „Ueberall"-Mindestbestand (Ort NULL) aus den Ort-Eintraegen.
Ersetzt die frueheren Spalten ``Product.min_stock``/``Group.min_stock``.
Ergebnis in Basiseinheiten; ``None`` = kein Ueberall-Bedarf hinterlegt.
"""
for e in eintraege:
if e.location_id is None:
return e.min_stock
return None
def schreibe_ueberall(db: Session, besitzer, wert: float | None) -> None:
"""Den „Ueberall"-Mindestbestand setzen oder (bei None/0) entfernen.
``besitzer`` ist ein ``Product`` oder eine ``Group`` beide haben eine
``location_min_stocks``-Beziehung mit derselben Form. Menge in
Basiseinheiten.
"""
vorhanden = next(
(e for e in besitzer.location_min_stocks if e.location_id is None), None
)
if wert is None or wert <= 0:
if vorhanden is not None:
besitzer.location_min_stocks.remove(vorhanden)
return
if vorhanden is not None:
vorhanden.min_stock = wert
return
if isinstance(besitzer, Product):
neu = ProductLocationMinStock(
product_id=besitzer.id, location_id=None, min_stock=wert
)
else:
neu = GroupLocationMinStock(
group_id=besitzer.id, location_id=None, min_stock=wert
)
besitzer.location_min_stocks.append(neu)
def migriere_mindestbestaende(db: Session) -> None:
"""Einmalig: Gesamt-Werte zu „Ueberall" machen, alles in Basiseinheiten.
Laeuft beim Start (siehe main.py) und tut beim zweiten Mal nichts mehr.
"""
if db.get(Setting, MIGRATION_KEY) is not None:
return
# 1. Bestehende Ort-Zeilen umrechnen: Artikel- bzw. Gruppeneinheit ->
# Basiseinheiten. Der einzige Schritt, der bei doppelter Ausfuehrung
# falsche Werte erzeugen wuerde daher der Merker unten.
for eintrag in db.query(ProductLocationMinStock).all():
product = db.get(Product, eintrag.product_id)
if product is None:
continue
faktor, _ = article_unit(product)
eintrag.min_stock = eintrag.min_stock * (faktor or 1.0)
for eintrag in db.query(GroupLocationMinStock).all():
group = db.get(Group, eintrag.group_id)
if group is None:
continue
eintrag.min_stock = eintrag.min_stock * (group_min_context(group).divisor or 1.0)
# 2. Artikel-Gesamtwerte werden zur Ueberall-Zeile. ``Product.min_stock``
# stand bereits in Basiseinheiten, also keine Umrechnung.
artikel = (
db.query(Product)
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
.all()
)
for product in artikel:
schon_da = (
db.query(ProductLocationMinStock)
.filter(
ProductLocationMinStock.product_id == product.id,
ProductLocationMinStock.location_id.is_(None),
)
.first()
)
if schon_da is None:
db.add(
ProductLocationMinStock(
product_id=product.id, location_id=None, min_stock=product.min_stock
)
)
product.min_stock = None
# 3. Gruppen-Gesamtwerte genauso die stehen in der Erfassungseinheit der
# Gruppe und muessen deshalb ueber den Divisor.
gruppen = db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
for group in gruppen:
divisor = group_min_context(group).divisor or 1.0
schon_da = (
db.query(GroupLocationMinStock)
.filter(
GroupLocationMinStock.group_id == group.id,
GroupLocationMinStock.location_id.is_(None),
)
.first()
)
if schon_da is None:
db.add(
GroupLocationMinStock(
group_id=group.id,
location_id=None,
min_stock=group.min_stock * divisor,
)
)
group.min_stock = None
db.add(Setting(key=MIGRATION_KEY, value="done"))
db.commit()

View File

@@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Sequence
from datetime import date
from sqlalchemy import asc
from sqlalchemy import asc, func
from sqlalchemy.orm import Session
from ..models import (
@@ -215,6 +216,90 @@ def current_stock(db: Session, product_id: int) -> float:
return float(sum(q for (q,) in total))
def _summe_je_artikel(
db: Session, produkte: Sequence[Product], orte: set[str] | None
) -> dict[int, float]:
"""Bestand JE ARTIKEL (Basiseinheiten) in zwei Abfragen.
``orte`` = None zaehlt alles (auch Chargen ohne Lagerort), sonst nur die
genannten Orte. Gruppiert wird nach Artikel, weil verschiedene Artikel
unterschiedliche Umrechnungsfaktoren haben koennen (Zweiteinheit).
"""
if not produkte:
return {}
# Gleiche Fallunterscheidung wie current_stock: Einzelstuecke zaehlen ihre
# Items, alle anderen summieren die Lot-Mengen.
einzel = [p.id for p in produkte if p.individual]
lots = [p.id for p in produkte if not p.individual]
je_artikel: dict[int, float] = {}
if lots:
abfrage = (
db.query(Lot.product_id, func.coalesce(func.sum(Lot.quantity), 0.0))
.filter(Lot.product_id.in_(lots))
)
if orte is not None:
abfrage = abfrage.filter(Lot.location_id.in_(orte))
for pid, menge in abfrage.group_by(Lot.product_id).all():
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
if einzel:
abfrage = (
db.query(Item.product_id, func.count(Item.id))
.filter(Item.product_id.in_(einzel))
)
if orte is not None:
abfrage = abfrage.filter(Item.location_id.in_(orte))
for pid, anzahl in abfrage.group_by(Item.product_id).all():
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(anzahl or 0)
return je_artikel
def summe_bestand_gewichtet(
db: Session,
produkte: Sequence[Product],
faktoren: dict[int, float] | None = None,
) -> float:
"""Gesamtbestand mehrerer Artikel, je Artikel mit eigenem Faktor.
Der Faktor kommt aus der Zweiteinheit (``GroupMinContext.faktoren``): eine
Gruppe in Kilogramm rechnet einen in Stueck gefuehrten Artikel ueber
83,333 g je Stueck mit. Ohne Faktoren-Tabelle wird ungewichtet addiert.
"""
je_artikel = _summe_je_artikel(db, produkte, None)
if faktoren is None:
return float(sum(je_artikel.values()))
return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items()))
def summe_bestand_base(db: Session, produkte: Sequence[Product]) -> float:
"""Gesamtbestand mehrerer Artikel (Basiseinheiten), ungewichtet."""
return summe_bestand_gewichtet(db, produkte, None)
def summe_bestand_im_subtree_gewichtet(
db: Session,
produkte: Sequence[Product],
location_id: str,
faktoren: dict[int, float] | None = None,
) -> float:
"""Wie ``summe_bestand_gewichtet``, aber nur an einem Ort inkl. Unterorten.
Chargen ohne Lagerort liegen in keinem Subtree und zaehlen hier bewusst
nicht mit im Gesamtbestand („Ueberall") dagegen schon.
"""
ids = {location_id} | descendant_location_ids(db, location_id)
je_artikel = _summe_je_artikel(db, produkte, ids)
if faktoren is None:
return float(sum(je_artikel.values()))
return float(sum(m * faktoren.get(pid, 1.0) for pid, m in je_artikel.items()))
def summe_bestand_im_subtree_base(
db: Session, produkte: Sequence[Product], location_id: str
) -> float:
"""Ungewichtete Variante von ``summe_bestand_im_subtree_gewichtet``."""
return summe_bestand_im_subtree_gewichtet(db, produkte, location_id, None)
def location_stock_base(db: Session, product: Product, location_id: str) -> float:
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
@@ -299,7 +384,90 @@ def check_in(
note=note,
)
)
return lot
db.flush()
# Liegt am selben Ort schon eine Charge mit gleichem MHD, wird eingelagert =
# dazugebucht statt eine zweite Charge anzulegen. Die Bewegung bleibt als
# eigener Eintrag im Verlauf erhalten (nur auf die bestehende Charge bezogen).
return consolidate_lot_group(
db, product.id, lot.best_before, lot.best_before_precision, location_id
)
def consolidate_lot_group(
db: Session,
product_id: int,
best_before: date | None,
best_before_precision: str,
location_id: str | None,
) -> Lot | None:
"""Chargen mit gleichem Artikel, MHD, Genauigkeit und Lagerort zu EINER
zusammenfassen: Mengen addieren, Bewegungen auf die verbleibende (älteste)
Charge umhängen, die übrigen löschen. Gibt die verbleibende Charge zurück.
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.
"""
query = db.query(Lot).filter(
Lot.product_id == product_id,
Lot.best_before_precision == best_before_precision,
)
query = (
query.filter(Lot.best_before.is_(None))
if best_before is None
else query.filter(Lot.best_before == best_before)
)
query = (
query.filter(Lot.location_id.is_(None))
if location_id is None
else query.filter(Lot.location_id == location_id)
)
lots = query.order_by(asc(Lot.created_at), asc(Lot.id)).all()
if len(lots) <= 1:
return lots[0] if lots else None
survivor = lots[0]
for extra in lots[1:]:
survivor.quantity += extra.quantity
db.query(Movement).filter(Movement.lot_id == extra.id).update(
{Movement.lot_id: survivor.id}, synchronize_session=False
)
db.delete(extra)
db.flush()
return survivor
def consolidate_duplicate_lots(db: Session) -> int:
"""Alle bereits vorhandenen Dubletten einmalig zusammenfassen (beim Start).
Idempotent: Nach dem ersten Lauf gibt es keine Gruppen mit mehr als einer
Charge mehr. Gibt die Anzahl entfernter Chargen zurück."""
gruppen = (
db.query(
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
)
.group_by(
Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id
)
.having(func.count(Lot.id) > 1)
.all()
)
entfernt = 0
for product_id, best_before, precision, location_id in gruppen:
vorher = (
db.query(func.count(Lot.id))
.filter(
Lot.product_id == product_id,
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,
)
.scalar()
)
consolidate_lot_group(db, product_id, best_before, precision, location_id)
entfernt += max(0, (vorher or 0) - 1)
if entfernt:
db.commit()
return entfernt
def check_out_lot(

View File

@@ -0,0 +1,159 @@
"""Sicherung: Obergruppen und Mindestbestände je Ort überstehen den Umlauf.
Ohne diesen Weg gingen genau die Daten verloren, um die es beim Umbau ging
die Kanten des Gruppen-Graphen und die „Überall"-Zeilen. Das JSON-Backup führt
Gruppen über ihren NAMEN, nicht über IDs: die sind zwischen zwei Instanzen
nicht gleich.
"""
import json
import pytest
from app.models import (
BaseUnit,
Group,
GroupLocationMinStock,
Location,
Product,
ProductLocationMinStock,
Role,
User,
)
from app.routers.transfer import _import_json, export_backup_json
@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
def _sicherung(db, user) -> bytes:
return export_backup_json(db=db, _=user).body
def test_backup_nimmt_obergruppen_und_bedarfe_mit(db, user, monkeypatch):
keller = Location(name="Keller")
db.add(keller)
db.flush()
wurst = Group(name="Wurst")
grillgut = Group(name="Grillgut")
db.add_all([wurst, grillgut])
db.flush()
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
db.add(grillwurst)
db.flush()
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
db.add(p)
db.flush()
db.add_all([
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
GroupLocationMinStock(group_id=grillwurst.id, location_id=keller.id, min_stock=2000),
ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=800),
])
db.commit()
daten = json.loads(_sicherung(db, user))
assert daten["version"] == 3
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"]
assert {"location": "Keller", "min_stock": 2000} in nach_name["Grillwurst"]["min_stocks"]
artikel = daten["products"][0]
assert {"location": None, "min_stock": 800} in artikel["min_stocks"]
def test_wiederherstellen_baut_den_graphen_neu_auf(db, user):
"""Sicherung aus einer Instanz, Einspielen in eine leere zweite."""
keller = Location(name="Keller")
db.add(keller)
db.flush()
wurst = Group(name="Wurst")
grillgut = Group(name="Grillgut")
db.add_all([wurst, grillgut])
db.flush()
grillwurst = Group(name="Grillwurst", parents=[wurst, grillgut])
db.add(grillwurst)
db.flush()
p = Product(name="Bell Grillwurst", base_unit=BaseUnit.gram, group_id=grillwurst.id)
db.add(p)
db.flush()
db.add_all([
GroupLocationMinStock(group_id=wurst.id, location_id=None, min_stock=5000),
ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=800),
])
db.commit()
inhalt = _sicherung(db, user)
# Zweite, leere Instanz.
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.seed import ensure_builtin_units
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")
neu_wurst = zweite.query(Group).filter(Group.name == "Wurst").one()
neu_grillwurst = zweite.query(Group).filter(Group.name == "Grillwurst").one()
assert sorted(g.name for g in neu_grillwurst.parents) == ["Grillgut", "Wurst"]
assert neu_wurst.location_min_stocks[0].location_id is None
assert neu_wurst.location_min_stocks[0].min_stock == pytest.approx(5000)
neu_artikel = zweite.query(Product).filter(Product.name == "Bell Grillwurst").one()
orte = {
(e.location.name if e.location else None): e.min_stock
for e in neu_artikel.location_min_stocks
}
assert orte == {"Keller": pytest.approx(800)}
zweite.close()
engine.dispose()
def test_ring_aus_der_sicherung_wird_abgewiesen(db, user):
"""Eine beschädigte Datei darf keinen Ring einschleusen."""
daten = {
"version": 3,
"groups": [
{"name": "A", "parents": ["B"]},
{"name": "B", "parents": ["A"]},
],
"products": [],
}
ergebnis = _import_json(db, json.dumps(daten).encode(), user, "add")
a = db.query(Group).filter(Group.name == "A").one()
b = db.query(Group).filter(Group.name == "B").one()
# Eine der beiden Kanten greift, die andere wird als Ring abgewiesen.
assert not (a.parents and b.parents)
assert any("Ring" in f for f in ergebnis.get("errors", []))
def test_sicherung_nimmt_zweiteinheit_mit(db, user):
"""Ohne das ginge die Brücke beim Wiederherstellen stillschweigend verloren."""
p = Product(name="Bratwurst", base_unit=BaseUnit.piece,
secondary_base="gram", secondary_count=3, secondary_amount=250)
db.add(p)
db.commit()
daten = json.loads(_sicherung(db, user))
artikel = daten["products"][0]
assert artikel["secondary_base"] == "gram"
assert artikel["secondary_count"] == 3
assert artikel["secondary_amount"] == 250

View File

@@ -9,8 +9,9 @@ import json
import pytest
from app.crud import product_tracking
from app.crud import product_to_out, product_tracking, products_to_out_bulk
from app.models import (
BaseUnit,
Category,
CategoryTracking,
FieldDefinition,
@@ -74,6 +75,66 @@ def test_gegenstandskategorie_macht_object(db):
assert product_tracking(db, product) == "object"
def test_verbrauchsgegenstand_ohne_kategorie_ist_object(db):
# Nach dem Umstellen Lebensmittel -> Verbrauchsgegenstand hat der Artikel oft
# keine (Gegenstands-)Kategorie mehr. Das Flag muss ihn trotzdem als
# Gegenstand führen, sonst rutscht er zurück in die Lebensmittelliste.
product = Product(name="Sonnencreme", bulk=True)
db.add(product)
db.commit()
assert product_tracking(db, product) == "object"
def test_einzelstueck_ohne_kategorie_ist_object(db):
product = Product(name="Powerbank", individual=True)
db.add(product)
db.commit()
assert product_tracking(db, product) == "object"
# ---- Gebündelte Listen-Ausgabe ----
def test_bulk_liste_zaehlt_bestand_wie_einzeln(db):
# products_to_out_bulk muss denselben Bestand liefern wie product_to_out
# Lot-Summe für Mengen, Item-Anzahl für Einzelstücke.
reis = Product(name="Reis", base_unit=BaseUnit.gram, package_size=500)
db.add(reis)
db.flush()
a = _loc(db, "Regal")
object_add(db, reis, 1000, a.id, None) # 1000 g als Charge
powerbank = _individual_product(db, name="Powerbank")
for _ in range(2):
db.add(Item(uid=generate_uid(db), product_id=powerbank.id))
db.commit()
einzeln = {product_to_out(db, p).id: product_to_out(db, p).stock for p in [reis, powerbank]}
gebuendelt = {o.id: o.stock for o in products_to_out_bulk(db, [reis, powerbank])}
assert gebuendelt == einzeln
assert gebuendelt[reis.id] == 1000
assert gebuendelt[powerbank.id] == 2
def test_chargenliste_nur_charge_artikel(db):
# Die Chargenliste zeigt nur Lebensmittel + Verbrauchsgegenstände nicht die
# Lots von „Menge je Lagerort".
from app.routers.stock import list_all_lots
a = _loc(db, "Regal")
food = Product(name="Reis", base_unit=BaseUnit.gram)
db.add(food)
db.flush()
object_add(db, food, 500, a.id, None)
unterhose, _ = _object_product(db, name="Unterhose") # Menge je Lagerort
object_add(db, unterhose, 5, a.id, None)
creme = _bulk_product(db, name="Sonnencreme") # Verbrauchsgegenstand
object_add(db, creme, 200, a.id, None)
db.commit()
namen = {r.product_name for r in list_all_lots(db=db, _=None)}
assert "Reis" in namen
assert "Sonnencreme" in namen
assert "Unterhose" not in namen
def test_neue_oberkategorie_ist_gegenstand(db):
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
assert out.tracking == CategoryTracking.object
@@ -275,3 +336,58 @@ def test_mengen_produkt_zaehlt_weiter_lots(db):
object_add(db, product, 5, a.id, None)
db.commit()
assert current_stock(db, product.id) == 5
# ---- Verbrauchsgegenstand (bulk): wie ein Lebensmittel geführt ----
def _bulk_product(db, name="Sonnencreme", base_unit=BaseUnit.milliliter, min_stock=500):
"""Gegenstand, der als Charge (Menge + Einheit) geführt wird wie ein
Lebensmittel, aber in der Gegenstände-Welt und ohne eindeutigen Code."""
cat = Category(name="Pflege", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(
name=name, category_id=cat.id, bulk=True,
base_unit=base_unit, min_stock=min_stock,
)
db.add(product)
db.commit()
db.refresh(product)
return product
def test_verbrauchsgegenstand_zaehlt_ueber_lots(db):
# bulk-Gegenstand (individual=False) zählt über Chargen wie ein Lebensmittel.
product = _bulk_product(db)
assert product.bulk is True and product.individual is False
a = _loc(db, "Bad")
object_add(db, product, 200, a.id, None)
object_add(db, product, 200, a.id, None)
db.commit()
assert current_stock(db, product.id) == 400
def test_verbrauchsgegenstand_unter_mindestbestand(db):
product = _bulk_product(db, min_stock=500) # 500 ml
a = _loc(db, "Bad")
object_add(db, product, 200, a.id, None)
db.commit()
# Unter dem Mindestbestand -> gehört auf die Einkaufsliste (wie ein Lebensmittel).
assert current_stock(db, product.id) < product.min_stock
# ---- Zuletzt geändert ----
def test_updated_at_wird_bei_aenderung_nachgezogen(db):
import time
product = Product(name="Alt")
db.add(product)
db.commit()
db.refresh(product)
erst = product.updated_at
assert erst is not None
time.sleep(0.01)
product.name = "Neu"
db.commit()
db.refresh(product)
assert product.updated_at > erst

View File

@@ -0,0 +1,236 @@
"""Ober- und Untergruppen: Bestand, Verrechnung und Ringschutz.
Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum: „Grillwurst"
haengt unter „Wurst" UND unter „Grillgut". Der wichtigste Test hier ist deshalb
``test_zwei_wege_zaehlen_nur_einmal`` ein Baum-Walk statt einer Mengenbildung
wuerde den Bestand einer ueber zwei Wege erreichbaren Untergruppe verdoppeln.
"""
import pytest
from fastapi import HTTPException
from app.models import (
BaseUnit,
Group,
GroupLocationMinStock,
Location,
Lot,
Product,
Role,
Unit,
User,
)
from app.routers.groups import _group_to_out, create_group, delete_group, update_group
from app.routers.views import group_shopping_list, shopping_list_by_location
from app.schemas import GroupCreate, GroupUpdate
from app.services.min_stock import schreibe_ueberall
@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
def _gramm(db) -> Unit:
return db.query(Unit).filter(Unit.name == "Gramm").one()
def _gruppe(db, name: str, *, eltern: list[Group] | None = None) -> Group:
g = Group(name=name, min_stock_unit=_gramm(db))
if eltern:
g.parents = list(eltern)
db.add(g)
db.commit()
db.refresh(g)
return g
def _artikel(db, name: str, gruppe: Group, bestand: float = 0.0, ort=None) -> Product:
p = Product(name=name, base_unit=BaseUnit.gram, group=gruppe)
db.add(p)
db.flush()
if bestand:
db.add(Lot(product_id=p.id, quantity=bestand,
location_id=ort.id if ort is not None else None))
db.commit()
return p
def _ueberall(db, besitzer, menge_base: float) -> None:
schreibe_ueberall(db, besitzer, menge_base)
db.commit()
def test_bestand_zaehlt_untergruppen_transitiv(db, user):
"""Bestand nur im Blatt die Obergruppe zwei Ebenen darueber sieht ihn."""
wurst = _gruppe(db, "Wurst")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
bratwurst = _gruppe(db, "Bratwurst", eltern=[grillwurst])
_artikel(db, "Bell Bratwurst", bratwurst, bestand=800)
assert _group_to_out(db, bratwurst).stock == pytest.approx(800)
assert _group_to_out(db, grillwurst).stock == pytest.approx(800)
assert _group_to_out(db, wurst).stock == pytest.approx(800)
# product_count zaehlt transitiv, direct_product_count nur die eigenen.
oben = _group_to_out(db, wurst)
assert oben.product_count == 1
assert oben.direct_product_count == 0
def test_zwei_wege_zaehlen_nur_einmal(db, user):
"""Grillwurst haengt unter Wurst UND Grillgut, beide unter Fleisch.
„Fleisch" erreicht Grillwurst ueber zwei Wege der Bestand darf trotzdem
nur einmal zaehlen.
"""
fleisch = _gruppe(db, "Fleisch")
wurst = _gruppe(db, "Wurst", eltern=[fleisch])
grillgut = _gruppe(db, "Grillgut", eltern=[fleisch])
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst, grillgut])
_artikel(db, "Bell Grillwurst", grillwurst, bestand=500)
assert _group_to_out(db, grillwurst).stock == pytest.approx(500)
assert _group_to_out(db, wurst).stock == pytest.approx(500)
assert _group_to_out(db, grillgut).stock == pytest.approx(500)
# Der eigentliche Punkt: NICHT 1000.
assert _group_to_out(db, fleisch).stock == pytest.approx(500)
assert _group_to_out(db, fleisch).product_count == 1
def test_ring_wird_abgelehnt(db, user):
"""Weder sich selbst noch einer eigenen Untergruppe unterordnen."""
wurst = _gruppe(db, "Wurst")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
with pytest.raises(HTTPException) as fehler:
update_group(group_id=wurst.id, payload=GroupUpdate(parent_ids=[grillwurst.id]),
db=db, _=user)
assert fehler.value.status_code == 409
with pytest.raises(HTTPException):
update_group(group_id=wurst.id, payload=GroupUpdate(parent_ids=[wurst.id]),
db=db, _=user)
def test_obergruppe_beim_anlegen_setzen(db, user):
"""parent_ids wirkt auch schon beim Anlegen (braucht die frische ID)."""
wurst = _gruppe(db, "Wurst")
out = create_group(
payload=GroupCreate(name="Salami", parent_ids=[wurst.id]), db=db, _=user
)
assert out.parent_ids == [wurst.id]
assert _group_to_out(db, wurst).child_ids == [out.id]
def test_bedarf_ober_und_untergruppe_wird_verrechnet(db, user):
"""Wurst 5 kg, Grillwurst 2 kg, 1 kg vorhanden.
Fuer Grillwurst fehlt 1 kg. Das deckt bei Wurst mit, dort fehlen also
5 - (1 vorhanden + 1 gekauft) = 3 kg nicht 4.
"""
wurst = _gruppe(db, "Wurst")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
_artikel(db, "Bell Grillwurst", grillwurst, bestand=1000)
_ueberall(db, wurst, 5000)
_ueberall(db, grillwurst, 2000)
nach_name = {i.name: i for i in group_shopping_list(db=db, _=user)}
assert nach_name["Grillwurst"].deficit == pytest.approx(1000)
assert nach_name["Wurst"].deficit == pytest.approx(3000)
assert nach_name["Wurst"].subgroup_count == 1
def test_untergruppen_kauf_deckt_obergruppe_ganz(db, user):
"""Deckt der Kauf fuer die Untergruppe alles, verschwindet die Obergruppe."""
wurst = _gruppe(db, "Wurst")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
_artikel(db, "Bell Grillwurst", grillwurst)
_ueberall(db, wurst, 2000)
_ueberall(db, grillwurst, 2000)
namen = {i.name for i in group_shopping_list(db=db, _=user)}
assert namen == {"Grillwurst"}
def test_ueberlappung_bleibt_stehen(db, user):
"""Bekannte Grenze, absichtlich festgehalten.
„Wurst" und „Grillgut" sind keine Vorfahren voneinander, teilen sich aber
„Grillwurst". Ohne eigenen Mindestbestand auf der gemeinsamen Untergruppe
gibt es keinen Schluessel, ueber den verrechnet werden koennte beide
Bedarfe stehen einzeln da, obwohl ein Kauf beide decken wuerde.
"""
wurst = _gruppe(db, "Wurst")
grillgut = _gruppe(db, "Grillgut")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst, grillgut])
_artikel(db, "Bell Grillwurst", grillwurst)
_ueberall(db, wurst, 2000)
_ueberall(db, grillgut, 2000)
nach_name = {i.name: i for i in group_shopping_list(db=db, _=user)}
assert nach_name["Wurst"].deficit == pytest.approx(2000)
assert nach_name["Grillgut"].deficit == pytest.approx(2000)
def test_einheitenfilter_gilt_auch_fuer_untergruppen(db, user):
"""Eine Obergruppe in Gramm zaehlt stueckweise Artikel der Untergruppe nicht."""
stueck = db.query(Unit).filter(Unit.name == "Stück").one()
wurst = _gruppe(db, "Wurst") # zaehlt in Gramm
dosen = Group(name="Wurstdosen", min_stock_unit=stueck, parents=[wurst])
db.add(dosen)
db.commit()
_artikel(db, "Wurst im Glas", wurst, bestand=500)
p = Product(name="Wurstdose", base_unit=BaseUnit.piece, group=dosen)
db.add(p)
db.flush()
db.add(Lot(product_id=p.id, quantity=4))
db.commit()
# Die 4 Stueck zaehlen bei „Wurst" (Gramm) nicht mit.
assert _group_to_out(db, wurst).stock == pytest.approx(500)
assert _group_to_out(db, dosen).stock == pytest.approx(4)
def test_gruppenbedarf_je_ort_verrechnet_beide_hierarchien(db, user):
"""Kauf fuer die Untergruppe im Unterort deckt die Obergruppe im Oberort."""
haus = Location(name="Haus")
db.add(haus)
db.flush()
kueche = Location(name="Küche", parent_id=haus.id)
db.add(kueche)
db.commit()
wurst = _gruppe(db, "Wurst")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
_artikel(db, "Bell Grillwurst", grillwurst, bestand=1000, ort=kueche)
db.add_all([
GroupLocationMinStock(group_id=wurst.id, location_id=haus.id, min_stock=5000),
GroupLocationMinStock(group_id=grillwurst.id, location_id=kueche.id, min_stock=2000),
])
db.commit()
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
kueche_bedarf = {g.name: g for g in nach_ort["Küche"].groups}
haus_bedarf = {g.name: g for g in nach_ort["Haus"].groups}
assert kueche_bedarf["Grillwurst"].deficit == pytest.approx(1000)
# 5000 - (1000 vorhanden + 1000 fuer die Kueche gekauft)
assert haus_bedarf["Wurst"].deficit == pytest.approx(3000)
def test_loeschen_einer_gruppe_mit_kindern(db, user):
"""Untergruppen bleiben bestehen und verlieren nur die Verbindung."""
wurst = _gruppe(db, "Wurst")
grillwurst = _gruppe(db, "Grillwurst", eltern=[wurst])
p = _artikel(db, "Bell Grillwurst", grillwurst, bestand=500)
delete_group(group_id=wurst.id, db=db, _=user)
db.refresh(grillwurst)
assert db.get(Group, wurst.id) is None
assert _group_to_out(db, grillwurst).parent_ids == []
db.refresh(p)
assert p.group_id == grillwurst.id

View File

@@ -96,3 +96,51 @@ def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
needs = shopping_list_by_location(db=db, _=user)
assert needs[0].location_name == "Hedingen"
assert needs[0].products[0].deficit == 3 # 5 - 2
def test_verschachtelte_orte_werden_verrechnet(db, user):
"""Ober- UND Unterort haben einen Mindestbestand. Was in den Unterort gekauft
wird, liegt im Subtree des Oberorts und deckt ihn mit dann taucht der Oberort
nicht mehr auf (keine Doppelzählung)."""
p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
lemgo = Location(name="Lemgo")
db.add(lemgo)
db.flush()
kueche = Location(name="Kueche", parent_id=lemgo.id)
db.add(kueche)
db.flush()
# 3 direkt in Lemgo, 1 in der Kueche -> Lemgo-Subtree = 4, Kueche = 1.
db.add(Lot(product_id=p.id, quantity=3, location_id=lemgo.id))
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5))
db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2))
db.commit()
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
# 1 in die Kueche gekauft (2-1) hebt Lemgo auf 5 -> Lemgo verschwindet.
assert set(nach_ort) == {"Kueche"}
assert nach_ort["Kueche"].products[0].deficit == 1
def test_verschachtelte_orte_restbedarf_am_oberort(db, user):
"""Deckt der Unterort-Kauf den Oberort nicht ganz, bleibt der Restbedarf am
Oberort aber die Unterort-Menge wird nicht doppelt gezaehlt."""
p = Product(name="Mehl", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
lemgo = Location(name="Lemgo")
db.add(lemgo)
db.flush()
kueche = Location(name="Kueche", parent_id=lemgo.id)
db.add(kueche)
db.flush()
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id)) # nur 1 in der Kueche
db.add(ProductLocationMinStock(product_id=p.id, location_id=lemgo.id, min_stock=5))
db.add(ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2))
db.commit()
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
assert nach_ort["Kueche"].products[0].deficit == 1 # 2 - 1
assert nach_ort["Lemgo"].products[0].deficit == 3 # 5 - (1 da + 1 aus Kueche-Kauf), nicht 4

View File

@@ -0,0 +1,192 @@
"""Mindestbestände hängen nur noch an Lagerorten „Überall" ist Ort NULL.
„Überall" (egal wo, Hauptsache im Haus) ersetzt den früheren Gesamt-Mindest-
bestand und liegt als Wurzel über allen Lagerorten. Dadurch verrechnet dieselbe
Faltung wie bei verschachtelten Orten auch Überall gegen Küche & Co. ein
Artikel steht nicht mehr doppelt in der Einkaufsliste.
"""
import pytest
from sqlalchemy.exc import IntegrityError
from app.models import (
BaseUnit,
Group,
GroupLocationMinStock,
Location,
Lot,
Product,
ProductLocationMinStock,
Role,
Setting,
Unit,
User,
)
from app.crud import product_to_out
from app.routers.views import shopping_list, shopping_list_by_location
from app.services.min_stock import (
MIGRATION_KEY,
UEBERALL_NAME,
lies_ueberall,
migriere_mindestbestaende,
schreibe_ueberall,
)
@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
def _artikel(db, name="Gurken", **kwargs) -> Product:
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar.
p = Product(name=name, base_unit=BaseUnit.gram, package_size=1, **kwargs)
db.add(p)
db.commit()
db.refresh(p)
return p
def test_ueberall_zeile_wird_gelesen(db, user):
p = _artikel(db)
schreibe_ueberall(db, p, 500)
db.commit()
out = product_to_out(db, p)
assert out.min_stock == pytest.approx(500)
(zeile,) = out.location_min_stocks
assert zeile.location_id is None
assert zeile.location_name == UEBERALL_NAME
assert lies_ueberall(p.location_min_stocks) == pytest.approx(500)
def test_ueberall_auf_null_setzen_entfernt_die_zeile(db, user):
p = _artikel(db)
schreibe_ueberall(db, p, 500)
db.commit()
schreibe_ueberall(db, p, None)
db.commit()
assert p.location_min_stocks == []
assert product_to_out(db, p).min_stock is None
def test_zwei_ueberall_zeilen_werden_abgelehnt(db, user):
"""Der Teilindex ersetzt den UNIQUE-Schutz, den NULLs nicht bekommen."""
p = _artikel(db)
db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=500))
db.commit()
db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=900))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_ueberall_wird_gegen_lagerort_verrechnet(db, user):
"""Überall 5, Küche 2, je 1 vorhanden -> nicht 6 kaufen.
Für die Küche fehlt 1. Das liegt danach auch im Haus und deckt Überall mit:
dort fehlen 5 - (1 vorhanden + 1 gekauft) = 3.
"""
p = _artikel(db, "Gurken")
kueche = Location(name="Küche")
db.add(kueche)
db.flush()
db.add(Lot(product_id=p.id, quantity=1, location_id=kueche.id))
db.add_all([
ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=5),
ProductLocationMinStock(product_id=p.id, location_id=kueche.id, min_stock=2),
])
db.commit()
(gesamt,) = shopping_list(db=db, _=user)
assert gesamt.deficit == pytest.approx(3)
nach_ort = {n.location_name: n for n in shopping_list_by_location(db=db, _=user)}
assert nach_ort["Küche"].products[0].deficit == pytest.approx(1)
# „Überall" gehört nicht in die Ortsliste.
assert UEBERALL_NAME not in nach_ort
def test_ueberall_zaehlt_chargen_ohne_ort_mit(db, user):
"""Eine Charge ohne Lagerort liegt in keinem Ort-Subtree, aber im Haus."""
p = _artikel(db, "Butter")
db.add(Lot(product_id=p.id, quantity=4, location_id=None))
db.add(ProductLocationMinStock(product_id=p.id, location_id=None, min_stock=5))
db.commit()
(gesamt,) = shopping_list(db=db, _=user)
assert gesamt.stock == pytest.approx(4)
assert gesamt.deficit == pytest.approx(1)
# ---- Datenwanderung ----
def _gramm(db) -> Unit:
return db.query(Unit).filter(Unit.name == "Gramm").one()
def test_migration_macht_gesamtwerte_zu_ueberall(db, user):
"""Alter Stand: min_stock am Artikel, Ort-Werte in Artikeleinheiten."""
p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=150,
package_label="Glas", min_stock=600)
db.add(p)
db.flush()
keller = Location(name="Keller")
db.add(keller)
db.flush()
# 2 Gläser à 150 g frueher so gespeichert (Artikeleinheiten).
db.add(ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=2))
db.commit()
migriere_mindestbestaende(db)
db.refresh(p)
assert p.min_stock is None # Quelle geleert
werte = {e.location_id: e.min_stock for e in p.location_min_stocks}
assert werte[None] == pytest.approx(600) # war schon Basiseinheiten
assert werte[keller.id] == pytest.approx(300) # 2 Gläser * 150 g
def test_migration_rechnet_gruppenwerte_ueber_den_divisor(db, user):
gruppe = Group(name="Pesto", min_stock=3, min_stock_unit=_gramm(db),
package_size=150, package_label="Glas", min_stock_in_packages=True)
db.add(gruppe)
db.flush()
keller = Location(name="Keller")
db.add(keller)
db.flush()
db.add(GroupLocationMinStock(group_id=gruppe.id, location_id=keller.id, min_stock=2))
db.commit()
migriere_mindestbestaende(db)
db.refresh(gruppe)
assert gruppe.min_stock is None
werte = {e.location_id: e.min_stock for e in gruppe.location_min_stocks}
assert werte[None] == pytest.approx(450) # 3 Gläser * 150 g
assert werte[keller.id] == pytest.approx(300) # 2 Gläser * 150 g
def test_migration_laeuft_nur_einmal(db, user):
"""Der zweite Lauf darf die Werte nicht erneut hochrechnen."""
p = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=150, min_stock=600)
db.add(p)
db.flush()
keller = Location(name="Keller")
db.add(keller)
db.flush()
db.add(ProductLocationMinStock(product_id=p.id, location_id=keller.id, min_stock=2))
db.commit()
migriere_mindestbestaende(db)
migriere_mindestbestaende(db)
db.refresh(p)
werte = {e.location_id: e.min_stock for e in p.location_min_stocks}
assert werte[keller.id] == pytest.approx(300) # nicht 45000
assert db.get(Setting, MIGRATION_KEY) is not None

View File

@@ -1,4 +1,4 @@
from app.off import parse_quantity
from app.off import parse_gebinde, parse_quantity
def test_grams():
@@ -22,3 +22,31 @@ def test_unparsable_is_piece():
assert parse_quantity(None) == ("piece", None)
assert parse_quantity("6 Stück") == ("piece", None)
assert parse_quantity("") == ("piece", None)
def test_multiplikator_wird_erkannt():
"""„3 x 80 g" ist nicht dasselbe wie „240 g": drei zaehlbare Teile."""
assert parse_gebinde("3 x 80 g") == ("gram", 240.0, 3.0, 80.0)
def test_multiplikator_liter():
assert parse_gebinde("6 × 1,5 l") == ("milliliter", 9000.0, 6.0, 1500.0)
def test_multiplikator_mit_a_akzent():
assert parse_gebinde("4 Stück à 125 g") == ("gram", 500.0, 4.0, 125.0)
def test_ohne_multiplikator_unveraendert():
assert parse_gebinde("500 g") == ("gram", 500.0, None, None)
assert parse_quantity("500 g") == ("gram", 500)
def test_zaehlwort_ohne_muster_bleibt_grenze():
"""Bekannte Grenze: „12 Eier à 53 g" faellt auf die Einzelmenge zurueck.
Das Muster kennt nur eine kurze Liste von Zaehlwoertern. Ein generisches
Wort wuerde es zu weit oeffnen lieber eine nicht erkannte Stueckzahl als
eine falsch geratene.
"""
assert parse_gebinde("12 Eier à 53 g").stueck is None

View File

@@ -0,0 +1,60 @@
"""Kassenzettel-Abgleich: OCR-Zeilen den ähnlichsten Lebensmitteln zuordnen."""
import pytest
from app.models import BaseUnit, Product, Role, User
from app.routers.products import match_receipt
from app.schemas import MatchRequest
@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
def test_abgekuerzte_kassenzeile_trifft_lebensmittel(db, user):
p = Product(
name="Vegane Mühlen-Schnitzel auf Basis von Soja",
base_unit=BaseUnit.gram, package_size=180,
)
db.add(p)
db.commit()
db.refresh(p)
res = match_receipt(
MatchRequest(lines=["MÜHLEN SCHNITZ", "voelliger unsinn xyz"]),
db=db, _=user,
)
# Abgekürzte Kassenzeile findet den vollen Namen mit hohem Score.
assert res[0].text == "MÜHLEN SCHNITZ"
assert res[0].candidates
assert res[0].candidates[0].product_id == p.id
assert res[0].candidates[0].score >= 70
# Unsinnszeile bleibt ohne Treffer über dem Schwellwert.
assert res[1].candidates == []
def test_nur_lebensmittel_keine_gegenstaende(db, user):
# Einzelstück-Flag -> tracking "object" -> darf nicht vorgeschlagen werden.
obj = Product(name="Powerbank Anker", base_unit=BaseUnit.piece, individual=True)
db.add(obj)
db.commit()
res = match_receipt(MatchRequest(lines=["POWERBANK ANKER"], threshold=10), db=db, _=user)
assert res[0].candidates == []
def test_schwellwert_filtert(db, user):
p = Product(name="Basmati Reis", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.commit()
# Nur teilweise passende Zeile: bei sehr hohem Schwellwert kein Treffer.
hart = match_receipt(MatchRequest(lines=["reis lose"], threshold=99), db=db, _=user)
weich = match_receipt(MatchRequest(lines=["reis lose"], threshold=20), db=db, _=user)
assert hart[0].candidates == []
assert weich[0].candidates and weich[0].candidates[0].product_id == p.id

View File

@@ -0,0 +1,110 @@
"""Einkaufsliste: Fehlmenge als Gebinde-Leitangabe (auf ganze Packungen
aufgerundet) mit der Basiseinheit als kleinem Hinweis „3 Gläser (450 g)".
Produkte tragen ihre eigene Packungsgröße; Gruppen legen ein eigenes
Gruppen-Gebinde als Richtwert fest (weil ihre Produkte unterschiedlich große
Packungen haben können).
"""
import pytest
from app.models import BaseUnit, Group, PackageType, Product, Role, Unit, User
from app.routers.groups import _group_to_out, update_group
from app.routers.views import group_shopping_list, shopping_list
from app.schemas import GroupUpdate
from app.services.min_stock import schreibe_ueberall
@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
def _gramm(db) -> Unit:
return db.query(Unit).filter(Unit.name == "Gramm").one()
def _ueberall(db, besitzer, menge_base: float) -> None:
"""Mindestbestand „Überall" setzen Mengen immer in Basiseinheiten."""
schreibe_ueberall(db, besitzer, menge_base)
db.commit()
def test_produkt_bedarf_in_ganzen_gebinden(db, user):
"""500 g fehlen bei 195-g-Gläsern -> 3 Gläser (aufgerundet), 500 g als Hinweis."""
db.add(PackageType(singular="Glas", plural="Gläser"))
pesto = Product(name="Pesto", base_unit=BaseUnit.gram, package_size=195,
package_label="Glas")
db.add(pesto)
db.commit() # kein Bestand -> es fehlt der volle Mindestbestand
_ueberall(db, pesto, 500)
(item,) = shopping_list(db=db, _=user)
assert item.need is not None
assert item.need.is_package is True
assert item.need.count == 3 # ceil(500 / 195)
assert item.need.text == "3 Gläser"
assert item.need.hint == "500 g"
def test_gruppe_mit_gruppen_gebinde(db, user):
"""Gruppe mit eigenem Richt-Gebinde (1 Glas ≈ 150 g), Mindestbestand 3 Gläser."""
db.add(PackageType(singular="Glas", plural="Gläser"))
gruppe = Group(name="Pesto alla Genovese", min_stock_unit=_gramm(db),
package_size=150, package_label="Glas", min_stock_in_packages=True)
db.add(gruppe)
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, package_size=99, group=gruppe))
db.commit() # kein Bestand -> es fehlen 3 Gläser (450 g)
_ueberall(db, gruppe, 3 * 150) # 3 Gläser à 150 g
(item,) = group_shopping_list(db=db, _=user)
assert item.need is not None
assert item.need.is_package is True
assert item.need.count == 3
assert item.need.text == "3 Gläser"
assert item.need.hint == "450 g" # 3 * 150 g
def test_gruppe_ohne_gebinde_bleibt_basiseinheit(db, user):
"""Ohne aktives Gruppen-Gebinde bleibt es bei der verwalteten Einheit."""
gruppe = Group(name="Mehl", min_stock_unit=_gramm(db))
db.add(gruppe)
db.add(Product(name="Mehl 405", base_unit=BaseUnit.gram, group=gruppe))
db.commit()
_ueberall(db, gruppe, 500)
(item,) = group_shopping_list(db=db, _=user)
assert item.need is not None
assert item.need.is_package is False
assert item.need.text == "500 Gramm"
assert item.need.hint == ""
def test_gruppe_umschalten_haelt_physischen_bedarf(db, user):
"""Umschalten g -> Gebinde laesst den physischen Bedarf unveraendert.
Gespeichert wird in Basiseinheiten, deshalb bleiben die 500 g stehen und es
ist NUR die Anzeige, die auf ~3,33 Glaeser wechselt. Frueher musste der
gespeicherte Wert dafuer umgerechnet werden.
"""
gruppe = Group(name="Pesto", min_stock_unit=_gramm(db))
db.add(gruppe)
db.add(Product(name="Pesto Barilla", base_unit=BaseUnit.gram, group=gruppe))
db.commit()
_ueberall(db, gruppe, 500)
update_group(
group_id=gruppe.id,
payload=GroupUpdate(min_stock_in_packages=True, package_size=150, package_label="Glas"),
db=db, _=user,
)
db.refresh(gruppe)
assert gruppe.min_stock_in_packages is True
# Gespeichert unveraendert in Basiseinheiten …
assert gruppe.location_min_stocks[0].min_stock == pytest.approx(500)
# … angezeigt jetzt in Glaesern.
assert _group_to_out(db, gruppe).min_stock == pytest.approx(500 / 150, abs=0.01)

View File

@@ -0,0 +1,244 @@
"""Zweiteinheit am Artikel: die Brücke zwischen den Einheiten-Arten.
Der Artikel bleibt in seiner Basiseinheit geführt (Stück) und trägt zusätzlich
eine Äquivalenz zur anderen Art („3 Stück ≙ 250 g"). Erst dadurch zählt er in
einer Gramm-Gruppe mit und lässt sich in Gramm ein- und auslagern. Das Gebinde
bleibt davon unberührt.
Ohne Brücke bleibt alles beim Alten das halten
``test_conversion.py::test_wrong_kind_rejected`` und
``test_gruppen_hierarchie.py::test_einheitenfilter_gilt_auch_fuer_untergruppen``
fest, die beide unverändert grün bleiben.
"""
import pytest
from app.models import BaseUnit, Group, Location, Lot, Product, Role, Unit, User
from app.routers.groups import _group_to_out
from app.services.conversion import (
ConversionError,
group_min_context,
in_artikel_basis,
to_base,
zweit_faktor,
)
from app.services.stock import (
check_out,
current_stock,
summe_bestand_gewichtet,
summe_bestand_im_subtree_gewichtet,
)
@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
def _gramm(db) -> Unit:
return db.query(Unit).filter(Unit.name == "Gramm").one()
def _kilo(db) -> Unit:
return db.query(Unit).filter(Unit.name == "Kilogramm").one()
def _riegel(db, *, bruecke: bool = True, bestand: float = 0.0, ort=None) -> Product:
"""Ein in STÜCK geführter Artikel, wahlweise mit „3 Stück ≙ 250 g"."""
p = Product(
name="Bratwurst",
base_unit=BaseUnit.piece,
secondary_base="gram" if bruecke else None,
secondary_count=3 if bruecke else None,
secondary_amount=250 if bruecke else None,
)
db.add(p)
db.flush()
if bestand:
db.add(Lot(product_id=p.id, quantity=bestand,
location_id=ort.id if ort is not None else None))
db.commit()
return p
# ---- Die Brücke selbst ----
def test_bruecke_beide_richtungen(db):
p = _riegel(db)
assert zweit_faktor(p, BaseUnit.gram) == pytest.approx(250 / 3)
assert zweit_faktor(p, BaseUnit.piece) == 1.0
# In eine DRITTE Art gibt es keine Antwort.
assert zweit_faktor(p, BaseUnit.milliliter) is None
# Gegenrichtung: 250 g sind exakt 3 Stück.
assert in_artikel_basis(p, 250, BaseUnit.gram) == pytest.approx(3)
def test_ohne_bruecke_kein_faktor(db):
p = _riegel(db, bruecke=False)
assert zweit_faktor(p, BaseUnit.gram) is None
assert in_artikel_basis(p, 250, BaseUnit.gram) is None
def test_unvollstaendige_bruecke_zaehlt_nicht(db):
"""Halbe oder widersprüchliche Angaben sind keine Brücke und kein Absturz."""
p = _riegel(db)
p.secondary_count = 0
db.commit()
assert zweit_faktor(p, BaseUnit.gram) is None # keine Division durch Null
p.secondary_count = 3
p.secondary_amount = None
db.commit()
assert zweit_faktor(p, BaseUnit.gram) is None
# Brücke auf die EIGENE Art bringt nichts zu übersetzen.
p.secondary_base = "piece"
p.secondary_amount = 5
db.commit()
assert zweit_faktor(p, BaseUnit.gram) is None
# ---- Ein- und Auslagern ----
def test_einlagern_in_der_fremdeinheit(db):
p = _riegel(db)
assert to_base(db, p, 250, "Gramm") == pytest.approx(3)
assert to_base(db, p, 0.25, "kg") == pytest.approx(3)
# Die eigene Art bleibt unberührt.
assert to_base(db, p, 2, "Stück") == pytest.approx(2)
def test_ohne_bruecke_bleibt_gesperrt(db):
p = _riegel(db, bruecke=False)
with pytest.raises(ConversionError):
to_base(db, p, 250, "Gramm")
def test_krumme_menge_wird_gebucht_nicht_gerundet(db):
"""100 g bei 83,33 g je Stück sind 1,2 Stück genau das wird gebucht."""
p = _riegel(db)
assert to_base(db, p, 100, "Gramm") == pytest.approx(1.2)
def test_auslagern_trifft_den_bestand_genau(db, user):
"""Der ganze Bestand muss sich in der Fremdeinheit auslagern lassen.
Sichert das gespeicherte PAAR gegen einen gerundeten Faktor ab: der Umweg
ueber 250/3 ergaebe 3,0000000000000004 und liefe gegen die Bestandspruefung.
"""
p = _riegel(db, bestand=3)
check_out(db, p, 250, "Gramm", user) # darf nicht an „zu wenig Bestand" scheitern
db.commit()
assert current_stock(db, p.id) == pytest.approx(0)
# ---- Gruppen ----
def _wurst_gruppe(db) -> Group:
g = Group(name="Wurst", min_stock_unit=_kilo(db))
db.add(g)
db.commit()
db.refresh(g)
return g
def test_gruppe_in_kilogramm_zaehlt_stueck_artikel_mit(db):
"""6 Stück à 83,33 g sind 500 g zusammen mit 500 g Salami genau 1 kg."""
gruppe = _wurst_gruppe(db)
riegel = _riegel(db, bestand=6)
riegel.group_id = gruppe.id
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
db.add(salami)
db.flush()
db.add(Lot(product_id=salami.id, quantity=500))
db.commit()
assert _group_to_out(db, gruppe).stock == pytest.approx(1.0) # in Kilogramm
def test_gruppe_filtert_artikel_ohne_bruecke_weiter(db):
"""Ohne Zweiteinheit bleibt der Stück-Artikel aussen vor wie bisher."""
gruppe = _wurst_gruppe(db)
riegel = _riegel(db, bruecke=False, bestand=6)
riegel.group_id = gruppe.id
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
db.add(salami)
db.flush()
db.add(Lot(product_id=salami.id, quantity=500))
db.commit()
assert _group_to_out(db, gruppe).stock == pytest.approx(0.5)
# ``product_count`` zaehlt weiterhin ALLE Artikel im Untergraphen; wer
# mitrechnet, steht in ``ctx.matching``.
assert [p.name for p in group_min_context(gruppe).matching] == ["Salami"]
def test_faktoren_stehen_je_artikel_im_kontext(db):
gruppe = _wurst_gruppe(db)
riegel = _riegel(db, bestand=6)
riegel.group_id = gruppe.id
salami = Product(name="Salami", base_unit=BaseUnit.gram, group_id=gruppe.id)
db.add(salami)
db.commit()
ctx = group_min_context(gruppe)
assert ctx.faktoren[riegel.id] == pytest.approx(250 / 3)
assert ctx.faktoren[salami.id] == 1.0
# ---- Gewichtete Summen ----
def test_gewichtete_summe_nutzt_je_artikel_eigenen_faktor(db):
"""Zwei Artikel, zwei verschiedene Faktoren kein gemeinsamer."""
a = _riegel(db, bestand=6) # 6 Stück
b = Product(name="Salami", base_unit=BaseUnit.gram)
db.add(b)
db.flush()
db.add(Lot(product_id=b.id, quantity=500))
db.commit()
faktoren = {a.id: 250 / 3, b.id: 1.0}
assert summe_bestand_gewichtet(db, [a, b], faktoren) == pytest.approx(1000)
# Ohne Faktoren wird roh addiert (6 + 500).
assert summe_bestand_gewichtet(db, [a, b], None) == pytest.approx(506)
def test_gewichtete_summe_im_subtree(db):
haus = Location(name="Haus")
db.add(haus)
db.flush()
kueche = Location(name="Küche", parent_id=haus.id)
db.add(kueche)
db.commit()
a = _riegel(db, bestand=6, ort=kueche)
# Zweite Charge ohne Ort: zaehlt im Gesamtbestand, aber in keinem Subtree.
db.add(Lot(product_id=a.id, quantity=3, location_id=None))
db.commit()
faktoren = {a.id: 250 / 3}
assert summe_bestand_im_subtree_gewichtet(db, [a], haus.id, faktoren) == pytest.approx(500)
assert summe_bestand_gewichtet(db, [a], faktoren) == pytest.approx(750)
# ---- Mindestbestand in der Zweiteinheit ----
def test_mindestbestand_in_der_zweiteinheit_anzeigen(db):
"""„Mindestens 250 g" an einem stückweise geführten Artikel."""
from app.crud import product_to_out
from app.services.min_stock import schreibe_ueberall
p = _riegel(db)
p.min_stock_unit_id = _gramm(db).id
schreibe_ueberall(db, p, 3) # 3 Stück, in Basiseinheiten gespeichert
db.commit()
out = product_to_out(db, p)
assert out.min_stock == pytest.approx(3) # Speicherung unveraendert
assert out.min_stock_display == pytest.approx(250) # Anzeige in Gramm
assert out.min_stock_unit_label == "Gramm"
assert out.secondary_factor == pytest.approx(250 / 3)

View File

@@ -8,6 +8,8 @@ Name `box`) in Weiß auf der Akzentfläche.
- **`app-icon.svg`** 1024×1024, vollflächig, feste Farbe (`#4d5cd4`), ohne
abgerundete Ecken. Für App-Icon (iOS/macOS via Icon Composer oder Xcode
„AppIcon", App Store). Die Rundung macht das System selbst.
- **`icons/`** Quellgrafiken für Ein-/Auslagern und Korrektur. Derzeit nicht
im Einsatz; siehe `icons/README.md`.
- **Favicon / Web-Logo:** `../web/public/favicon.svg` dasselbe Zeichen mit
abgerundeter Platte und Hell-/Dunkel-Umschaltung (für den Browser-Tab).

32
brand/icons/README.md Normal file
View File

@@ -0,0 +1,32 @@
# Icon-Quellen
> **Derzeit nicht im Einsatz.** Die Verlaufsansicht nutzt die Strich-Icons
> `checkin` / `checkout` / `edit` aus `web/src/components/Icon.jsx` dieselben
> wie die Ein-/Auslagern-Knöpfe.
>
> Grund: Die beiden Kisten unterscheiden sich **nur in der Pfeilrichtung**. Bei
> 1820 px war das nicht zu trennen; erkannt hat man sie allein an der Farbe,
> und die trug mit Grün/Orange eine Wertung, die es nicht geben soll
> Auslagern ist kein Missstand. Die Dateien bleiben liegen, falls sie später
> woanders passen; größer dargestellt trennen sie sehr wohl.
## Dateien
| Datei | Bedeutung |
|---|---|
| `einlagern.svg` | Kiste, Pfeil **hinein** Bewegungsart „Eingelagert" |
| `auslagern.svg` | Kiste, Pfeil **heraus** Bewegungsart „Ausgelagert" |
| `korrektur.svg` | Stapel mit Stift Bewegungsart „Korrektur" |
## Wieder einbauen
Es sind gefüllte Grafiken mit eigenem `viewBox`; die übrigen in `Icon.jsx` sind
Strichzeichnungen (`fill:none`, `stroke:currentColor`) und vertragen sich nicht
damit. Es braucht also wieder einen eigenen Zweig, der mit
`fill="currentColor"` rendert inline, damit die Icons die Textfarbe annehmen
(die Originale tragen festes Schwarz).
Die Pfaddaten übernimmt man mit einem kurzen Skript, statt sie abzutippen:
`viewBox` aus dem `<svg>`-Tag lesen und alle `d="…"` der enthaltenen `<path>`
ausgeben. Ein Tippfehler in den Kurvendaten fällt sonst erst am gerenderten
Bild auf.

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m11.987 24c-.03 0-.061-.002-.09-.006l-8.243-1c-.377-.045-.66-.365-.66-.744v-6c0-.414.336-.75.75-.75s.75.336.75.75v5.336l6.743.817v-11.543c-.034-.236.044-.482.234-.655.221-.2.543-.258.813-.136.271.12.452.384.452.681v12.5c0 .215-.092.419-.253.562-.136.122-.313.188-.496.188z"/><path d="m12.007 24c-.183 0-.359-.066-.497-.188-.161-.143-.253-.347-.253-.562v-12.25c0-.414.336-.75.75-.75s.75.336.75.75v11.403l6.742-.817v-5.336c0-.414.336-.75.75-.75s.75.336.75.75v6c0 .379-.283.699-.659.744l-8.242 1c-.031.004-.061.006-.091.006z"/><path d="m22.25 17h-6.753c-.257 0-.495-.131-.633-.348l-3.494-5.5c-.155-.245-.156-.558-.002-.803.155-.244.44-.379.725-.344l8.246 1c.263.031.488.198.596.439l2.001 4.5c.103.232.082.5-.057.713s-.375.343-.629.343zm-6.341-1.5h5.187l-1.359-3.057-6.251-.758z"/><path d="m8.503 17h-6.753c-.254 0-.49-.129-.629-.342s-.159-.48-.057-.713l2.001-4.5c.107-.241.333-.408.596-.439l8.246-1c.288-.036.569.1.725.344.154.245.153.558-.002.803l-3.494 5.5c-.138.216-.376.347-.633.347zm-5.599-1.5h5.187l2.424-3.814-6.251.758z"/><path d="m12 8c-.414 0-.75-.336-.75-.75v-6.5c0-.414.336-.75.75-.75s.75.336.75.75v6.5c0 .414-.336.75-.75.75z"/><path d="m15.249 4c-.159 0-.32-.051-.456-.155l-2.793-2.149-2.793 2.149c-.328.252-.799.19-1.052-.138-.252-.328-.19-.799.138-1.052l3.25-2.5c.27-.207.645-.207.914 0l3.25 2.5c.328.253.39.724.138 1.052-.149.192-.37.293-.596.293z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg enable-background="new 0 0 24 24" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg"><path d="m11.987 24c-.03 0-.061-.002-.09-.006l-8.243-1c-.377-.045-.66-.365-.66-.744v-6c0-.414.336-.75.75-.75s.75.336.75.75v5.336l6.743.817v-11.543c-.034-.236.044-.482.234-.655.221-.2.543-.258.813-.136.271.12.452.384.452.681v12.5c0 .215-.092.419-.253.562-.136.122-.313.188-.496.188z"/><path d="m12.007 24c-.183 0-.359-.066-.497-.188-.161-.143-.253-.347-.253-.562v-12.25c0-.414.336-.75.75-.75s.75.336.75.75v11.403l6.742-.817v-5.336c0-.414.336-.75.75-.75s.75.336.75.75v6c0 .379-.283.699-.659.744l-8.242 1c-.031.004-.061.006-.091.006z"/><path d="m22.25 17h-6.753c-.257 0-.495-.131-.633-.348l-3.494-5.5c-.155-.245-.156-.558-.002-.803.155-.244.439-.379.725-.344l8.246 1c.263.031.488.198.596.439l2.001 4.5c.103.232.082.5-.057.713s-.375.343-.629.343zm-6.341-1.5h5.187l-1.359-3.057-6.251-.758z"/><path d="m8.503 17h-6.753c-.254 0-.49-.129-.629-.342s-.159-.48-.057-.713l2.001-4.5c.107-.241.333-.408.596-.439l8.246-1c.286-.036.569.1.725.344.154.245.153.558-.002.803l-3.494 5.5c-.138.216-.376.347-.633.347zm-5.599-1.5h5.187l2.424-3.814-6.251.758z"/><path d="m12 8c-.414 0-.75-.336-.75-.75v-6.5c0-.414.336-.75.75-.75s.75.336.75.75v6.5c0 .414-.336.75-.75.75z"/><path d="m12 8c-.161 0-.322-.052-.457-.155l-3.25-2.5c-.328-.253-.39-.724-.138-1.052.253-.328.724-.391 1.052-.138l2.793 2.149 2.793-2.148c.328-.253.799-.19 1.052.138.252.328.19.799-.138 1.052l-3.25 2.5c-.135.102-.296.154-.457.154z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@@ -0,0 +1 @@
<svg id="Layer_1" enable-background="new 0 0 64 64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"><g fill="rgb(0,0,0)"><path d="m64 39.97-8.58-8.58-18.48 18.47-3.86 12.36 12.44-3.76zm-25.83 17.21 1.73-5.53 15.52-15.52 3.84 3.84-15.51 15.53z"/><path d="m26.74 34.89c10.1 0 20.29-2.02 24.6-5.92h2.14v-18.79c0-6.67-13.45-10.16-26.73-10.16-13.3.01-26.75 3.5-26.75 10.16v43.62c0 6.67 13.45 10.17 26.74 10.17v-3.35c-14.28 0-23.39-4.04-23.39-6.82v-9.35c4.82 3.28 14.14 4.97 23.39 4.97 1.9 0 3.72-.07 5.42-.2l-.26-3.34c-1.61.12-3.35.19-5.16.19-14.28 0-23.39-4.04-23.39-6.82v-9.35c4.82 3.29 14.14 4.99 23.39 4.99zm0-31.51c14.28 0 23.38 4.03 23.38 6.81s-9.11 6.81-23.38 6.81-23.39-4.04-23.39-6.82c0-2.77 9.11-6.8 23.39-6.8zm-23.39 12c.02.01.04.02.06.04.28.19.57.37.88.55.03.02.06.03.08.05.29.17.6.33.91.48.05.03.1.05.16.08 5.14 2.5 13.25 3.78 21.3 3.78s16.16-1.28 21.3-3.78c.05-.03.11-.05.16-.08.32-.16.62-.32.91-.48.03-.01.05-.03.08-.04.31-.18.6-.36.88-.55.02-.01.04-.02.06-.04v9.35c0 2.78-9.11 6.81-23.38 6.81s-23.4-4.05-23.4-6.82z"/></g></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -1 +0,0 @@
<svg id="Layer_1" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m4 6h2v12h-2zm4 12h2v-12h-2zm12-12h-2v12h2zm-9 12h3v-12h-3zm-9 1v-3h-2v3c0 1.654 1.346 3 3 3h3v-2h-3c-.551 0-1-.449-1-1zm20 0c0 .551-.449 1-1 1h-3v2h3c1.654 0 3-1.346 3-3v-3h-2zm-1-17h-3v2h3c.551 0 1 .449 1 1v3h2v-3c0-1.654-1.346-3-3-3zm-21 3v3h2v-3c0-.551.449-1 1-1h3v-2h-3c-1.654 0-3 1.346-3 3z"/><path d="m16 6h-1v12h1z"/></svg>

Before

Width:  |  Height:  |  Size: 458 B

View File

@@ -0,0 +1 @@
<svg id="Layer_1" height="512" viewBox="0 0 24 24" width="512" xmlns="http://www.w3.org/2000/svg" data-name="Layer 1"><path d="m12 24c-.5 0-1-.12-1.46-.37l-7.97-4.27c-.98-.52-1.58-1.54-1.58-2.64v-9.46c0-1.11.61-2.12 1.59-2.65l7.97-4.25c.91-.49 1.99-.49 2.9 0l7.96 4.25c.98.52 1.59 1.54 1.59 2.65v9.46c0 1.11-.61 2.12-1.58 2.64l-7.96 4.27c-.46.24-.96.37-1.46.37zm0-22c-.17 0-.35.04-.51.13l-7.96 4.25c-.33.17-.53.51-.53.88v9.46c0 .37.2.71.53.88l7.97 4.27c.32.17.7.17 1.02 0l7.96-4.27c.33-.17.53-.51.53-.88v-9.46c0-.37-.2-.71-.53-.88l-7.97-4.25c-.16-.08-.33-.13-.51-.13z"/></svg>

After

Width:  |  Height:  |  Size: 576 B

View File

@@ -1 +0,0 @@
<svg id="Layer_1" enable-background="new 0 0 500 500" viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg"><path clip-rule="evenodd" d="m394.394 334.235v-62.008l-53.677 31.007v62.009zm-120.521 0 53.733 31.007v-62.009l-53.733-31.007zm60.288-104.356 53.677 31.002-53.677 31.007-53.733-31.007zm73.344 31.001v77.136c0 2.342-1.233 4.51-3.306 5.681l-66.788 38.56c-2.049 1.189-4.531 1.175-6.555 0l-66.788-38.56c-2.017-1.171-3.306-3.339-3.306-5.681v-77.136c0-2.342 1.289-4.499 3.306-5.67l66.788-38.577c2.017-1.171 4.538-1.171 6.555 0l66.788 38.577c2.073 1.171 3.306 3.328 3.306 5.67zm-241.658-31.001-53.677 31.002 53.677 31.007 53.677-31.007zm60.233 104.356-53.677 31.007v-62.009l53.677-31.007zm-120.465 0v-62.008l53.677 31.007v62.009zm-13.111 3.782v-77.137c0-2.342 1.233-4.499 3.25-5.67l66.844-38.577c2.017-1.171 4.482-1.171 6.555 0l66.788 38.577c2.017 1.171 3.25 3.328 3.25 5.67v77.136c0 2.342-1.233 4.51-3.25 5.681l-66.788 38.56c-2.028 1.177-4.518 1.182-6.555 0l-66.844-38.56c-2.017-1.17-3.25-3.338-3.25-5.68zm157.5-253.245-53.677 30.996 53.677 31.007 53.677-31.007zm60.233 104.351v-62.003l-53.677 31.007v62.003zm-120.465 0 53.677 31.007v-62.003l-53.677-31.007zm-9.861 9.463 66.788 38.565c2.065 1.161 4.512 1.18 6.555 0l66.844-38.565c2.017-1.171 3.25-3.339 3.25-5.676v-77.142c0-2.331-1.233-4.499-3.25-5.67l-66.844-38.565c-2.017-1.171-4.482-1.171-6.555 0l-66.788 38.565c-2.017 1.171-3.25 3.339-3.25 5.67v77.142c0 2.337 1.233 4.505 3.25 5.676zm205.91 216.298-.28 2.264-14.344 42.695c-.952 2.734-3.474 4.466-6.219 4.466-4.443 0-7.617-4.413-6.219-8.64l7.9-23.572c-84.823 54.5-197.289 43.07-269.56-29.192-77.322-77.31-84.773-200.369-17.369-286.246 2.241-2.846 6.388-3.345 9.245-1.109 2.802 2.241 3.306 6.354 1.064 9.2-63.314 80.666-56.31 196.267 16.305 268.888 68.749 68.749 175.99 78.689 255.665 25.841l-28.183-3.636c-3.586-.459-6.107-3.748-5.659-7.329.448-3.592 3.754-6.135 7.34-5.664l44.6 5.737v.006c2.218.442 2.167.629 3.754 1.793l.504.538.112.123c.761 1.255 1.344 2.163 1.344 3.837zm34.402-31.539c-2.245 2.86-6.315 3.324-9.189 1.109-2.857-2.236-3.362-6.359-1.121-9.211 63.37-80.655 56.366-196.25-16.305-268.882-68.693-68.749-175.99-78.694-255.665-25.835l28.239 3.625c3.586.465 6.107 3.748 5.659 7.34-.47 3.604-3.822 6.133-7.34 5.659l-44.656-5.749c-1.283-.325-2.054-.403-3.306-1.429-.112-.106-.224-.23-.392-.342l-.56-.656c-.467-.093-1.457-2.622-1.457-4.102l.336-2.051 14.344-42.661c1.121-3.435 4.875-5.289 8.293-4.135s5.267 4.869 4.146 8.303l-7.9 23.561c84.549-54.349 197.113-43.238 269.56 29.197 77.323 77.318 84.775 200.382 17.314 286.259z" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -2,7 +2,7 @@
"fill-specializations" : [
{
"value" : {
"automatic-gradient" : "display-p3:0.68468,0.62596,1.03953,1.00000",
"automatic-gradient" : "display-p3:0.49658,0.53906,0.88428,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
@@ -18,7 +18,7 @@
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.23587,0.21584,0.35184,1.00000",
"automatic-gradient" : "display-p3:0.24018,0.26161,0.43578,1.00000",
"orientation" : {
"start" : {
"x" : 0.5,
@@ -39,48 +39,20 @@
"fill-specializations" : [
{
"value" : {
"solid" : "display-p3:0.00000,0.00000,0.00000,1.00000"
"solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
}
},
{
"appearance" : "dark",
"value" : {
"solid" : "display-p3:0.58245,0.53181,0.88393,1.00000"
"solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
}
}
],
"glass" : true,
"image-name" : "stock-rotation.svg",
"name" : "stock-rotation",
"image-name" : "hexagon.svg",
"name" : "hexagon",
"position" : {
"scale" : 1.7,
"translation-in-points" : [
0,
0
]
}
},
{
"fill-specializations" : [
{
"appearance" : "dark",
"value" : {
"automatic-gradient" : "display-p3:0.42536,0.31875,0.88861,1.00000"
}
},
{
"appearance" : "tinted",
"value" : {
"automatic-gradient" : "extended-gray:0.25000,1.00000"
}
}
],
"glass" : false,
"hidden" : true,
"image-name" : "barcode-gelesen.svg",
"name" : "barcode-gelesen",
"position" : {
"scale" : 1.4,
"scale" : 1.18,
"translation-in-points" : [
0,
0

View File

@@ -1,12 +1,16 @@
# Vorrania iOS-App
Native SwiftUI-App zum Ein- und Auslagern per Barcode-Scan. Sie spricht dieselbe
Native SwiftUI-App für Lebensmittel **und** Gegenstände. Sie spricht dieselbe
REST-API wie die Web-Oberfläche.
> **Stand:** Läuft auf dem Gerät. Login, Scanner, Einlagern mit mehreren
> Chargen/MHDs, MHD per Texterkennung, Auslagern mit Chargenauswahl, Artikel
> anlegen aus Open Food Facts, Einkaufsliste, Ablaufliste sowie Produkte
> ansehen und bearbeiten.
> **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per
> Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke;
> Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit
> UID/QR, Kaufdatum, Garantie und Beleg; Verbrauchsgegenstände als Charge;
> Artikel anlegen (Open Food/Products Facts oder manuell, mit Foto); getrennte
> Listen für Lebensmittel/Gegenstände/Einzelstücke, Kategoriefilter, Sortierung;
> Einkaufsliste, Ablaufliste, Mindestbestände; Verlauf mit Sprung zum Produkt;
> lokale Dashboards und Push-Benachrichtigungen bei ablaufenden Produkten.
## Projekt in Xcode öffnen
@@ -62,12 +66,17 @@ Beide Wege öffnen die App **direkt in der Kamera**.
| `ScannerView.swift` | Kamera + Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR) |
| `RootView.swift` | Startbildschirm und Routing |
| `LoginView.swift` | Server + Anmeldung |
| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Menge, mehrere Chargen mit MHD |
| `CheckInView.swift` / `CheckInFormView.swift` | Scan → Charge (MHD), Menge je Lagerort oder Einzelstück |
| `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO |
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) |
| `ProductDetailView.swift` | Produkt bearbeiten, Chargen korrigieren und löschen |
| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste |
| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen |
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung, Foto, Text-Scan) |
| `ProductDetailView.swift` | Produkt bearbeiten, Verwaltungsart ändern, Chargen korrigieren |
| `ObjectStockView.swift` | Gegenstände: Menge je Lagerort (hinzufügen/umlagern/entfernen) |
| `ItemViews.swift` / `ItemListView.swift` | Einzelstücke anlegen, bearbeiten und auflisten |
| `AssignScanView.swift` | Einzelstück (Scan oder Liste) einem Lagerort-QR zuordnen |
| `ListViews.swift` | Produktlisten, Einkaufsliste, „bald ablaufend", Mindestbestände |
| `DashboardView.swift` / `DashboardCards.swift` / `ChartCards.swift` | Übersicht mit Karten und Diagrammen |
| `NotificationScheduler.swift` / `NotificationSettings*.swift` | Push-Benachrichtigungen bei Ablauf |
| `DateScanView.swift` / `BestBeforeText.swift` | MHD/Text per Texterkennung ablesen |
| `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
| `CategoryPicker.swift` | Kategorie-Auswahl und Gruppe anlegen |
@@ -84,6 +93,6 @@ Bestand, keinen Mindestbestand und keine EAN-Codes. Filterst du auf eine
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Beim Anlegen
schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor.
## Noch offen
- Push-Benachrichtigungen bei ablaufenden Produkten
- Lagerort je Charge beim Einlagern wählbar
## Weiteres
Push-Benachrichtigungen bei Ablauf und Lagerort je Charge beim Einlagern sind
umgesetzt. Weitere Ideen stehen in [../docs/ROADMAP.md](../docs/ROADMAP.md).

View File

@@ -301,6 +301,13 @@ actor APIClient {
return try await send(request, as: StockResponse.self)
}
/// Kassenzettel-Zeilen (OCR) den aehnlichsten Lebensmitteln zuordnen.
func matchReceipt(_ lines: [String], threshold: Int? = nil) async throws -> [MatchLine] {
var request = try makeRequest("/products/match", method: "POST")
try jsonBody(&request, MatchRequest(lines: lines, threshold: threshold))
return try await send(request, as: [MatchLine].self)
}
func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkout", method: "POST")
try jsonBody(&request, payload)
@@ -416,8 +423,9 @@ actor APIClient {
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.self)
}
func byCategory() async throws -> [CategoryShare] {
try await send(try makeRequest("/dashboard/by-category"), as: [CategoryShare].self)
func byCategory(depth: Int? = nil) async throws -> [CategoryShare] {
let suffix = depth.map { "?depth=\($0)" } ?? ""
return try await send(try makeRequest("/dashboard/by-category" + suffix), as: [CategoryShare].self)
}
func timeline(days: Int, productId: Int? = nil) async throws -> [TimelinePoint] {
@@ -519,6 +527,20 @@ actor APIClient {
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE"))
}
/// Gruppe aendern (Name, Mindestbestand, Einheit, Obergruppen).
func updateGroup(id: Int, _ payload: GroupUpdateRequest) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: GroupItem.self)
}
/// Mindestbestaende einer Gruppe je Lagerort ersetzen (Basiseinheiten).
func setGroupLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)/location-min-stock", method: "PUT")
try jsonBody(&request, list)
return try await send(request, as: GroupItem.self)
}
func renameGroup(id: Int, name: String) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name))

View File

@@ -16,11 +16,21 @@ struct AppCard: Codable, Identifiable, Equatable {
let id: UUID
var type: String
var productId: Int?
// Diagramm-Einstellungen (nur fuer bestimmte Diagramme relevant). Optional,
// damit aeltere gespeicherte Uebersichten ohne diese Schluessel weiter
// dekodieren. Bedeutung wie im Web-Editor:
var mitOhneMhd: Bool? // Ablauf-Ring: Ohne MHD" einbeziehen (nil = ja)
var detail: Bool? // Kategorien-Ring: Detailansicht, alle einzeln (nil = nein)
var tiefe: Int? // Kategorien-Ring: Tiefe (nil = feinste Ebene)
init(id: UUID = UUID(), type: String, productId: Int? = nil) {
init(id: UUID = UUID(), type: String, productId: Int? = nil,
mitOhneMhd: Bool? = nil, detail: Bool? = nil, tiefe: Int? = nil) {
self.id = id
self.type = type
self.productId = productId
self.mitOhneMhd = mitOhneMhd
self.detail = detail
self.tiefe = tiefe
}
}

View File

@@ -9,9 +9,10 @@ struct AssignScanView: View {
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var pending: Item? // gescanntes Stück, wartet auf Ort
@State private var pending: Item? // gescanntes/gewähltes Stück, wartet auf Ort
@State private var status: String?
@State private var error: String?
@State private var showPicker = false
var body: some View {
ScrollView {
@@ -31,11 +32,17 @@ struct AssignScanView: View {
.font(.caption).padding(.top, 2)
}
.padding().frame(maxWidth: .infinity)
.background(Color.accentColor.opacity(0.15))
.background(Color.marke.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
} else {
Text("Schritt 1: Einzelstück-QR scannen")
.font(.caption).foregroundStyle(.secondary)
VStack(spacing: 8) {
Text("Schritt 1: Einzelstück-QR scannen oder ohne Code:")
.font(.caption).foregroundStyle(.secondary)
// Für Stücke ohne eigenen QR (z.B. Kleidung): aus der Liste wählen.
Button { showPicker = true } label: {
Label("Einzelstück aus Liste wählen", systemImage: "list.bullet")
}
}
}
if let status {
@@ -55,6 +62,9 @@ struct AssignScanView: View {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
}
.sheet(isPresented: $showPicker) {
ItemPickerSheet { it in pending = it; status = nil; error = nil }
}
}
private func handle(_ code: String) async {
@@ -90,3 +100,49 @@ struct AssignScanView: View {
error = "Unbekannter Code bitte einen Einzelstück- oder Lagerort-QR scannen."
}
}
/// Einzelstück aus einer durchsuchbaren Liste wählen für Stücke ohne eigenen QR.
struct ItemPickerSheet: View {
var onPick: (Item) -> Void
@Environment(\.dismiss) private var dismiss
@State private var items: [Item] = []
@State private var locations: [StorageLocation] = []
@State private var query = ""
@State private var busy = true
private var gefiltert: [Item] {
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
guard !q.isEmpty else { return items }
return items.filter {
[$0.productName, $0.uid, $0.productBrand, $0.note]
.compactMap { $0 }.joined(separator: " ").lowercased().contains(q)
}
}
var body: some View {
NavigationStack {
List {
if busy && items.isEmpty { ProgressView() }
ForEach(gefiltert) { it in
Button { onPick(it); dismiss() } label: {
ItemRow(item: it, locations: locations)
}
.buttonStyle(.plain)
}
if !busy && gefiltert.isEmpty {
Text("Nichts gefunden.").foregroundStyle(.secondary)
}
}
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
.navigationTitle("Einzelstück wählen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.task {
items = (try? await APIClient.shared.allItems()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
busy = false
}
}
}
}

View File

@@ -31,6 +31,10 @@ enum BestBeforeText {
(#"(\d{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
(#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "month"),
// Nur Tag und Monat, kein Jahr (z.B. 05.01" bei Frischware / zu
// verbrauchen bis"). Das Jahr ergänzt build(). Steht bewusst zuletzt,
// damit Muster mit Jahr Vorrang haben.
(#"(\d{1,2})[./-](\d{1,2})(?!\d)"#, ["d", "m"], "day"),
]
/// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten.
@@ -73,6 +77,18 @@ enum BestBeforeText {
jahr = vier
} else if let zwei = teile["Y"] {
jahr = 2000 + zwei
} else if precision == "day", let tag = teile["d"] {
// Kein Jahr aufgedruckt (Frischware): aktuelles Jahr annehmen; liegt
// das Datum schon in der Vergangenheit, das nächste Jahr nehmen.
let cal = Calendar.current
let aktuell = cal.component(.year, from: Date())
var probe = DateComponents()
probe.year = aktuell; probe.month = monat; probe.day = tag
if let d = cal.date(from: probe), d < cal.startOfDay(for: Date()) {
jahr = aktuell + 1
} else {
jahr = aktuell
}
} else {
return nil
}
@@ -92,3 +108,9 @@ enum BestBeforeText {
return Calendar.current.date(from: komponenten)
}
}
/// 1 Untergruppe" / 2 Untergruppen" Ein-/Mehrzahl an einer Stelle.
func anzahlWort(_ n: Int, _ einzahl: String, _ mehrzahl: String) -> String {
"\(n) \(n == 1 ? einzahl : mehrzahl)"
}

View File

@@ -76,11 +76,15 @@ private struct ProportionChart: View {
struct ChartCardView: View {
let type: String
let stand: Int
// Aus den Web-Karten-Einstellungen (props): werden hier angewandt.
var detail: Bool = false
var tiefe: Int? = nil
var mitOhneMhd: Bool = true
var body: some View {
switch type {
case "expiry-donut": ExpiryDonutCard(stand: stand)
case "category-donut": CategoryDonutCard(stand: stand)
case "expiry-donut": ExpiryDonutCard(stand: stand, mitOhneMhd: mitOhneMhd)
case "category-donut": CategoryDonutCard(stand: stand, detail: detail, tiefe: tiefe)
case "category-bars": CategoryBarsCard(stand: stand)
case "stock-timeline": StockTimelineCard(stand: stand)
case "activity-timeline": ActivityTimelineCard(stand: stand)
@@ -122,14 +126,19 @@ private struct ChartLoader<Data, Inhalt: View>: View {
struct ExpiryDonutCard: View {
let stand: Int
// Web-Karte Ohne MHD einbeziehen": aus, blendet den grauen Anteil aus.
var mitOhneMhd: Bool = true
var body: some View {
ChartLoader(laden: { try await APIClient.shared.expirySplit() }, stand: stand) { s in
ProportionChart(segments: [
var segmente = [
Segment(label: "In Ordnung", value: s.ok, color: ExpiryColor.ok),
Segment(label: "Bald ablaufend", value: s.soon, color: ExpiryColor.soon),
Segment(label: "Abgelaufen", value: s.expired, color: ExpiryColor.expired),
Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate),
], einheit: "AE")
]
if mitOhneMhd {
segmente.append(Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate))
}
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
}
}
}
@@ -138,28 +147,48 @@ struct ExpiryDonutCard: View {
struct CategoryDonutCard: View {
let stand: Int
// Web-Karte Detailansicht": alle Kategorien einzeln statt Übrige".
var detail: Bool = false
// Web-Karte Kategorie-Tiefe": auf welche Ebene hochgerollt wird (nil = feinste).
var tiefe: Int? = nil
// Wiederholbare, ruhige Farbfolge fuer die Kategorien.
private static let palette: [Color] = [
.blue, .green, .orange, .purple, .pink, .teal, .indigo, .brown,
]
// Ab dem Ende der Palette werden Farben ueber den goldenen Winkel erzeugt,
// damit auch viele Kategorien (Detailansicht) unterscheidbar bleiben.
private static func farbe(_ i: Int) -> Color {
if i < palette.count { return palette[i] }
let hue = (Double(i) * 0.61803398875).truncatingRemainder(dividingBy: 1)
return Color(hue: hue, saturation: 0.55, brightness: 0.78)
}
var body: some View {
ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in
ChartLoader(laden: { try await APIClient.shared.byCategory(depth: tiefe) }, stand: stand) { liste in
let sortiert = liste.filter { $0.articleUnits > 0 }
.sorted { $0.articleUnits > $1.articleUnits }
// Die groessten sechs einzeln, der Rest gebuendelt - sonst wird der
// Ring unlesbar.
let kopf = sortiert.prefix(6)
let restWert = sortiert.dropFirst(6).reduce(0) { $0 + $1.articleUnits }
var segmente = kopf.enumerated().map { i, c in
Segment(label: c.name, value: c.articleUnits,
color: Self.palette[i % Self.palette.count])
let segmente: [Segment]
if detail {
// Detailansicht: alle Kategorien einzeln, nichts wird gebuendelt.
segmente = sortiert.enumerated().map { i, c in
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
}
} else {
// Die groessten sechs einzeln, der Rest gebuendelt - sonst wird der
// Ring unlesbar.
let kopf = sortiert.prefix(6)
let restWert = sortiert.dropFirst(6).reduce(0) { $0 + $1.articleUnits }
var s = kopf.enumerated().map { i, c in
Segment(label: c.name, value: c.articleUnits, color: Self.farbe(i))
}
if restWert > 0 {
s.append(Segment(label: "Übrige", value: restWert, color: .gray))
}
segmente = s
}
if restWert > 0 {
segmente.append(Segment(label: "Übrige", value: restWert, color: .gray))
}
return ProportionChart(segments: segmente, einheit: "AE")
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
}
}
}
@@ -238,9 +267,9 @@ private struct LinienDiagramm: View {
Chart(punkte) { p in
if let datum = DisplaySettings.parseTimestamp(p.at) {
LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
.foregroundStyle(Color.accentColor)
.foregroundStyle(Color.marke)
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
.foregroundStyle(Color.accentColor.opacity(0.12))
.foregroundStyle(Color.marke.opacity(0.12))
}
}
.frame(height: 170)

View File

@@ -35,7 +35,10 @@ struct LabeledField: View {
Spacer(minLength: 12)
TextField(label, text: $text)
.keyboardType(keyboard)
.multilineTextAlignment(.trailing)
// Linksbündig: bei rechtsbündigen Feldern rendert iOS ein
// getipptes Leerzeichen am Ende nicht, bis ein weiteres Zeichen
// folgt das wirkt, als würde die Eingabe nicht reagieren.
.multilineTextAlignment(.leading)
.fieldBox(width: 190)
}
}
@@ -138,6 +141,7 @@ struct CheckInFormView: View {
var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings
struct Line: Identifiable {
let id = UUID()
@@ -167,8 +171,12 @@ struct CheckInFormView: View {
/// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] {
// Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart das
// Backend rechnet sie ueber die Bruecke am Artikel um.
var arten: Set<String> = [product.kind]
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
var options = units
.filter { $0.kind == product.kind }
.filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
@@ -404,7 +412,8 @@ struct CheckInFormView: View {
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
)
let total = response.productStock / product.articleUnitFactor
onDone("Eingelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
// Titel + Detail (zweizeilig im Banner); Einheit mit Mehrzahl.
onDone("Eingelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
dismiss()
} catch {
self.error = error.localizedDescription

View File

@@ -1,4 +1,7 @@
import SwiftUI
import Vision
import PhotosUI
import UIKit
struct CheckInView: View {
@Environment(\.dismiss) private var dismiss
@@ -131,9 +134,23 @@ struct CheckInView: View {
}
.sheet(item: $product) { item in
NavigationStack {
CheckInFormView(product: item, units: units, locations: locations) { message in
status = message
product = nil
if item.isIndividual {
// Einzelstücke werden nicht als Charge eingelagert, sondern als
// physische Exemplare (UID/QR) angelegt die zählen den Bestand.
ItemAddSheet(productId: item.id, onDone: {
status = "Eingelagert\n\(item.name): Einzelstück(e) angelegt."
})
} else if item.foodLike {
// Lebensmittel + Verbrauchsgegenstand: Charge mit Menge/MHD.
CheckInFormView(product: item, units: units, locations: locations) { message in
status = message
product = nil
}
} else {
// Menge je Lagerort (Gegenstand): ohne MHD, direkt Menge + Lagerort.
ObjectAddSheet(product: item, locations: locations,
einheit: item.unitName.isEmpty ? "Stück" : item.unitName,
perform: { status = "Eingelagert\n\(item.name): Menge hinzugefügt." })
}
}
}
@@ -173,15 +190,21 @@ struct CheckInView: View {
// MARK: - Bausteine
private func banner(_ text: String, color: Color) -> some View {
Text(text)
.font(.callout)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(color.opacity(0.9))
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 10))
.listRowInsets(EdgeInsets())
.listRowBackground(Color.clear)
// Erste Zeile = Titel (fett), Rest = Detail ergibt eine saubere,
// rundum eingerückte Melde-Pille statt eines randlosen Balkens.
let zeilen = text.components(separatedBy: "\n")
return VStack(alignment: .leading, spacing: 2) {
Text(zeilen.first ?? text).font(.headline)
if zeilen.count > 1 {
Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline)
}
}
.padding(.horizontal, 16).padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.foregroundStyle(.white)
.background(color, in: RoundedRectangle(cornerRadius: 14))
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
.listRowBackground(Color.clear)
}
private func bestandText(_ p: Product) -> String {
@@ -317,3 +340,441 @@ struct CheckInView: View {
}
}
}
// MARK: - Kassenzettel scannen (nur Lebensmittel)
private extension UIImage {
/// Orientierung auf `.up` normalisieren, damit `cgImage`-Pixel und `size`-Punkte
/// zusammenpassen (Kamerafotos sind sonst gedreht).
func nachObenGedreht() -> UIImage {
guard imageOrientation != .up else { return self }
let format = UIGraphicsImageRendererFormat.default()
format.scale = scale
return UIGraphicsImageRenderer(size: size, format: format).image { _ in
draw(in: CGRect(origin: .zero, size: size))
}
}
/// Auf ein Rechteck in Punkt-Koordinaten (size) zuschneiden.
func zugeschnitten(auf rect: CGRect) -> UIImage? {
let px = CGRect(x: rect.minX * scale, y: rect.minY * scale,
width: rect.width * scale, height: rect.height * scale)
guard let cg = cgImage?.cropping(to: px.integral) else { return nil }
return UIImage(cgImage: cg, scale: scale, orientation: .up)
}
}
/// OCR (on-device) auf einem Ausschnitt deutsche Texterkennung, zeilenweise von
/// oben nach unten. Das Bild verlässt das Gerät nicht; nur die Textzeilen gehen zum
/// Server für den Abgleich.
private func ocrZeilen(_ cg: CGImage) async -> [String] {
await withCheckedContinuation { fortsetzung in
DispatchQueue.global(qos: .userInitiated).async {
let anfrage = VNRecognizeTextRequest()
anfrage.recognitionLevel = .accurate
anfrage.usesLanguageCorrection = true
anfrage.recognitionLanguages = ["de-DE"]
try? VNImageRequestHandler(cgImage: cg, options: [:]).perform([anfrage])
let treffer = (anfrage.results as? [VNRecognizedTextObservation]) ?? []
let vonOben = treffer.sorted { $0.boundingBox.maxY > $1.boundingBox.maxY }
fortsetzung.resume(returning: vonOben.compactMap { $0.topCandidates(1).first?.string })
}
}
}
/// Offensichtliche Nicht-Artikel (Preise, Summen, Kopf/Fuß) grob aussortieren.
private func plausibleArtikelZeilen(_ zeilen: [String]) -> [String] {
let muell = ["summe", "gesamt", "zwischensumme", "mwst", "ust", "eur", "bar",
"rueckgeld", "rückgeld", "kartenzahlung", "betrag", "total",
"kassenbon", "beleg", "datum", "uhr", "filiale", "kunde", "steuer"]
return zeilen.compactMap { roh in
let s = roh.trimmingCharacters(in: .whitespacesAndNewlines)
guard s.filter({ $0.isLetter }).count >= 3 else { return nil }
let klein = s.lowercased()
return muell.contains(where: { klein.contains($0) }) ? nil : s
}
}
/// Menge aus einer Zeile lesen (2x", 2 Stk", führende Zahl), sonst 1.
private func mengeAusZeile(_ zeile: String) -> Double {
for muster in ["(\\d+)\\s*[xX]", "(\\d+)\\s*[sS][tT]", "^\\s*(\\d+)\\b"] {
if let r = zeile.range(of: muster, options: .regularExpression) {
let ziffern = zeile[r].filter { $0.isNumber }
if let n = Int(ziffern), n > 0, n < 100 { return Double(n) }
}
}
return 1
}
/// Einstieg: Foto Bereich markieren Zeilen prüfen einlagern.
struct KassenzettelView: View {
@Environment(\.dismiss) private var dismiss
@State private var bild: UIImage?
@State private var zeilen: [MatchLine]?
@State private var analysiere = false
@State private var kameraAn = false
@State private var galerie: PhotosPickerItem?
@State private var locations: [StorageLocation] = []
var body: some View {
NavigationStack {
inhalt
.navigationTitle("Kassenzettel")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Schließen") { dismiss() } } }
.task { locations = (try? await APIClient.shared.locations()) ?? [] }
.fullScreenCover(isPresented: $kameraAn) {
CameraPicker(isPresented: $kameraAn) { img in bild = img.nachObenGedreht(); zeilen = nil }
.ignoresSafeArea()
}
.onChange(of: galerie) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let ui = UIImage(data: data) { bild = ui.nachObenGedreht(); zeilen = nil }
galerie = nil
}
}
}
}
@ViewBuilder private var inhalt: some View {
if analysiere {
VStack(spacing: 12) { ProgressView(); Text("Lese Artikel…").foregroundStyle(.secondary) }
} else if let zeilen {
KassenzettelPruefenView(match: zeilen, locations: locations) { dismiss() }
} else if let bild {
KassenzettelMarkierenView(bild: bild, onWeiter: starteErkennung) { self.bild = nil }
} else {
start
}
}
private var start: some View {
VStack(spacing: 16) {
Spacer()
Image(systemName: "doc.text.viewfinder").font(.system(size: 52)).foregroundStyle(.secondary)
Text("Kassenzettel fotografieren, den Artikelbereich markieren die App liest die Zeilen und schlägt Lebensmittel vor.")
.multilineTextAlignment(.center).foregroundStyle(.secondary).padding(.horizontal)
Button { kameraAn = true } label: {
Label("Foto aufnehmen", systemImage: "camera").frame(maxWidth: .infinity)
}.buttonStyle(.borderedProminent)
PhotosPicker(selection: $galerie, matching: .images) {
Label("Aus Galerie wählen", systemImage: "photo").frame(maxWidth: .infinity)
}.buttonStyle(.bordered)
Spacer()
}.padding()
}
private func starteErkennung(_ ausschnitt: UIImage) {
analysiere = true
Task {
let erg = await erkenne(ausschnitt)
await MainActor.run { zeilen = erg; analysiere = false }
}
}
private func erkenne(_ img: UIImage) async -> [MatchLine] {
guard let cg = img.cgImage else { return [] }
let roh = await ocrZeilen(cg)
let zeilen = plausibleArtikelZeilen(roh)
guard !zeilen.isEmpty else { return [] }
return (try? await APIClient.shared.matchReceipt(zeilen))
?? zeilen.map { MatchLine(text: $0, candidates: []) }
}
}
/// Rechteck über den Artikelblock ziehen; liefert den zugeschnittenen Bildausschnitt.
struct KassenzettelMarkierenView: View {
let bild: UIImage
var onWeiter: (UIImage) -> Void
var onNeu: () -> Void
@State private var rect: CGRect = .zero
@State private var startRect: CGRect?
@State private var fitRect: CGRect = .zero
var body: some View {
VStack(spacing: 12) {
Text("Ziehe das Rechteck über die Artikel. Am Griff unten rechts vergrößern.")
.font(.callout).foregroundStyle(.secondary)
.multilineTextAlignment(.center).padding(.horizontal)
GeometryReader { geo in
ZStack(alignment: .topLeading) {
Image(uiImage: bild).resizable().scaledToFit()
.frame(width: geo.size.width, height: geo.size.height)
Rectangle().stroke(Color.marke, lineWidth: 2)
.background(Color.marke.opacity(0.15))
.frame(width: rect.width, height: rect.height)
.offset(x: rect.minX, y: rect.minY)
.gesture(zieh(resize: false))
Rectangle().fill(Color.marke)
.frame(width: 26, height: 26)
.offset(x: rect.maxX - 13, y: rect.maxY - 13)
.gesture(zieh(resize: true))
}
.onAppear { setzeFit(geo.size) }
.onChange(of: geo.size) { setzeFit($0) }
}
HStack {
Button("Neues Foto", action: onNeu)
Spacer()
Button("Weiter") { weiter() }.buttonStyle(.borderedProminent)
}.padding(.horizontal)
}.padding(.vertical)
}
private func setzeFit(_ container: CGSize) {
let iw = bild.size.width, ih = bild.size.height
guard iw > 0, ih > 0, container.width > 0 else { return }
let s = min(container.width / iw, container.height / ih)
let w = iw * s, h = ih * s
fitRect = CGRect(x: (container.width - w) / 2, y: (container.height - h) / 2, width: w, height: h)
if rect == .zero { rect = fitRect.insetBy(dx: fitRect.width * 0.08, dy: fitRect.height * 0.12) }
}
private func zieh(resize: Bool) -> some Gesture {
DragGesture()
.onChanged { v in
if startRect == nil { startRect = rect }
guard let s = startRect else { return }
var r = s
if resize {
r.size.width = max(48, s.width + v.translation.width)
r.size.height = max(48, s.height + v.translation.height)
} else {
r.origin.x = s.origin.x + v.translation.width
r.origin.y = s.origin.y + v.translation.height
}
rect = klemme(r)
}
.onEnded { _ in startRect = nil }
}
private func klemme(_ r: CGRect) -> CGRect {
guard fitRect.width > 0 else { return r }
var o = r
o.size.width = min(o.size.width, fitRect.width)
o.size.height = min(o.size.height, fitRect.height)
o.origin.x = min(max(o.origin.x, fitRect.minX), fitRect.maxX - o.width)
o.origin.y = min(max(o.origin.y, fitRect.minY), fitRect.maxY - o.height)
return o
}
private func weiter() {
guard fitRect.width > 0 else { onWeiter(bild); return }
let s = bild.size.width / fitRect.width // Bildschirm-Einheiten Bildpunkte
let crop = CGRect(x: (rect.minX - fitRect.minX) * s, y: (rect.minY - fitRect.minY) * s,
width: rect.width * s, height: rect.height * s)
onWeiter(bild.zugeschnitten(auf: crop) ?? bild)
}
}
private struct KassenzettelArtikel: Hashable { let id: Int; let name: String; let unit: String }
private struct KassenzettelZeile: Identifiable {
let id = UUID()
let text: String
let kandidaten: [MatchCandidate]
var artikel: KassenzettelArtikel?
var menge: Double
var ortId: String?
}
private struct ZeilenRef: Identifiable { let id: UUID }
/// Prüfen-Liste: je Zeile Artikel (Auto-Treffer + eigene Suche), Menge, Lagerort.
struct KassenzettelPruefenView: View {
let match: [MatchLine]
let locations: [StorageLocation]
var onFertig: () -> Void
@State private var zeilen: [KassenzettelZeile] = []
@State private var sucheZiel: ZeilenRef?
@State private var scanZiel: ZeilenRef?
@State private var busy = false
@State private var meldung: String?
private var zugeordnet: Int { zeilen.filter { $0.artikel != nil }.count }
var body: some View {
Group {
if zeilen.isEmpty {
VStack(spacing: 10) {
Image(systemName: "doc.text.magnifyingglass").font(.system(size: 40)).foregroundStyle(.secondary)
Text("Keine Artikel erkannt.").font(.headline)
Text("Versuch es mit einem engeren Rechteck oder besserem Licht.")
.font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center)
}.padding()
} else {
List {
ForEach($zeilen) { $z in
Section {
zeile($z)
} header: {
Text(z.text)
.textCase(nil).font(.footnote).foregroundStyle(.secondary)
}
}
Section {
Button { Task { await einlagern() } } label: {
HStack {
if busy { ProgressView().padding(.trailing, 4) }
Text("\(zugeordnet) Artikel einlagern")
}
}.disabled(busy || zugeordnet == 0)
} footer: {
Text("Nicht zugeordnete Zeilen werden übersprungen. Neue Artikel werden nicht angelegt.")
}
}
.listStyle(.insetGrouped)
}
}
.onAppear(perform: aufbauen)
.sheet(item: $sucheZiel) { ref in
NavigationStack {
ArtikelSucheSheet { produkt in setzeArtikel(ref.id, produkt: produkt); sucheZiel = nil }
}
}
.sheet(item: $scanZiel) { ref in
LocationScannerView(locations: locations) { loc in setzeOrt(ref.id, loc.id); scanZiel = nil }
}
.alert("Fertig", isPresented: Binding(get: { meldung != nil },
set: { if !$0 { meldung = nil; onFertig() } })) {
Button("OK") { }
} message: { Text(meldung ?? "") }
}
/// Drei Zeilen (Rows) je Artikel: Auswahl, Menge, Lagerort. In einer eigenen
/// Section gruppiert so ist klar, was zusammengehört, und nichts ist gequetscht.
@ViewBuilder private func zeile(_ z: Binding<KassenzettelZeile>) -> some View {
let zeile = z.wrappedValue
// Artikel wählen
Menu {
ForEach(zeile.kandidaten) { k in
Button("\(k.name) · \(k.score) %") {
z.wrappedValue.artikel = KassenzettelArtikel(id: k.productId, name: k.name, unit: k.checkInUnit)
}
}
Divider()
Button { sucheZiel = ZeilenRef(id: zeile.id) } label: { Label("Suchen…", systemImage: "magnifyingglass") }
if zeile.artikel != nil {
Button(role: .destructive) { z.wrappedValue.artikel = nil } label: {
Label("Nicht zuordnen", systemImage: "xmark")
}
}
} label: {
HStack {
Image(systemName: zeile.artikel == nil ? "questionmark.circle" : "checkmark.circle.fill")
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.green)
Text(zeile.artikel?.name ?? " Artikel wählen ")
.foregroundStyle(zeile.artikel == nil ? Color.secondary : Color.primary)
Spacer()
Image(systemName: "chevron.up.chevron.down").font(.caption2).foregroundStyle(.secondary)
}
}
// Menge
Stepper("Menge: \(Int(zeile.menge))", value: z.menge, in: 1...99)
// Lagerort + QR-Scan
HStack {
Menu {
Button(" ohne ") { z.wrappedValue.ortId = nil }
ForEach(locations) { loc in Button(loc.name) { z.wrappedValue.ortId = loc.id } }
} label: {
Label(ortName(zeile.ortId), systemImage: "mappin.and.ellipse")
}
Spacer()
Button { scanZiel = ZeilenRef(id: zeile.id) } label: {
Label("QR", systemImage: "qrcode.viewfinder")
}.buttonStyle(.borderless)
}
}
private func ortName(_ id: String?) -> String {
guard let id else { return "ohne Ort" }
return locations.first { $0.id == id }?.name ?? "Ort"
}
private func aufbauen() {
guard zeilen.isEmpty else { return }
zeilen = match.map { m in
KassenzettelZeile(
text: m.text, kandidaten: m.candidates,
artikel: m.candidates.first.map {
KassenzettelArtikel(id: $0.productId, name: $0.name, unit: $0.checkInUnit)
},
menge: mengeAusZeile(m.text), ortId: nil,
)
}
}
private func setzeArtikel(_ id: UUID, produkt: Product) {
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
let unit = (produkt.packageSize ?? 0) > 0 ? "package" : produkt.baseUnit
zeilen[i].artikel = KassenzettelArtikel(id: produkt.id, name: produkt.name, unit: unit)
}
private func setzeOrt(_ id: UUID, _ ortId: String) {
guard let i = zeilen.firstIndex(where: { $0.id == id }) else { return }
zeilen[i].ortId = ortId
}
private func einlagern() async {
busy = true
var ein = 0, weg = 0
var fehler: String?
for z in zeilen {
guard let a = z.artikel else { weg += 1; continue }
let payload = BatchCheckInRequest(
productId: a.id, unit: a.unit,
lines: [CheckInLine(quantity: z.menge, bestBefore: nil,
bestBeforePrecision: "day", locationId: z.ortId)],
)
do { _ = try await APIClient.shared.checkInBatch(payload); ein += 1 }
catch { fehler = error.localizedDescription }
}
busy = false
meldung = "\(ein) eingelagert, \(weg) übersprungen."
+ (fehler.map { " Fehler: \($0)" } ?? "")
}
}
/// Freie Artikelsuche (nur Lebensmittel) als Rückfall, wenn kein Auto-Treffer passt.
struct ArtikelSucheSheet: View {
var onPick: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@State private var text = ""
@State private var treffer: [Product] = []
@State private var suchlauf = UUID()
var body: some View {
List(treffer) { p in
Button {
onPick(p); dismiss()
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).foregroundStyle(.primary)
if let b = p.brand, !b.isEmpty {
Text(b).font(.caption).foregroundStyle(.secondary)
}
}
}
}
.listStyle(.plain)
.searchable(text: $text, prompt: "Lebensmittel suchen")
.navigationTitle("Artikel suchen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onChange(of: text) { q in
let lauf = UUID(); suchlauf = lauf
Task {
try? await Task.sleep(nanoseconds: 250_000_000)
if suchlauf != lauf { return }
let liste = (try? await APIClient.shared.searchProducts(q)) ?? []
let food = liste.filter { $0.tracking != "object" }
await MainActor.run { if suchlauf == lauf { treffer = Array(food.prefix(25)) } }
}
}
}
}

View File

@@ -136,8 +136,12 @@ struct CheckOutFormView: View {
@State private var error: String?
private var unitOptions: [UnitOption] {
// Bei hinterlegter Zweiteinheit auch die Einheiten der Gegenart das
// Backend rechnet sie ueber die Bruecke am Artikel um.
var arten: Set<String> = [product.kind]
if product.zweitFaktor != nil, let k = product.zweitKind { arten.insert(k) }
var options = units
.filter { $0.kind == product.kind }
.filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package".
@@ -215,7 +219,7 @@ struct CheckOutFormView: View {
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
)
let total = response.productStock / product.articleUnitFactor
onDone("Ausgelagert. Neuer Bestand: \(format(total)) \(product.articleUnitLabel)")
onDone("Ausgelagert\nNeuer Bestand: \(format(total)) \(display.articleUnit(product, count: total))")
dismiss()
} catch {
self.error = error.localizedDescription

View File

@@ -81,7 +81,7 @@ struct QuickBookingCard: View {
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.tint(einlagern ? Color.accentColor : .orange)
.tint(einlagern ? Color.marke : .orange)
.disabled(!bereit)
}
@@ -171,10 +171,10 @@ struct StatusCard: View {
switch art {
case "kpi-products":
Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand",
detail: "von \(stats.productsTotal)", tint: .accentColor)
detail: "von \(stats.productsTotal)", tint: .marke)
case "kpi-units":
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
detail: "", tint: .accentColor)
detail: "", tint: .marke)
default:
HStack(alignment: .top, spacing: 12) {
Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend",
@@ -295,7 +295,7 @@ struct ExpiryCard: View {
.font(.caption2).foregroundStyle(.secondary)
}
Spacer()
Text(item.daysLeft < 0 ? "abgelaufen" : "\(item.daysLeft) Tage")
Text(item.restlaufzeitText)
.font(.caption).bold()
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
}
@@ -330,7 +330,13 @@ struct MovementsCard: View {
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(movements.prefix(5)) { movement in
MovementRow(movement: movement)
// Tippen auf eine Bewegung öffnet das jeweilige Produkt.
NavigationLink {
ProductByIdView(productId: movement.productId)
} label: {
MovementRow(movement: movement)
}
.buttonStyle(.plain)
}
NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption)
}

View File

@@ -103,7 +103,10 @@ struct MirrorOverview: View {
ForEach(karten) { karte in
Section {
DashboardCardView(type: karte.type, productId: karte.props?.productId,
stand: stand) { stand += 1 }
stand: stand,
detail: karte.props?.detail ?? false,
tiefe: karte.props?.tiefe.flatMap { Int($0) },
mitOhneMhd: karte.props?.mitOhneMhd ?? true) { stand += 1 }
} header: {
Text(CardCatalog.titel(karte.type))
}
@@ -174,6 +177,11 @@ struct DashboardCardView: View {
let type: String
let productId: Int?
let stand: Int
// Aus den Web-Karten-Einstellungen (props). Standardwerte = bisheriges Verhalten,
// damit die lokale App-Übersicht unverändert bleibt.
var detail: Bool = false
var tiefe: Int? = nil
var mitOhneMhd: Bool = true
let onChange: () -> Void
var body: some View {
@@ -198,7 +206,8 @@ struct DashboardCardView: View {
ProductTimelineChart(productId: productId, stand: stand)
case "expiry-donut", "category-donut", "category-bars",
"stock-timeline", "activity-timeline":
ChartCardView(type: type, stand: stand)
ChartCardView(type: type, stand: stand,
detail: detail, tiefe: tiefe, mitOhneMhd: mitOhneMhd)
default:
Text("Unbekannte Karte „\(type)“.")
.font(.caption).foregroundStyle(.secondary)

View File

@@ -81,6 +81,139 @@ struct DateScanView: View {
}
}
/// Liest eine Textzeile (Name/Marke) per Texterkennung ein die erkannten
/// Zeilen erscheinen zum Antippen, wie beim MHD-Scan. Nützlich, wenn ein Produkt
/// nicht in Open Food/Products Facts steht und man nicht abtippen will.
struct TextScanView: View {
let titel: String
var onPick: (String) -> Void
@Environment(\.dismiss) private var dismiss
@State private var zeilen: [String] = []
static var isSupported: Bool {
DataScannerViewController.isSupported && DataScannerViewController.isAvailable
}
var body: some View {
ZStack(alignment: .bottom) {
if Self.isSupported {
TextLinesScannerRepresentable { erkannt in
// Ein erkannter Textblock kann mehrere Zeilen umfassen strikt
// in einzelne Zeilen zerlegen, damit jede Auswahl genau eine ist.
let einzeln = erkannt.flatMap { $0.components(separatedBy: .newlines) }
for t in einzeln.reversed() {
let sauber = t.trimmingCharacters(in: .whitespacesAndNewlines)
guard sauber.count >= 2, !zeilen.contains(sauber) else { continue }
zeilen.insert(sauber, at: 0)
}
if zeilen.count > 8 { zeilen = Array(zeilen.prefix(8)) }
}
.ignoresSafeArea()
} else {
Color.black.ignoresSafeArea()
Text("Dieses Gerät unterstützt keine Texterkennung.")
.foregroundStyle(.white)
}
VStack(spacing: 8) {
if zeilen.isEmpty {
hinweis("Text in den Sucher halten und die passende Zeile antippen.")
} else {
ScrollView {
VStack(spacing: 8) {
ForEach(zeilen, id: \.self) { zeile in
Button {
onPick(zeile)
dismiss()
} label: {
HStack {
Text(zeile).font(.headline).lineLimit(2)
.frame(maxWidth: .infinity, alignment: .leading)
Image(systemName: "square.and.arrow.down")
}
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
}
}
}
.frame(maxHeight: 300)
}
}
.padding()
}
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
}
private func hinweis(_ text: String) -> some View {
Text(text)
.font(.caption)
.padding(10)
.frame(maxWidth: .infinity)
.background(.ultraThinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
}
}
/// Wie TextScannerRepresentable, meldet aber die einzelnen erkannten Zeilen
/// (statt sie zu einem String zu verketten) für die Zeilenauswahl.
private struct TextLinesScannerRepresentable: UIViewControllerRepresentable {
var onLines: ([String]) -> Void
func makeCoordinator() -> Coordinator { Coordinator(onLines: onLines) }
func makeUIViewController(context: Context) -> DataScannerViewController {
let controller = DataScannerViewController(
recognizedDataTypes: [.text()],
qualityLevel: .accurate,
recognizesMultipleItems: true,
isHighFrameRateTrackingEnabled: false,
isHighlightingEnabled: true
)
controller.delegate = context.coordinator
return controller
}
func updateUIViewController(_ controller: DataScannerViewController, context: Context) {
guard !controller.isScanning else { return }
try? controller.startScanning()
}
static func dismantleUIViewController(_ controller: DataScannerViewController, coordinator: Coordinator) {
controller.stopScanning()
}
final class Coordinator: NSObject, DataScannerViewControllerDelegate {
let onLines: ([String]) -> Void
init(onLines: @escaping ([String]) -> Void) { self.onLines = onLines }
func dataScanner(_ scanner: DataScannerViewController,
didAdd addedItems: [RecognizedItem], allItems: [RecognizedItem]) {
handle(allItems)
}
func dataScanner(_ scanner: DataScannerViewController,
didUpdate updatedItems: [RecognizedItem], allItems: [RecognizedItem]) {
handle(allItems)
}
private func handle(_ items: [RecognizedItem]) {
let lines = items.compactMap { item -> String? in
if case .text(let erkannt) = item { return erkannt.transcript }
return nil
}
guard !lines.isEmpty else { return }
onLines(lines)
}
}
}
/// Duenne Huelle um den Text-Scanner von VisionKit.
private struct TextScannerRepresentable: UIViewControllerRepresentable {
var onText: (String) -> Void

View File

@@ -13,18 +13,39 @@ final class DisplaySettings: ObservableObject {
static let fallbackFormat = "de"
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat
/// Gebinde-Mehrzahlformen (Einzahl -> Mehrzahl), z.B. Packung" -> Packungen".
@Published private(set) var packagePlural: [String: String] = ["Packung": "Packungen"]
private init() {}
/// Nach der Anmeldung aufrufen. Schlaegt der Abruf fehl, bleibt es beim
/// Nach der Anmeldung aufrufen. Schlaegt ein Abruf fehl, bleibt es beim
/// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
/// nicht scheitern.
func load() async {
guard let entries = try? await APIClient.shared.settings() else { return }
if let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
if let entries = try? await APIClient.shared.settings(),
let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
!entry.value.isEmpty {
dateFormat = entry.value
}
if let typen = try? await APIClient.shared.packageTypes() {
var map = ["Packung": "Packungen"]
for t in typen where !t.singular.isEmpty { map[t.singular] = t.plural }
packagePlural = map
}
}
/// Ein Gebinde in der passenden Zahlform: 1 Packung", aber 2 Packungen".
func gebinde(_ count: Double, _ singular: String) -> String {
count == 1 ? singular : (packagePlural[singular] ?? singular)
}
/// Artikeleinheit mit Mehrzahl: bei Packungen die Gebinde-Mehrzahl, sonst die
/// Basiseinheit (Gramm/Milliliter/Stück im Deutschen ohne Mehrzahl).
func articleUnit(_ product: Product, count: Double) -> String {
if let size = product.packageSize, size > 0 {
return gebinde(count, product.packageLabel ?? "Packung")
}
return product.articleUnitLabel
}
// MARK: - Datum

View File

@@ -0,0 +1,373 @@
import SwiftUI
// Gruppen: Stammdaten, Obergruppen und Mindestbestände.
//
// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
// Obergruppen hängen (Grillwurst" unter Wurst" UND unter Grillgut").
// `TreeMasterView` (MasterDataViews.swift) kann nur einen Elternteil und würde
// dieselbe Gruppe doppelt anzeigen deshalb hier eine eigene, flache Ansicht
// statt der Baumdarstellung der Lagerorte.
/// Gruppenliste mit unter: " als Untertitel.
struct GroupsView: View {
@State private var groups: [GroupItem] = []
@State private var busy = true
@State private var error: String?
@State private var editing: GroupItem?
@State private var anlegen = false
@State private var loeschen: GroupItem?
private var sortiert: [GroupItem] {
groups.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
List {
if !busy && groups.isEmpty {
Text("Noch keine Gruppen.").foregroundStyle(.secondary)
}
ForEach(sortiert) { g in
Button { editing = g } label: { zeile(g) }
.buttonStyle(.plain)
.swipeActions {
Button(role: .destructive) { loeschen = g } label: {
Label("Löschen", systemImage: "trash")
}
}
}
}
.navigationTitle("Gruppen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { anlegen = true } label: { Image(systemName: "plus") }
}
}
.overlay { if busy && groups.isEmpty { ProgressView() } }
.refreshable { await load() }
.task { await load() }
.sheet(item: $editing) { g in
NavigationStack { GroupEditor(item: g, all: groups) { Task { await load() } } }
}
.sheet(isPresented: $anlegen) {
NavigationStack { GroupEditor(item: nil, all: groups) { Task { await load() } } }
}
.confirmationDialog("Gruppe löschen?", isPresented: Binding(
get: { loeschen != nil }, set: { if !$0 { loeschen = nil } }
), titleVisibility: .visible) {
Button("Löschen", role: .destructive) {
if let g = loeschen { Task { await entfernen(g) } }
}
Button("Abbrechen", role: .cancel) { loeschen = nil }
} message: {
Text("Die Artikel bleiben erhalten und verlieren nur ihre Zuordnung. "
+ "Untergruppen bleiben ebenfalls bestehen sie rücken nicht nach oben.")
}
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
}
@ViewBuilder
private func zeile(_ g: GroupItem) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(g.name).foregroundStyle(.primary)
Text(untertitel(g)).font(.caption).foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
private func untertitel(_ g: GroupItem) -> String {
let map = Dictionary(groups.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
let eltern = (g.parentIds ?? []).compactMap { map[$0] }
var teile = [eltern.isEmpty ? "oberste Ebene" : "unter: " + eltern.joined(separator: ", ")]
teile.append("\(g.productCount ?? 0) Artikel")
if let n = g.childIds?.count, n > 0 {
teile.append(anzahlWort(n, "Untergruppe", "Untergruppen"))
}
return teile.joined(separator: " · ")
}
private func load() async {
busy = true
defer { busy = false }
do { groups = try await APIClient.shared.groups() }
catch { self.error = error.localizedDescription }
}
private func entfernen(_ g: GroupItem) async {
loeschen = nil
do {
try await APIClient.shared.deleteGroup(id: g.id)
await load()
} catch { self.error = error.localizedDescription }
}
}
/// Gruppe anlegen/ändern: Name, Obergruppen, Einheit und Mindestbestände je Ort.
struct GroupEditor: View {
let item: GroupItem?
let all: [GroupItem]
var done: () -> Void
@Environment(\.dismiss) private var dismiss
private struct MinRow: Identifiable {
let id = UUID()
var locationId: String?
var amount: String
}
@State private var name = ""
@State private var parentIds: Set<Int> = []
@State private var rows: [MinRow] = []
@State private var units: [Unit] = []
@State private var unitId: Int?
@State private var locations: [StorageLocation] = []
@State private var busy = false
@State private var error: String?
/// Basiseinheiten je Erfassungseinheit gespeichert wird in Basiseinheiten.
private var faktor: Double {
let f = item?.minFaktor ?? 1
return f == 0 ? 1 : f
}
private var einheit: String { item?.minEinheit ?? "" }
/// Auswahlmenge für Obergruppen: alles außer der Gruppe selbst und ihren
/// Untergruppen sonst entstünde ein Ring.
private var moeglicheEltern: [GroupItem] {
guard let item else { return all }
let verboten = Self.nachfahren(of: item.id, in: all).union([item.id])
return all.filter { !verboten.contains($0.id) }
}
private var elternText: String {
let map = Dictionary(all.map { ($0.id, $0.name) }, uniquingKeysWith: { a, _ in a })
let liste = parentIds.compactMap { map[$0] }.sorted()
return liste.isEmpty ? " keine " : liste.joined(separator: ", ")
}
private var mengenTitel: String {
einheit.isEmpty ? "Mindestbestand" : "Mindestbestand (in \(einheit))"
}
var body: some View {
Form {
Section("Name") {
TextField("z.B. Wurst", text: $name)
}
Section {
NavigationLink {
GroupParentPicker(auswahl: $parentIds, moeglich: moeglicheEltern)
} label: {
LabeledContent("Obergruppen", value: elternText)
}
} footer: {
Text("Eine Gruppe darf unter mehreren Obergruppen hängen „Grillwurst“ "
+ "etwa unter „Wurst“ und unter „Grillgut“. Bestand und Mindestbestand "
+ "der Obergruppe zählen sie dann mit.")
}
if item != nil {
Section("Einheit") {
Picker("Einheit", selection: $unitId) {
Text(" Basiseinheit ").tag(Int?.none)
ForEach(units) { u in Text(u.name).tag(Int?.some(u.id)) }
}
}
Section {
ForEach($rows) { $row in
mengenZeile($row)
}
Button {
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
} header: {
Text(mengenTitel)
} footer: {
Text("„Überall“ heißt: egal wo, Hauptsache im Haus Käufe für einen "
+ "Lagerort decken das mit ab.")
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(item == nil ? "Gruppe anlegen" : "Gruppe ändern")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
.task { await load() }
}
@ViewBuilder
private func mengenZeile(_ row: Binding<MinRow>) -> some 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))
}
}
TextField("Menge", text: row.amount)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(width: 70)
Button(role: .destructive) {
let id = row.wrappedValue.id
rows.removeAll { $0.id == id }
} label: { Image(systemName: "trash") }
.buttonStyle(.borderless)
}
}
private func load() async {
name = item?.name ?? ""
parentIds = Set(item?.parentIds ?? [])
unitId = item?.minStockUnitId
units = (try? await APIClient.shared.units()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
rows = (item?.locationMinStocks ?? []).map {
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
amount: formatAmount($0.minStock / faktor))
}
}
private func save() async {
busy = true
defer { busy = false }
let sauber = name.trimmingCharacters(in: .whitespaces)
do {
guard let item else {
_ = try await APIClient.shared.createGroup(NewGroupRequest(
name: sauber, minStock: nil, minStockUnitId: unitId,
parentIds: Array(parentIds)))
done()
dismiss()
return
}
_ = try await APIClient.shared.updateGroup(id: item.id, GroupUpdateRequest(
name: sauber, minStockUnitId: unitId, parentIds: Array(parentIds)))
var liste: [LocationMinStockIn] = []
var gesehen: Set<String> = []
for row in rows {
guard let loc = row.locationId, !gesehen.contains(loc) else { continue }
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
// Eingabe in der Erfassungseinheit Basiseinheiten.
liste.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * faktor))
}
}
_ = try await APIClient.shared.setGroupLocationMinStock(id: item.id, liste)
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
/// Alle Untergruppen (transitiv). Menge statt Baum-Walk: eine Gruppe ist
/// über mehrere Wege erreichbar und würde sonst mehrfach besucht.
private static func nachfahren(of id: Int, in all: [GroupItem]) -> Set<Int> {
var ergebnis: Set<Int> = []
var offen = [id]
while let cur = offen.popLast() {
for kind in all.first(where: { $0.id == cur })?.childIds ?? [] {
if ergebnis.insert(kind).inserted { offen.append(kind) }
}
}
return ergebnis
}
}
/// Mehrfachauswahl der Obergruppen. Ein Picker kann das nicht deshalb eine
/// Liste mit Häkchen, das SwiftUI-Idiom dafür.
struct GroupParentPicker: View {
@Binding var auswahl: Set<Int>
let moeglich: [GroupItem]
private var sortiert: [GroupItem] {
moeglich.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
}
var body: some View {
List {
ForEach(sortiert) { g in
Button {
if auswahl.contains(g.id) { auswahl.remove(g.id) } else { auswahl.insert(g.id) }
} label: {
HStack {
Text(g.name).foregroundStyle(.primary)
Spacer()
if auswahl.contains(g.id) {
Image(systemName: "checkmark").foregroundStyle(Color.marke)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
}
.navigationTitle("Obergruppen")
.navigationBarTitleDisplayMode(.inline)
}
}
/// Eine Zeile fuer ein Gruppen-Auswahlfeld: Gruppe plus ihre Tiefe im Baum.
struct GruppenOption: Identifiable {
let id: String // Pfad eine Gruppe kann unter mehreren haengen
let gruppe: GroupItem
let tiefe: Int
/// Eingerueckte Beschriftung fuer den Picker.
var label: String {
String(repeating: " ", count: tiefe) + (tiefe > 0 ? "" : "") + gruppe.name
}
}
/// Gruppen als Baum ausrollen fuer Auswahlfelder.
///
/// Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER; das ist der Sinn
/// mehrerer Obergruppen, und ausgewaehlt wird ohnehin dieselbe ID.
func gruppenOptionen(_ groups: [GroupItem]) -> [GruppenOption] {
let vorhanden = Set(groups.map(\.id))
var kinderVon: [Int: [GroupItem]] = [:]
var wurzeln: [GroupItem] = []
for g in groups {
let eltern = (g.parentIds ?? []).filter { vorhanden.contains($0) }
if eltern.isEmpty { wurzeln.append(g) }
for e in eltern { kinderVon[e, default: []].append(g) }
}
let nachName: (GroupItem, GroupItem) -> Bool = {
$0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending
}
var out: [GruppenOption] = []
func walk(_ g: GroupItem, _ tiefe: Int, _ pfad: String, _ gesehen: Set<Int>) {
let eigen = "\(pfad)/\(g.id)"
out.append(GruppenOption(id: eigen, gruppe: g, tiefe: tiefe))
if gesehen.contains(g.id) { return } // Ringschutz
var weiter = gesehen
weiter.insert(g.id)
for k in (kinderVon[g.id] ?? []).sorted(by: nachName) {
walk(k, tiefe + 1, eigen, weiter)
}
}
for w in wurzeln.sorted(by: nachName) { walk(w, 0, "", []) }
return out
}

View File

@@ -23,7 +23,17 @@ struct HistoryView: View {
ForEach(tage, id: \.key) { tag in
Section(display.formatDay(tag.eintraege[0].createdAt)) {
ForEach(tag.eintraege) { movement in
MovementRow(movement: movement, showProduct: productId == nil)
// Im Gesamtverlauf führt jede Zeile zum Produkt; im
// Artikel-eigenen Verlauf ist man schon dort (kein Link).
if productId == nil {
NavigationLink {
ProductByIdView(productId: movement.productId)
} label: {
MovementRow(movement: movement, showProduct: true)
}
} else {
MovementRow(movement: movement, showProduct: false)
}
}
}
}

View File

@@ -29,6 +29,13 @@
<key>CFBundleVersion</key>
<string>1</string>
<!-- App-Store-Kategorie. Muss hier stehen: Das Projekt nutzt ein eigenes
Info.plist (GENERATE_INFOPLIST_FILE = NO), deshalb wird die
INFOPLIST_KEY_LSApplicationCategoryType-Build-Einstellung NICHT in das
gebaute Info.plist uebernommen. -->
<key>LSApplicationCategoryType</key>
<string>public.app-category.utilities</string>
<!-- Export-Compliance: Die App nutzt nur die Standard-Verschluesselung des
Systems (HTTPS ueber Apples Betriebssystem) und faellt damit unter die
Ausnahme. Mit diesem Schluessel entfaellt die "App Encryption
@@ -39,13 +46,12 @@
<key>UILaunchScreen</key>
<dict/>
<!-- Am iPad verlangt das System alle Ausrichtungen, solange die App nicht
auf Vollbild besteht. Am iPhone bleibt Hochkant plus Querformat. -->
<!-- Am iPhone bewusst nur Hochkant - damit sich beim Drehen nichts
verstellt. Am iPad verlangt das System alle Ausrichtungen, solange die
App nicht auf Vollbild besteht. -->
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>

View File

@@ -6,20 +6,72 @@ import SwiftUI
struct ItemListView: View {
@State private var items: [Item] = []
@State private var locations: [StorageLocation] = []
@State private var categories: [CategoryItem] = []
@State private var query = ""
@State private var locationFilter: String? // Lagerort-ID
@State private var categoryFilter: String? // Kategoriename
@State private var categoryFilter: Int? // Kategorie-ID (Unterkategorien zählen mit)
@State private var busy = true
@State private var error: String?
private var categories: [String] {
Array(Set(items.compactMap { $0.categoryName })).sorted()
private struct TreeEntry: Identifiable {
let item: CategoryItem
let depth: Int
var id: Int { item.id }
var label: String { String(repeating: " ", count: depth) + item.name }
}
private var catById: [Int: CategoryItem] {
Dictionary(categories.map { ($0.id, $0) }, uniquingKeysWith: { a, _ in a })
}
/// Ist `catId` die Kategorie `target` oder eine ihrer Unterkategorien?
private func imTeilbaum(_ catId: Int?, target: Int) -> Bool {
let map = catById
var cur = catId
var g = 0
while let c = cur, g < 50 {
if c == target { return true }
cur = map[c]?.parentId
g += 1
}
return false
}
/// Kategorien fürs Filter-Menü: alle, die selbst oder als Oberkategorie zu einem
/// vorhandenen Einzelstück gehören so ist z.B. auch Klamotten" wählbar. Baum
/// mit eingerückter Beschriftung (hierarchisch).
private var filterKategorien: [TreeEntry] {
let map = catById
var relevant = Set<Int>()
for it in items {
var cur = it.categoryId
var g = 0
while let c = cur, !relevant.contains(c), g < 50 {
relevant.insert(c)
cur = map[c]?.parentId
g += 1
}
}
let liste = categories.filter { relevant.contains($0.id) }
let ids = Set(liste.map(\.id))
var result: [TreeEntry] = []
func walk(_ node: CategoryItem, _ depth: Int) {
result.append(TreeEntry(item: node, depth: depth))
for child in liste.filter({ $0.parentId == node.id }) { walk(child, depth + 1) }
}
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { walk(root, 0) }
return result
}
private var filterKategorieName: String {
if let id = categoryFilter, let t = categories.first(where: { $0.id == id }) { return t.name }
return "Alle"
}
private var gefiltert: [Item] {
let q = query.trimmingCharacters(in: .whitespaces).lowercased()
return items.filter { it in
if let cat = categoryFilter, it.categoryName != cat { return false }
if let cat = categoryFilter, !imTeilbaum(it.categoryId, target: cat) { return false }
if let loc = locationFilter, it.locationId != loc { return false }
if !q.isEmpty {
let heu = [it.productName, it.uid, it.note, it.productBrand]
@@ -50,7 +102,8 @@ struct ItemListView: View {
.toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenu } }
.overlay { if busy && items.isEmpty { ProgressView() } }
.refreshable { await load() }
.task { await load() }
// Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert).
.onAppear { Task { await load() } }
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
@@ -64,11 +117,11 @@ struct ItemListView: View {
check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id }
}
}
if !categories.isEmpty {
if !filterKategorien.isEmpty {
Menu("Kategorie") {
check("Alle", categoryFilter == nil) { categoryFilter = nil }
ForEach(categories, id: \.self) { c in
check(c, categoryFilter == c) { categoryFilter = c }
ForEach(filterKategorien) { e in
check(e.label, categoryFilter == e.item.id) { categoryFilter = e.item.id }
}
}
}
@@ -98,6 +151,7 @@ struct ItemListView: View {
do {
items = try await APIClient.shared.allItems()
locations = (try? await APIClient.shared.locations()) ?? []
categories = (try? await APIClient.shared.categories()) ?? []
} catch {
self.error = error.localizedDescription
}

View File

@@ -33,6 +33,42 @@ private func stringToDate(_ s: String) -> Date? {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.date(from: s)
}
/// Datumszeile mit einheitlichem Format (dd.MM.yyyy). Die native compact-DatePicker
/// zeigt das Jahr je nach Feld mal zwei-, mal vierstellig deshalb hier ein fester
/// Formatierer und ein grafischer Picker im Popover.
struct DateRow: View {
let label: String
@Binding var date: Date
@State private var open = false
private static let fmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "de_DE")
f.dateFormat = "dd.MM.yyyy"
return f
}()
var body: some View {
Group {
Button { withAnimation(.easeInOut(duration: 0.15)) { open.toggle() } } label: {
HStack {
Text(label).foregroundStyle(.primary)
Spacer()
Text(Self.fmt.string(from: date))
.foregroundStyle(open ? Color.marke : .secondary)
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
if open {
DatePicker(label, selection: $date, displayedComponents: .date)
.datePickerStyle(.graphical)
.labelsHidden()
}
}
}
}
// MARK: - Liste (in der Produkt-Detailansicht)
/// Einzelstücke eines Gegenstands: Liste mit Anlegen; jedes Stück öffnet die
@@ -40,6 +76,8 @@ private func stringToDate(_ s: String) -> Date? {
struct ProductItemsSection: View {
let product: Product
var onChanged: () async -> Void
/// Bis zur Liste schließen (für Anlegen & fertig").
var onFinish: (() -> Void)? = nil
@State private var items: [Item] = []
@State private var showAdd = false
@@ -66,7 +104,8 @@ struct ProductItemsSection: View {
.task(id: product.id) { await reload() }
.sheet(isPresented: $showAdd) {
NavigationStack {
ItemAddSheet(productId: product.id) { await refresh() }
ItemAddSheet(productId: product.id, onDone: { await refresh() },
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
}
}
}
@@ -83,6 +122,66 @@ struct ProductItemsSection: View {
// MARK: - Detail / Bearbeiten (auch Ziel eines Scans)
/// Lagerort per QR (/l/<Code>) auswählen selbstständige Scan-Ansicht, meldet
/// den getroffenen Lagerort zurück. So kann man einen Ort auch ohne den QR des
/// Einzelstücks setzen (z.B. bei Kleidung ohne eigenen Code).
struct LocationScannerView: View {
let locations: [StorageLocation]
var onPicked: (StorageLocation) -> Void
@Environment(\.dismiss) private var dismiss
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var error: String?
var body: some View {
NavigationStack {
ScannerView(onCode: { code in handle(code) },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
VStack(spacing: 6) {
if let error {
Text(error)
.font(.callout).foregroundStyle(.white)
.padding(.horizontal, 12).padding(.vertical, 8)
.background(.red, in: Capsule())
}
Text("QR am Regal/Fach vor die Kamera halten")
.font(.callout).padding(10)
.background(.ultraThinMaterial, in: Capsule())
}
.padding(.bottom, 24)
}
.navigationTitle("Lagerort scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $torchOn, locked: $torchLocked)
}
}
}
}
private func handle(_ code: String) {
guard let r = code.range(of: "/l/") else {
error = "Das ist kein Lagerort-QR."
return
}
let ziel = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if let treffer = locations.first(where: { $0.id == ziel }) {
onPicked(treffer)
dismiss()
} else {
error = "Dieser Lagerort ist hier nicht bekannt."
}
}
}
struct ItemEditView: View {
let item: Item
var onChanged: (() -> Void)? = nil
@@ -117,6 +216,7 @@ struct ItemEditView: View {
@State private var showFileImporter = false
@State private var pickerItem: PhotosPickerItem?
@State private var previewURL: URL?
@State private var locScanShown = false
var body: some View {
Form {
@@ -141,14 +241,19 @@ struct ItemEditView: View {
Text(" ohne ").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
}
// Lagerort per QR setzen ohne den (evtl. nicht vorhandenen) QR
// des Einzelstücks. Praktisch z.B. bei Kleidung.
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
Picker("Gekauft bei", selection: $shopId) {
Text(" unbekannt ").tag(Int?.none)
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
}
Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) }
if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
}
@@ -228,6 +333,10 @@ struct ItemEditView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } }
.task { await load() }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea()
}
.sheet(isPresented: $showRemove) {
NavigationStack { removeSheet }
}
@@ -400,6 +509,8 @@ struct ItemEditView: View {
struct ItemAddSheet: View {
let productId: Int
var onDone: () async -> Void
/// Wenn gesetzt: Anlegen & fertig" schließt bis zur Liste (nicht nur das Sheet).
var onFinish: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss
@@ -424,6 +535,7 @@ struct ItemAddSheet: View {
@State private var pickerItem: PhotosPickerItem?
@State private var busy = false
@State private var error: String?
@State private var locScanShown = false
var body: some View {
Form {
@@ -433,14 +545,17 @@ struct ItemAddSheet: View {
Text(" ohne ").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
}
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
Picker("Gekauft bei", selection: $shopId) {
Text(" unbekannt ").tag(Int?.none)
ForEach(shops) { s in Text(s.name).tag(Int?.some(s.id)) }
}
Toggle("Kaufdatum", isOn: $hasAcquired)
if hasAcquired { DatePicker("Gekauft am", selection: $acquired, displayedComponents: .date) }
if hasAcquired { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DatePicker("Garantie bis", selection: $warranty, displayedComponents: .date) }
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
} footer: {
Text("Gemeinsame Startwerte danach je Stück änderbar. Jedes Stück bekommt eine eigene UID + QR.")
@@ -478,12 +593,20 @@ struct ItemAddSheet: View {
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } }.disabled(busy)
Button(busy ? "Anlegen…" : "Anlegen") { Task { await create(finish: false) } }.disabled(busy)
if onFinish != nil {
Button(busy ? "Anlegen…" : "Anlegen & fertig") { Task { await create(finish: true) } }
.disabled(busy)
}
}
}
.navigationTitle("Einzelstücke anlegen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea()
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
@@ -539,7 +662,7 @@ struct ItemAddSheet: View {
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create() async {
private func create(finish: Bool) async {
busy = true; defer { busy = false }; error = nil
do {
var sid = shopId
@@ -562,7 +685,7 @@ struct ItemAddSheet: View {
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
await onDone()
dismiss()
if finish, let onFinish { onFinish() } else { dismiss() }
} catch { self.error = error.localizedDescription }
}
}

View File

@@ -76,7 +76,11 @@ struct ShoppingListView: View {
ListRow(
systemImage: "square.stack.3d.up",
title: group.name,
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel"
subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName)"
+ " · \(group.productCount) Artikel"
+ ((group.subgroupCount ?? 0) > 0
? " · inkl. " + anzahlWort(group.subgroupCount ?? 0, "Untergruppe", "Untergruppen")
: "")
) { GroupBadge() }
}
}
@@ -146,7 +150,7 @@ struct ExpiringView: View {
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
Text(restText(item.daysLeft))
Text(item.restlaufzeitText)
.font(.caption).bold()
.foregroundStyle(item.daysLeft < 0 ? .red : .orange)
}
@@ -181,25 +185,238 @@ struct ExpiringView: View {
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
}
private func restText(_ days: Int) -> String {
if days < 0 { return "abgelaufen" }
if days == 0 { return "heute" }
return days == 1 ? "1 Tag" : "\(days) Tage"
}
// MARK: - Mindestbestände
/// Übersicht aller Mindestbestände nach LAGERORT gegliedert.
///
/// Ein Mindestbestand ist immer ein Bedarf an einem Ort. Überall" (egal wo,
/// Hauptsache im Haus) ist dabei der oberste Ort; Käufe für einen einzelnen
/// Lagerort decken ihn mit ab. Mengen kommen in Basiseinheiten und werden hier
/// in die Erfassungseinheit des Ziels umgerechnet.
///
/// Artikelzeilen öffnen den Artikel, Gruppenzeilen die Gruppe beide lassen
/// sich dort bearbeiten.
struct MinStockView: View {
@State private var products: [Product] = []
@State private var groups: [GroupItem] = []
@State private var busy = true
@State private var error: String?
private struct MinRow: Identifiable {
let id: String
let title: String
let soll: String
let bestand: String?
let productId: Int? // Tippziel (Artikel)
let groupId: Int? // Tippziel (Gruppe)
let unter: Bool
let untergruppen: Int
}
private struct OrtBlock: Identifiable {
let id: String
let name: String
let zeilen: [MinRow]
}
/// Alle Zeilen, gebündelt je Ort. Überall" (Ort nil) steht zuerst.
private var bloecke: [OrtBlock] {
var jeOrt: [String: [MinRow]] = [:]
var namen: [String: String] = [UEBERALL_ID: UEBERALL_NAME]
func merken(_ locationId: String?, _ locationName: String?, _ row: MinRow) {
let key = locationId ?? UEBERALL_ID
if let n = locationName, locationId != nil { namen[key] = n }
jeOrt[key, default: []].append(row)
}
for p in products.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let disp = p.unitFactor == 0 ? 1 : p.unitFactor // Basis -> Anzeigeeinheit
for l in (p.locationMinStocks ?? []) {
merken(l.locationId, l.locationName, MinRow(
id: "p\(p.id)l\(l.id)", title: p.name,
soll: "\(formatAmount(l.minStock / disp)) \(p.unitName)",
bestand: l.stock.map { "\(formatAmount($0 / disp)) \(p.unitName)" },
productId: p.id, groupId: nil,
unter: (l.stock ?? 0) < l.minStock, untergruppen: 0))
}
}
for g in groups.sorted(by: { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }) {
let faktor = g.minFaktor == 0 ? 1 : g.minFaktor
let einheit = g.minEinheit
for l in (g.locationMinStocks ?? []) {
merken(l.locationId, l.locationName, MinRow(
id: "g\(g.id)l\(l.id)", title: g.name,
soll: "\(formatAmount(l.minStock / faktor)) \(einheit)",
bestand: l.stock.map { "\(formatAmount($0 / faktor)) \(einheit)" },
productId: nil, groupId: g.id,
unter: (l.stock ?? 0) < l.minStock,
untergruppen: g.childIds?.count ?? 0))
}
}
// Überall" zuerst, danach die Lagerorte alphabetisch.
let schluessel = jeOrt.keys.sorted { a, b in
if a == UEBERALL_ID { return true }
if b == UEBERALL_ID { return false }
return (namen[a] ?? "").localizedCaseInsensitiveCompare(namen[b] ?? "") == .orderedAscending
}
return schluessel.map {
OrtBlock(id: $0, name: namen[$0] ?? "Lagerort",
zeilen: jeOrt[$0] ?? [])
}
}
var body: some View {
List {
if !busy && bloecke.isEmpty {
Text("Noch keine Mindestbestände gesetzt.").foregroundStyle(.secondary)
}
ForEach(bloecke) { block in
Section(block.id == UEBERALL_ID ? "Überall (egal wo)" : block.name) {
ForEach(block.zeilen) { r in
if let pid = r.productId, let p = products.first(where: { $0.id == pid }) {
NavigationLink { ProductDetailView(product: p) } label: { zeile(r) }
} else if let gid = r.groupId, let g = groups.first(where: { $0.id == gid }) {
NavigationLink {
GroupEditor(item: g, all: groups) { Task { await load() } }
} label: { zeile(r) }
} else {
zeile(r)
}
}
}
}
}
.navigationTitle("Mindestbestände")
.navigationBarTitleDisplayMode(.inline)
.overlay { if busy && bloecke.isEmpty { ProgressView() } }
.refreshable { await load() }
.onAppear { Task { await load() } }
.alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) {
Button("OK", role: .cancel) {}
} message: { Text(error ?? "") }
}
@ViewBuilder
private func zeile(_ r: MinRow) -> some View {
HStack(spacing: 12) {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(r.title)
if r.groupId != nil {
Text("Gruppe").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.marke.opacity(0.2), in: Capsule())
}
if r.untergruppen > 0 {
Text("inkl. \(r.untergruppen)").font(.caption2)
.padding(.horizontal, 6).padding(.vertical, 1)
.background(Color.secondary.opacity(0.15), in: Capsule())
}
}
Text("Soll \(r.soll)" + (r.bestand.map { " · Bestand \($0)" } ?? ""))
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
if r.unter {
Text("niedrig").font(.caption2)
.padding(.horizontal, 8).padding(.vertical, 3)
.background(Color.orange.opacity(0.2), in: Capsule())
}
}
}
private func load() async {
busy = true
defer { busy = false }
do {
async let ps = APIClient.shared.searchProducts("")
async let gs = APIClient.shared.groups()
products = try await ps
groups = try await gs
Task { await ProductImageCache.shared.preload(products) }
} catch {
self.error = error.localizedDescription
}
}
}
// MARK: - Produktliste
struct ProductListView: View {
/// nil = alle, "food" = nur Lebensmittel, "object" = nur Gegenstände.
/// Getrennte Listen wie im Web (dort über `fixedType`/`p.tracking`).
var fixedType: String? = nil
@EnvironmentObject private var session: Session
@State private var query = ""
@State private var products: [Product] = []
@State private var categories: [CategoryItem] = []
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
@State private var categoryId: Int?
@State private var sortMode: SortMode = .nameAsc
/// nil = alle; sonst "count"/"individual"/"bulk" (nur in der Gegenstände-Liste).
@State private var verwaltungFilter: String?
@State private var busy = false
@State private var showNew = false
enum SortMode: String, CaseIterable, Identifiable {
case nameAsc = "Name AZ"
case nameDesc = "Name ZA"
case updated = "Zuletzt geändert"
var id: String { rawValue }
}
private let verwaltungOptionen: [(String, String)] = [
("count", "Menge je Lagerort"),
("individual", "Einzelstücke"),
("bulk", "Verbrauchsgegenstand"),
]
/// Verwaltungsart eines Gegenstands.
private func verwaltung(_ p: Product) -> String {
p.isIndividual ? "individual" : (p.isBulk ? "bulk" : "count")
}
/// Auf den festen Typ und ggf. die Verwaltungsart gefiltert (wie im Web).
private var sichtbar: [Product] {
products.filter { p in
if let fixedType, (p.isObject ? "object" : "food") != fixedType { return false }
if let vf = verwaltungFilter, verwaltung(p) != vf { return false }
return true
}
}
/// Sichtbare Produkte in der gewählten Reihenfolge.
private var sortiert: [Product] {
switch sortMode {
case .nameAsc:
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
case .nameDesc:
return sichtbar.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedDescending }
case .updated:
// ISO-Strings gleichen Formats sind chronologisch vergleichbar; neueste zuerst.
return sichtbar.sorted { ($0.updatedAt ?? "") > ($1.updatedAt ?? "") }
}
}
/// Nur Kategorien des Listentyps (Lebensmittel-Liste zeigt nur Food-Kategorien usw.).
private var typKategorien: [CategoryItem] {
guard let fixedType else { return categories }
return categories.filter { ($0.tracking ?? "food") == fixedType }
}
private var titel: String {
switch fixedType {
case "food": return "Lebensmittel"
case "object": return "Gegenstände"
default: return "Produkte"
}
}
private var filterLabel: String {
if categoryId == 0 { return "Ohne Kategorie" }
if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) {
@@ -225,24 +442,41 @@ struct ProductListView: View {
Text(filterLabel).foregroundStyle(.secondary)
}
}
if fixedType == "object" {
Picker(selection: $verwaltungFilter) {
Text("Alle").tag(String?.none)
ForEach(verwaltungOptionen, id: \.0) { opt in
Text(opt.1).tag(String?.some(opt.0))
}
} label: {
Label("Verwaltung", systemImage: "square.grid.2x2")
}
.pickerStyle(.menu)
}
Picker(selection: $sortMode) {
ForEach(SortMode.allCases) { Text($0.rawValue).tag($0) }
} label: {
Label("Sortierung", systemImage: "arrow.up.arrow.down")
}
.pickerStyle(.menu)
}
Section {
ForEach(products) { product in
ForEach(sortiert) { product in
NavigationLink {
ProductDetailView(product: product)
} label: {
ProductRow(product: product)
}
}
if products.isEmpty && !busy {
if sichtbar.isEmpty && !busy {
Text("Keine Treffer").foregroundStyle(.secondary)
}
}
}
.searchable(text: $query, prompt: "Artikel suchen")
.onChange(of: query) { _ in Task { await load() } }
.navigationTitle("Produkte")
.navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline)
.refreshable { await load() }
.toolbar {
@@ -256,9 +490,10 @@ struct ProductListView: View {
}
.sheet(isPresented: $showNew) {
NavigationStack {
// Ohne Barcode: nur Name + Kategorie nötig. Nach dem Anlegen
// schließt sich das Formular und die Liste wird aufgefrischt.
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
// 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
showNew = false
Task { await load() }
}
@@ -269,10 +504,11 @@ struct ProductListView: View {
}
}
}
.task {
categories = (try? await APIClient.shared.categories()) ?? []
await load()
}
.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
// Einzelstücke) sofort stimmt und nicht 0" hängen bleibt.
.onAppear { Task { await load() } }
}
private func pick(_ id: Int?) {
@@ -289,17 +525,18 @@ struct ProductListView: View {
}
private func categoryTree() -> [TreeEntry] {
let ids = Set(categories.map(\.id))
let liste = typKategorien
let ids = Set(liste.map(\.id))
var result: [TreeEntry] = []
func walk(_ node: CategoryItem, _ depth: Int) {
result.append(TreeEntry(item: node, depth: depth))
for child in categories.filter({ $0.parentId == node.id }) {
for child in liste.filter({ $0.parentId == node.id }) {
walk(child, depth + 1)
}
}
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene.
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
for root in liste.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0)
}
return result

View File

@@ -30,7 +30,10 @@ struct LocalOverview: View {
anzeige
}
}
.toolbar { ToolbarItemGroup(placement: .bottomBar) { werkzeugleiste } }
// Eigene Leiste statt `.bottomBar`: der Start-Tab liegt in einer TabView,
// deren Tab-Balken die `.bottomBar` verdecken würde. `safeAreaInset` legt
// die Leiste sichtbar direkt über den Tab-Balken.
.safeAreaInset(edge: .bottom) { werkzeugleiste }
.sheet(isPresented: $addShown) {
CardPickerView { neu in fuegeHinzu(neu) }
}
@@ -59,7 +62,10 @@ struct LocalOverview: View {
ForEach(aktives.cards) { karte in
Section {
DashboardCardView(type: karte.type, productId: karte.productId,
stand: stand) { stand += 1 }
stand: stand,
detail: karte.detail ?? false,
tiefe: karte.tiefe,
mitOhneMhd: karte.mitOhneMhd ?? true) { stand += 1 }
} header: {
Text(titel(karte))
}
@@ -82,14 +88,12 @@ struct LocalOverview: View {
}
Section {
ForEach(aktives?.cards ?? []) { karte in
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(CardCatalog.titel(karte.type))
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
artikelWahl(fuer: karte)
}
VStack(alignment: .leading, spacing: 6) {
Text(CardCatalog.titel(karte.type))
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
artikelWahl(fuer: karte)
}
Spacer()
diagrammEinstellungen(fuer: karte)
}
}
.onDelete(perform: loesche)
@@ -110,33 +114,41 @@ struct LocalOverview: View {
.pickerStyle(.menu)
}
@ViewBuilder private var werkzeugleiste: some View {
if bearbeiten {
Button { addShown = true } label: { Label("Karte", systemImage: "plus") }
Spacer()
Menu {
Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() }
if aktives != nil {
Button("Umbenennen", systemImage: "pencil") {
renameText = aktives?.name ?? ""; renameShown = true
}
if config.dashboards.count > 1 {
Button("Dashboard löschen", systemImage: "trash", role: .destructive) {
loescheDashboard()
private var werkzeugleiste: some View {
VStack(spacing: 0) {
Divider()
HStack {
if bearbeiten {
Button { addShown = true } label: { Label("Karte", systemImage: "plus") }
Spacer()
Menu {
Button("Dashboard hinzufügen", systemImage: "plus") { neuesDashboard() }
if aktives != nil {
Button("Umbenennen", systemImage: "pencil") {
renameText = aktives?.name ?? ""; renameShown = true
}
if config.dashboards.count > 1 {
Button("Dashboard löschen", systemImage: "trash", role: .destructive) {
loescheDashboard()
}
}
}
} label: {
Label("Dashboards", systemImage: "rectangle.stack")
}
Spacer()
Button("Fertig") { bearbeiten = false }.fontWeight(.semibold)
} else {
Spacer()
Button { bearbeiten = true } label: {
Label("Bearbeiten", systemImage: "slider.horizontal.3")
}
}
} label: {
Label("Dashboards", systemImage: "rectangle.stack")
}
Spacer()
Button("Fertig") { bearbeiten = false }
} else {
Spacer()
Button { bearbeiten = true } label: {
Label("Bearbeiten", systemImage: "slider.horizontal.3")
}
.padding(.horizontal)
.padding(.vertical, 10)
}
.background(.bar)
}
private func titel(_ karte: AppCard) -> String {
@@ -154,7 +166,54 @@ struct LocalOverview: View {
}
} label: {
Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen")
.font(.caption).foregroundStyle(Color.accentColor)
.font(.caption).foregroundStyle(Color.marke)
}
}
// MARK: - Diagramm-Einstellungen (nur bestimmte Ringe, wie im Web-Editor)
private struct TiefeOption: Identifiable {
let tiefe: Int?
let label: String
var id: String { label }
}
private static let tiefeOptionen: [TiefeOption] = [
.init(tiefe: nil, label: "Feinste (jede Kategorie einzeln)"),
.init(tiefe: 1, label: "Nur oberste Stufe"),
.init(tiefe: 2, label: "Bis 2. Stufe"),
.init(tiefe: 3, label: "Bis 3. Stufe"),
]
@ViewBuilder private func diagrammEinstellungen(fuer karte: AppCard) -> some View {
switch karte.type {
case "expiry-donut":
Toggle(isOn: Binding(
get: { karte.mitOhneMhd ?? true },
set: { setzeMitOhneMhd(karte, $0) }
)) {
Text("„Ohne MHD“ einbeziehen").font(.caption)
}
case "category-donut":
Toggle(isOn: Binding(
get: { karte.detail ?? false },
set: { setzeDetail(karte, $0) }
)) {
Text("Detailansicht (alle einzeln)").font(.caption)
}
Menu {
ForEach(Self.tiefeOptionen) { opt in
Button(opt.label) { setzeTiefe(karte, opt.tiefe) }
}
} label: {
HStack(spacing: 4) {
Text("Tiefe:").font(.caption).foregroundStyle(.secondary)
Text(Self.tiefeOptionen.first { $0.tiefe == karte.tiefe }?.label ?? "Feinste")
.font(.caption).foregroundStyle(Color.marke)
}
}
default:
EmptyView()
}
}
@@ -169,15 +228,32 @@ struct LocalOverview: View {
private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } }
private func setzeProdukt(_ karte: AppCard, _ pid: Int?) {
/// Ändert genau eine Karte und lädt die Anzeige neu.
private func mutiereKarte(_ karte: AppCard, _ block: (inout AppCard) -> Void) {
mutiere { dash in
if let i = dash.cards.firstIndex(where: { $0.id == karte.id }) {
dash.cards[i].productId = pid
block(&dash.cards[i])
}
}
stand += 1
}
private func setzeProdukt(_ karte: AppCard, _ pid: Int?) {
mutiereKarte(karte) { $0.productId = pid }
}
private func setzeMitOhneMhd(_ karte: AppCard, _ wert: Bool) {
mutiereKarte(karte) { $0.mitOhneMhd = wert }
}
private func setzeDetail(_ karte: AppCard, _ wert: Bool) {
mutiereKarte(karte) { $0.detail = wert }
}
private func setzeTiefe(_ karte: AppCard, _ wert: Int?) {
mutiereKarte(karte) { $0.tiefe = wert }
}
private func loesche(at offsets: IndexSet) { mutiere { $0.cards.remove(atOffsets: offsets) } }
private func verschiebe(from: IndexSet, to: Int) { mutiere { $0.cards.move(fromOffsets: from, toOffset: to) } }
@@ -240,7 +316,7 @@ struct CardPickerView: View {
Text("· Artikel").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "plus.circle").foregroundStyle(Color.accentColor)
Image(systemName: "plus.circle").foregroundStyle(Color.marke)
}
}
.foregroundStyle(.primary)

View File

@@ -1126,34 +1126,6 @@ struct FieldEditor: View {
}
}
struct GroupsView: View {
var body: some View {
MasterDataListView(
title: "Gruppen",
singular: "Die Gruppe",
load: {
try await APIClient.shared.groups().map {
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false)
}
},
delete: { try await APIClient.shared.deleteGroup(id: $0) }
) { item, done in
NameEditor(
title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen",
initial: item?.title ?? "",
save: { name in
if let item {
_ = try await APIClient.shared.renameGroup(id: item.id, name: name)
} else {
_ = try await APIClient.shared.createGroup(
NewGroupRequest(name: name, minStock: nil, minStockUnitId: nil))
}
},
done: done
)
}
}
}
/// Bezugsquellen für gekauft bei" (nur Gegenstände). Wie im Web pflegbar
/// Name plus optionale Website.

View File

@@ -50,6 +50,14 @@ struct Product: Codable, Identifiable, Hashable {
let baseUnit: String
let packageSize: Double?
let packageLabel: String?
/// Zweiteinheit: secondaryCount Basiseinheiten secondaryAmount
/// secondaryBase". Bruecke zwischen den sonst strikt getrennten Arten.
/// Optionals, damit die App auch gegen einen aelteren Server laeuft.
let secondaryBase: String?
let secondaryCount: Double?
let secondaryAmount: Double?
/// Vom Server ausgerechnet: Zweit-Basiseinheiten je EINER Basiseinheit.
let secondaryFactor: Double?
let groupId: Int?
let minStock: Double?
let stock: Double
@@ -78,12 +86,17 @@ struct Product: Codable, Identifiable, Hashable {
let fieldValues: [String: String?]?
/// Gegenstand als Einzelstücke (Items mit UID/QR) statt als Menge geführt.
let individual: Bool?
/// Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel (Charge) geführt.
let bulk: Bool?
/// Mindestbestand je Lagerort (zusaetzlich zum globalen Mindestbestand).
let locationMinStocks: [LocationMinStock]?
/// Zeitpunkt der letzten Änderung (ISO-String), zum Sortieren Zuletzt geändert".
let updatedAt: String?
enum CodingKeys: String, CodingKey {
case id, barcode, name, brand, stock, kind, barcodes, tracking, individual
case id, barcode, name, brand, stock, kind, barcodes, tracking, individual, bulk
case locationMinStocks = "location_min_stocks"
case updatedAt = "updated_at"
case datePrecision = "date_precision"
case categoryId = "category_id"
case categoryName = "category_name"
@@ -93,6 +106,10 @@ struct Product: Codable, Identifiable, Hashable {
case baseUnit = "base_unit"
case packageSize = "package_size"
case packageLabel = "package_label"
case secondaryBase = "secondary_base"
case secondaryCount = "secondary_count"
case secondaryAmount = "secondary_amount"
case secondaryFactor = "secondary_factor"
case groupId = "group_id"
case minStock = "min_stock"
case expiredCount = "expired_count"
@@ -109,6 +126,11 @@ struct Product: Codable, Identifiable, Hashable {
var isObject: Bool { tracking == "object" }
/// Gegenstand, der als Einzelstücke (UID/QR) geführt wird?
var isIndividual: Bool { individual == true }
/// Verbrauchsgegenstand: Gegenstand, der wie ein Lebensmittel als Charge läuft.
var isBulk: Bool { bulk == true }
/// Nutzt die Lebensmittel-Pfade (Chargen, Einheit, MHD, Mindestbestand):
/// echte Lebensmittel UND Verbrauchsgegenstände.
var foodLike: Bool { !isObject || isBulk }
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
var articleUnitLabel: String {
@@ -124,6 +146,38 @@ struct Product: Codable, Identifiable, Hashable {
var stockInArticleUnits: Double { stock / articleUnitFactor }
/// Zweit-Basiseinheiten je EINER Basiseinheit nil ohne Bruecke.
var zweitFaktor: Double? {
guard let basis = secondaryBase, !basis.isEmpty, basis != baseUnit else { return nil }
if let f = secondaryFactor, f > 0 { return f }
// Rueckfall, falls das abgeleitete Feld fehlt (aelterer Server).
guard let n = secondaryCount, let m = secondaryAmount, n > 0, m > 0 else { return nil }
return m / n
}
/// Art der Zweiteinheit ("count"/"weight"/"volume") fuer die Einheitenwahl.
var zweitKind: String? {
switch secondaryBase {
case "piece": return "count"
case "gram": return "weight"
case "milliliter": return "volume"
default: return nil
}
}
/// Kuerzel der Zweit-Basiseinheit fuer die Anzeige.
var zweitKurz: String {
switch secondaryBase {
case "gram": return "g"
case "milliliter": return "ml"
case "piece": return "Stück"
default: return ""
}
}
/// Bestand in der Zweiteinheit, z.B. 500 g bei 6 Stueck.
var stockInZweit: Double? { zweitFaktor.map { stock * $0 } }
/// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen.
enum StockLevel { case none, ok, close, below }
@@ -136,30 +190,50 @@ struct Product: Codable, Identifiable, Hashable {
}
/// Mindestbestand eines Produkts/einer Gruppe an einem Lagerort (Anzeige).
///
/// ``locationId`` nil heisst Ueberall": egal wo, Hauptsache die Menge ist im
/// Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand. Mengen in
/// Basiseinheiten (g/ml/Stueck).
struct LocationMinStock: Codable, Hashable, Identifiable {
let locationId: String
let locationId: String?
let locationName: String?
let minStock: Double
var id: String { locationId }
/// Bestand an diesem Ort (inkl. Unterorte), ebenfalls in Basiseinheiten.
var stock: Double? = nil
var id: String { locationId ?? UEBERALL_ID }
enum CodingKeys: String, CodingKey {
case stock
case minStock = "min_stock"
case locationId = "location_id"
case locationName = "location_name"
}
}
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Artikeleinheiten).
/// Ein Mindestbestand-Eintrag zum Speichern (Menge in Basiseinheiten).
/// ``locationId`` nil = Ueberall".
struct LocationMinStockIn: Codable {
let locationId: String
let locationId: String?
let minStock: Double
enum CodingKeys: String, CodingKey {
case minStock = "min_stock"
case locationId = "location_id"
}
// Den Ort immer senden auch null, sonst laesst sich Ueberall" gar nicht
// ausdruecken (Swift laesst nil-Optionals sonst weg).
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encode(locationId, forKey: .locationId)
try c.encode(minStock, forKey: .minStock)
}
}
/// Platzhalter-ID fuer Ueberall" in Pickern im JSON ist es schlicht null.
let UEBERALL_ID = "__ueberall__"
let UEBERALL_NAME = "Überall"
/// Auswahleintrag fuer Einheiten.
///
/// Anzeige und uebertragener Wert sind bewusst getrennt: Das Backend kennt
@@ -203,6 +277,10 @@ struct LookupResult: Codable {
let baseUnit: String?
let packageSize: Double?
let quantityText: String?
/// Aus 3 x 80 g" vorgeschlagene Umrechnung nur ein Vorschlag.
let secondaryBase: String?
let secondaryCount: Double?
let secondaryAmount: Double?
enum CodingKeys: String, CodingKey {
case barcode, name, brand
@@ -210,6 +288,9 @@ struct LookupResult: Codable {
case baseUnit = "base_unit"
case packageSize = "package_size"
case quantityText = "quantity_text"
case secondaryBase = "secondary_base"
case secondaryCount = "secondary_count"
case secondaryAmount = "secondary_amount"
}
}
}
@@ -267,6 +348,38 @@ struct BatchCheckInRequest: Codable {
}
}
// MARK: - Kassenzettel-Abgleich
struct MatchRequest: Codable {
let lines: [String]
let threshold: Int?
}
struct MatchCandidate: Codable, Identifiable, Hashable {
let productId: Int
let name: String
let brand: String?
let score: Int
let packageSize: Double?
let baseUnit: String
var id: Int { productId }
/// Einheit fuers Einlagern: ganze Gebinde, sonst die Basiseinheit.
var checkInUnit: String { (packageSize ?? 0) > 0 ? "package" : baseUnit }
enum CodingKeys: String, CodingKey {
case name, brand, score
case productId = "product_id"
case packageSize = "package_size"
case baseUnit = "base_unit"
}
}
struct MatchLine: Codable {
let text: String
let candidates: [MatchCandidate]
}
struct CheckOutRequest: Codable {
let productId: Int
let quantity: Double
@@ -300,9 +413,17 @@ struct NewProductRequest: Codable {
let groupId: Int?
let categoryId: Int?
var individual: Bool? = nil
/// Verbrauchsgegenstand: Gegenstand wie ein Lebensmittel (Charge) geführt.
var bulk: Bool? = nil
/// Selbst definierte Feldwerte der Kategorie: {feld_id (als Text): Wert}.
var fieldValues: [String: String?]? = nil
/// Umrechnung in die andere Art (3 Stück 250 g"), optional.
var secondaryBase: String? = nil
var secondaryCount: Double? = nil
var secondaryAmount: Double? = nil
enum CodingKeys: String, CodingKey {
case barcode, name, brand, individual
case barcode, name, brand, individual, bulk
case imageUrl = "image_url"
case baseUnit = "base_unit"
case packageSize = "package_size"
@@ -310,6 +431,10 @@ struct NewProductRequest: Codable {
case datePrecision = "date_precision"
case groupId = "group_id"
case categoryId = "category_id"
case fieldValues = "field_values"
case secondaryBase = "secondary_base"
case secondaryCount = "secondary_count"
case secondaryAmount = "secondary_amount"
}
}
@@ -343,6 +468,8 @@ struct GroupShoppingItem: Codable, Identifiable {
let deficit: Double
let unitName: String
let productCount: Int
/// Wie viele Untergruppen mitgezaehlt werden (0 = keine).
var subgroupCount: Int? = nil
var id: Int { groupId }
@@ -352,6 +479,7 @@ struct GroupShoppingItem: Codable, Identifiable {
case minStock = "min_stock"
case unitName = "unit_name"
case productCount = "product_count"
case subgroupCount = "subgroup_count"
}
}
@@ -457,6 +585,15 @@ struct ExpiringItem: Codable, Identifiable {
var id: Int { lotId }
/// Restlaufzeit als kurzer Text. 0 Tage" ergibt keinen Sinn - am Ablauftag
/// selbst steht heute". Karte und Liste nutzen dieselbe Formulierung.
var restlaufzeitText: String {
if daysLeft < 0 { return "abgelaufen" }
if daysLeft == 0 { return "heute" }
if daysLeft == 1 { return "morgen" }
return "\(daysLeft) Tage"
}
enum CodingKeys: String, CodingKey {
case quantity
case lotId = "lot_id"
@@ -494,9 +631,27 @@ struct ProductUpdateRequest: Encodable {
var shopId: Int? = nil
var productUrl: String? = nil
var fieldValues: [String: String?]? = nil
// Mindestbestand nur senden, wenn ausdrücklich gewünscht sonst bliebe der
// erfasste Modus (z.B. 5 kg") sonst erhalten und wird nicht überschrieben.
var minStock: Double? = nil
var minStockInPackages: Bool? = nil
var minStockUnitId: Int? = nil
var sendMinStock = false
// Verwaltungsart eines Gegenstands (Einzelstück/Verbrauchsgegenstand) und die
// Basiseinheit nur senden, wenn ausdrücklich gewünscht sonst nichts ändern.
var baseUnit: String? = nil
var individual: Bool? = nil
var bulk: Bool? = nil
var sendMode = false
// Zweiteinheit ebenfalls nur auf Wunsch ein PATCH aus einer anderen Maske
// soll die Bruecke nicht unbeabsichtigt loeschen.
var secondaryBase: String? = nil
var secondaryCount: Double? = nil
var secondaryAmount: Double? = nil
var sendSecondary = false
enum CodingKeys: String, CodingKey {
case name, brand
case name, brand, individual, bulk
case packageSize = "package_size"
case packageLabel = "package_label"
case datePrecision = "date_precision"
@@ -505,6 +660,13 @@ struct ProductUpdateRequest: Encodable {
case shopId = "shop_id"
case productUrl = "product_url"
case fieldValues = "field_values"
case minStock = "min_stock"
case minStockInPackages = "min_stock_in_packages"
case minStockUnitId = "min_stock_unit_id"
case baseUnit = "base_unit"
case secondaryBase = "secondary_base"
case secondaryCount = "secondary_count"
case secondaryAmount = "secondary_amount"
}
func encode(to encoder: Encoder) throws {
@@ -520,18 +682,46 @@ struct ProductUpdateRequest: Encodable {
try container.encode(productUrl, forKey: .productUrl)
// Feldwerte nur senden, wenn gesetzt sonst nichts an den Feldern ändern.
if let fieldValues { try container.encode(fieldValues, forKey: .fieldValues) }
if sendMinStock {
try container.encode(minStock, forKey: .minStock)
try container.encode(minStockInPackages, forKey: .minStockInPackages)
try container.encode(minStockUnitId, forKey: .minStockUnitId)
}
if sendMode {
try container.encode(individual, forKey: .individual)
try container.encode(bulk, forKey: .bulk)
if let baseUnit { try container.encode(baseUnit, forKey: .baseUnit) }
}
if sendSecondary {
try container.encode(secondaryBase, forKey: .secondaryBase)
try container.encode(secondaryCount, forKey: .secondaryCount)
try container.encode(secondaryAmount, forKey: .secondaryAmount)
}
}
}
struct LotUpdateRequest: Codable {
struct LotUpdateRequest: Encodable {
var quantity: Double?
var bestBefore: String?
var bestBeforePrecision: String?
var locationId: String?
enum CodingKeys: String, CodingKey {
case quantity
case bestBefore = "best_before"
case bestBeforePrecision = "best_before_precision"
case locationId = "location_id"
}
// Den Lagerort immer senden (auch null), damit ohne Ort" den gesetzten Ort
// wieder entfernt. Die übrigen Felder bleiben weg, wenn sie nicht gesetzt
// sind das Backend fasst sie dann als unverändert" auf.
func encode(to encoder: Encoder) throws {
var c = encoder.container(keyedBy: CodingKeys.self)
try c.encodeIfPresent(quantity, forKey: .quantity)
try c.encodeIfPresent(bestBefore, forKey: .bestBefore)
try c.encodeIfPresent(bestBeforePrecision, forKey: .bestBeforePrecision)
try c.encode(locationId, forKey: .locationId)
}
}
@@ -542,9 +732,87 @@ struct SettingEntry: Codable {
}
/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl").
///
/// Gruppen bilden einen Graphen, keinen Baum: eine Gruppe darf unter MEHREREN
/// Obergruppen haengen (Grillwurst" unter Wurst" UND unter Grillgut").
/// Deshalb ``parentIds`` als Liste und deshalb konformiert GroupItem NICHT zu
/// ``TreeItem``, das genau einen Elternteil kennt.
struct GroupItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
// Mindestbestand-Infos (nur aus /groups befuellt; Picker brauchen sie nicht).
var minStock: Double? = nil
var minStockUnitId: Int? = nil
var minStockUnitName: String? = nil
var minStockUnitFactor: Double? = nil
var kind: String? = nil
var packageSize: Double? = nil
var packageLabel: String? = nil
var minStockInPackages: Bool? = nil
var stock: Double? = nil
var locationMinStocks: [LocationMinStock]? = nil
var parentIds: [Int]? = nil
var childIds: [Int]? = nil
/// Artikel inkl. Untergruppen; ``directProductCount`` nur die eigenen.
var productCount: Int? = nil
var directProductCount: Int? = nil
enum CodingKeys: String, CodingKey {
case id, name, stock, kind
case minStock = "min_stock"
case minStockUnitId = "min_stock_unit_id"
case minStockUnitName = "min_stock_unit_name"
case minStockUnitFactor = "min_stock_unit_factor"
case packageSize = "package_size"
case packageLabel = "package_label"
case minStockInPackages = "min_stock_in_packages"
case locationMinStocks = "location_min_stocks"
case parentIds = "parent_ids"
case childIds = "child_ids"
case productCount = "product_count"
case directProductCount = "direct_product_count"
}
/// Basiseinheiten je Erfassungseinheit zum Umrechnen der Mindestbestaende,
/// die in Basiseinheiten gespeichert sind.
var minFaktor: Double {
if minStockInPackages == true, let s = packageSize, s > 0 { return s }
return minStockUnitFactor ?? 1
}
/// Beschriftung der Erfassungseinheit (Glas", Gramm", ).
var minEinheit: String {
if minStockInPackages == true, (packageSize ?? 0) > 0 {
return packageLabel ?? "Packung"
}
if let n = minStockUnitName { return n }
switch kind {
case "weight": return "g"
case "volume": return "ml"
case "count": return "Stück"
default: return ""
}
}
}
/// Gruppe aendern. Nur mitgeschickte Felder wertet das Backend aus deshalb
/// KEIN eigener Encoder: der synthetisierte laesst nil-Optionals weg
/// (encodeIfPresent). Ein handgeschriebener wuerde ``"name": null`` senden und
/// damit den Namen loeschen.
struct GroupUpdateRequest: Codable {
var name: String? = nil
var minStock: Double? = nil
var minStockUnitId: Int? = nil
var minStockInPackages: Bool? = nil
var parentIds: [Int]? = nil
enum CodingKeys: String, CodingKey {
case name
case minStock = "min_stock"
case minStockUnitId = "min_stock_unit_id"
case minStockInPackages = "min_stock_in_packages"
case parentIds = "parent_ids"
}
}
/// Kategorie: ordnet die Artikelliste und bestimmt die Verwaltungsart
@@ -953,9 +1221,17 @@ struct DashboardCard: Codable, Identifiable, Hashable {
/// Web-Oberflaeche; hier bestimmen sie allein die Reihenfolge.
struct CardProps: Codable, Hashable {
let productId: Int?
let locationId: String?
let detail: Bool? // Kategorien-Ring: alle einzeln statt Übrige"
let tiefe: String? // Kategorie-Tiefe ("" = feinste, "1"/"2"/)
let mitOhneMhd: Bool? // Ablauf-Ring: Ohne MHD" einbeziehen
enum CodingKeys: String, CodingKey {
case productId = "product_id"
case locationId = "location_id"
case detail
case tiefe
case mitOhneMhd = "mit_ohne_mhd"
}
}
}
@@ -1168,10 +1444,13 @@ struct NewGroupRequest: Codable {
let name: String
let minStock: Double?
let minStockUnitId: Int?
/// Obergruppen (mehrere moeglich) optional, Standard: keine.
var parentIds: [Int]? = nil
enum CodingKeys: String, CodingKey {
case name
case minStock = "min_stock"
case minStockUnitId = "min_stock_unit_id"
case parentIds = "parent_ids"
}
}

View File

@@ -199,17 +199,14 @@ final class NotificationScheduler {
let token = Keychain.read(account: Session.keychainAccount(for: prof.id))
else { continue }
let config = NotificationStore.config(for: prof.id)
do {
let snapshot = try await ServerFetch.snapshot(
baseURL: url, token: token, horizonDays: Self.horizonDays)
geplant += ExpiryPlanner.plan(
profileID: prof.id, serverName: prof.name, config: config,
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
} catch {
// Unerreichbar (Heimnetz von unterwegs) oder Token abgelaufen:
// diesen Server ueberspringen, die anderen trotzdem planen.
continue
}
// snapshot ist widerstandsfaehig: unerreichbare Server (Heimnetz von
// unterwegs) liefern leere Listen -> es wird nichts geplant, die
// anderen Server trotzdem.
let snapshot = await ServerFetch.snapshot(
baseURL: url, token: token, horizonDays: Self.horizonDays)
geplant += ExpiryPlanner.plan(
profileID: prof.id, serverName: prof.name, config: config,
snapshot: snapshot, today: heute, horizonDays: Self.horizonDays)
}
// Nach Datum sortieren und auf das iOS-Limit kappen - die naechsten zuerst.

View File

@@ -152,8 +152,7 @@ struct NotificationSettingsView: View {
let url = profile.url,
let token = Keychain.read(account: Session.keychainAccount(for: profileID))
else { return }
categories = (try? await ServerFetch.snapshot(
baseURL: url, token: token, horizonDays: 1).categories) ?? []
categories = (try? await ServerFetch.categories(baseURL: url, token: token)) ?? []
}
private func pruefeBerechtigung() async {

View File

@@ -4,27 +4,29 @@ import SwiftUI
/// Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
/// Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik. Wird als Abschnitt
/// in die Produkt-Detailansicht eingebettet.
/// Welches Mengen-Sheet gerade offen ist. Wird auf der stabilen Form-Ebene der
/// Detailansicht präsentiert (nicht im Abschnitt selbst), sonst schloss sich das
/// Fenster beim ersten Öffnen sofort wieder (das spät fertige Laden der
/// Entnahme-Statistik hat das frische Sheet weggerissen).
enum ObjectStockSheet: Identifiable {
case add
case relocate(from: String?)
case remove(loc: String?)
var id: String {
switch self {
case .add: return "add"
case .relocate(let f): return "relocate-\(f ?? "none")"
case .remove(let l): return "remove-\(l ?? "none")"
}
}
}
struct ObjectStockSection: View {
let product: Product
let locations: [StorageLocation]
let lots: [Lot]
var onChanged: () async -> Void
@State private var removals: RemovalSummary?
@State private var sheet: StockSheet?
enum StockSheet: Identifiable {
case add
case relocate(from: String?)
case remove(loc: String?)
var id: String {
switch self {
case .add: return "add"
case .relocate(let f): return "relocate-\(f ?? "none")"
case .remove(let l): return "remove-\(l ?? "none")"
}
}
}
let removals: RemovalSummary?
@Binding var sheet: ObjectStockSheet?
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
private func ortName(_ id: String?) -> String {
@@ -73,32 +75,6 @@ struct ObjectStockSection: View {
}
}
}
.sheet(item: $sheet) { welche in
NavigationStack {
switch welche {
case .add:
ObjectAddSheet(product: product, locations: locations, einheit: einheit,
perform: { await afterAction() })
case .relocate(let from):
ObjectRelocateSheet(product: product, locations: locations, initialFrom: from,
perform: { await afterAction() })
case .remove(let loc):
ObjectRemoveSheet(product: product, locations: locations, einheit: einheit,
initialLoc: loc, perform: { await afterAction() })
}
}
}
.task(id: product.id) { await loadRemovals() }
.onChange(of: lots) { _ in Task { await loadRemovals() } }
}
private func loadRemovals() async {
removals = try? await APIClient.shared.productRemovals(id: product.id)
}
private func afterAction() async {
await onChanged()
await loadRemovals()
}
}
@@ -193,11 +169,15 @@ struct ObjectAddSheet: View {
@State private var quantity = ""
@State private var busy = false
@State private var error: String?
@State private var locScanShown = false
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
@@ -209,6 +189,10 @@ struct ObjectAddSheet: View {
.navigationTitle("Menge hinzufügen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea()
}
}
private func save() async {
@@ -239,12 +223,16 @@ struct ObjectRelocateSheet: View {
@State private var quantity = ""
@State private var busy = false
@State private var error: String?
@State private var locScanShown = false
var body: some View {
Form {
Section {
LocationPicker(title: "Von", locations: locations, selection: $fromId)
LocationPicker(title: "Nach", locations: locations, selection: $toId)
Button { locScanShown = true } label: {
Label("Ziel-Lagerort scannen", systemImage: "qrcode.viewfinder")
}
QuantityField(label: "Menge", text: $quantity, suffix: product.unitName)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
@@ -255,6 +243,10 @@ struct ObjectRelocateSheet: View {
.navigationTitle("Umlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in toId = loc.id }
.ignoresSafeArea()
}
.onAppear { fromId = initialFrom }
}
@@ -288,11 +280,15 @@ struct ObjectRemoveSheet: View {
@State private var note = ""
@State private var busy = false
@State private var error: String?
@State private var locScanShown = false
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
Picker("Grund", selection: $reason) {
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
@@ -309,6 +305,10 @@ struct ObjectRemoveSheet: View {
.navigationTitle("Aus Bestand entfernen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { loc in locationId = loc.id }
.ignoresSafeArea()
}
.onAppear { locationId = initialLoc }
}

View File

@@ -5,8 +5,10 @@ struct ProductDetailView: View {
let product: Product
@EnvironmentObject private var display: DisplaySettings
@Environment(\.dismiss) private var dismiss
@State private var current: Product
@State private var showLeaveConfirm = false
@State private var lots: [Lot] = []
@State private var busy = false
@State private var error: String?
@@ -18,20 +20,31 @@ struct ProductDetailView: View {
@State private var packageSize = ""
@State private var packageLabel = ""
@State private var datePrecision = "day"
// Zweiteinheit (3 Stück 250 g") Bruecke zur anderen Art.
@State private var zweitAnzahl = ""
@State private var zweitMenge = ""
@State private var zweitBasis = ""
@State private var groupId: Int?
@State private var categoryId: Int?
// Verwaltungsart eines Gegenstands: count | individual | bulk umstellbar.
@State private var objMode = "count"
@State private var groups: [GroupItem] = []
@State private var categories: [CategoryItem] = []
// Gegenstände (Non-Food): Bezugsquelle, Link, Lagerorte und eigene Felder.
@State private var shopId: Int?
@State private var productUrl = ""
// Gesamt-Mindestbestand (nur Gegenstände; Menge in Stück/Artikeleinheit).
@State private var shops: [ShopItem] = []
@State private var locations: [StorageLocation] = []
@State private var fieldDefs: [FieldDefinition] = []
@State private var fieldValues: [String: String] = [:]
@State private var editLot: Lot?
// 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 objectRemovals: RemovalSummary?
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
@@ -40,6 +53,36 @@ struct ProductDetailView: View {
_current = State(initialValue: product)
}
/// Ungespeicherte Änderungen an den Artikel-Stammdaten? (Foto und Lagerort-
/// Bedarf speichern sofort und zählen hier nicht.)
private var isDirty: Bool {
if name != current.name { return true }
if brand != (current.brand ?? "") { return true }
if categoryId != current.categoryId { return true }
// Gruppe gibt es für alles außer Einzelstücke.
if !current.isIndividual, groupId != current.groupId { return true }
// Packung/MHD nur bei Lebensmitteln und Verbrauchsgegenständen.
if current.foodLike {
if packageSize != (current.packageSize.map { formatAmount($0) } ?? "") { return true }
if packageLabel != (current.packageLabel ?? "") { return true }
if datePrecision != (current.datePrecision == "month" ? "month" : "day") { return true }
if zweitBasis != (current.secondaryBase ?? "") { return true }
if zweitAnzahl != (current.secondaryCount.map { formatAmount($0) } ?? "") { return true }
if zweitMenge != (current.secondaryAmount.map { formatAmount($0) } ?? "") { return true }
}
if current.isObject {
if objMode != currentObjMode { return true }
if !current.isIndividual && !current.isBulk, shopId != current.shopId { return true }
if !current.isBulk, productUrl != (current.productUrl ?? "") { return true }
if fieldValues != Self.stringValues(current.fieldValues) { return true }
}
return false
}
private func attemptLeave() {
if isDirty { showLeaveConfirm = true } else { dismiss() }
}
var body: some View {
Form {
if let status {
@@ -52,14 +95,21 @@ struct ProductDetailView: View {
ProductPhotoSection(productId: current.id)
if current.isObject && current.isIndividual {
ProductItemsSection(product: current, onChanged: { await reload() })
} else if current.isObject {
ProductItemsSection(product: current, onChanged: { await reload() },
onFinish: { dismiss() })
} else if current.isObject && !current.isBulk {
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
ObjectStockSection(product: current, locations: locations, lots: lots,
onChanged: { await reload() })
removals: objectRemovals, sheet: $objectSheet)
} else {
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
Section("Bestand") {
LabeledContent("Vorrat",
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
// Mit Zweiteinheit beide Lesarten: 6 Stück · 500 g".
LabeledContent("Vorrat", value: {
let haupt = "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)"
guard let zweit = current.stockInZweit else { return haupt }
return "\(haupt) · \(formatAmount(zweit)) \(current.zweitKurz)"
}())
if current.expiredCount > 0 {
LabeledContent("Abgelaufen", value: "\(current.expiredCount)")
.foregroundStyle(.red)
@@ -72,17 +122,47 @@ struct ProductDetailView: View {
Section("Artikel") {
LabeledField(label: "Name", text: $name)
LabeledField(label: "Marke", text: $brand)
if !current.isObject {
QuantityField(label: "Packungsgröße", text: $packageSize,
suffix: display.baseUnitLabel(current.baseUnit))
Picker("Bezeichnung", selection: $packageLabel) {
}
// Alles zur Einheit steht zusammen und liest sich als Satz: das
// Gebinde zuerst, weil die Zeile darunter es beim Namen nennt.
if current.foodLike {
Section("Einheit und Menge") {
Picker("Gebinde", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
}
QuantityField(label: "1 \(gebindeName) sind", text: $packageSize,
suffix: display.baseUnitLabel(current.baseUnit))
Picker("MHD-Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
}
// Umrechnung in die ANDERE Art: 3 Stück wiegen 250 g". Damit
// zaehlt der Artikel auch in Gruppen, die in dieser Einheit
// rechnen. Das Gebinde bleibt davon unberuehrt.
Section {
Picker("Umrechnen in", selection: Binding(
get: { zweitBasis },
set: { waehleZweitart($0) }
)) {
Text(" keine ").tag("")
ForEach(zweitBasisOptionen, id: \.self) { basis in
Text(display.baseUnitLabel(basis)).tag(basis)
}
}
if !zweitBasis.isEmpty {
QuantityField(label: "Menge", text: $zweitAnzahl,
suffix: display.baseUnitLabel(current.baseUnit))
QuantityField(label: zweitVerb, text: $zweitMenge,
suffix: display.baseUnitLabel(zweitBasis))
}
} header: {
Text("\(zweitTitel) (optional)")
} footer: {
Text(zweitHinweis)
}
}
Section {
@@ -94,7 +174,26 @@ struct ProductDetailView: View {
: "Nur Lebensmittel-Kategorien.")
}
// Verwaltungsart eines Gegenstands umstellen (z.B. Menge je Lagerort
// Einzelstücke). Wirkt nach dem Speichern; der Bestand wird nicht
// automatisch umgerechnet.
if current.isObject {
Section {
Picker("Verwaltung", selection: $objMode) {
Text("Menge je Lagerort").tag("count")
Text("Einzelstücke").tag("individual")
Text("Verbrauchsgegenstand").tag("bulk")
}
} footer: {
Text(objMode == currentObjMode
? "Wie der Bestand geführt wird."
: "Wird beim Speichern umgestellt vorhandener Bestand wird nicht umgerechnet.")
}
}
// Bezugsquelle/Link nur für Menge je Lagerort und Einzelstücke
// Verbrauchsgegenstände laufen wie Lebensmittel und lassen das weg.
if current.isObject && !current.isBulk {
// Gekauft bei" gehört bei Einzelstücken zum einzelnen Stück
// (jedes kann woanders gekauft sein), nicht ans Modell wie im
// Web wird der Shop hier dann ausgeblendet. Der Produktlink
@@ -111,37 +210,58 @@ struct ProductDetailView: View {
Link("Im Onlineshop öffnen", destination: url)
}
}
if !fieldDefs.isEmpty {
Section("Eigene Felder") {
ForEach(fieldDefs) { def in
ObjectFieldRow(def: def, value: fieldBinding(def))
}
}
}
} else {
}
// Gruppe für alles außer Einzelstücke (Lebensmittel, Verbrauchs-
// gegenstand, Menge je Lagerort).
if !current.isIndividual {
Section {
Picker("Gruppe", selection: $groupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
// Als Baum, damit sichtbar ist, was unter was haengt.
ForEach(gruppenOptionen(groups)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
}
}
} footer: {
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
+ "wandert bei einem Wechsel mit.")
}
}
Section {
NavigationLink {
ProductLocationMinView(product: current) { await reload() }
} label: {
let n = (current.locationMinStocks ?? []).count
Label(n > 0 ? "Mindestbestand je Lagerort (\(n))" : "Mindestbestand je Lagerort",
systemImage: "mappin.and.ellipse")
// Eigene Felder für alle Gegenstände mit Kategorie-Feldern.
if current.isObject && !fieldDefs.isEmpty {
Section("Eigene Felder") {
ForEach(fieldDefs) { def in
ObjectFieldRow(def: def, value: fieldBinding(def))
}
}
}
// Mindestbestand bei Lebensmitteln UND Verbrauchsgegenständen (Charge).
// Menge je Lagerort und Einzelstücke haben keinen Mindestbestand.
//
// Es gibt kein separates Gesamt"-Feld mehr: der Bedarf hängt immer
// an einem Ort, und Überall" ist einer davon (der oberste).
if current.foodLike {
Section {
NavigationLink {
ProductLocationMinView(product: current) { await reload() }
} label: {
let zeilen = current.locationMinStocks ?? []
let ueberall = zeilen.first { $0.locationId == nil }
Label {
VStack(alignment: .leading, spacing: 2) {
Text("Mindestbestand")
Text(minStockUntertitel(zeilen: zeilen, ueberall: ueberall))
.font(.caption).foregroundStyle(.secondary)
}
} icon: {
Image(systemName: "mappin.and.ellipse")
}
}
} footer: {
Text("„Überall“ heißt: egal wo, Hauptsache im Haus Käufe für einen "
+ "Lagerort decken das mit ab.")
}
} footer: {
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum Gesamt-Mindestbestand.")
}
Section("Erkennung") {
@@ -156,7 +276,7 @@ struct ProductDetailView: View {
.disabled(busy || name.isEmpty)
}
if !current.isObject {
if current.foodLike {
Section("Chargen") {
ForEach(lots) { lot in
Button {
@@ -165,7 +285,10 @@ struct ProductDetailView: View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)")
// Ohne den Lagerort sehen zwei Chargen mit
// gleichem MHD identisch aus er ist hier
// oft das einzige Unterscheidungsmerkmal.
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel) · \(lotLocationLabel(lot))")
.font(.caption).foregroundStyle(.secondary)
}
Spacer()
@@ -193,13 +316,47 @@ struct ProductDetailView: View {
}
.navigationTitle(current.name)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button { attemptLeave() } label: { Label("Zurück", systemImage: "chevron.left") }
}
}
.confirmationDialog("Ungespeicherte Änderungen", isPresented: $showLeaveConfirm,
titleVisibility: .visible) {
Button("Speichern") { Task { await save(); dismiss() } }
Button("Verwerfen", role: .destructive) { dismiss() }
Button("Abbrechen", role: .cancel) {}
} message: {
Text("Änderungen an Name/Marke/Kategorie sind noch nicht gespeichert. "
+ "Foto und Lagerort-Bedarf werden sofort gespeichert.")
}
.sheet(item: $editLot) { lot in
NavigationStack {
LotEditView(lot: lot, product: current) {
LotEditView(lot: lot, product: current, locations: locations) {
Task { await reload() }
}
}
}
// 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
NavigationStack {
switch welche {
case .add:
ObjectAddSheet(product: current, locations: locations,
einheit: current.unitName.isEmpty ? "Stück" : current.unitName,
perform: { await reload() })
case .relocate(let from):
ObjectRelocateSheet(product: current, locations: locations, initialFrom: from,
perform: { await reload() })
case .remove(let loc):
ObjectRemoveSheet(product: current, locations: locations,
einheit: current.unitName.isEmpty ? "Stück" : current.unitName,
initialLoc: loc, perform: { await reload() })
}
}
}
.task {
fillForm()
await reload()
@@ -216,16 +373,96 @@ struct ProductDetailView: View {
}
}
/// Kurzfassung der hinterlegten Mindestbestände für die Übersichtszeile.
private func minStockUntertitel(zeilen: [LocationMinStock], ueberall: LocationMinStock?) -> String {
if zeilen.isEmpty { return "keiner hinterlegt" }
let faktor = max(current.unitFactor, 1)
var teile: [String] = []
if let u = ueberall {
teile.append("Überall \(formatAmount(u.minStock / faktor)) \(current.unitName)")
}
let orte = zeilen.count - (ueberall == nil ? 0 : 1)
if orte > 0 { teile.append("\(orte) \(orte == 1 ? "Lagerort" : "Lagerorte")") }
return teile.joined(separator: " · ")
}
/// Basiseinheiten der ANDEREN Arten eine Bruecke auf die eigene Art
/// haette nichts umzurechnen.
private var zweitBasisOptionen: [String] {
["piece", "gram", "milliliter"].filter { $0 != current.baseUnit }
}
/// Die Umrechnung heisst nach dem, was sie liefert Zweiteinheit" musste
/// man erst uebersetzen. Ohne gewaehlte Zielart ein neutraler Titel.
private var zweitTitel: String {
switch zweitBasis {
case "gram": return "Gewicht"
case "milliliter": return "Volumen"
case "piece": return "Stückzahl"
default: return "Umrechnung"
}
}
/// Verb passend zur Zielart und zur Anzahl: 1 Stück wiegt" / 3 wiegen".
private var zweitVerb: String {
let eins = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) == 1
switch zweitBasis {
case "gram": return eins ? "wiegt" : "wiegen"
case "milliliter": return eins ? "fasst" : "fassen"
case "piece": return eins ? "ist" : "sind"
default: return "sind"
}
}
/// Das Gebinde beim Namen nennen: 1 Packung sind ". Ohne Auswahl der
/// Standard, sonst stuende dort 1 sind 3 Stück".
private var gebindeName: String {
packageLabel.trimmingCharacters(in: .whitespaces).isEmpty ? "Packung" : packageLabel
}
/// Zielart waehlen und die linke Zahl EINMALIG mit der Packungsgroesse
/// vorbelegen im Normalfall ist sie genau das. Bewusst keine dauerhafte
/// Kopplung: wer die Packung spaeter aendert, hat dieselbe Ware.
private func waehleZweitart(_ basis: String) {
zweitBasis = basis
if !basis.isEmpty, zweitAnzahl.isEmpty {
zweitAnzahl = packageSize.isEmpty ? "1" : packageSize
}
}
/// Kontrolle unter der Eingabe: 1 Stück 83,33 g".
private var zweitHinweis: String {
let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) ?? 0
let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) ?? 0
guard !zweitBasis.isEmpty, n > 0, m > 0 else {
return "Zusätzliche Lesart desselben Artikels damit zählt er auch in "
+ "Gruppen der anderen Art und lässt sich darin ein- und auslagern."
}
return "1 \(display.baseUnitLabel(current.baseUnit))\(formatAmount(m / n)) "
+ "\(display.baseUnitLabel(zweitBasis)). Damit zählt der Artikel auch in "
+ "Gruppen, die in dieser Einheit rechnen. Bestände bleiben beim Ändern "
+ "unverändert nur ihre Umrechnung verschiebt sich."
}
/// Aktuelle Verwaltungsart des geladenen Artikels.
private var currentObjMode: String {
current.isIndividual ? "individual" : (current.isBulk ? "bulk" : "count")
}
private func fillForm() {
name = current.name
brand = current.brand ?? ""
packageSize = current.packageSize.map { formatAmount($0) } ?? ""
packageLabel = current.packageLabel ?? ""
datePrecision = current.datePrecision == "month" ? "month" : "day"
zweitBasis = current.secondaryBase ?? ""
zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? ""
zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? ""
groupId = current.groupId
categoryId = current.categoryId
shopId = current.shopId
productUrl = current.productUrl ?? ""
objMode = currentObjMode
fieldValues = Self.stringValues(current.fieldValues)
}
@@ -241,6 +478,14 @@ struct ProductDetailView: View {
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
}
/// Lagerort einer Charge als Pfad (Keller Regal 2"). Chargen ohne Ort
/// und solche, deren Ort (noch) nicht geladen ist bleiben ohne Ort".
private func lotLocationLabel(_ lot: Lot) -> String {
guard let id = lot.locationId,
let ort = locations.first(where: { $0.id == id }) else { return "ohne Ort" }
return ort.path(in: locations)
}
private func reload() async {
groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? []
@@ -255,6 +500,20 @@ struct ProductDetailView: View {
} else {
fieldDefs = []
}
// Entnahme-Statistik nur für Menge je Lagerort"-Gegenstände.
if current.isObject && !current.isBulk && !current.isIndividual {
objectRemovals = try? await APIClient.shared.productRemovals(id: current.id)
} else {
objectRemovals = nil
}
}
/// Verwaltungsart (individual/bulk) mitschicken, wenn sie umgestellt wurde.
private func applyModeChange(_ req: inout ProductUpdateRequest) {
guard current.isObject, objMode != currentObjMode else { return }
req.individual = objMode == "individual"
req.bulk = objMode == "bulk"
req.sendMode = true
}
private func save() async {
@@ -263,37 +522,64 @@ struct ProductDetailView: View {
busy = true
defer { busy = false }
do {
if current.isObject {
// Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder.
if current.foodLike {
// Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
// Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
// zusätzlich eigene Felder und einen Mindestbestand.
var req = ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision,
groupId: groupId,
categoryId: categoryId
)
// Der Mindestbestand wird in ProductLocationMinView gepflegt und
// hier bewusst NICHT mitgeschickt sonst überschriebe das
// Stammdaten-Speichern die dort gesetzte Überall"-Zeile.
//
// Zweiteinheit nur vollstaendig; leere oder halbe Angabe loescht
// die Bruecke (secondary_base null raeumt im Backend mit auf).
let zn = Double(zweitAnzahl.replacingOccurrences(of: ",", with: "."))
let zm = Double(zweitMenge.replacingOccurrences(of: ",", with: "."))
req.sendSecondary = true
if !zweitBasis.isEmpty, let zn, let zm, zn > 0, zm > 0 {
req.secondaryBase = zweitBasis
req.secondaryCount = zn
req.secondaryAmount = zm
}
if current.isObject {
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
req.fieldValues = fv
}
applyModeChange(&req)
current = try await APIClient.shared.updateProduct(id: current.id, req)
if current.isObject { fieldValues = Self.stringValues(current.fieldValues) }
} else {
// Gegenstand: Menge je Lagerort oder Einzelstücke. Kein
// Mindestbestand (Alt-Werte werden geleert); Menge-Gegenstände
// dürfen weiter in eine Gruppe.
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
current = try await APIClient.shared.updateProduct(
id: current.id,
ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: nil, packageLabel: nil, datePrecision: nil,
groupId: nil, categoryId: categoryId,
// Einzelstücke tragen den Shop je Stück nichts ans Modell.
shopId: current.isIndividual ? nil : shopId,
productUrl: productUrl.isEmpty ? nil : productUrl,
fieldValues: fv
)
var req = ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: nil, packageLabel: nil, datePrecision: nil,
groupId: current.isIndividual ? nil : groupId, categoryId: categoryId,
// Einzelstücke tragen den Shop je Stück nichts ans Modell.
shopId: current.isIndividual ? nil : shopId,
productUrl: productUrl.isEmpty ? nil : productUrl,
fieldValues: fv,
minStock: nil,
minStockInPackages: false,
minStockUnitId: nil,
sendMinStock: true
)
applyModeChange(&req)
current = try await APIClient.shared.updateProduct(id: current.id, req)
fieldValues = Self.stringValues(current.fieldValues)
} else {
current = try await APIClient.shared.updateProduct(
id: current.id,
ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision,
groupId: groupId,
categoryId: categoryId
)
)
}
status = "Gespeichert."
} catch {
@@ -314,10 +600,47 @@ struct ProductDetailView: View {
}
}
/// Menge und MHD einer Charge korrigieren.
/// Lädt ein Produkt anhand seiner ID nach und zeigt dann die Detailansicht
/// z.B. beim Antippen einer Bewegung im Verlauf oder auf der Startseite.
struct ProductByIdView: View {
let productId: Int
@State private var product: Product?
@State private var error: String?
var body: some View {
Group {
if let product {
ProductDetailView(product: product)
} else if let error {
VStack(spacing: 12) {
Text(error).foregroundStyle(.red).font(.callout)
.multilineTextAlignment(.center)
Button("Erneut versuchen") { Task { await load() } }
}
.padding()
} else {
ProgressView()
}
}
.task { if product == nil { await load() } }
}
private func load() async {
do {
product = try await APIClient.shared.product(id: productId)
error = nil
} catch {
self.error = error.localizedDescription
}
}
}
/// Menge, MHD und Lagerort einer Charge korrigieren.
struct LotEditView: View {
let lot: Lot
let product: Product
let locations: [StorageLocation]
var onSaved: () -> Void
@Environment(\.dismiss) private var dismiss
@@ -326,6 +649,8 @@ struct LotEditView: View {
@State private var hasDate = false
@State private var bestBefore = Date()
@State private var precision = "day"
@State private var locationId: String?
@State private var locScanShown = false
@State private var busy = false
@State private var error: String?
@@ -351,6 +676,19 @@ struct LotEditView: View {
}
}
if !locations.isEmpty {
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)) }
}
// Ort per QR am Regal/Fach setzen, statt in der Liste zu suchen.
Button { locScanShown = true } label: {
Label("Lagerort scannen", systemImage: "qrcode.viewfinder")
}
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
@@ -365,11 +703,16 @@ struct LotEditView: View {
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { ort in locationId = ort.id }
.ignoresSafeArea()
}
.onAppear(perform: fill)
}
private func fill() {
quantity = formatAmount(lot.quantity / product.articleUnitFactor)
locationId = lot.locationId
precision = lot.bestBeforePrecision == "month" ? "month" : "day"
if let raw = lot.bestBefore {
let formatter = DateFormatter()
@@ -401,7 +744,8 @@ struct LotEditView: View {
LotUpdateRequest(
quantity: menge * product.articleUnitFactor,
bestBefore: hasDate ? formatter.string(from: datum) : nil,
bestBeforePrecision: precision
bestBeforePrecision: precision,
locationId: locationId
)
)
onSaved()
@@ -433,16 +777,21 @@ struct ProductLocationMinView: View {
@State private var busy = false
@State private var error: String?
// Gespeichert wird in Basiseinheiten, erfasst in der Anzeigeeinheit (g/ml/Stück).
private var dispFaktor: Double { product.unitFactor == 0 ? 1 : product.unitFactor }
var body: some View {
Form {
Section {
Text("Bedarf je Lagerort (z.B. Ferienhaus, Zuhause). Menge in \(product.articleUnitLabel).")
Text("Was soll wo immer da sein? „Überall“ heißt: egal wo, Hauptsache "
+ "im Haus Käufe für einen Lagerort decken das mit ab. "
+ "Menge in \(product.unitName).")
.font(.caption).foregroundStyle(.secondary)
}
ForEach($rows) { $row in
HStack {
Picker("Lagerort", selection: $row.locationId) {
Text(" wählen ").tag(String?.none)
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)) }
}
TextField("Menge", text: $row.amount)
@@ -458,15 +807,15 @@ struct ProductLocationMinView: View {
}
}
Button {
rows.append(MinRow(locationId: nil, amount: ""))
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Lagerort hinzufügen", systemImage: "plus")
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Bedarf je Lagerort")
.navigationTitle("Mindestbestand")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
@@ -479,8 +828,10 @@ struct ProductLocationMinView: View {
private func load() async {
locations = (try? await APIClient.shared.locations()) ?? []
// Überall" ist der Ort nil im Picker braucht es einen Platzhalter.
rows = (product.locationMinStocks ?? []).map {
MinRow(locationId: $0.locationId, amount: formatAmount($0.minStock))
MinRow(locationId: $0.locationId ?? UEBERALL_ID,
amount: formatAmount($0.minStock / dispFaktor))
}
}
@@ -494,7 +845,10 @@ struct ProductLocationMinView: View {
let wert = Double(row.amount.replacingOccurrences(of: ",", with: ".")) ?? 0
if wert > 0 {
gesehen.insert(loc)
list.append(LocationMinStockIn(locationId: loc, minStock: wert))
// Eingabe in Anzeigeeinheit Basiseinheiten (so gespeichert).
list.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * dispFaktor))
}
}
do {

View File

@@ -196,7 +196,7 @@ struct ProductPhotoSection: View {
}
private func upload(_ image: UIImage) async {
guard let data = Self.prepared(image) else { return }
guard let data = productImageJPEG(image) else { return }
busy = true
defer { busy = false }
error = nil
@@ -223,22 +223,24 @@ struct ProductPhotoSection: View {
}
}
/// Foto verkleinern und als JPEG unter die Server-Grenze (2 MB) drücken.
private static func prepared(_ image: UIImage, maxDim: CGFloat = 1600) -> Data? {
let size = image.size
let scale = min(1, maxDim / max(size.width, size.height))
let target = CGSize(width: size.width * scale, height: size.height * scale)
let scaled = UIGraphicsImageRenderer(size: target).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
var quality: CGFloat = 0.8
var data = scaled.jpegData(compressionQuality: quality)
while let d = data, d.count > 1_800_000, quality > 0.3 {
quality -= 0.15
data = scaled.jpegData(compressionQuality: quality)
}
return data
}
/// Foto verkleinern und als JPEG unter die Server-Grenze (2 MB) drücken.
/// Frei (nicht an eine View gebunden), damit auch das Anlege-Formular es nutzt.
func productImageJPEG(_ image: UIImage, maxDim: CGFloat = 1600) -> Data? {
let size = image.size
let scale = min(1, maxDim / max(size.width, size.height))
let target = CGSize(width: size.width * scale, height: size.height * scale)
let scaled = UIGraphicsImageRenderer(size: target).image { _ in
image.draw(in: CGRect(origin: .zero, size: target))
}
var quality: CGFloat = 0.8
var data = scaled.jpegData(compressionQuality: quality)
while let d = data, d.count > 1_800_000, quality > 0.3 {
quality -= 0.15
data = scaled.jpegData(compressionQuality: quality)
}
return data
}
/// Kamera über UIImagePickerController SwiftUI hat keine eigene Kamera-Ansicht.

View File

@@ -1,4 +1,5 @@
import SwiftUI
import PhotosUI
/// Artikel per Namenssuche auswählen (Alternative zum Scannen).
struct ProductSearchView: View {
@@ -56,6 +57,9 @@ struct ProductFormView: View {
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
var categoryId: Int?
var suggestion: LookupResult.Suggestion?
/// Vorbelegter Typ ("food"/"object"), z.B. aus der Lebensmittel- oder
/// Gegenstände-Liste heraus. Ein Kategorie-Vorschlag hat weiter Vorrang.
var initialType: String?
var onCreated: (Product) -> Void
@Environment(\.dismiss) private var dismiss
@@ -72,25 +76,59 @@ struct ProductFormView: View {
@State private var selectedCategoryId: Int?
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
@State private var modus = "object"
// Gegenstand als Einzelstücke (UID/QR je Stück) führen?
@State private var individual = false
// Verwaltungsart eines Gegenstands: "count" (Menge je Lagerort),
// "individual" (Einzelstücke) oder "bulk" (Verbrauchsgegenstand wie Lebensmittel).
@State private var objMode = "count"
@State private var groups: [GroupItem] = []
@State private var categories: [CategoryItem] = []
@State private var units: [Unit] = []
@State private var newGroupShown = false
@State private var busy = false
@State private var error: String?
// Foto schon beim Anlegen: erst nach dem Anlegen hochladen (ID nötig).
@State private var pendingImage: UIImage?
@State private var showCamera = false
@State private var pickerItem: PhotosPickerItem?
// Eigene Felder der (Gegenstands-)Kategorie schon beim Anlegen ausfüllbar.
@State private var fieldDefs: [FieldDefinition] = []
@State private var fieldValues: [String: String] = [:]
// Text-Scan für Name/Marke (statt abtippen), wenn ein Produkt neu ist.
@State private var scanTarget: TextScanTarget?
enum TextScanTarget: Identifiable {
case name, brand
var id: Int { self == .name ? 0 : 1 }
var titel: String { self == .name ? "Name scannen" : "Marke scannen" }
}
private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
// Umrechnung in die andere Art (3 Stück 250 g") siehe ProductDetailView.
@State private var zweitBasis = ""
@State private var zweitAnzahl = ""
@State private var zweitMenge = ""
/// Nutzt die Lebensmittel-Pfade (Einheit, MHD, Charge): Lebensmittel ODER
/// Verbrauchsgegenstand.
private var foodLike: Bool { modus == "food" || objMode == "bulk" }
/// Gruppe anbieten: Lebensmittel, Verbrauchsgegenstand oder Menge je Lagerort
/// (nicht bei Einzelstücken).
private var showGroup: Bool { modus == "food" || (modus == "object" && objMode != "individual") }
var body: some View {
Form {
Section("Artikel") {
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
LabeledField(label: "Name", text: $name)
if TextScanView.isSupported {
Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") }
}
LabeledField(label: "Marke", text: $brand)
if TextScanView.isSupported {
Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") }
}
}
Section {
Picker("Typ", selection: $modus) {
@@ -100,13 +138,19 @@ struct ProductFormView: View {
.pickerStyle(.segmented)
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
if modus == "object" {
Toggle("Einzelstücke (UID/QR je Stück)", isOn: $individual)
Picker("Verwaltung", selection: $objMode) {
Text("Menge je Lagerort").tag("count")
Text("Einzelstücke").tag("individual")
Text("Verbrauchsgegenstand").tag("bulk")
}
}
} footer: {
Text(modus == "object"
? (individual
? (objMode == "individual"
? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie."
: "Gegenstand: Menge je Lagerort und eigene Felder.")
: objMode == "bulk"
? "Verbrauchsgegenstand: wie ein Lebensmittel als Charge mit Menge und Einheit (z.B. Sonnencreme in ml) nur ohne eindeutigen Code."
: "Gegenstand: Menge je Lagerort und eigene Felder.")
: "Lebensmittel: Chargen mit Mindesthaltbarkeit.")
}
.onChange(of: modus) { neu in
@@ -116,14 +160,15 @@ struct ProductFormView: View {
selectedCategoryId = nil
}
}
// Lebensmittel-Felder (Gruppe, Einheit, MHD) nur im Lebensmittel-Modus.
// Fuer Gegenstaende irrelevant - sie zaehlen in Stueck je Lagerort.
if modus == "food" {
// Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort
// (nur Einzelstücke bleiben außen vor).
if showGroup {
Section {
Picker("Gruppe", selection: $selectedGroupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
// Als Baum, damit sichtbar ist, was unter was haengt.
ForEach(gruppenOptionen(groups)) { eintrag in
Text(eintrag.label).tag(Int?.some(eintrag.gruppe.id))
}
}
if session.isAdmin {
@@ -134,9 +179,12 @@ struct ProductFormView: View {
}
}
} footer: {
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
Text("Gruppe: zählt Bestände mehrerer Artikel zusammen. Der EAN-Code "
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.")
}
}
// Einheit + MHD nur für Lebensmittel und Verbrauchsgegenstände (Charge).
if foodLike {
Section("Einheit") {
Picker("Basiseinheit", selection: $baseUnit) {
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) }
@@ -153,6 +201,59 @@ struct ProductFormView: View {
Text("nur Monat/Jahr").tag("month")
}
}
// Dieselbe Ware in der ANDEREN Art lesbar machen. Damit zaehlt
// der Artikel auch in Gruppen, die in dieser Einheit rechnen.
Section {
Picker("Umrechnen in", selection: Binding(
get: { zweitBasis },
set: { waehleZweitart($0) }
)) {
Text(" keine ").tag("")
ForEach(zweitBasisOptionen, id: \.self) { basis in
Text(DisplaySettings.baseUnitLabel(basis)).tag(basis)
}
}
if !zweitBasis.isEmpty {
QuantityField(label: "Menge", text: $zweitAnzahl,
suffix: DisplaySettings.baseUnitLabel(baseUnit))
QuantityField(label: zweitVerb, text: $zweitMenge,
suffix: DisplaySettings.baseUnitLabel(zweitBasis))
}
} header: {
Text("\(zweitTitel) (optional)")
} footer: {
Text("Zweite Lesart desselben Artikels z.B. „3 Stück wiegen 250 g“. "
+ "Lässt sich auch später am Artikel nachtragen.")
}
}
// Eigene Felder der Gegenstands-Kategorie (z.B. Größe, Farbe beim
// T-Shirt) schon beim Anlegen ausfüllbar, nicht erst danach.
if !fieldDefs.isEmpty {
Section("Eigene Felder") {
ForEach(fieldDefs) { def in
ObjectFieldRow(def: def, value: fieldBinding(def))
}
}
}
// Foto direkt beim Anlegen z.B. wenn Open Food/Products Facts keins
// liefert. Wird nach dem Anlegen hochgeladen (dann gibt es eine ID).
Section("Foto") {
if let pendingImage {
Image(uiImage: pendingImage)
.resizable().scaledToFit()
.frame(maxWidth: .infinity).frame(maxHeight: 200)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
Button { showCamera = true } label: { Label("Foto machen", systemImage: "camera") }
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Aus Galerie", systemImage: "photo.on.rectangle")
}
if pendingImage != nil {
Button(role: .destructive) { pendingImage = nil } label: {
Label("Foto entfernen", systemImage: "trash")
}
}
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
@@ -164,6 +265,37 @@ struct ProductFormView: View {
}
.navigationTitle("Neuer Artikel")
.navigationBarTitleDisplayMode(.inline)
.fullScreenCover(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { image in pendingImage = image }
.ignoresSafeArea()
}
.fullScreenCover(item: $scanTarget) { target in
NavigationStack {
TextScanView(titel: target.titel) { text in
if target == .name { name = text } else { brand = text }
}
}
}
.onChange(of: pickerItem) { item in
guard let item else { return }
Task {
if let data = try? await item.loadTransferable(type: Data.self),
let ui = UIImage(data: data) {
pendingImage = ui
}
pickerItem = nil
}
}
.onChange(of: selectedCategoryId) { cid in
// Bei einer Gegenstands-Kategorie deren (vererbte) Felder nachladen.
Task {
if let cid, categories.first(where: { $0.id == cid })?.isObject == true {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
}
}
.onAppear(perform: prefill)
.task {
groups = (try? await APIClient.shared.groups()) ?? []
@@ -185,7 +317,63 @@ struct ProductFormView: View {
}
}
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
let key = String(def.id)
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
}
/// Werte der geltenden eigenen Felder als {feld_id: Wert} nil, wenn keine.
private func eigeneFeldwerte() -> [String: String?]? {
guard !fieldDefs.isEmpty else { return nil }
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
return fv
}
/// Basiseinheiten der ANDEREN Arten eine Bruecke auf die eigene Art
/// haette nichts umzurechnen.
private var zweitBasisOptionen: [String] {
["piece", "gram", "milliliter"].filter { $0 != baseUnit }
}
private var zweitTitel: String {
switch zweitBasis {
case "gram": return "Gewicht"
case "milliliter": return "Volumen"
case "piece": return "Stückzahl"
default: return "Umrechnung"
}
}
private var zweitVerb: String {
let eins = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) == 1
switch zweitBasis {
case "gram": return eins ? "wiegt" : "wiegen"
case "milliliter": return eins ? "fasst" : "fassen"
case "piece": return eins ? "ist" : "sind"
default: return "sind"
}
}
/// Zielart waehlen und die linke Zahl EINMALIG mit der Packungsgroesse
/// vorbelegen im Normalfall ist sie genau das.
private func waehleZweitart(_ basis: String) {
zweitBasis = basis
if !basis.isEmpty, zweitAnzahl.isEmpty {
zweitAnzahl = packageSize.isEmpty ? "1" : packageSize
}
}
/// Nur eine vollstaendige Angabe ist eine Bruecke eine halbe waere keine.
private var zweitVollstaendig: Bool {
guard !zweitBasis.isEmpty,
let n = Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")),
let m = Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) else { return false }
return n > 0 && m > 0
}
private func prefill() {
if let initialType { modus = initialType }
barcode = suggestion?.barcode ?? prefillBarcode ?? ""
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
@@ -197,6 +385,14 @@ struct ProductFormView: View {
brand = suggestion.brand ?? ""
baseUnit = suggestion.baseUnit ?? "piece"
if let size = suggestion.packageSize { packageSize = String(size) }
// 3 x 80 g" schlaegt die Umrechnung gleich mit vor.
if let basis = suggestion.secondaryBase,
let anzahl = suggestion.secondaryCount,
let menge = suggestion.secondaryAmount {
zweitBasis = basis
zweitAnzahl = formatAmount(anzahl)
zweitMenge = formatAmount(menge)
}
}
}
@@ -217,9 +413,22 @@ struct ProductFormView: View {
datePrecision: datePrecision,
groupId: selectedGroupId,
categoryId: selectedCategoryId,
individual: modus == "object" ? individual : nil
individual: modus == "object" ? (objMode == "individual") : nil,
bulk: modus == "object" ? (objMode == "bulk") : nil,
fieldValues: eigeneFeldwerte(),
secondaryBase: zweitVollstaendig ? zweitBasis : nil,
secondaryCount: zweitVollstaendig
? Double(zweitAnzahl.replacingOccurrences(of: ",", with: ".")) : nil,
secondaryAmount: zweitVollstaendig
? Double(zweitMenge.replacingOccurrences(of: ",", with: ".")) : nil
)
)
// Vorgemerktes Foto hochladen ein Fehler darf das Anlegen nicht
// zunichtemachen, der Artikel existiert bereits.
if let pendingImage, let data = productImageJPEG(pendingImage) {
_ = try? await APIClient.shared.uploadProductImage(
id: product.id, data: data, contentType: "image/jpeg")
}
onCreated(product)
dismiss()
} catch {

View File

@@ -99,14 +99,25 @@ struct HomeView: View {
.tabItem { Label("Verwaltung", systemImage: "gearshape") }
}
}
.tint(Color.marke)
}
}
extension Color {
/// Markenblau (wie App-Icon und Web-Akzent) statt des System-Blaus.
static let marke = Color(UIColor { trait in
trait.userInterfaceStyle == .dark
? UIColor(red: 0.486, green: 0.541, blue: 0.910, alpha: 1) // #7C8AE8
: UIColor(red: 0.247, green: 0.318, blue: 0.710, alpha: 1) // #3F51B5
})
}
/// Die beiden Kacheln, die frueher die Startseite waren.
struct ScanTabView: View {
@EnvironmentObject private var router: Router
@EnvironmentObject private var session: Session
@State private var showNew = false
@State private var showKassenzettel = false
var body: some View {
NavigationStack {
@@ -132,6 +143,13 @@ struct ScanTabView: View {
subtitle: "Barcode/QR scannen und nur ansehen",
systemImage: "doc.text.magnifyingglass")
}
Button {
showKassenzettel = true
} label: {
ActionTile(title: "Kassenzettel",
subtitle: "Bon fotografieren und Lebensmittel einlagern",
systemImage: "doc.text.viewfinder")
}
// Ohne Barcode: gerade bei Gegenstaenden (Kleidung, Werkzeug) gibt
// es keinen Code zum Scannen. Deshalb hier ein offensichtlicher Weg.
if session.isAdmin {
@@ -152,8 +170,10 @@ struct ScanTabView: View {
}
Spacer()
}
.buttonStyle(.plain)
.padding()
.navigationTitle("Scannen")
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
.sheet(isPresented: $showNew) {
NavigationStack {
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
@@ -169,11 +189,19 @@ struct ListsTabView: View {
var body: some View {
NavigationStack {
VStack(spacing: 16) {
NavigationLink {
ProductListView()
} label: {
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten",
systemImage: "shippingbox")
// Getrennte Produktlisten wie im Web: Lebensmittel und Gegenstände
// nebeneinander als halbbreite Kacheln.
HStack(spacing: 16) {
NavigationLink {
ProductListView(fixedType: "food")
} label: {
CompactTile(title: "Lebensmittel", systemImage: "fork.knife")
}
NavigationLink {
ProductListView(fixedType: "object")
} label: {
CompactTile(title: "Gegenstände", systemImage: "shippingbox")
}
}
NavigationLink {
ItemListView()
@@ -187,6 +215,12 @@ struct ListsTabView: View {
ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt",
systemImage: "cart")
}
NavigationLink {
MinStockView()
} label: {
ActionTile(title: "Mindestbestände", subtitle: "Soll je Artikel ansehen und pflegen",
systemImage: "checklist")
}
NavigationLink {
ExpiringView()
} label: {
@@ -201,12 +235,36 @@ struct ListsTabView: View {
}
Spacer()
}
.buttonStyle(.plain)
.padding()
.navigationTitle("Listen")
}
}
}
/// Kompakte, halbbreite Kachel (Symbol oben, Titel darunter) für zwei
/// nebeneinanderstehende Einträge z.B. Lebensmittel/Gegenstände.
struct CompactTile: View {
let title: String
let systemImage: String
var body: some View {
VStack(spacing: 8) {
Image(systemName: systemImage)
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.marke.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 10))
Text(title).font(.headline)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.secondarySystemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.foregroundStyle(.primary)
}
}
struct ActionTile: View {
let title: String
let subtitle: String
@@ -217,7 +275,7 @@ struct ActionTile: View {
Image(systemName: systemImage)
.font(.title2)
.frame(width: 44, height: 44)
.background(Color.accentColor.opacity(0.15))
.background(Color.marke.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline)

View File

@@ -67,7 +67,7 @@ struct TorchButton: View {
isOn.toggle()
} label: {
Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill")
.foregroundStyle(locked ? Color.yellow : Color.accentColor)
.foregroundStyle(locked ? Color.yellow : Color.marke)
}
.simultaneousGesture(
LongPressGesture(minimumDuration: 0.4).onEnded { _ in

View File

@@ -15,12 +15,24 @@ enum ServerFetch {
let categories: [CategoryItem]
}
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async throws -> Snapshot {
static func snapshot(baseURL: URL, token: String, horizonDays: Int) async -> Snapshot {
async let expiring: [ExpiringItem] = get(
"/expiring?days=\(horizonDays)", baseURL: baseURL, token: token)
async let products: [Product] = get("/products", baseURL: baseURL, token: token)
async let categories: [CategoryItem] = get("/categories", baseURL: baseURL, token: token)
return try await Snapshot(expiring: expiring, products: products, categories: categories)
// Einzelne Ausfaelle sollen nicht alles kippen (sonst fehlten z.B. die
// Kategorien nur, weil die Produktliste langsam war).
return await Snapshot(
expiring: (try? await expiring) ?? [],
products: (try? await products) ?? [],
categories: (try? await categories) ?? []
)
}
/// Nur den Kategorie-Baum eines bestimmten Servers holen (fuer die
/// Regel-Bearbeitung in den Benachrichtigungen).
static func categories(baseURL: URL, token: String) async throws -> [CategoryItem] {
try await get("/categories", baseURL: baseURL, token: token)
}
private static func get<T: Decodable>(

View File

@@ -68,7 +68,7 @@ struct ServerListView: View {
} label: {
Image(systemName: NotificationStore.config(for: profile.id).enabled
? "bell.fill" : "bell")
.foregroundStyle(Color.accentColor)
.foregroundStyle(Color.marke)
}
.buttonStyle(.borderless)
}
@@ -138,7 +138,7 @@ struct ServerListView: View {
}
Spacer()
if profile.id == session.activeProfileID {
Image(systemName: "checkmark").foregroundStyle(Color.accentColor)
Image(systemName: "checkmark").foregroundStyle(Color.marke)
}
}
.foregroundStyle(.primary)

View File

@@ -30,17 +30,31 @@ final class Router: ObservableObject {
route = .expiring
}
/// vorrania://checkin bzw. vorrania://checkout oder ein Universal Link
/// (https:///i/<UID>), der das Einzelstück direkt öffnet.
/// vorrania://checkin | checkout (Aktionen) oder vorrania://i/<UID>
/// (Einzelstück aus einem gescannten QR, auch über die normale Kamera-App).
///
/// Früher lief das Einzelstück über einen Universal Link auf eine feste Domain.
/// Das war für einen selbstgehosteten Dienst untauglich (jede Instanz hat eine
/// andere URL, und die Domain stünde im offenen Quellcode). Das Custom-Scheme
/// braucht keine Domain: Die Kamera-App öffnet den QR trotzdem, und der Code
/// verrät keinen Server.
func handle(url: URL) {
if url.scheme == "vorrania" {
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased()
if let route = Route(rawValue: target) { self.route = route }
// Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"].
let comps = ([url.host].compactMap { $0 } + url.pathComponents)
.filter { $0 != "/" && !$0.isEmpty }
// Einzelstück: vorrania://i/<UID> am aktuell aktiven Server öffnen.
if let i = comps.firstIndex(where: { $0.lowercased() == "i" }), i + 1 < comps.count {
openItemUid = comps[i + 1].uppercased()
return
}
if let route = comps.first.flatMap({ Route(rawValue: $0.lowercased()) }) { self.route = route }
return
}
let parts = url.pathComponents // z.B. ["/", "i", "ABC123"]
// Alt-Fall: Universal Link (/i/<UID>) nur noch relevant, falls je wieder
// eine Associated Domain hinterlegt würde (dann Serverwechsel per Host).
let parts = url.pathComponents
if let idx = parts.firstIndex(of: "i"), idx + 1 < parts.count {
// Auf den Server aus dem Link wechseln, damit die UID dort gesucht wird.
Session.shared.switchToProfile(matching: url)
openItemUid = parts[idx + 1].uppercased()
}

View File

@@ -11,6 +11,7 @@
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; };
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */; };
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */; };
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ED41226394E7F31F37EC3B8 /* GroupViews.swift */; };
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */; };
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
@@ -22,6 +23,7 @@
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; };
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.swift */; };
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1847E65D41ACEDD01CD108BC /* ServerFetch.swift */; };
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00288DFA409BAD753AA4CA5D /* ItemListView.swift */; };
75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
@@ -46,9 +48,11 @@
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
00288DFA409BAD753AA4CA5D /* ItemListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListView.swift; sourceTree = "<group>"; };
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInView.swift; sourceTree = "<group>"; };
090485CC54558EB4433376A9 /* DashboardCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardCards.swift; sourceTree = "<group>"; };
099CCD94907BE0179B7D5742 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = wrapper.icon; path = AppIcon.icon; sourceTree = "<group>"; };
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GroupViews.swift; sourceTree = "<group>"; };
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerProfiles.swift; sourceTree = "<group>"; };
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationScheduler.swift; sourceTree = "<group>"; };
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerFetch.swift; sourceTree = "<group>"; };
@@ -103,7 +107,9 @@
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
717C8EB336170526F5F3E695 /* DateScanView.swift */,
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */,
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
00288DFA409BAD753AA4CA5D /* ItemListView.swift */,
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
A6785CD9174067EFBB2B9043 /* ListViews.swift */,
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
@@ -231,7 +237,9 @@
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */,
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */,
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,

View File

@@ -17,9 +17,21 @@ server {
return 200 '{"applinks":{"details":[{"appIDs":["PP34X97WS3.com.scarriffle.vorrania"],"components":[{"/":"/i/*"}]}]}}';
}
# SPA-Routing: unbekannte Pfade auf index.html mappen
# Gehashte Assets (index-<hash>.js/.css, Bilder, Schriften): der Dateiname
# aendert sich bei jedem Build, wenn sich der Inhalt aendert -> unveraenderlich,
# deshalb aggressiv cachen.
location ~* \.(?:js|css|woff2?|ttf|eot|png|jpe?g|gif|svg|ico|webp)$ {
try_files $uri =404;
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA-Routing: unbekannte Pfade auf index.html mappen. index.html selbst darf
# NICHT gecacht werden - sonst laedt der Browser nach einem Deploy weiter die
# alte Seite mit den alten Asset-Verweisen, und Aenderungen kommen nie an.
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
}
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)

View File

@@ -1,13 +1,16 @@
import { useEffect, useState } from "react";
import { NavLink, Navigate, Route, Routes, useNavigate } from "react-router-dom";
import { NavLink, Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import { api } from "./api";
import { useAuth } from "./auth";
import Icon from "./components/Icon";
import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark";
import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products";
import ItemList from "./pages/ItemList";
import Charges from "./pages/Charges";
import MinStock from "./pages/MinStock";
import ProductForm from "./pages/ProductForm";
import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut";
@@ -75,9 +78,12 @@ function Sidebar() {
))}
<NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" />
<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" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
<NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && (
<>
@@ -110,9 +116,32 @@ function Sidebar() {
);
}
/** Server nicht erreichbar die Anmeldung bleibt bestehen, nur der Abruf nicht. */
function StartFehler({ text, erneut }) {
return (
<div className="center" style={{ padding: 60 }}>
<div className="alert error" style={{ maxWidth: 520 }}>
<Icon name="alert" size={16} />
<div>
<div>{text}</div>
<div className="muted small" style={{ marginTop: 4 }}>
Deine Anmeldung ist noch gültig sobald der Server wieder antwortet,
geht es weiter.
</div>
</div>
</div>
<button className="btn primary" style={{ marginTop: 16 }} onClick={erneut}>
Erneut versuchen
</button>
</div>
);
}
function Protected({ children, adminOnly = false, wide = false }) {
const { user, isAdmin, loading } = useAuth();
const { user, isAdmin, loading, startFehler, erneutVersuchen } = useAuth();
const { pathname: pfad } = useLocation();
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 />;
return (
@@ -120,7 +149,12 @@ function Protected({ children, adminOnly = false, wide = false }) {
<Sidebar />
<div className="main">
{/* `wide` hebt die Lesebreite auf nur fürs Dashboard-Raster sinnvoll. */}
<div className={wide ? "content content--wide" : "content"}>{children}</div>
<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>
</div>
</div>
</div>
);
@@ -139,15 +173,19 @@ export default function App() {
<Route path="/d/:id" element={<Protected wide><Dashboard /></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>} />
<Route path="/gegenstaende" element={<Protected wide><Products fixedType="object" /></Protected>} />
<Route path="/products" element={<Protected wide><Products /></Protected>} />
<Route path="/items" element={<Protected wide><ItemList /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly wide><ProductForm /></Protected>} />
<Route path="/chargen" element={<Protected wide><Charges /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected wide><ProductForm /></Protected>} />
<Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
<Route path="/l/:id" element={<Protected><LocationResolve /></Protected>} />
<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="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />

View File

@@ -15,6 +15,14 @@ export function setToken(token) {
else localStorage.removeItem(TOKEN_KEY);
}
// Wird bei einer abgelaufenen/ungueltigen Anmeldung gerufen (HTTP 401).
// Ohne das bleibt die Oberflaeche „angemeldet", waehrend jede Abfrage scheitert
// man sieht dann leere Seiten statt eines Hinweises.
let beiAbmeldung = null;
export function setUnauthorizedHandler(fn) {
beiAbmeldung = fn;
}
export class ApiError extends Error {
constructor(message, status) {
super(message);
@@ -41,6 +49,12 @@ async function request(path, { method = "GET", body, form, formData } = {}) {
const resp = await fetch(`${API_BASE}${path}`, { method, headers, body: payload });
// Abgelaufene Anmeldung: EINMAL zentral behandeln statt in jeder Seite.
// Beim Anmelden selbst nicht dort heisst 401 schlicht „Passwort falsch".
if (resp.status === 401 && path !== "/auth/login") {
beiAbmeldung?.();
}
if (resp.status === 204) return null;
let data = null;
@@ -190,7 +204,14 @@ export const api = {
listLots: (productId) =>
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
// Alle Chargen (über alle Artikel) mit Artikel-Infos.
listAllLots: () => request("/lots/rows"),
// 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 } }),
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 }),
// Mindestbestand je Lagerort (ersetzt jeweils die komplette Liste).
setProductLocationMinStock: (id, list) =>
@@ -222,7 +243,7 @@ export const api = {
request(`/dashboard/layout/enforced?value=${value ? "true" : "false"}`, { method: "PUT" }),
dashboardStats: () => request("/dashboard/stats"),
dashboardExpirySplit: () => request("/dashboard/expiry-split"),
dashboardByCategory: () => request("/dashboard/by-category"),
dashboardByCategory: (depth) => request(`/dashboard/by-category${depth ? `?depth=${depth}` : ""}`),
dashboardTimeline: (days, productId) =>
request(`/dashboard/timeline?days=${days}${productId ? `&product_id=${productId}` : ""}`),
dashboardActivity: (days) => request(`/dashboard/activity?days=${days}`),
@@ -231,6 +252,8 @@ export const api = {
// Stammdaten
listLocations: () => request("/locations"),
// Was liegt an einem Lagerort (inkl. Unterorte)? Ziel des Lagerort-QR.
locationContents: (code) => request(`/location/${encodeURIComponent(code)}`),
createLocation: (body) => request("/locations", { method: "POST", body }),
updateLocation: (id, body) => request(`/locations/${id}`, { method: "PATCH", body }),
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),

View File

@@ -1,24 +1,56 @@
import { createContext, useContext, useEffect, useState } from "react";
import { api, getToken, setToken } from "./api";
import { api, ApiError, getToken, setToken, setUnauthorizedHandler } from "./api";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
// Anmeldung war da, ist aber abgelaufen der Login-Bildschirm sagt warum.
const [abgelaufen, setAbgelaufen] = useState(false);
// Start fehlgeschlagen, ABER nicht wegen der Anmeldung (Server weg, Netz weg).
// Dann bleibt das Token liegen; alles andere hiesse, ein kurzer Aussetzer
// meldet einen ab.
const [startFehler, setStartFehler] = useState(null);
useEffect(() => {
async function boot() {
if (getToken()) {
try {
setUser(await api.me());
} catch {
setToken(null);
}
async function pruefen() {
setStartFehler(null);
if (!getToken()) {
setLoading(false);
return;
}
try {
setUser(await api.me());
} catch (err) {
// NUR bei abgelehnter Anmeldung abmelden. Ein 502 waehrend eines
// Server-Neustarts ist kein Grund, die Sitzung wegzuwerfen.
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
setToken(null);
setUser(null);
} else if (err instanceof ApiError) {
setStartFehler(err.message);
} else {
// Kein HTTP-Fehler, sondern gar keine Antwort (Netz weg, Server aus).
// Die Browser-Meldung („Failed to fetch") hilft niemandem.
setStartFehler("Server nicht erreichbar.");
}
} finally {
setLoading(false);
}
boot();
}
useEffect(() => { pruefen(); }, []);
// Laeuft die Anmeldung waehrend der Nutzung ab, scheitert ab da JEDE Abfrage.
// Ohne diesen Griff bliebe die Oberflaeche „angemeldet" und zeigte nur noch
// leere Seiten genau das war der Fehler.
useEffect(() => {
setUnauthorizedHandler(() => {
setToken(null);
setUser(null);
setAbgelaufen(true);
});
return () => setUnauthorizedHandler(null);
}, []);
async function login(username, password) {
@@ -26,18 +58,29 @@ export function AuthProvider({ children }) {
setToken(res.access_token);
const me = await api.me();
setUser(me);
setAbgelaufen(false);
setStartFehler(null);
return me;
}
function logout() {
setToken(null);
setUser(null);
setAbgelaufen(false);
}
async function erneutVersuchen() {
setLoading(true);
await pruefen();
}
const isAdmin = user?.role === "admin";
return (
<AuthContext.Provider value={{ user, isAdmin, loading, login, logout }}>
<AuthContext.Provider value={{
user, isAdmin, loading, login, logout,
abgelaufen, startFehler, erneutVersuchen,
}}>
{children}
</AuthContext.Provider>
);

View File

@@ -0,0 +1,29 @@
import Icon from "./Icon";
/**
* Die Art einer Bestandsbewegung als Sinnbild statt als Wort.
*
* Grosser Pfeil auf eine Linie zu (Einlagern), von ihr weg (Auslagern), Stift
* (Korrektur) dieselben Zeichen wie auf den Ein-/Auslagern-Knoepfen.
*
* Vorher standen hier zwei gefuellte Kisten, die sich NUR in einem kleinen
* Pfeil unterschieden. Bei 18 px war das nicht zu trennen; erkannt hat man sie
* allein an der Farbe und die trug mit Gruen/Orange eine Wertung, die es
* nicht geben soll. Auslagern ist kein Missstand.
*
* Deshalb: eine Farbe fuer alle drei, die Richtung liegt im Zeichen selbst.
*
* Titel und aria-label behalten das Wort: fuer Screenreader und fuer alle, die
* ein Sinnbild nicht auf Anhieb lesen.
*/
const LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
const ICON = { in: "checkin", out: "checkout", adjust: "edit" };
export default function BewegungIcon({ typ, size = 19 }) {
const wort = LABEL[typ] || typ;
return (
<span className="bewegung-icon" title={wort} aria-label={wort} role="img">
<Icon name={ICON[typ] || "package"} size={size} />
</span>
);
}

View File

@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import CategoryPathLabel from "./CategoryPathLabel";
/**
* Aufklappbarer Kategorie-Baum als Auswahlfeld Ersatz für die früheren, mit
@@ -39,6 +40,23 @@ export default function CategorySelect({
const selectedId = !isRoot && !extra ? value : null;
const label = isRoot ? rootLabel : extra ? extra.label : nameById[selectedId] ?? rootLabel;
// Voller Pfad der gewählten Kategorie (Oberkategorien → Blatt), damit im
// geschlossenen Feld sichtbar bleibt, WELCHE „Früchte" gemeint sind (z.B.
// „TK → Früchte" statt nur „Früchte").
function pathPartsFor(id) {
const parts = [];
let cur = id;
const gesehen = new Set();
while (cur != null && idset.has(cur) && !gesehen.has(cur)) {
gesehen.add(cur);
parts.unshift(nameById[cur]);
cur = parentById[cur];
}
return parts;
}
const selectedParts = selectedId != null && nameById[selectedId] != null
? pathPartsFor(selectedId) : null;
function place() {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.left, top: r.bottom + 4, width: r.width });
@@ -108,7 +126,9 @@ export default function CategorySelect({
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={isRoot ? "muted" : ""}>{label}</span>
<span className={isRoot ? "muted" : ""}>
{selectedParts ? <CategoryPathLabel parts={selectedParts} /> : label}
</span>
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
</button>

View File

@@ -1,6 +1,7 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { Skeleton } from "./Skeleton";
/**
* Excel-artige Tabelle in der bestehenden Designsprache: feste Filterzeile je
@@ -28,7 +29,8 @@ function savePersisted(id, data) {
export default function DataTable({
id, columns, rows, getRowKey, rowClassName,
empty = "Nichts gefunden.", toolbarExtra = null, tree = null,
empty = "Nichts gefunden.", loading = false, toolbarExtra = null, tree = null,
onVisibleRows = null, onRowClick = null,
}) {
const colByKey = useMemo(() => Object.fromEntries(columns.map((c) => [c.key, c])), [columns]);
@@ -38,9 +40,39 @@ export default function DataTable({
const rest = columns.map((c) => c.key).filter((k) => !saved.includes(k));
return [...saved, ...rest];
});
// `order` ist eine Momentaufnahme vom Mounten. Seiten duerfen ihre Spalten
// aber danach aendern (die Artikelliste zeigt „Verwaltung" nur bei
// Gegenstaenden), und eine neu hinzugefuegte Spalte steht ohnehin nie in der
// gespeicherten Reihenfolge. Fehlt sie dort, faellt sie aus allOrdered heraus
// und ist WEG nicht nur aus dem Spalten-Menue, sondern aus der Tabelle.
// Deshalb hier nachziehen. Angehaengt, nicht einsortiert: eine selbst
// gewaehlte Reihenfolge soll nicht umspringen.
const schluessel = columns.map((c) => c.key).join("|");
useEffect(() => {
setOrder((alt) => {
const fehlend = columns.map((c) => c.key).filter((k) => !alt.includes(k));
return fehlend.length ? [...alt, ...fehlend] : alt;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [schluessel]);
const [widths, setWidths] = useState(() => loadPersisted(id).widths || {});
const [filters, setFilters] = useState({}); // { key: { text, values:Set|null } }
const [sort, setSort] = useState(null); // { key, dir }
// Filter + Sortierung bleiben erhalten (localStorage je Tabelle), bis man
// „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(() => {
const saved = loadPersisted(id).filters || {};
const out = {};
for (const [k, v] of Object.entries(saved)) {
if (!colByKey[k] || !v) continue;
out[k] = { text: v.text || "", values: Array.isArray(v.values) ? new Set(v.values) : null };
}
return out;
}); // { key: { text, values:Set|null } }
const [sort, setSort] = useState(() => {
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());
@@ -50,8 +82,15 @@ export default function DataTable({
const [chooserOpen, setChooserOpen] = useState(false);
useEffect(() => {
savePersisted(id, { order, widths, hidden: [...hidden] });
}, [id, order, widths, hidden]);
// 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 });
}, [id, order, widths, hidden, filters, sort]);
// Containerbreite messen, damit die grow-Spalte den Rest füllt.
const wrapRef = useRef(null);
@@ -75,7 +114,14 @@ export default function DataTable({
useLayoutEffect(() => {
if (chooserOpen && chooserBtn.current) {
const r = chooserBtn.current.getBoundingClientRect();
setChooserPos({ left: Math.min(r.left, window.innerWidth - 240), top: r.bottom + 4 });
// Hoehe an den freien Platz koppeln. Eine feste Obergrenze schnitt die
// letzten Eintraege ab, ohne dass man es sah auf macOS blendet das
// System die Bildlaufleiste aus, und dann fehlt die Spalte scheinbar.
setChooserPos({
left: Math.min(r.left, window.innerWidth - 240),
top: r.bottom + 4,
maxHeight: Math.max(180, window.innerHeight - r.bottom - 24),
});
}
}, [chooserOpen]);
useEffect(() => {
@@ -197,6 +243,15 @@ export default function DataTable({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [treeActive, treeData, flat, collapsed]);
// Sichtbare (gefilterte/sortierte) Zeilen nach außen melden z.B. damit eine
// Seite „alle sichtbaren auswählen" anbieten kann. Ref, damit eine inline
// übergebene Funktion keine Endlosschleife auslöst.
const onVisibleRef = useRef(onVisibleRows);
onVisibleRef.current = onVisibleRows;
useEffect(() => {
onVisibleRef.current?.(displayRows.map((d) => d.row));
}, [displayRows]);
function toggleSort(c) {
if (!c.sortValue && !c.filterText) return;
setSort((s) => {
@@ -260,10 +315,11 @@ export default function DataTable({
}
function resetColumns() {
// Nur Spalten (Reihenfolge/Breiten/Sichtbarkeit) zurücksetzen Filter und
// Sortierung bleiben; der Speicher-Effekt schreibt den neuen Stand.
setOrder(columns.map((c) => c.key));
setWidths({});
setHidden(new Set());
savePersisted(id, {});
}
return (
@@ -283,11 +339,11 @@ export default function DataTable({
</div>
{chooserOpen && chooserPos && createPortal(
<div ref={chooserPop} className="dt-pop" style={{ left: chooserPos.left, top: chooserPos.top }}>
<div className="dt-pop-list">
<div className="dt-pop-list" style={{ maxHeight: chooserPos.maxHeight }}>
{allOrdered.map((c) => (
<label key={c.key} className="dt-pop-opt">
<input type="checkbox" checked={!hidden.has(c.key)} onChange={() => toggleHidden(c.key)} />
<span>{c.header || c.key}</span>
<span>{c.label || c.header || c.key}</span>
</label>
))}
</div>
@@ -339,7 +395,9 @@ export default function DataTable({
</thead>
<tbody>
{displayRows.map(({ row, depth, hasChildren }) => (
<tr key={getRowKey(row)} className={rowClassName?.(row) || ""}>
<tr key={getRowKey(row)} className={rowClassName?.(row) || ""}
onClick={onRowClick ? (e) => onRowClick(row, e) : undefined}
style={onRowClick ? { cursor: "pointer" } : undefined}>
{orderedCols.map((c) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>
{tree && treeActive && c.key === tree.column ? (
@@ -360,7 +418,30 @@ export default function DataTable({
</tr>
))}
{displayRows.length === 0 && (
<tr><td className="empty" colSpan={orderedCols.length}>{empty}</td></tr>
loading && rows.length === 0 ? (
// Erstladung: schimmernde Platzhalterzeilen in Spaltenform statt
// eines nackten „Wird geladen…". (Beim Nachladen mit bereits
// vorhandenen Zeilen greift dieser Zweig nicht - die Daten
// bleiben stehen.)
Array.from({ length: 8 }, (_, r) => (
<tr key={`skel-${r}`} className="skel-tr" aria-hidden="true">
{orderedCols.map((c, ci) => (
<td key={c.key} className={c.align === "num" ? "num" : ""}>
<Skeleton
w={c.align === "num" ? "40%" : `${[80, 64, 72, 58, 86][(r + ci) % 5]}%`}
style={c.align === "num" ? { marginLeft: "auto" } : undefined}
/>
</td>
))}
</tr>
))
) : (
// Während des Ladens NICHT „nichts gefunden" behaupten die Leer-
// Meldung kommt erst, wenn die Daten wirklich da (und leer) sind.
<tr><td className="empty" colSpan={orderedCols.length}>
{loading ? "Wird geladen…" : empty}
</td></tr>
)
)}
</tbody>
</table>

View File

@@ -6,6 +6,7 @@ import { useToast } from "../toast";
import Icon from "./Icon";
import { REASONS } from "./ObjektBestand";
import { locationOptions, locationPathById } from "../locationPath";
import { itemDeepLink } from "../qr";
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
@@ -224,7 +225,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
async function druckeEtiketten() {
const labels = await Promise.all(items.map(async (it) => {
const url = await QRCode.toDataURL(`${origin}/i/${it.uid}`, { margin: 1, width: 260 });
const url = await QRCode.toDataURL(itemDeepLink(it.uid), { margin: 1, width: 260 });
const name = (product.name || "").replace(/[<>&]/g, "");
return `<div class="label"><img src="${url}"/><div class="uid">${it.uid}</div><div class="name">${name}</div></div>`;
}));
@@ -270,7 +271,7 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh
{items.map((it) => (
<Fragment key={it.id}>
<tr className="einzel-mainrow">
<td><QrImg text={`${origin}/i/${it.uid}`} size={56} /></td>
<td><QrImg text={itemDeepLink(it.uid)} size={56} /></td>
<td data-label="UID" className="strong nowrap">{it.uid}</td>
<td data-label="Lagerort">
{isAdmin ? (

View File

@@ -0,0 +1,74 @@
import { Component } from "react";
import Icon from "./Icon";
/**
* Fängt Fehler beim Rendern ab.
*
* Ohne so eine Grenze hängt React den kompletten Baum aus, sobald irgendwo eine
* Ausnahme fliegt die Seite wird schlagartig weiß und nur ein Neuladen hilft.
* Man sieht dann NICHT, was passiert ist, und kann es folglich auch nicht
* melden. Genau das war hier das Problem.
*
* Die Grenze zeigt deshalb die Meldung selbst an, nicht nur ein „Ups". Wer sie
* einmal sieht, kann sie weitergeben, und dann ist die Ursache in einer Minute
* gefunden statt in einer Stunde.
*
* `bereich` beschreibt, was gekapselt ist für die Meldung.
*/
export default class ErrorBoundary extends Component {
constructor(props) {
super(props);
this.state = { fehler: null, info: null };
}
static getDerivedStateFromError(fehler) {
return { fehler };
}
componentDidCatch(fehler, info) {
// React schreibt selbst schon in die Konsole; der Komponentenpfad hilft
// zusätzlich beim Einordnen.
console.error("Fehler beim Rendern:", fehler, info?.componentStack);
this.setState({ info });
}
render() {
const { fehler, info } = this.state;
if (!fehler) return this.props.children;
return (
<div style={{ padding: "var(--sp-4)" }}>
<div className="alert error" style={{ alignItems: "flex-start" }}>
<Icon name="alert" size={16} />
<div style={{ minWidth: 0 }}>
<div className="strong">
{this.props.bereich ? `${this.props.bereich} konnte nicht angezeigt werden.` : "Etwas ist schiefgelaufen."}
</div>
<div className="small" style={{ marginTop: 4, wordBreak: "break-word" }}>
{fehler.message || String(fehler)}
</div>
</div>
</div>
<div className="field-inline" style={{ marginTop: "var(--sp-3)" }}>
<button className="btn primary" onClick={() => this.setState({ fehler: null, info: null })}>
Nochmal versuchen
</button>
<button className="btn" onClick={() => window.location.reload()}>
Seite neu laden
</button>
</div>
{/* Aufgeklappt nur auf Wunsch: die Angaben sind für einen Fehlerbericht
gedacht, nicht für den Alltag. */}
<details style={{ marginTop: "var(--sp-3)" }}>
<summary className="muted small">Technische Angaben</summary>
<pre className="muted small" style={{ whiteSpace: "pre-wrap", overflowX: "auto" }}>
{fehler.stack || String(fehler)}
{info?.componentStack || ""}
</pre>
</details>
</div>
);
}
}

View File

@@ -0,0 +1,152 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Icon from "./Icon";
import { gruppenOptionen, nachfahrenIds, pfadText } from "../groupGraph";
/**
* Mehrfachauswahl der Obergruppen einer Gruppe.
*
* Bewusst KEIN Baum wie CategorySelect: eine Gruppe darf unter mehreren
* Obergruppen hängen („Grillwurst" unter „Wurst" UND unter „Grillgut"). Ein
* Baum müsste sie mehrfach anzeigen; eine flache, alphabetische Liste mit
* Häkchen ist ehrlicher und deutlich weniger Code. Der volle Weg steht als
* Titel an jeder Zeile, damit gleichnamige Äste unterscheidbar bleiben.
*
* Die eigene Gruppe und ihre Untergruppen sind gesperrt sie würden einen Ring
* erzeugen. Der Server lehnt das ohnehin ab; hier wird es vorher sichtbar.
*
* Das Menü hängt per Portal am <body>, damit es der horizontal scrollende
* Tabellenrahmen nicht abschneidet (wie bei CategorySelect).
*/
export default function GroupParentSelect({
value = [],
onChange,
groups = [],
selfId = null,
disabled = false,
}) {
const [open, setOpen] = useState(false);
const [pos, setPos] = useState(null);
const [filter, setFilter] = useState("");
const btnRef = useRef(null);
const popRef = useRef(null);
const gewaehlt = new Set(value || []);
const gesperrt = selfId == null ? new Set() : new Set([selfId, ...nachfahrenIds(groups, selfId)]);
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
// Als Baum, damit die Struktur beim Zuordnen sichtbar ist. Beim Suchen
// flacht die Liste ab eine Einrückung ohne ihre Eltern wäre irreführend.
const sichtbar = filter
? groups
.filter((g) => g.name.toLowerCase().includes(filter.toLowerCase()))
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "de"))
.map((g) => ({ id: g.id, key: `f${g.id}`, gruppe: g, tiefe: 0, label: g.name }))
: gruppenOptionen(groups);
const label = value?.length
? value.map((id) => nameById[id]).filter(Boolean).join(", ")
: " keine ";
function place() {
const r = btnRef.current?.getBoundingClientRect();
if (r) setPos({ left: r.left, top: r.bottom + 4, width: r.width });
}
useLayoutEffect(() => { if (open) place(); }, [open]);
useEffect(() => {
if (!open) return;
function onDoc(e) {
if (btnRef.current?.contains(e.target)) return;
if (popRef.current?.contains(e.target)) return;
setOpen(false);
}
function onScroll(e) {
if (popRef.current && e.target instanceof Node && popRef.current.contains(e.target)) return;
setOpen(false);
}
function onResize() { setOpen(false); }
function onKey(e) { if (e.key === "Escape") setOpen(false); }
document.addEventListener("mousedown", onDoc);
window.addEventListener("scroll", onScroll, true);
window.addEventListener("resize", onResize);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDoc);
window.removeEventListener("scroll", onScroll, true);
window.removeEventListener("resize", onResize);
document.removeEventListener("keydown", onKey);
};
}, [open]);
// Bei Mehrfachauswahl bleibt das Menü offen sonst müsste man es für jede
// weitere Obergruppe neu aufklappen.
function umschalten(id) {
const neu = new Set(gewaehlt);
if (neu.has(id)) neu.delete(id);
else neu.add(id);
onChange([...neu]);
}
return (
<>
<button
type="button"
ref={btnRef}
className="tree-select-btn"
disabled={disabled}
aria-expanded={open}
onClick={() => setOpen((o) => !o)}
>
<span className={value?.length ? "" : "muted"}>{label}</span>
<Icon name="chevronRight" size={13} className={`caret ${open ? "open" : ""}`} />
</button>
{open && pos && createPortal(
<div
ref={popRef}
className="tree-pop"
style={{ left: pos.left, top: pos.top, minWidth: Math.max(pos.width, 240) }}
>
{groups.length > 8 && (
<div className="tree-pop-line">
<input
autoFocus
placeholder="Suchen…"
value={filter}
style={{ marginTop: 0, width: "100%" }}
onChange={(e) => setFilter(e.target.value)}
/>
</div>
)}
{sichtbar.map((o) => {
const aus = gesperrt.has(o.id);
const an = gewaehlt.has(o.id);
return (
<div key={o.key} className="tree-pop-line" style={{ paddingLeft: o.tiefe * 14 }}>
<button
type="button"
className={`tree-pop-opt ${an ? "sel" : ""}`}
disabled={aus}
title={aus
? "Würde einen Ring erzeugen (die Gruppe selbst oder eine ihrer Untergruppen)"
: pfadText(groups, o.id)}
onClick={() => umschalten(o.id)}
>
<span style={{ display: "inline-block", width: 18 }}>{an ? "✓" : ""}</span>
{o.tiefe > 0 && <span className="muted"> </span>}
{o.gruppe.name}
</button>
</div>
);
})}
{sichtbar.length === 0 && (
<div className="tree-pop-line muted" style={{ padding: 6 }}>Keine Gruppe gefunden.</div>
)}
</div>,
document.body,
)}
</>
);
}

View File

@@ -2,6 +2,12 @@
// Verwendung: <Icon name="package" /> oder <Icon name="trash" size={16} />
const PATHS = {
food: (
<>
<path d="M12 20.94c1.5 0 2.75 1.06 4 1.06 3 0 6-8 6-12.22A4.91 4.91 0 0 0 17 5c-2.22 0-4 1.44-5 2-1-.56-2.78-2-5-2a4.9 4.9 0 0 0-5 4.78C2 12 5 20 8 20c1.25 0 2.5-1.06 4-1.06Z" />
<path d="M10 2c1 .5 2 2 2 5" />
</>
),
dashboard: (
<>
<rect x="3" y="3" width="7" height="9" rx="1" />
@@ -10,6 +16,10 @@ const PATHS = {
<rect x="3" y="16" width="7" height="5" rx="1" />
</>
),
// Ein- und Auslagern sind ein PAAR und duerfen sich nur in EINEM Merkmal
// unterscheiden: der Pfeilrichtung. Die Linie ist das Lager und liegt bei
// beiden unten. Frueher lag sie beim Auslagern oben und der Pfeil zeigte auf
// sie zu das las sich wie „hinein, nur nach oben".
checkin: (
<>
<path d="M12 3v12" />
@@ -19,9 +29,9 @@ const PATHS = {
),
checkout: (
<>
<path d="M12 21V9" />
<path d="m7 14 5-5 5 5" />
<path d="M5 3h14" />
<path d="M12 3v12" />
<path d="m7 8 5-5 5 5" />
<path d="M5 21h14" />
</>
),
package: (
@@ -32,6 +42,14 @@ const PATHS = {
<path d="M12 22V12" />
</>
),
split: (
<>
<line x1="6" y1="3" x2="6" y2="15" />
<circle cx="18" cy="6" r="3" />
<circle cx="6" cy="18" r="3" />
<path d="M18 9a9 9 0 0 1-9 9" />
</>
),
tag: (
<>
<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z" />
@@ -128,6 +146,7 @@ const PATHS = {
),
};
export default function Icon({ name, size = 18, className = "", strokeWidth = 2 }) {
const path = PATHS[name];
if (!path) return null;

View File

@@ -2,20 +2,36 @@ import { useState } from "react";
import Icon from "./Icon";
import { locationOptions } from "../locationPath";
// „Ü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
// Platzhalter, der beim Speichern wieder zu null wird.
const UEBERALL = "__ueberall__";
const zuId = (wert) => (wert === UEBERALL ? null : wert);
const vonId = (id) => (id == null ? UEBERALL : String(id));
/**
* Editor für Mindestbestände je Lagerort (Bedarfe). Eine Zeile je Ort mit Menge;
* Editor für Mindestbestände je Lagerort. Eine Zeile je Ort mit Menge;
* „Bedarfe speichern" ersetzt über onSave die komplette Liste. Menge 0/leer =
* Ort fällt weg.
*
* Den früheren separaten Gesamt-Mindestbestand gibt es nicht mehr er ist die
* Zeile „Überall (egal wo)" und wird hier genauso gepflegt wie jeder Lagerort.
* Mengen in Basiseinheiten (g/ml/Stück).
*/
export default function LocationMinStock({ locations, initial = [], unitLabel = "", onSave, onError }) {
const [rows, setRows] = useState(() =>
initial.map((e) => ({ location_id: String(e.location_id), min_stock: String(e.min_stock) })),
initial.map((e) => ({ location_id: vonId(e.location_id), min_stock: String(e.min_stock) })),
);
const [busy, setBusy] = useState(false);
const [ok, setOk] = useState(false);
const used = new Set(rows.map((r) => r.location_id));
const frei = locations.filter((l) => !used.has(String(l.id)));
// „Überall" steht ganz oben und ist wie ein Ort nur einmal vergebbar.
const alleOrte = [
{ id: UEBERALL, label: "Überall (egal wo)" },
...locationOptions(locations),
];
const frei = alleOrte.filter((o) => !used.has(String(o.id)));
function addRow() {
if (!frei.length) return;
@@ -36,7 +52,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
try {
const list = rows
.filter((r) => r.location_id !== "" && Number(r.min_stock) > 0)
.map((r) => ({ location_id: r.location_id, min_stock: Number(r.min_stock) }));
.map((r) => ({ location_id: zuId(r.location_id), min_stock: Number(r.min_stock) }));
await onSave(list);
setOk(true);
} catch (err) {
@@ -46,20 +62,16 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
}
}
if (!locations.length) {
return <p className="muted small mt-0">Erst Lagerorte anlegen, dann Bedarfe je Ort möglich.</p>;
}
return (
<div className="stack">
{rows.length === 0 && (
<p className="muted small mt-0">Kein Bedarf je Lagerort festgelegt.</p>
<p className="muted small mt-0">Kein Mindestbestand festgelegt.</p>
)}
{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 })}>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
{alleOrte.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</label>
<label style={{ margin: 0, width: 130 }}>
@@ -74,7 +86,7 @@ export default function LocationMinStock({ locations, initial = [], unitLabel =
))}
<div className="field-inline" style={{ marginBottom: 0 }}>
<button type="button" className="btn" onClick={addRow} disabled={!frei.length}>
<Icon name="plus" size={16} />Lagerort
<Icon name="plus" size={16} />Mindestbestand
</button>
<button type="button" className="btn primary" onClick={save} disabled={busy}>
<Icon name="check" size={16} />{busy ? "Speichern…" : "Bedarfe speichern"}

View File

@@ -35,6 +35,7 @@ export default function OffVergleich({
{abweichend.length === 0 && !bildAbweichend ? (
<p className="muted small">Keine abweichenden Angaben deine Daten sind aktuell.</p>
) : (
<div className="table-wrap">
<table className="table off-tabelle">
<thead>
<tr>
@@ -93,6 +94,7 @@ export default function OffVergleich({
</tr>
</tbody>
</table>
</div>
)}
</section>
);

View File

@@ -12,8 +12,15 @@ import Lightbox from "./Lightbox";
* ``onLoaded`` meldet, ob ein Bild vorliegt so kann die Artikelseite z.B. den
* „Entfernen"-Knopf ausblenden, wenn es nichts zu entfernen gibt.
*/
// Modulweiter Cache: Produkt-ID+Version → Objekt-URL (oder null = „kein Bild").
// So werden Thumbnails beim erneuten Öffnen einer Liste nicht jedes Mal neu
// geholt. Die Objekt-URLs bleiben absichtlich bestehen (werden nicht widerrufen),
// bis eine neue Version dieselbe ID ablöst.
const bildCache = new Map();
function useBildUrl(productId, version = 0, onLoaded) {
const [url, setUrl] = useState(null);
const key = `${productId}:${version}`;
const [url, setUrl] = useState(() => (bildCache.has(key) ? bildCache.get(key) : null));
// Über einen Ref, damit ein bei jedem Render neu erzeugtes onLoaded den Effekt
// nicht erneut auslöst.
const onLoadedRef = useRef(onLoaded);
@@ -21,8 +28,13 @@ function useBildUrl(productId, version = 0, onLoaded) {
useEffect(() => {
if (!productId) return undefined;
if (bildCache.has(key)) {
const cached = bildCache.get(key);
onLoadedRef.current?.(Boolean(cached));
setUrl(cached);
return undefined;
}
let abgebrochen = false;
let erzeugt = null;
// ``version`` haengt einen Wert an die Adresse, damit der Browser nach dem
// Speichern nicht seine zwischengespeicherte Fassung ausliefert.
@@ -32,24 +44,26 @@ function useBildUrl(productId, version = 0, onLoaded) {
if (neu) URL.revokeObjectURL(neu);
return;
}
onLoadedRef.current?.(Boolean(neu));
if (!neu) {
setUrl(null);
return;
// Alte Version derselben ID freigeben, dann neu cachen.
const alt = `${productId}:`;
for (const k of bildCache.keys()) {
if (k !== key && k.startsWith(alt)) {
const u = bildCache.get(k);
if (u) URL.revokeObjectURL(u);
bildCache.delete(k);
}
}
erzeugt = neu;
setUrl(neu);
bildCache.set(key, neu || null);
onLoadedRef.current?.(Boolean(neu));
setUrl(neu || null);
})
.catch(() => {
if (!abgebrochen) onLoadedRef.current?.(false);
});
return () => {
abgebrochen = true;
if (erzeugt) URL.revokeObjectURL(erzeugt);
setUrl(null);
};
}, [productId, version]);
// Objekt-URL NICHT widerrufen sie bleibt im Cache für den nächsten Mount.
return () => { abgebrochen = true; };
}, [productId, version, key]);
return url;
}

View File

@@ -0,0 +1,24 @@
import { fmt, gebinde } from "../units";
/**
* Einheitliche Bedarfszeile der Einkaufsliste: die Gebinde-Leitangabe groß
* ("3 Gläser"), die Basiseinheit klein daneben ("(500 g)"), dann Bestand/min in
* derselben Leitangabe. Die Rechnung (Aufrunden auf ganze Packungen,
* Pluralisierung des Leitworts) kommt fertig vom Server als `need`.
*/
export default function ShoppingNeedText({ need, stock, minStock, unitName }) {
// Fallback für den (Übergangs-)Fall ohne `need` vom Server: altes Format.
if (!need) {
return (
<>fehlt <strong>{fmt(stock == null ? 0 : minStock - stock)} {unitName}</strong></>
);
}
const einheit = need.singular ? ` ${gebinde(need.min_stock, need.singular)}` : "";
return (
<>
fehlt <strong>{need.text}</strong>
{need.hint ? <> <span className="muted">({need.hint})</span></> : null}{" "}
(Bestand {fmt(need.stock)} / min {fmt(need.min_stock)}{einheit})
</>
);
}

View File

@@ -0,0 +1,61 @@
/**
* Schimmernde Ladeplatzhalter ("Skeleton"). Nur CSS-Tokens (--surface-2 /
* --border), passt sich daher automatisch an Hell/Dunkel an. Reine Deko, deshalb
* durchgehend aria-hidden Screenreader sollen keine leeren Balken vorlesen.
*/
/** Eine einzelne schimmernde Leiste. Breite/Höhe als px oder CSS-Wert ("60%"). */
export function Skeleton({ w, h = 14, radius, className = "", style }) {
return (
<span
className={`skeleton ${className}`.trim()}
aria-hidden="true"
style={{ width: w, height: h, borderRadius: radius, ...style }}
/>
);
}
/** Mehrere gestapelte Text-Leisten unterschiedlicher Breite. */
export function SkeletonLines({ lines = 3 }) {
const breiten = ["90%", "72%", "82%", "60%", "76%"];
return (
<span className="skel-stack" aria-hidden="true">
{Array.from({ length: lines }, (_, i) => (
<Skeleton key={i} w={breiten[i % breiten.length]} />
))}
</span>
);
}
/**
* Kartenkörper-Skelett fürs Dashboard, grob in der Form des späteren Inhalts:
* - "kpi" Label-Zeile + große Wertzahl
* - "liste" ein paar Zeilen mit je zwei Werten (Standard)
* - "chart" ein großer Block, füllt die Karte
*/
export function CardSkeleton({ variant = "liste" }) {
if (variant === "kpi") {
return (
<span className="skel-stack" aria-hidden="true">
<Skeleton w="45%" h={12} />
<Skeleton w="60%" h={26} />
</span>
);
}
if (variant === "chart") {
// Höhe bewusst null -> keine Inline-Höhe, damit .skel-chart (flex:1) den
// Kartenkörper ausfüllt.
return <Skeleton className="skel-chart" w="100%" h={null} />;
}
// "liste"
return (
<span className="skel-stack" aria-hidden="true">
{Array.from({ length: 5 }, (_, i) => (
<span className="skel-row" key={i}>
<Skeleton w={`${72 - i * 4}%`} />
<Skeleton w="18%" />
</span>
))}
</span>
);
}

View File

@@ -0,0 +1,83 @@
import { useState } from "react";
import { api } from "../api";
import Icon from "./Icon";
import { fmt } from "../units";
import { locationOptions, locationPathById } from "../locationPath";
/**
* Charge aufteilen: einen Teil der Menge (in Artikeleinheiten Packungen/Stück/g)
* mit gleichem MHD an einen anderen Lagerort verschieben. Die Restmenge bleibt in
* der ursprünglichen Charge; der Gesamtbestand ändert sich nicht.
*
* `factor` Basiseinheiten je Artikeleinheit (z.B. 415 ml pro Packung).
* `labelFor` (menge) => Einheitentext, mengenabhängig ("1 Packung"/"3 Packungen").
*/
export default function SplitLotDialog({ lot, factor, labelFor, locations, onClose, onDone, onError }) {
const total = lot.quantity / factor; // Gesamtmenge in Artikeleinheiten
const [menge, setMenge] = useState("");
const [locId, setLocId] = useState("");
const [busy, setBusy] = useState(false);
const num = Number(String(menge).trim().replace(",", "."));
const gueltig = menge !== "" && !Number.isNaN(num) && num > 0 && num < total;
const rest = gueltig ? total - num : null;
const zielGleich = gueltig && (locId || null) === (lot.location_id || null);
async function submit() {
if (!gueltig || zielGleich || busy) return;
setBusy(true);
try {
await api.splitLot(lot.id, { quantity: num * factor, location_id: locId || null });
onClose();
await onDone();
} catch (err) {
onError?.(err.message);
} finally {
setBusy(false);
}
}
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="split" size={18} /><h2>Charge aufteilen</h2></div>
<p className="modal-body mt-0">
Aktuell {fmt(total)} {labelFor(total)}
{lot.location_id ? ` in ${locationPathById(lot.location_id, locations) || "?"}` : " ohne Lagerort"}.
Ein Teil davon wird mit gleichem MHD an einen anderen Lagerort verschoben.
</p>
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
<label>
Menge abteilen (in {labelFor(2)})
{/* eslint-disable-next-line jsx-a11y/no-autofocus */}
<input type="number" step="any" min="0" max={total} value={menge} autoFocus
onChange={(e) => setMenge(e.target.value)} placeholder={`max. ${fmt(total)}`} />
</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>
</label>
{rest != null && !zielGleich && (
<p className="muted small mt-0">
Danach: {fmt(num)} {labelFor(num)} am neuen Ort, {fmt(rest)} {labelFor(rest)} bleiben.
</p>
)}
{zielGleich && (
<p className="small mt-0" style={{ color: "var(--danger)" }}>
Bitte einen anderen Lagerort als den aktuellen wählen.
</p>
)}
</div>
<div className="btn-pair">
<button type="button" className="btn" onClick={onClose}>Abbrechen</button>
<button type="button" className="btn primary" disabled={!gueltig || zielGleich || busy} onClick={submit}>
<Icon name="split" size={16} />Aufteilen
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
import Icon from "../components/Icon";
import { ZEITRAEUME } from "./cards";
import { locationOptions } from "../locationPath";
/**
* Konfig-Dialog einer Karte wie in HomeAssistant: ein eigener Dialog mit den
* Optionen der Karte, statt Dropdowns im Kartenkopf. Welche Felder erscheinen,
* bestimmt `karte.config`. Die Werte landen in den Karten-Props (bzw. `tage`
* beim Zeitraum) und werden mit dem Layout gespeichert.
*/
export default function KartenConfig({
karte, props: karteProps = {}, tage,
produkte = [], locations = [], onProps, onTage, onClose,
}) {
const felder = karte?.config || [];
function feld(f) {
if (f.type === "product") {
return (
<label key={f.key}>
{f.label}
<select value={karteProps[f.key] ?? ""}
onChange={(e) => onProps({ [f.key]: e.target.value ? Number(e.target.value) : undefined })}>
<option value=""> Artikel wählen </option>
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
</label>
);
}
if (f.type === "location") {
return (
<label key={f.key}>
{f.label}{f.hint ? <span className="muted small"> ({f.hint})</span> : null}
<select value={karteProps[f.key] ?? ""}
onChange={(e) => onProps({ [f.key]: e.target.value || undefined })}>
<option value=""> alle Orte </option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
</label>
);
}
if (f.type === "zeitraum") {
return (
<label key="zeitraum">
{f.label}
<select value={tage ?? karte.zeitraum} onChange={(e) => onTage(Number(e.target.value))}>
{ZEITRAEUME.map((z) => <option key={z.tage} value={z.tage}>{z.label}</option>)}
</select>
</label>
);
}
if (f.type === "select") {
return (
<label key={f.key}>
{f.label}{f.hint ? <span className="muted small"> ({f.hint})</span> : null}
<select value={karteProps[f.key] ?? f.default ?? ""}
onChange={(e) => onProps({ [f.key]: e.target.value })}>
{(f.optionen || []).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</label>
);
}
if (f.type === "boolean") {
return (
<label key={f.key} className="check-inline">
<input type="checkbox" checked={karteProps[f.key] ?? f.default ?? false}
onChange={(e) => onProps({ [f.key]: e.target.checked })} />
<span>{f.label}</span>
</label>
);
}
return null;
}
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="settings" size={18} />
<h2>{karte?.titel} einstellen</h2>
</div>
<div className="modal-body" style={{ display: "grid", gap: "var(--sp-3)" }}>
{felder.length === 0
? <p className="muted">Diese Karte hat keine Einstellungen.</p>
: felder.map(feld)}
</div>
<div className="btn-pair">
<button type="button" className="btn primary" onClick={onClose}>Fertig</button>
</div>
</div>
</div>
);
}

View File

@@ -2,9 +2,12 @@ import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import Icon from "../components/Icon";
import { CardSkeleton } from "../components/Skeleton";
import ShoppingNeedText from "../components/ShoppingNeedText";
import BewegungIcon from "../components/BewegungIcon";
import { useSettings } from "../settings";
import {
amountText, articlePrimary, articleSecondary, fmt, relativeExpiry,
articlePrimary, articleSecondary, fmt, relativeExpiry,
} from "../units";
import Bars from "./charts/Bars";
import Donut from "./charts/Donut";
@@ -35,9 +38,9 @@ function useDaten(laden, abhaengig = []) {
return { daten, fehler };
}
function Zustand({ fehler, daten, children, leer = "Keine Daten." }) {
function Zustand({ fehler, daten, children, leer = "Keine Daten.", skelett = "liste" }) {
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (daten == null) return <div className="empty">Lädt</div>;
if (daten == null) return <CardSkeleton variant={skelett} />;
if (Array.isArray(daten) && daten.length === 0) return <div className="empty">{leer}</div>;
return children;
}
@@ -183,7 +186,7 @@ function KarteArtikel({ props: karteProps }) {
return <div className="empty">Kein Artikel gewählt über Anordnen einstellen.</div>;
}
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
{daten && (
<div className="card-stack">
<div className="kpi">
@@ -219,7 +222,7 @@ function Kennzahl({ wert, label, icon, ton }) {
function KarteStatus() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
<div className="kpi-row">
<Kennzahl icon="clock" label="Bald ablaufend" wert={daten?.expiring_soon ?? 0}
ton={daten?.expiring_soon ? "warn" : ""} />
@@ -234,7 +237,7 @@ function KarteStatus() {
function KarteArtikelzahl() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
<Kennzahl icon="package" label="Artikel mit Bestand" wert={daten?.products_in_stock ?? 0} />
</Zustand>
);
@@ -243,7 +246,7 @@ function KarteArtikelzahl() {
function KarteEinheiten() {
const { daten, fehler } = useDaten(() => api.dashboardStats());
return (
<Zustand fehler={fehler} daten={daten}>
<Zustand fehler={fehler} daten={daten} skelett="kpi">
<Kennzahl icon="box" label="Artikeleinheiten im Lager" wert={fmt(daten?.article_units ?? 0)} />
</Zustand>
);
@@ -306,90 +309,126 @@ function KarteAblauf({ modus = "alle" }) {
);
}
function KarteEinkaufsliste() {
/**
* Einkaufsliste mit Gesamt-Bedarf UND Bedarf je Lagerort. Über die Karten-Konfig
* lässt sich ein Lagerort festlegen: leer = alle Orte, sonst nur dieser.
*/
function KarteEinkaufsliste({ props: karteProps }) {
const ortId = karteProps?.location_id || "";
const [erledigt, setErledigt] = useState({}); // abgehakte Zeilen (nur in dieser Sitzung)
const { daten, fehler } = useDaten(async () => {
const [produkte, gruppen] = await Promise.all([api.shoppingList(), api.groupShoppingList()]);
return { produkte, gruppen };
const [produkte, gruppen, orte] = await Promise.all([
api.shoppingList(), api.groupShoppingList(), api.shoppingByLocation(),
]);
return { produkte, gruppen, orte };
});
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten.produkte.length && !daten.gruppen.length) {
return <div className="empty">Alle Mindestbestände erreicht.</div>;
}
if (!daten) return <CardSkeleton variant="liste" />;
const orte = ortId ? daten.orte.filter((o) => o.location_id === ortId) : daten.orte;
const gesamtLeer = !daten.produkte.length && !daten.gruppen.length;
const orteLeer = orte.every((o) => !o.products.length && !o.groups.length);
if (gesamtLeer && orteLeer) return <div className="empty">Alle Mindestbestände erreicht.</div>;
const abhaken = (key, val) => setErledigt((e) => ({ ...e, [key]: val }));
// Ein Abschnitt (Gesamt oder ein Ort) als abhakbare Liste wie die volle Seite.
// `prefix` macht die Schlüssel über die Abschnitte hinweg eindeutig. Die
// Bedarfszeile kommt vom Server fertig aufbereitet (`need`) und ist für
// Produkte wie Gruppen, gesamt wie je Ort identisch aufgebaut.
const abschnitt = (gruppen, produkte, prefix) => (
<ul className="checklist">
{gruppen.map((it) => {
const key = `${prefix}g${it.group_id}`;
return (
<li key={key} className={erledigt[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
<span className="badge accent">Gruppe</span>
{it.subgroup_count > 0 && (
<span className="badge" title="Untergruppen sind mitgezählt.">+{it.subgroup_count}</span>
)}
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_name} />
</span>
</li>
);
})}
{produkte.map((it) => {
const key = `${prefix}p${it.product_id}`;
return (
<li key={key} className={erledigt[key] ? "done" : ""}>
<label>
<input type="checkbox" checked={!!erledigt[key]} onChange={(e) => abhaken(key, e.target.checked)} />
<span className="item-name">{it.name}</span>
</label>
<span className="muted small">
<ShoppingNeedText need={it.need} stock={it.stock} minStock={it.min_stock} unitName={it.unit_label} />
</span>
</li>
);
})}
</ul>
);
return (
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Bedarf</th><th className="num">Bestand</th><th className="num">Fehlt</th></tr>
</thead>
<tbody>
{daten.gruppen.map((it) => (
<tr key={`g${it.group_id}`}>
<td data-label="Bedarf"><span className="badge accent">Gruppe</span> {it.name}</td>
<td data-label="Bestand" className="num">{fmt(it.stock)} {it.unit_name}</td>
<td data-label="Fehlt" className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
</tr>
))}
{daten.produkte.map((it) => (
<tr key={`p${it.product_id}`}>
<td data-label="Bedarf">{it.name}</td>
<td data-label="Bestand" className="num">{amountText(it.stock, it.package_size, it.base_unit)}</td>
<td data-label="Fehlt" className="num strong">{amountText(it.deficit, it.package_size, it.base_unit)}</td>
</tr>
))}
</tbody>
</table>
<div className="card-stack">
{!gesamtLeer && (
<div>
<div className="muted small" style={{ fontWeight: 600 }}>Überall</div>
{abschnitt(daten.gruppen, daten.produkte, "")}
</div>
)}
{orte.map((o) => ((o.products.length || o.groups.length) ? (
<div key={o.location_id}>
<div className="muted small" style={{ fontWeight: 600 }}>
<Icon name="location" size={13} /> {o.location_name}
</div>
{abschnitt(o.groups, o.products, `l${o.location_id}`)}
</div>
) : null))}
</div>
);
}
const BEWEGUNG_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
function KarteBewegungen() {
const { daten, fehler } = useDaten(() => api.listMovements(20));
return (
<Zustand fehler={fehler} daten={daten} leer="Noch keine Bewegungen.">
<div className="table-wrap">
<table className="table">
<thead><tr><th>Zeitpunkt</th><th>Aktion</th><th>Produkt</th></tr></thead>
<tbody>
{(daten || []).map((m) => (
<tr key={m.id}>
<td data-label="Zeitpunkt" className="muted">
{new Date(m.created_at).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</td>
<td data-label="Aktion">
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
{BEWEGUNG_LABEL[m.type] || m.type}
</span>
</td>
<td data-label="Produkt">{m.product_name}</td>
</tr>
))}
</tbody>
</table>
{/* Kachel links, Zeitpunkt ueber Produkt. Die Bewegungsart trug frueher
eine eigene Spalte mit dem Wort „Ausgelagert" als Sinnbild kostet
sie keine Zeile mehr und bleibt auf einen Blick erkennbar. */}
<div className="bewegung-liste">
{(daten || []).map((m) => (
<div className="bewegung-zeile" key={m.id}>
<BewegungIcon typ={m.type} />
<div className="text">
<div className="zeit">
{new Date(m.created_at).toLocaleString("de-DE", {
day: "2-digit", month: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</div>
<div className="produkt">{m.product_name}</div>
</div>
</div>
))}
</div>
</Zustand>
);
}
// ---------------------------------------------------------------- Diagramme
function KarteAblaufRing() {
function KarteAblaufRing({ props: karteProps }) {
const { daten, fehler } = useDaten(() => api.dashboardExpirySplit());
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird gemerkt.
const [mitOhneMhd, setMitOhneMhd] = useState(
() => localStorage.getItem("ablauf_mit_ohne_mhd") !== "0",
);
function umschalten(on) {
setMitOhneMhd(on);
localStorage.setItem("ablauf_mit_ohne_mhd", on ? "1" : "0");
}
// Ob „Ohne MHD" (Gegenstände/undatierte Chargen) mitgezählt wird Karten-Einstellung.
const mitOhneMhd = karteProps?.mit_ohne_mhd ?? true;
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt</div>;
if (!daten) return <CardSkeleton variant="chart" />;
// Zustandsfarben, nie Serienfarben und immer mit Beschriftung.
const segmente = [
@@ -400,26 +439,21 @@ function KarteAblaufRing() {
if (mitOhneMhd) {
segmente.push({ name: "Ohne MHD", value: daten.no_date, color: tokenFarbe("--muted", "#6b7480") });
}
return (
<div className="ring-mit-schalter">
<label className="check-inline ring-schalter">
<input type="checkbox" checked={mitOhneMhd}
onChange={(e) => umschalten(e.target.checked)} />
<span>Ohne MHD" einbeziehen</span>
</label>
<Donut segmente={segmente} gesamtLabel="Einheiten" />
</div>
);
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
}
function KarteKategorienRing() {
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
function KarteKategorienRing({ props: karteProps }) {
// Tiefe: bis zu welcher Kategorie-Stufe zusammengefasst wird (leer = feinste).
const tiefe = karteProps?.tiefe ? Number(karteProps.tiefe) : undefined;
const { daten, fehler } = useDaten(() => api.dashboardByCategory(tiefe), [tiefe]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt…</div>;
if (!daten) return <CardSkeleton variant="chart" />;
// Detailansicht: jede Kategorie einzeln statt der Bündelung zu „Andere“.
const segmente = mitFarben(
daten.map((k) => ({ name: k.name, value: k.article_units })),
istDunkel(),
{ alle: !!karteProps?.detail },
);
return <Donut segmente={segmente} gesamtLabel="Einheiten" />;
}
@@ -427,7 +461,7 @@ function KarteKategorienRing() {
function KarteKategorienSaeulen() {
const { daten, fehler } = useDaten(() => api.dashboardByCategory());
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt…</div>;
if (!daten) return <CardSkeleton variant="chart" />;
const zeilen = daten.slice(0, 8).map((k) => ({
name: k.name,
@@ -474,7 +508,7 @@ function KarteBestandsverlauf({ tage = 90 }) {
const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt…</div>;
if (!daten) return <CardSkeleton variant="chart" />;
return (
<Line
@@ -533,7 +567,7 @@ function KarteBewegungsverlauf({ tage = 30 }) {
const { daten, fehler } = useDaten(() => api.dashboardFlow(tage), [tage]);
if (fehler) return <div className="alert error"><Icon name="alert" size={16} />{fehler}</div>;
if (!daten) return <div className="empty">Lädt…</div>;
if (!daten) return <CardSkeleton variant="chart" />;
return (
<Wasserfall
@@ -575,7 +609,7 @@ export const KARTEN = {
titel: "Artikel im Blick", icon: "package", vorschau: "kennzahl",
beschreibung: "Bestand eines festen Artikels mehrfach verwendbar",
standard: { w: 3, h: 3 }, min: { w: 2, h: 3 },
artikel: true,
config: [{ key: "product_id", type: "product", label: "Artikel" }],
komponente: KarteArtikel,
},
status: {
@@ -618,6 +652,7 @@ export const KARTEN = {
titel: "Einkaufsliste", icon: "cart", vorschau: "tabelle",
beschreibung: "Produkte und Gruppen unter Mindestbestand",
standard: { w: 6, h: 6 }, min: { w: 4, h: 4 },
config: [{ key: "location_id", type: "location", label: "Lagerort", hint: "leer = alle Orte" }],
komponente: KarteEinkaufsliste,
},
movements: {
@@ -630,12 +665,24 @@ export const KARTEN = {
titel: "Ablauf-Anteile", icon: "clock", vorschau: "ring",
beschreibung: "Ring: in Ordnung, bald ablaufend, abgelaufen",
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
config: [{ key: "mit_ohne_mhd", type: "boolean", label: "„Ohne MHD“ einbeziehen", default: true }],
komponente: KarteAblaufRing,
},
"category-donut": {
titel: "Kategorien-Anteile", icon: "tag", vorschau: "ring",
beschreibung: "Ring: welche Kategorie wie viel ausmacht",
standard: { w: 4, h: 6 }, min: { w: 3, h: 5 },
config: [
{ key: "tiefe", type: "select", label: "Kategorie-Tiefe",
hint: "wie weit Unterkategorien zusammengefasst werden", default: "",
optionen: [
{ value: "", label: "Feinste (jede Kategorie einzeln)" },
{ value: "1", label: "Nur oberste Stufe" },
{ value: "2", label: "Bis 2. Stufe" },
{ value: "3", label: "Bis 3. Stufe" },
] },
{ key: "detail", type: "boolean", label: "Detailansicht (alle Kategorien einzeln, keine Gruppe „Andere“)" },
],
komponente: KarteKategorienRing,
},
"category-bars": {
@@ -648,19 +695,24 @@ export const KARTEN = {
titel: "Bestandsverlauf", icon: "history", vorschau: "linie",
beschreibung: "Artikeleinheiten im Lager über 90 Tage",
zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 },
config: [{ type: "zeitraum", label: "Zeitraum" }],
komponente: KarteBestandsverlauf,
},
"product-timeline": {
titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie",
beschreibung: "Ein fester Artikel über die Zeit mehrfach verwendbar",
zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 },
artikel: true,
config: [
{ key: "product_id", type: "product", label: "Artikel" },
{ type: "zeitraum", label: "Zeitraum" },
],
komponente: KarteArtikelverlauf,
},
"activity-timeline": {
titel: "Ein- und Auslagerungen", icon: "history", vorschau: "wasserfall",
beschreibung: "Brücke: Bestandslinie mit grünen und roten Balken je Bewegung",
zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 5, h: 5 },
config: [{ type: "zeitraum", label: "Zeitraum" }],
komponente: KarteBewegungsverlauf,
},
};

View File

@@ -65,19 +65,22 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", einheit =
</text>
</svg>
<ul className="chart-legend">
{boegen.map((b) => (
<li key={b.name}
onMouseEnter={() => setAktiv(b)}
onMouseLeave={() => setAktiv(null)}>
<span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span>
<span className="legend-value">
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
</span>
</li>
))}
</ul>
{/* Eigener Scroll-Bereich: nur die Legende scrollt, der Ring bleibt stehen. */}
<div className="chart-legend-scroll">
<ul className="chart-legend">
{boegen.map((b) => (
<li key={b.name}
onMouseEnter={() => setAktiv(b)}
onMouseLeave={() => setAktiv(null)}>
<span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span>
<span className="legend-value">
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
</span>
</li>
))}
</ul>
</div>
</div>
);
}

View File

@@ -58,13 +58,36 @@ export function andereFarbe(dunkel) {
}
/**
* Bündelt eine sortierte Liste auf SERIES_MAX Einträge plus „Andere“ und
* vergibt die Farben in fester Reihenfolge.
* Erwartet Objekte mit { name, value }.
* Zusätzliche Farben für die Detailansicht (mehr Kategorien als die geprüfte
* Palette hat). Bewusst nur für den Detail-Fall: gleichmäßig verteilte Farbtöne
* (Goldener Winkel) statt der farbfehlsicht-geprüften Festfarben.
*/
export function mitFarben(eintraege, dunkel) {
function extraFarben(anzahl, dunkel) {
const s = dunkel ? 62 : 66;
const l = dunkel ? 58 : 46;
return Array.from({ length: anzahl }, (_, j) => {
const ton = Math.round((28 + j * 137.508) % 360);
return `hsl(${ton}, ${s}%, ${l}%)`;
});
}
/**
* Vergibt Farben für eine sortierte Anteilsliste (Objekte mit { name, value }).
*
* Standard: höchstens SERIES_MAX Einträge in fester Reihenfolge, der Rest zu
* „Andere“ gebündelt. Mit `alle` (Detailansicht) bekommt JEDE Kategorie eine
* eigene Farbe (Festpalette vorne, danach erzeugte Töne) ohne „Andere“.
*/
export function mitFarben(eintraege, dunkel, { alle = false } = {}) {
const farben = kategorieFarben(dunkel);
const sortiert = [...eintraege].sort((a, b) => b.value - a.value);
if (alle) {
const extra = extraFarben(Math.max(0, sortiert.length - farben.length), dunkel);
return sortiert.map((e, i) => ({
...e,
color: i < farben.length ? farben[i] : extra[i - farben.length],
}));
}
const vorne = sortiert.slice(0, SERIES_MAX).map((e, i) => ({ ...e, color: farben[i] }));
const rest = sortiert.slice(SERIES_MAX);
if (rest.length === 0) return vorne;

148
web/src/groupGraph.js Normal file
View File

@@ -0,0 +1,148 @@
// Gruppen bilden einen gerichteten azyklischen Graphen, keinen Baum:
// „Grillwurst" hängt unter „Wurst" UND unter „Grillgut". Deshalb hier
// Mengen-Helfer statt der Baum-Helfer aus categoryTree.js ein Baum-Walk
// würde eine über zwei Wege erreichbare Gruppe doppelt liefern.
//
// Alle Funktionen schützen sich mit einem gesehen-Set gegen Ringe. Die API
// lässt keinen zu, eine eingespielte Sicherung könnte aber einen mitbringen.
const byId = (groups) => new Map((groups || []).map((g) => [g.id, g]));
/** IDs aller Untergruppen (transitiv, ohne die Gruppe selbst). */
export function nachfahrenIds(groups, id) {
const map = byId(groups);
const gesehen = new Set();
const offen = [id];
while (offen.length) {
const cur = offen.pop();
for (const kind of map.get(cur)?.child_ids || []) {
if (!gesehen.has(kind)) {
gesehen.add(kind);
offen.push(kind);
}
}
}
gesehen.delete(id);
return gesehen;
}
/** IDs aller Obergruppen (transitiv, ohne die Gruppe selbst). */
export function vorfahrenIds(groups, id) {
const map = byId(groups);
const gesehen = new Set();
const offen = [...(map.get(id)?.parent_ids || [])];
while (offen.length) {
const cur = offen.pop();
if (gesehen.has(cur)) continue;
gesehen.add(cur);
offen.push(...(map.get(cur)?.parent_ids || []));
}
gesehen.delete(id);
return gesehen;
}
/**
* Alle Wege zu einer Gruppe, z.B. [["Wurst","Grillwurst"], ["Grillgut","Grillwurst"]].
* Anders als bei Kategorien gibt es nicht DEN einen Pfad deshalb eine Liste.
*/
export function pfade(groups, id) {
const map = byId(groups);
const bauen = (cur, gesehen) => {
const g = map.get(cur);
if (!g) return [[]];
const eltern = (g.parent_ids || []).filter((p) => !gesehen.has(p));
if (eltern.length === 0) return [[g.name]];
const raus = [];
for (const p of eltern) {
for (const oben of bauen(p, new Set([...gesehen, cur]))) {
raus.push([...oben, g.name]);
}
}
return raus;
};
return bauen(id, new Set());
}
/** Die Wege als ein Text: „Wurst → Grillwurst · Grillgut → Grillwurst". */
export function pfadText(groups, id) {
return pfade(groups, id)
.map((teile) => teile.join(" → "))
.join(" · ");
}
/** Namen der Obergruppen, für Chips und Spaltentexte. */
export function elternNamen(groups, group) {
const map = byId(groups);
return (group?.parent_ids || []).map((id) => map.get(id)?.name).filter(Boolean);
}
/**
* Gruppen für ein Auswahlfeld: als Baum ausgerollt und eingerückt.
*
* Eine Gruppe mit mehreren Obergruppen erscheint unter JEDER genau das ist
* der Sinn mehrerer Obergruppen. Der Wert ist ohnehin dieselbe ID, egal welche
* Zeile man trifft.
*
* Eingerückt wird mit GESCHÜTZTEN Leerzeichen: normale fasst der Browser in
* einem <option> zusammen, die Einrückung wäre dann wirkungslos.
*/
export function gruppenOptionen(groups) {
const liste = groups || [];
const vorhanden = new Set(liste.map((g) => g.id));
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
const kinderVon = new Map();
const wurzeln = [];
for (const g of liste) {
const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id));
if (eltern.length === 0) wurzeln.push(g);
for (const e of eltern) {
if (!kinderVon.has(e)) kinderVon.set(e, []);
kinderVon.get(e).push(g);
}
}
const out = [];
const walk = (g, tiefe, pfad, gesehen) => {
const eigen = `${pfad}/${g.id}`;
out.push({
id: g.id,
key: eigen,
gruppe: g,
tiefe,
label: `${"\u00A0\u00A0\u00A0".repeat(tiefe)}${tiefe > 0 ? "\u21B3 " : ""}${g.name}`,
});
if (gesehen.has(g.id)) return; // Ringschutz
const weiter = new Set(gesehen).add(g.id);
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
walk(k, tiefe + 1, eigen, weiter);
}
};
for (const w of wurzeln.slice().sort(nachName)) walk(w, 0, "", new Set());
return out;
}
/**
* Gegenstück zu categoryInfoMap für Gruppen: je Gruppe der Weg von oben.
*
* Anders als bei Kategorien kann es MEHRERE Wege geben („Grillwurst" unter
* „Wurst" und unter „Grillgut"). Angezeigt wird der erste, gefiltert wird über
* alle wer auf „Wurst" filtert, will „Grillwurst" mitsehen.
*/
export function gruppenInfoMap(groups) {
const out = new Map();
for (const g of groups || []) {
const wege = pfade(groups, g.id);
const erster = wege[0]?.length ? wege[0] : [g.name];
const tokens = new Set();
for (const weg of wege) {
weg.forEach((_, i) => tokens.add(weg.slice(0, i + 1).join(" → ")));
}
out.set(g.id, {
parts: erster,
path: erster.join(" → "),
alle: wege.map((w) => w.join(" → ")),
tokens: [...tokens],
});
}
return out;
}

View File

@@ -8,12 +8,16 @@ import { ConfirmProvider } from "./confirm";
import { ToastProvider } from "./toast";
import "./styles.css";
import { applyFavicon } from "./branding";
import ErrorBoundary from "./components/ErrorBoundary";
// Eigenes Favicon, falls hinterlegt - bewusst vor dem Login.
applyFavicon();
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
{/* Letzte Instanz: ohne sie haengt ein Fehler beim Rendern den ganzen
Baum aus und die Seite bleibt weiss. */}
<ErrorBoundary>
<BrowserRouter>
<AuthProvider>
<SettingsProvider>
@@ -25,5 +29,6 @@ ReactDOM.createRoot(document.getElementById("root")).render(
</SettingsProvider>
</AuthProvider>
</BrowserRouter>
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -26,12 +26,16 @@ export default function Categories() {
const [feldKat, setFeldKat] = useState(null);
// "" = alle, sonst "food"/"object": trennt Lebensmittel und Gegenstände.
const [typFilter, setTypFilter] = useState("");
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
setCategories(await api.listCategories());
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
@@ -127,7 +131,7 @@ export default function Categories() {
} },
{ key: "count", header: "Artikel", width: 90, align: "num", sortValue: (c) => c.product_count,
render: (c) => <span className="muted">{c.product_count}</span> },
{ key: "actions", header: "", fixed: true, width: 96, align: "num",
{ key: "actions", header: "", label: "Aktionen", fixed: true, width: 96, align: "num",
render: (c) => (
<span className="cell-row" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Felder verwalten"
@@ -170,7 +174,7 @@ export default function Categories() {
getRowKey={(c) => c.id}
rowClassName={(c) => (feldKat && feldKat.id === c.id ? "row-active" : "")}
tree={{ column: "name", idOf: (c) => c.id, parentIdOf: (c) => c.parent_id }}
empty="Noch keine Kategorien." />
loading={loading} empty="Noch keine Kategorien." />
</div>
<div className="stack">

225
web/src/pages/Charges.jsx Normal file
View File

@@ -0,0 +1,225 @@
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import SplitLotDialog from "../components/SplitLotDialog";
import { ProduktThumb } from "../components/ProduktBild";
import { useSettings } from "../settings";
import { locationOptions, locationPathById } from "../locationPath";
import { expiryRowClass, fmt, gebinde, unitShort } from "../units";
// Artikeleinheit einer Charge (Gebinde, sonst Produkteinheit).
const artFactor = (l) => (l.package_size && l.package_size > 0 ? l.package_size : (l.unit_factor || 1));
function mengeText(l) {
const menge = l.quantity / artFactor(l);
const label = l.package_size && l.package_size > 0
? gebinde(menge, l.package_label)
: (l.unit_name || unitShort(l.base_unit));
return `${fmt(menge)} ${label}`;
}
/**
* Übergreifende Chargenliste (wie die Einzelstückliste, aber für Chargen). Zeigt
* je Charge Artikel, Menge, MHD und Lagerort; der Lagerort ist je Zeile direkt
* änderbar, und alle Chargen ohne Ort lassen sich in einem Klick zuordnen.
*/
export default function Charges() {
const { isAdmin } = useAuth();
const { formatBestBefore } = useSettings();
const [lots, setLots] = useState([]);
const [locations, setLocations] = useState([]);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
const [bulkLoc, setBulkLoc] = useState("");
const [selected, setSelected] = useState(() => new Set());
const [visible, setVisible] = useState([]); // aktuell gefiltert sichtbare Chargen
const [splitting, setSplitting] = useState(null); // Charge, die gerade aufgeteilt wird
const [warnDays, setWarnDays] = useState(7); // Warnfrist für „bald ablaufend"
async function load() {
setLoading(true);
try {
const [ls, locs] = await Promise.all([api.listAllLots(), api.listLocations()]);
setLots(ls);
setLocations(locs);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
// Warnfrist (Tage) für die gelbe „bald ablaufend"-Markierung wie auf der Artikelseite.
useEffect(() => {
api.listSettings()
.then((s) => { const r = s.find((x) => x.key === "expiry_warning_days"); if (r) setWarnDays(parseInt(r.value, 10) || 7); })
.catch(() => {});
}, []);
const ohneOrt = useMemo(() => lots.filter((l) => !l.location_id), [lots]);
function toggle(id) {
setSelected((alt) => {
const neu = new Set(alt);
if (neu.has(id)) neu.delete(id); else neu.add(id);
return neu;
});
}
// Menge von IDs zur Auswahl hinzufügen (für „alle sichtbaren/ohne Ort").
function selectAll(ids) { setSelected(new Set(ids)); }
function clearSelection() { setSelected(new Set()); }
async function setLocation(lotId, locId) {
setError(null);
try {
await api.updateLot(lotId, { location_id: locId || null });
setLots((ls) => ls.map((l) => (l.id === lotId ? { ...l, location_id: locId || null } : l)));
} catch (err) {
setError(err.message);
}
}
// Lagerort für die aktuell ausgewählten Chargen setzen (leer = Ort entfernen).
async function applyToSelection() {
if (selected.size === 0) return;
setError(null);
try {
await api.bulkLotLocation([...selected], bulkLoc);
setBulkLoc("");
clearSelection();
await load();
} catch (err) {
setError(err.message);
}
}
const columns = [
{ key: "select", header: "", label: "Auswahl", fixed: true, width: 44,
render: (l) => (
<input type="checkbox" style={{ width: "auto", margin: 0, cursor: "pointer" }}
checked={selected.has(l.id)} onChange={() => toggle(l.id)}
aria-label={`${l.product_name} auswählen`} />
) },
{ key: "thumb", header: "", label: "Bild", fixed: true, width: 56,
render: (l) => <ProduktThumb productId={l.product_id} alt={l.product_name} /> },
{ key: "product", header: "Artikel", grow: true, min: 180,
filterText: (l) => `${l.product_name} ${l.product_brand || ""}`, sortValue: (l) => l.product_name,
render: (l) => (
<span className="cell-row">
<span className="strong">{l.product_name}</span>
{l.product_brand && <span className="muted small">{l.product_brand}</span>}
</span>
) },
{ key: "art", header: "Art", width: 130,
filterText: (l) => (l.tracking === "object" ? "Gegenstand" : "Lebensmittel"),
filterValues: (l) => [l.tracking === "object" ? "Gegenstand" : "Lebensmittel"],
render: (l) => (
<span className="badge nowrap">{l.tracking === "object" ? "Gegenstand" : "Lebensmittel"}</span>
) },
{ key: "menge", header: "Menge", width: 150, align: "num",
sortValue: (l) => l.quantity / artFactor(l), render: mengeText },
{ key: "mhd", header: "MHD", width: 150,
filterText: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : ""),
sortValue: (l) => l.best_before || "9999-99-99",
render: (l) => (l.best_before ? formatBestBefore(l.best_before, l.best_before_precision) : <span className="muted"></span>) },
{ key: "location", header: "Lagerort", width: 240,
filterText: (l) => (l.location_id ? (locationPathById(l.location_id, locations) || "") : "(ohne)"),
filterValues: (l) => [l.location_id ? (locationPathById(l.location_id, locations) || "?") : "(ohne)"],
render: (l) => (isAdmin ? (
<select value={l.location_id || ""} style={{ marginTop: 0, minWidth: 150 }}
onChange={(e) => setLocation(l.id, e.target.value)}>
<option value=""> ohne </option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
) : (
l.location_id ? (locationPathById(l.location_id, locations) || "?") : <span className="muted"> ohne </span>
)) },
{ key: "created", header: "Eingelagert", width: 130,
sortValue: (l) => new Date(l.created_at).getTime(),
render: (l) => <span className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</span> },
{ key: "split", header: "Aufteilen", label: "Aufteilen", width: 118, align: "num",
render: (l) => (isAdmin ? (
<button type="button" className="btn sm ghost" title="Charge aufteilen und Teil umlagern"
onClick={() => setSplitting(l)}><Icon name="split" size={15} />Aufteilen</button>
) : null) },
{ key: "details", header: "Artikel", label: "Artikel", width: 90, align: "num",
render: (l) => <Link to={`/products/${l.product_id}`}>Artikel</Link> },
];
return (
<div>
<div className="page-head">
<div>
<h1>Chargen</h1>
<div className="sub">
{loading ? "Wird geladen…" : `${lots.length} Chargen · ${ohneOrt.length} ohne Lagerort`}
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{isAdmin && (
<div className="card" style={{ marginBottom: "var(--sp-3)" }}>
<div className="card-head"><Icon name="location" /><h2>Lagerort für ausgewählte Chargen setzen</h2></div>
<p className="muted small mt-0">
{selected.size > 0
? `${selected.size} Charge(n) ausgewählt diese bekommen den gewählten Lagerort (leer = Ort entfernen).`
: "Chargen in der Tabelle anhaken, dann hier den Lagerort setzen. Oder unten schnell alle sichtbaren / alle ohne Ort auswählen."}
</p>
<div className="field-inline" style={{ flexWrap: "wrap" }}>
<select value={bulkLoc} onChange={(e) => setBulkLoc(e.target.value)} style={{ maxWidth: 360 }}
disabled={selected.size === 0}>
<option value=""> ohne Lagerort </option>
{locationOptions(locations).map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
<button className="btn primary" disabled={selected.size === 0} onClick={applyToSelection}>
<Icon name="check" size={16} />Auf {selected.size} Auswahl anwenden
</button>
</div>
<div className="field-inline" style={{ flexWrap: "wrap", marginTop: "var(--sp-2)", marginBottom: 0 }}>
<button type="button" className="btn sm ghost" onClick={() => selectAll(visible.map((l) => l.id))}
disabled={visible.length === 0}>
Alle {visible.length} sichtbaren auswählen
</button>
<button type="button" className="btn sm ghost" onClick={() => selectAll(ohneOrt.map((l) => l.id))}
disabled={ohneOrt.length === 0}>
Alle {ohneOrt.length} ohne Lagerort auswählen
</button>
{selected.size > 0 && (
<button type="button" className="btn sm ghost" onClick={clearSelection}>Auswahl aufheben</button>
)}
</div>
</div>
)}
<div className="card">
<DataTable id="charges" columns={columns} rows={lots} loading={loading}
onVisibleRows={setVisible}
rowClassName={(l) => [expiryRowClass(l.best_before, warnDays), selected.has(l.id) ? "row-active" : ""].filter(Boolean).join(" ")}
onRowClick={(l, e) => {
// Klicks auf Bedienelemente (Checkbox, Lagerort-Dropdown, Link) nicht
// als Zeilenauswahl werten überall sonst die Zeile umschalten.
if (!e.target.closest("input, select, a, button, label")) toggle(l.id);
}}
getRowKey={(l) => l.id} empty="Keine Chargen im Bestand." />
</div>
{splitting && (
<SplitLotDialog
lot={splitting}
factor={artFactor(splitting)}
labelFor={(q) => (splitting.package_size && splitting.package_size > 0
? gebinde(q, splitting.package_label)
: (splitting.unit_name || unitShort(splitting.base_unit)))}
locations={locations}
onClose={() => setSplitting(null)}
onDone={load}
onError={setError}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { Link, useSearchParams } from "react-router-dom";
import { api } from "../api";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
@@ -10,6 +10,7 @@ import { asTree } from "../categoryTree";
import CategorySelect from "../components/CategorySelect";
import { locationOptions } from "../locationPath";
import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units";
import { gruppenOptionen } from "../groupGraph";
const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -43,6 +44,7 @@ export default function CheckIn() {
const [error, setError] = useState(null);
const [busy, setBusy] = useState(false);
const [searchParams] = useSearchParams();
useEffect(() => {
api.listLocations().then(setLocations).catch(() => {});
@@ -51,6 +53,12 @@ export default function CheckIn() {
api.listUnits().then(setUnits).catch(() => {});
}, []);
// Aus der Lagerort-Ansicht („Hier einlagern") den Ort vorbelegen.
useEffect(() => {
const loc = searchParams.get("location");
if (loc) setLocationId(loc);
}, [searchParams]);
function resetLookup() {
setSuggestion(null);
setUnknownBarcode(null);
@@ -152,7 +160,9 @@ export default function CheckIn() {
Gruppe
<select value={newGroupId} onChange={(e) => setNewGroupId(e.target.value)}>
<option value=""> keine </option>
{groups.map((g) => <option key={g.id} value={g.id}>{g.name}</option>)}
{gruppenOptionen(groups).map((o) => (
<option key={o.key} value={o.id}>{o.label}</option>
))}
</select>
<span className="muted small">
Zählt Bestände mehrerer Marken zusammen. Der EAN-Code landet dann auch dort.

View File

@@ -4,7 +4,7 @@ import Icon from "../components/Icon";
import { ProduktThumb } from "../components/ProduktBild";
import { useToast } from "../toast";
import { useSettings } from "../settings";
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort } from "../units";
import { buildUnitOptions, fmt, gebinde, isExpired, unitShort, zweitText } from "../units";
export default function CheckOut() {
const { formatBestBefore } = useSettings();
@@ -149,6 +149,10 @@ export default function CheckOut() {
<div className="title">{product.name}</div>
<div className="muted small">
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
{/* Zweite Lesart, wenn eine Zweiteinheit hinterlegt ist. */}
{zweitText(product.stock || 0, product) && (
<> · {zweitText(product.stock || 0, product)}</>
)}
</div>
</div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>

View File

@@ -6,7 +6,8 @@ import { useAuth } from "../auth";
import Icon from "../components/Icon";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import { KARTEN, KARTEN_IDS, ZEITRAEUME } from "../dashboard/cards";
import { KARTEN, KARTEN_IDS } from "../dashboard/cards";
import KartenConfig from "../dashboard/KartenConfig";
import Vorschau from "../dashboard/Vorschau";
const Raster = WidthProvider(GridLayout);
@@ -54,6 +55,8 @@ export default function Dashboard() {
const [fehler, setFehler] = useState(null);
const [menuOffen, setMenuOffen] = useState(false);
const [produkte, setProdukte] = useState([]);
const [locations, setLocations] = useState([]);
const [konfigKarte, setKonfigKarte] = useState(null); // Kennung der Karte im Konfig-Dialog
const aktivesId = routeId ? Number(routeId) : dashboards[0]?.id;
const aktives = dashboards.find((d) => d.id === aktivesId) ?? dashboards[0];
@@ -80,14 +83,15 @@ export default function Dashboard() {
useEffect(() => { laden(); }, []);
// Artikelliste nur einmal für die Einstellung der artikelbezogenen Karten.
// Artikel- und Lagerortliste einmal für die Karten-Konfiguration laden.
useEffect(() => {
api.listProducts().then(setProdukte).catch(() => {});
api.listLocations().then(setLocations).catch(() => {});
}, []);
// Beim Wechsel des Dashboards einen offenen Entwurf verwerfen sonst
// landete er beim Speichern auf dem falschen.
useEffect(() => { setEntwurf(null); setMenuOffen(false); }, [aktivesId]);
useEffect(() => { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }, [aktivesId]);
/**
* Unbekannte Karten aussortieren z.B. nach einem Update, das eine entfernt.
@@ -149,6 +153,7 @@ export default function Dashboard() {
const gespeichert = await api.updateDashboard(aktives.id, { layout: schlank(entwurf) });
setDashboards((alt) => alt.map((d) => (d.id === gespeichert.id ? gespeichert : d)));
setEntwurf(null);
setKonfigKarte(null);
toast("Anordnung gespeichert.");
} catch (err) {
setFehler(err.message);
@@ -271,26 +276,10 @@ export default function Dashboard() {
});
}
/**
* Zeitraum einer Verlaufskarte ändern. Außerhalb des Bearbeitungsmodus wird
* die Wahl still gespeichert sonst wäre sie nach dem nächsten Laden wieder
* weg, ohne dass es einen sichtbaren Speichern-Knopf gäbe.
*/
async function setzeZeitraum(kennung, tage) {
const geaendert = aktuell.map((k) => (k.i === kennung ? { ...k, tage } : k));
if (bearbeiten) {
setEntwurf(geaendert);
return;
}
setDashboards((alt) => alt.map((d) => (
d.id === aktives.id ? { ...d, layout: geaendert } : d
)));
if (erzwungen || !aktives?.id) return;
try {
await api.updateDashboard(aktives.id, { layout: geaendert });
} catch {
/* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */
}
/** Zeitraum einer Karte ändern. Passiert nur noch im Konfig-Dialog, also im
* Bearbeitungsmodus gespeichert wird mit „Speichern" wie jede andere Änderung. */
function setzeZeitraum(kennung, tage) {
setEntwurf((alt) => (alt ?? []).map((k) => (k.i === kennung ? { ...k, tage } : k)));
}
const eigenes = herkunft === "user" && aktives?.id;
@@ -348,7 +337,8 @@ export default function Dashboard() {
)}
</div>
<button className="btn primary" onClick={speichern}>Speichern</button>
<button className="btn ghost" onClick={() => { setEntwurf(null); setMenuOffen(false); }}>
<button className="btn ghost"
onClick={() => { setEntwurf(null); setMenuOffen(false); setKonfigKarte(null); }}>
Abbrechen
</button>
</>
@@ -387,33 +377,24 @@ export default function Dashboard() {
if (!karte) return null;
const Inhalt = karte.komponente;
const artikel = produkte.find((p) => p.id === eintrag.props?.product_id);
const ort = eintrag.props?.location_id
? locations.find((l) => l.id === eintrag.props.location_id)
: null;
const zusatz = artikel?.name || ort?.name; // im Titel gezeigt
return (
<section key={eintrag.i} className="card dash-card">
<div className="dash-card-head">
<Icon name={karte.icon} size={16} />
<h2>{artikel ? `${karte.titel}: ${artikel.name}` : karte.titel}</h2>
{/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe
dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */}
{karte.zeitraum && (
<select className="zeitraum" value={eintrag.tage ?? karte.zeitraum}
<h2>{zusatz ? `${karte.titel}: ${zusatz}` : karte.titel}</h2>
{/* Einstellungen sammelt der Konfig-Dialog (wie in HomeAssistant),
statt einzelner Dropdowns im Kopf. Nur beim Anordnen sichtbar
und nur, wenn die Karte überhaupt Optionen hat. */}
{bearbeiten && karte.config && (
<button className="btn-icon" title="Karte einstellen"
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => setzeZeitraum(eintrag.i, Number(e.target.value))}>
{ZEITRAEUME.map((z) => (
<option key={z.tage} value={z.tage}>{z.label}</option>
))}
</select>
)}
{/* Artikelbezogene Karten werden beim Anordnen eingestellt -
so kann dieselbe Art mehrfach auf verschiedene Artikel zeigen. */}
{bearbeiten && karte.artikel && (
<select className="zeitraum" value={eintrag.props?.product_id ?? ""}
onMouseDown={(e) => e.stopPropagation()}
onChange={(e) => setzeProps(eintrag.i, {
product_id: e.target.value ? Number(e.target.value) : undefined,
})}>
<option value=""> Artikel wählen </option>
{produkte.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
</select>
onClick={() => setKonfigKarte(eintrag.i)}>
<Icon name="settings" size={15} />
</button>
)}
{bearbeiten && (
<button className="btn-icon danger" title="Karte entfernen"
@@ -456,6 +437,24 @@ export default function Dashboard() {
</>
)}
</div>
{konfigKarte && (() => {
const inst = aktuell.find((k) => k.i === konfigKarte);
if (!inst) return null;
const karte = KARTEN[inst.type];
return (
<KartenConfig
karte={karte}
props={inst.props || {}}
tage={inst.tage ?? karte.zeitraum}
produkte={produkte}
locations={locations}
onProps={(partial) => setzeProps(konfigKarte, partial)}
onTage={(n) => setzeZeitraum(konfigKarte, n)}
onClose={() => setKonfigKarte(null)}
/>
);
})()}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
@@ -6,7 +6,8 @@ import BarcodeList from "../components/BarcodeList";
import Icon from "../components/Icon";
import DataTable from "../components/DataTable";
import LocationMinStock from "../components/LocationMinStock";
import { fmt } from "../units";
import GroupParentSelect from "../components/GroupParentSelect";
import { anzahlWort, fmt, grpPkgMode, grpUnit, kindShort } from "../units";
export default function Groups() {
const confirm = useConfirm();
@@ -14,11 +15,15 @@ export default function Groups() {
const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [locations, setLocations] = useState([]);
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [form, setForm] = useState({
name: "", min_stock: "", min_stock_unit_id: "", parent_ids: [],
});
const [selectedId, setSelectedId] = useState(null);
const [error, setError] = useState(null);
const [loading, setLoading] = useState(true);
async function load() {
setLoading(true);
try {
const [gs, us, ls] = await Promise.all([
api.listGroups(), api.listUnits(), api.listLocations(),
@@ -28,6 +33,8 @@ export default function Groups() {
setLocations(ls);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
@@ -41,8 +48,11 @@ export default function Groups() {
name: form.name,
min_stock: form.min_stock === "" ? null : Number(form.min_stock),
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
parent_ids: form.parent_ids,
});
setForm({
name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id, parent_ids: [],
});
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
load();
} catch (err) {
setError(err.message);
@@ -62,7 +72,9 @@ export default function Groups() {
async function remove(group) {
const ok = await confirm({
title: `Gruppe „${group.name}“ löschen?`,
message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe.",
message: "Die Produkte bleiben erhalten, verlieren aber ihre Zuordnung zu dieser Gruppe. "
+ "Untergruppen bleiben ebenfalls bestehen und verlieren nur die Verbindung — "
+ "sie rücken nicht automatisch eine Ebene hoch.",
confirmLabel: "Löschen",
danger: true,
});
@@ -77,6 +89,41 @@ export default function Groups() {
}
const selected = groups.find((g) => g.id === selectedId) || null;
const nameById = Object.fromEntries(groups.map((g) => [g.id, g.name]));
// Gruppen als Baum ausrollen. Eine Gruppe mit ZWEI Obergruppen erscheint
// unter beiden das ist die ehrliche Darstellung eines Graphen; sie zu
// verstecken hiesse, den Sinn mehrerer Obergruppen zu verbergen.
//
// Jede Zeile traegt ihren PFAD als Schluessel („/3/7"). Damit kommt die
// vorhandene Baumlogik der DataTable unveraendert zurecht: sie kennt nur
// einen Elternteil je Zeile, und ein Pfad hat genau einen.
const baumZeilen = useMemo(() => {
const vorhanden = new Set(groups.map((g) => g.id));
const nachName = (a, b) => a.name.localeCompare(b.name, "de");
const kinderVon = new Map();
const wurzeln = [];
for (const g of groups) {
const eltern = (g.parent_ids || []).filter((id) => vorhanden.has(id));
if (eltern.length === 0) wurzeln.push(g);
for (const e of eltern) {
if (!kinderVon.has(e)) kinderVon.set(e, []);
kinderVon.get(e).push(g);
}
}
const out = [];
const walk = (g, elternPfad, gesehen) => {
const pfad = `${elternPfad ?? ""}/${g.id}`;
out.push({ ...g, _pfad: pfad, _elternPfad: elternPfad });
if (gesehen.has(g.id)) return; // Ringschutz, falls doch einer da ist
const weiter = new Set(gesehen).add(g.id);
for (const k of (kinderVon.get(g.id) || []).slice().sort(nachName)) {
walk(k, pfad, weiter);
}
};
for (const w of wurzeln.slice().sort(nachName)) walk(w, null, new Set());
return out;
}, [groups]);
const columns = [
{ key: "name", header: "Gruppe", grow: true, min: 160,
@@ -90,33 +137,96 @@ export default function Groups() {
onBlur={(e) => { const v = e.target.value.trim(); if (v && v !== g.name) patch(g, { name: v }); }} />
) : <span className="strong">{g.name}</span>}
{low && <span className="badge warn">niedrig</span>}
{g.child_ids?.length > 0 && (
<span className="badge" title="Bestand und Mindestbestand zählen diese Untergruppen mit.">
{anzahlWort(g.child_ids.length, "Untergruppe", "Untergruppen")}
</span>
)}
{/* Ohne Einheit summiert eine Gruppe Gramm und Stück zu einer
sinnlosen Zahl. Bei einem grossen Untergraphen faellt das
besonders ins Gewicht. */}
{g.child_ids?.length > 0 && !g.min_stock_unit_id && (
<span className="badge warn" title="Ohne Einheit werden Artikel verschiedener Basiseinheiten (g, ml, Stück) zusammengezählt.">
Einheit fehlt
</span>
)}
</span>
);
} },
{ key: "products", header: "Produkte", width: 100, align: "num",
sortValue: (g) => g.product_count, render: (g) => <span className="muted">{g.product_count}</span> },
{ key: "stock", header: "Bestand", width: 130, align: "num",
sortValue: (g) => g.stock, render: (g) => `${fmt(g.stock)} ${g.min_stock_unit_name || ""}` },
{ key: "parents", header: "Obergruppen", width: 220,
filterText: (g) => (g.parent_ids || []).map((id) => nameById[id]).join(" "),
render: (g) => (isAdmin ? (
<GroupParentSelect
groups={groups}
selfId={g.id}
value={g.parent_ids || []}
onChange={(ids) => patch(g, { parent_ids: ids })}
/>
) : (
<span className="muted">
{(g.parent_ids || []).map((id) => nameById[id]).filter(Boolean).join(", ") || ""}
</span>
)) },
{ key: "products", header: "Produkte", width: 120, align: "num",
sortValue: (g) => g.product_count,
render: (g) => (
<span className="muted">
{g.product_count}
{g.direct_product_count !== g.product_count && (
<span className="small"> ({g.direct_product_count} direkt)</span>
)}
</span>
) },
// Bestand steht in der Einheit, in der die Gruppe ZÄHLT bei aktivem
// Gruppen-Gebinde also in Gläsern, nicht in Gramm. Vorher klebte hier
// immer min_stock_unit_name dahinter, was aus 790 g „4,16 Gramm" machte.
{ key: "stock", header: "Bestand", width: 140, align: "num",
sortValue: (g) => g.stock,
render: (g) => `${fmt(g.stock)} ${grpUnit(g, g.stock)}` },
{ key: "min", header: "Mindestbestand", width: 150, sortValue: (g) => g.min_stock ?? -1,
render: (g) => (isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 90 }}
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
) : (g.min_stock != null ? fmt(g.min_stock) : "")) },
<span className="field-inline" style={{ gap: "var(--sp-1)", flexWrap: "nowrap" }}>
<input type="number" step="any" defaultValue={g.min_stock ?? ""} style={{ marginTop: 0, minWidth: 70 }}
onBlur={(e) => { const v = e.target.value; if (v !== String(g.min_stock ?? "")) patch(g, { min_stock: v === "" ? null : Number(v) }); }} />
<span className="muted small" style={{ alignSelf: "center" }}>{grpUnit(g, g.min_stock ?? 1)}</span>
</span>
) : (g.min_stock != null ? `${fmt(g.min_stock)} ${grpUnit(g, g.min_stock)}` : "")) },
{ key: "unit", header: "Einheit", width: 150, filterText: (g) => g.min_stock_unit_name || "",
render: (g) => (isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")) },
render: (g) => (
<span className="cell-col">
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ marginTop: 0 }}
onChange={(e) => patch(g, { min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value) })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")}
{/* Zählt die Gruppe im Gebinde, sagt die Einheit darüber nur die
halbe Wahrheit der Zusatz nennt den Umrechnungswert. */}
{grpPkgMode(g) && (
<span className="muted small">
zählt in {grpUnit(g, 2)} à {fmt(g.package_size)} {kindShort(g.kind)}
{isAdmin && (
<>
{" · "}
<button type="button" className="link-btn"
title="Bestand und Mindestbestand wieder in der Einheit oben lesen"
onClick={() => patch(g, { min_stock_in_packages: false })}>
in {g.min_stock_unit_name || kindShort(g.kind)} rechnen
</button>
</>
)}
</span>
)}
</span>
) },
{ key: "codes", header: "EAN-Codes", width: 130, align: "num",
render: (g) => (
<button className="link-btn" onClick={() => setSelectedId(g.id)}>
{(g.product_barcodes?.length || 0) + (g.barcodes?.length || 0)} verwalten
</button>
) },
{ key: "actions", header: "", fixed: true, width: 56, align: "num",
{ key: "actions", header: "", label: "Aktionen", fixed: true, width: 56, align: "num",
render: (g) => isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
<Icon name="trash" size={16} />
@@ -136,67 +246,72 @@ export default function Groups() {
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2 wide-aside">
<div className="card">
<DataTable id="groups" columns={columns} rows={groups}
getRowKey={(g) => g.id} empty="Noch keine Gruppen." />
</div>
{/* Die Tabelle bekommt die volle Breite bei vielen Spalten (Obergruppen,
Bestand, Einheit) war die rechte Spalte teurer als sie half. Alles
Übrige steht darunter. */}
<div className="card">
<DataTable id="groups" columns={columns} rows={baumZeilen} loading={loading}
getRowKey={(g) => g._pfad}
tree={{ column: "name", idOf: (g) => g._pfad, parentIdOf: (g) => g._elternPfad }}
empty="Noch keine Gruppen." />
</div>
<div>
{selected && (
<section className="card">
<div className="card-head">
<Icon name="search" />
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
</div>
<BarcodeList
barcodes={selected.barcodes || []}
productBarcodes={selected.product_barcodes || []}
disabled={!isAdmin}
hint={"Codes der zugeordneten Artikel stehen automatisch hier, mit Marke und " +
"Artikelnamen; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
"wechselt. Zusätzlich sind Codes möglich, die noch zu keinem Artikel " +
"gehören für die gibt es eine Notiz, weil ihnen der Artikelname fehlt. " +
"Wird so ein Code beim Einlagern gescannt, landet der neu angelegte Artikel " +
"automatisch hier."}
onAdd={async (body) => {
try {
await api.addGroupBarcode(selected.id, body);
await load();
} catch (err) { setError(err.message); }
}}
onEditNote={async (code, note) => {
try {
await api.updateGroupBarcodeNote(selected.id, code, { note });
await load();
} catch (err) { setError(err.message); }
}}
onDelete={async (code) => {
try {
await api.deleteGroupBarcode(selected.id, code);
await load();
} catch (err) { setError(err.message); }
}}
/>
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
</section>
)}
{/* Karten zur AUSGEWÄHLTEN Gruppe erscheinen erst beim „verwalten". */}
{selected && (
<div className="grid-2" style={{ marginTop: "var(--sp-3)" }}>
<section className="card">
<div className="card-head">
<Icon name="search" />
<h2>EAN-Codes der Gruppe: {selected.name}</h2>
</div>
<BarcodeList
barcodes={selected.barcodes || []}
productBarcodes={selected.product_barcodes || []}
disabled={!isAdmin}
hint={"Codes der zugeordneten Artikel stehen automatisch hier, mit Marke und " +
"Artikelnamen; entfernen lassen sie sich nur, indem der Artikel die Gruppe " +
"wechselt. Zusätzlich sind Codes möglich, die noch zu keinem Artikel " +
"gehören für die gibt es eine Notiz, weil ihnen der Artikelname fehlt. " +
"Wird so ein Code beim Einlagern gescannt, landet der neu angelegte Artikel " +
"automatisch hier."}
onAdd={async (body) => {
try {
await api.addGroupBarcode(selected.id, body);
await load();
} catch (err) { setError(err.message); }
}}
onEditNote={async (code, note) => {
try {
await api.updateGroupBarcodeNote(selected.id, code, { note });
await load();
} catch (err) { setError(err.message); }
}}
onDelete={async (code) => {
try {
await api.deleteGroupBarcode(selected.id, code);
await load();
} catch (err) { setError(err.message); }
}}
/>
<button className="btn ghost" onClick={() => setSelectedId(null)}>Schließen</button>
</section>
{selected && isAdmin && (
{isAdmin && (
<section className="card">
<div className="card-head">
<Icon name="location" />
<h2>Mindestbestand je Lagerort</h2>
<h2>Mindestbestand</h2>
</div>
<p className="muted small mt-0">
Bedarf dieser Gruppe je Lagerort (z.B. Ferienhaus, Zuhause), zusätzlich zum
Gesamt-Mindestbestand{selected.min_stock_unit_name ? ` (in ${selected.min_stock_unit_name})` : ""}.
Bedarf dieser Gruppe je Lagerort Überall heißt: egal wo, Hauptsache
die Menge ist im Haus. Käufe für einen Lagerort decken Überall mit ab.
Mengen in {kindShort(selected.kind) || "Basiseinheiten"}.
</p>
<LocationMinStock
key={selected.id}
locations={locations}
initial={selected.location_min_stocks || []}
unitLabel={selected.min_stock_unit_name || ""}
unitLabel={kindShort(selected.kind)}
onError={setError}
onSave={async (list) => {
await api.setGroupLocationMinStock(selected.id, list);
@@ -205,39 +320,52 @@ export default function Groups() {
/>
</section>
)}
{isAdmin && (
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Mehl" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
Mindestbestand (optional)
<input type="number" step="any" value={form.min_stock}
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
</label>
<label className="grow">
Einheit
<select value={form.min_stock_unit_id}
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Der Bestand der Gruppe summiert nur Produkte, die zur gewählten Einheit passen.
Namen lassen sich in der Tabelle direkt ändern.
</p>
</form>
)}
</div>
</div>
)}
{isAdmin && (
<form className="card" onSubmit={add}
style={{ marginTop: "var(--sp-3)", maxWidth: 560 }}>
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label>
Name
<input placeholder="z.B. Mehl" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<div className="row">
<label className="grow">
Mindestbestand (optional)
<input type="number" step="any" value={form.min_stock}
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
</label>
<label className="grow">
Einheit
<select value={form.min_stock_unit_id}
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
</div>
<label>
Obergruppen (optional)
<GroupParentSelect
groups={groups}
selfId={null}
value={form.parent_ids}
onChange={(ids) => setForm({ ...form, parent_ids: ids })}
/>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small">
Bestand und Mindestbestand einer Gruppe zählen auch die Artikel ihrer
Untergruppen. Eine Gruppe darf unter mehreren Obergruppen hängen
Grillwurst etwa unter Wurst <em>und</em> unter Grillgut.
Gezählt werden nur Artikel, die zur gewählten Einheit passen, auch in
den Untergruppen. Namen lassen sich in der Tabelle direkt ändern.
</p>
</form>
)}
</div>
);
}

View File

@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import BewegungIcon from "../components/BewegungIcon";
import { articlePrimary, articleSecondary } from "../units";
const TYPE_LABEL = { in: "Eingelagert", out: "Ausgelagert", adjust: "Korrektur" };
export default function History() {
const [movements, setMovements] = useState([]);
const [error, setError] = useState(null);
@@ -28,8 +27,10 @@ export default function History() {
<table className="table">
<thead>
<tr>
{/* Die Bewegungsart zuerst: sie sagt in einem Blick, worum
es geht, und braucht als Zeichen kaum Breite. */}
<th style={{ width: 64 }}>Aktion</th>
<th>Zeitpunkt</th>
<th>Aktion</th>
<th>Produkt</th>
<th className="num">Menge</th>
<th>Benutzer</th>
@@ -38,13 +39,11 @@ export default function History() {
<tbody>
{movements.map((m) => (
<tr key={m.id}>
{/* Nur das Sinnbild die Tabelle hat noch Menge und
Benutzer zu zeigen, und das Wort stand ohnehin dreimal
untereinander. Titel und aria-label tragen es weiter. */}
<td data-label="Aktion"><BewegungIcon typ={m.type} /></td>
<td data-label="Zeitpunkt" className="muted">{new Date(m.created_at).toLocaleString("de-DE")}</td>
<td data-label="Aktion">
<span className={`badge ${m.type === "in" ? "ok" : m.type === "out" ? "warn" : ""}`}>
<Icon name={m.type === "in" ? "checkin" : m.type === "out" ? "checkout" : "edit"} size={13} />
{TYPE_LABEL[m.type] || m.type}
</span>
</td>
<td data-label="Produkt">{m.product_name}</td>
<td data-label="Menge" className="num">
{articlePrimary(m)}

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