Compare commits

..

206 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
Scarriffle
e2d2347524 Web: DataTable - Spalten ein-/ausblenden (Spalten-Waehler)
Neuer 'Spalten'-Button in der Tabellen-Toolbar oeffnet ein Popover mit einer Checkbox je Spalte. Abgewaehlte Spalten werden ausgeblendet (Kopf, Filterzeile, Zellen), wieder anhaken zeigt sie an alter Position. Die Auswahl merkt sich der Browser (localStorage, je Tabelle), mindestens eine Spalte bleibt sichtbar, 'Spalten zuruecksetzen' zeigt wieder alle.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:58:01 +02:00
Scarriffle
08ee1f20f5 Web: Kategorie-Filter - Oberkategorie subtil + als eigener Eintrag waehlbar
Die DataTable kann jetzt je Spalte mehrere Filter-Werte pro Zeile (filterValues/Tokens) und eine eigene Option-Darstellung (filterOptionLabel). Die Kategorie-Spalte liefert als Tokens jede Ebene kumuliert (Klamotten, Klamotten -> kurze Hosen); so erscheint die Oberkategorie als eigener, anklickbarer Eintrag und selektiert alle Unterkategorien. Ober-Ebenen werden in Zelle und Dropdown kleiner/gedaempft dargestellt (CategoryPathLabel), das Blatt normal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:44:38 +02:00
Scarriffle
487c5f5f38 Web: Kategorie-Filter mit vollem Pfad (Oberkategorie findet Unterkategorien)
Der Kategorie-Spaltenfilter zeigte nur die Blatt-Kategorien, die an Produkten haengen - die Oberkategorie (z.B. 'Klamotten') fehlte, obwohl darunter (kurze Hosen, T-Shirts) etwas eingelagert ist. Kategorie wird jetzt als voller Pfad 'Klamotten -> kurze Hosen' gefuehrt; Freitext 'Klamotten' findet damit alles darunter. Betrifft Produkt- und Einzelstueckliste. (Lagerorte nutzen den Pfad bereits.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 17:13:11 +02:00
Scarriffle
76dc29ac72 Web: Filter-Dropdown und Spalten-Resize gefixt
1) Die Checkboxen im Filter-Dropdown erbten die globale input-Regel (width:100%, Rahmen, margin) und sprengten die Zeile - jetzt zurueckgesetzt, Label fuellt die Breite links. 2) Beim Breiterziehen einer Spalte schrumpfte die flexible (grow-)Spalte, wodurch die Nachbarspalte schmaler wurde und alles nach links wanderte. Die grow-Spalte wird beim Start des Ziehens auf ihre Pixelbreite eingefroren; danach verhalten sich die Spalten unabhaengig wie in Excel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:16:35 +02:00
Scarriffle
050496a82d Web: Kategorien (Baum), Gruppen, Einheiten, Gebinde auf DataTable
Restliche Tabellenseiten auf die filterbare DataTable umgestellt. Kategorien nutzen den neuen Tree-Modus (ein-/ausklappbar wie bisher; sobald gefiltert/sortiert wird, flache Liste). Inline-Bearbeitung (Namen, Art, uebergeordnet, Mindestbestand, Gebinde-Formen) bleibt in den Zell-Renderern erhalten; Seiten-Formulare/Panels unveraendert. Damit haben Produkte, Einzelstuecke, Kategorien, Gruppen, Einheiten und Gebinde die gleiche Filter-/Spalten-Bedienung.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:10:00 +02:00
Scarriffle
ffbe33bec4 Web: Produkt-Seite nutzt die volle Breite (content--wide)
Die Produkt-Detail-/Anlege-Seite war auf 1400px gedeckelt; ihr zweispaltiges Layout (Formular + EAN-Codes/Chargen) wurde dadurch auf breiten Monitoren gequetscht. Jetzt volle Breite wie die uebrigen Tabellenseiten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 15:00:33 +02:00
Scarriffle
6b7ada33b3 Einzelstueck-Liste: Web-Seite /items + iOS-Liste (filterbar nach Lagerort)
Neue Seite 'Einzelstuecke' (Web, /items, in der Sidebar) listet alle physischen Exemplare ueber alle Produkte in der DataTable - je Spalte filterbar (Lagerort als Pfad, Produkt, Kategorie, Shop, ...), Lagerort und Shop inline aenderbar. iOS bekommt dieselbe Liste als schlichte, filterbare Ansicht (Kachel unter 'Listen'): Suche + Lagerort-/Kategorie-Filter, Tippen oeffnet das Einzelstueck. Item-Model/ItemOut um Kategorie ergaenzt; neuer APIClient.allItems().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:58:03 +02:00
Scarriffle
76acf57edf Web: Excel-artige DataTable + volle Breite; Produkte migriert
Neue wiederverwendbare DataTable: feste Filterzeile je Spalte (Freitext + Dropdown der vorhandenen Werte, ueber Spalten kombinierbar), Spalten per Drag verschieben und in der Breite ziehen, Klick-Sortierung; Reihenfolge/Breiten merkt der Browser (localStorage). Produkte-Seite darauf umgestellt. Tabellenseiten (Produkte/Kategorien/Gruppen/Einheiten/Gebinde) nutzen jetzt die volle Fensterbreite (content--wide); Kategorien-Seitenspalte etwas breiter und die Feldliste bricht um, statt ueber den Container zu laufen. Farbschema unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:53:05 +02:00
Scarriffle
4887596b43 Web: Artikelbild per Klick gross anzeigen (Lightbox)
Klick aufs Artikelbild oeffnet es bildschirmfuellend; Schliessen per Knopf, Klick auf den Hintergrund oder Escape. Neue Lightbox-Komponente (Portal an <body>), im bestehenden Farbschema.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:44:43 +02:00
Scarriffle
89d8ba4eff iOS: dauerhafter Bild-Cache (Platte) + Vorladen, nur geaenderte Bilder neu
ProductImageCache haelt Bilder jetzt zusaetzlich auf der Platte (Caches-Verzeichnis) mit gemerkter image_version - so ueberleben Thumbnails den App-Neustart und sind in Listen sofort da, statt bei drei Vierteln zu fehlen. Beim Laden der Produktliste werden alle Bilder im Hintergrund sichergestellt; per image_version (aus einem Listen-Request) werden nur fehlende oder geaenderte Bilder geladen. Product-Model um imageVersion ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:42:29 +02:00
Scarriffle
72256f4fe9 Backend: globale Einzelstueck-Liste (GET /items) + Bild-Version an Produkten
Neuer Endpoint GET /items liefert alle Einzelstuecke ueber alle Produkte, angereichert mit Produkt-, Kategorie-, Lagerort- und Shop-Angaben (Beziehungen vorgeladen). ItemOut bekommt category_id/category_name. ProductOut bekommt image_version (Epoch der letzten Bildaenderung, None ohne Bild) - identisch zum ETag der Bild-Route, damit Clients Bilder cachen und nur bei Aenderung neu laden. Grundlage fuer die neue Einzelstueck-Liste (Web+iOS) und den iOS-Bild-Cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:37:17 +02:00
Scarriffle
34149ff633 Dashboard nutzt die volle Breite (Lesebreiten-Cap nur fuer andere Seiten)
Die .content-Spalte war global auf max-width 1400px gedeckelt - gut fuer Tabellen/Formulare, aber das Dashboard-Raster klebte dadurch auf breiten Bildschirmen links mit viel Leerraum. Die Dashboard-Routen bekommen jetzt .content--wide (max-width: none), sodass das Raster (react-grid-layout, WidthProvider) die ganze Breite ausmisst und fuellt. Andere Seiten bleiben auf Lesebreite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 14:01:37 +02:00
Scarriffle
5991a807d2 Einzelstueck-Anlegen: Preis-Vorschlag in eigene Zeile statt in die Preiszeile gequetscht
Der 'Preis-Vorschlag aus Beleg' stand mit Kaufpreis, Waehrung und Datei-Auswahl in einer Zeile und wirkte gequetscht. Jetzt bleibt die Preiszeile zweispaltig (Kaufpreis | Beleg) und der Vorschlag bekommt eine eigene Zeile darunter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:43:32 +02:00
Scarriffle
1db2d6389c iOS: Lagerort-Picker zeigen den vollen Pfad statt nur den Namen
Wie im Web: alle Lagerort-Auswahlen (Einlagern, Einzelstueck/Item, Objekt-Bestand hinzufuegen/umlagern/entfernen, Mindestbestand je Ort) zeigen 'Hedingen -> Keller -> Schublade 1' statt nur 'Schublade 1', damit gleichnamige Orte unterscheidbar sind. Neuer Helfer StorageLocation.path(in:) bzw. locationPath(_:in:) in Models.swift.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:27:02 +02:00
Scarriffle
c8c1686395 Amazon-Kaufdatum erkennen; Preis-Vorschlaege beim Anlegen; Lagerort-Dropdowns mit Pfad
1) Beleganalyse erkennt Datumsangaben mit Monatsnamen (DE+EN, z.B. Amazon 'Bestelldatum 29 April 2026'), nicht nur 29.04.2026 - so wird das Kaufdatum solcher Rechnungen gefunden. 2) Beim direkten Anlegen eines Einzelstuecks mit Beleg werden jetzt alle erkannten Preise als Vorschlag angeboten (Dropdown), statt stillschweigend nur den wahrscheinlichsten zu nehmen. 3) Alle Lagerort-Dropdowns zeigen den vollen Pfad (Hedingen -> Keller -> Schublade 1) statt nur den Namen, damit gleichnamige Orte unterscheidbar sind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:24:08 +02:00
Scarriffle
21ab0a825a Nachschlagen: Lagerort-QR zeigt alle Artikel an diesem Ort
Neuer Endpunkt GET /location/{code} liefert alle Artikel an einem Lagerort inkl. der Unterorte (Lot-Bestaende und Einzelstuecke), sortiert nach Name mit Menge in Artikeleinheiten. In der App loest der /l/-QR beim Nachschlagen jetzt diese Liste auf; ein Tipp auf einen Eintrag oeffnet den Artikel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 13:10:29 +02:00
Scarriffle
4607e3a6a8 Lagerort-Auswahl zeigt gewaehlten uebergeordneten Ort wieder an
CategorySelect wandelte den Wert per Number() um - bei den neuen Lagerort-Codes (Zeichenkette) ergab das NaN, sodass die Anzeige auf 'keiner' zuruecksprang, obwohl korrekt gespeichert wurde. Wert jetzt unveraendert nehmen und typunabhaengig (String-Vergleich) markieren, damit Zahl-IDs (Kategorien) wie Code-IDs (Lagerorte) passen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:22:57 +02:00
Scarriffle
d518b4aa23 Lagerorte per 10-Zeichen-Code statt fortlaufender ID; iOS-Einlagern ohne Kamerazwang
Lagerort-IDs sind jetzt ein zufaelliger 10-Zeichen-Code (wie die Einzelstueck-UIDs) statt einer fortlaufenden Zahl - so kollidiert die Stammdaten-Sicherung zwischen zwei Instanzen praktisch nie mehr, und der Code ist zugleich der Inhalt des QR /l/<code>. Alle Fremdschluessel (lots, movements, items, Mindestbestaende, parent_id) ziehen mit; die Umstellung laeuft einmalig und transaktional beim Serverstart (_migrate_locations_to_code) und rollt bei Fehlern komplett zurueck. Vor dem Deploy ein DB-Backup machen.

iOS-Einlagern oeffnet nicht mehr sofort die Kamera, sondern ein Formular mit Artikelsuche; die Kamera kommt erst per Button. Im Formular laesst sich der Lagerort zusaetzlich per /l/-QR scannen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 12:15:25 +02:00
Scarriffle
6e48cd0a7a Stammdaten-Export/Import (JSON) + Lagerort-Eltern bleibt beim Anlegen
- Backend: services/master_data.py exportiert/importiert Kategorien (inkl.
  eigener Felder), Lagerorte, Einheiten und Gebinde als JSON – MIT IDs, damit
  gedruckte QR-Codes und Verweise nach dem Wiederherstellen passen. Import-Modus
  skip (Vorhandenes lassen) oder overwrite (per ID aktualisieren); Namens-
  Konflikte werden uebersprungen, nicht abgebrochen. Postgres-Sequenzen werden
  danach angehoben. Endpunkte GET /export/master-data, POST /import/master-data.
  5 neue Tests, Suite 162 gruen.
- Web: neue Karte "Stammdaten sichern & wiederherstellen (JSON)" in Import/Export.
- Web: beim Lagerort-Anlegen bleibt der uebergeordnete Ort in der Auswahl.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 11:17:14 +02:00
Scarriffle
636982f6c7 Web: Lagerort-Etiketten-CSV-Export (P-touch), wie bei Einzelstücken
Neuer Abschnitt in Import/Export: exportiert je Lagerort eine Zeile
(QR-Inhalt = Link auf den Ort, ID, Name, Pfad "Hedingen → Keller") als CSV mit
demselben wählbaren Trennzeichen wie der Einzelstück-Export und derselben
Formel-Injektion-Härtung. Client-seitig aus den Lagerorten gebaut.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:56:33 +02:00
Scarriffle
625b6ee263 Lagerorte: Baum-Picker beim Anlegen; Unter-Ort-Bestand zählt zum Bedarf
- Web: der übergeordnete Lagerort wird beim Anlegen jetzt über den
  aufklappbaren Baum (CategorySelect) gewählt statt über das flache Dropdown.
- Backend: Mindestbestand je Lagerort wird jetzt gegen den Bestand INKL. aller
  Unter-Lagerorte geprueft. Ein Bedarf auf „Hedingen" gilt also als gedeckt,
  wenn der Vorrat in „Hedingen -> Keller" liegt. Helfer
  location_subtree_stock_base; 2 neue Tests, Suite 157 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:50:31 +02:00
Scarriffle
4a950acd85 Backend: Zeilenpreise als Kaufpreis-Kandidaten anbieten
Bei einer Rechnung mit mehreren Artikeln soll man den Zeilenpreis des passenden
Produkts wählen können. Die Kandidatenliste enthaelt daher wieder alle
geldartigen Betraege (Zeilen- und Summenbetraege), nur ohne MwSt-Satz und
Nicht-Geld-Zahlen; der Auto-Tipp bleibt die Gesamtsumme und steht vorne.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:45:08 +02:00
Scarriffle
88b9bdcbea Backend: Beleg-Analyse präziser (Preis, Kaufdatum, Garantieende)
Anhand echter Galaxus-Rechnungen nachgeschärft:
- Preis: MwSt-Satz ("8.10 %") wird als Prozentangabe ignoriert; von den
  Betraegen nahe einem Summen-Stichwort gewinnt der GRÖSSTE (Endbetrag inkl.,
  steht rechts) statt des naechstgelegenen (bisher der Exkl.-Betrag).
- Kaufdatum: eindeutige Stichworte (Rechnungs-/Bestell-/Kaufdatum) haben Vorrang;
  "Fälligkeitsdatum"/Zahlungsziel werden nicht mehr als Kaufdatum gewertet.
- Garantieende: ein ausdruecklich genanntes Enddatum im Garantie-Umfeld
  (z.B. "… - 06.10.2027") hat Vorrang vor dem gerechneten. 4 neue Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 10:40:18 +02:00
Scarriffle
a38c0651c5 iOS: Kaufpreis + Beleg beim Anlegen; Kaufdatum/Shop-Vorschlag
- ItemAddSheet: Kaufpreis (+Währung) und Beleg (Bild/PDF). PDF wird beim Waehlen
  analysiert und Kaufdatum/Garantie/Preis/Shop vorbefuellt; erkannter neuer Shop
  wird beim Anlegen erstellt. Beleg haengt an das erste angelegte Stueck.
- ItemEditView-Vorschlag zeigt/uebernimmt jetzt auch Kaufdatum und Shop
  (bekannten zuweisen, sonst anlegen).
- Models/Client: DocumentSuggestions, analyzeItemDocument, ItemCreateRequest um
  price_cents/currency erweitert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:43:02 +02:00
Scarriffle
4e7512b4bc Web: Kaufpreis + Beleg schon beim Anlegen; Kaufdatum/Shop-Vorschlag
- Anlege-Maske: Kaufpreis (+Währung) und Beleg-Upload. Beim Datei-Auswählen wird
  der Beleg analysiert (ohne Speichern) und Kaufdatum/Garantie/Preis/Shop
  vorbefuellt; ein erkannter, noch nicht hinterlegter Shop wird beim Anlegen
  erstellt. Der Beleg haengt an das erste angelegte Stueck.
- Beleg-Vorschlag (bestehende Stuecke) zeigt/uebernimmt jetzt auch Kaufdatum und
  Shop (bekannter Shop zuweisen, sonst anlegen).
- API analyzeItemDocument.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:36:25 +02:00
Scarriffle
bbf10b36c6 Backend: Beleg-Analyse um Kaufdatum + Shop; Analyse-Endpoint
- guess_acquired_on (Kaufdatum) und guess_shop (bekannter Shop erkannt -> id;
  sonst Kandidatenname zum Anlegen) ergaenzt.
- Upload liefert zusaetzlich suggested_acquired_on / shop_id / shop_name.
- Neuer POST /items/analyze-document: analysiert einen Beleg OHNE zu speichern
  (zum Vorbefuellen beim Anlegen). Gemeinsamer Helfer _doc_suggestions.
- 6 neue Tests; Suite 150 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 09:30:35 +02:00
Scarriffle
729dfe766b brand: App-Icon-SVG (sechseckiges Zeichen) ablegen
Saubere App-Icon-Version des Vorrania-Zeichens (vollflächig, feste Farbe, 1024px,
ohne abgerundete Ecken) unter brand/app-icon.svg, plus README. Quelle des
Zeichens ist das "box"-Icon; das Favicon liegt weiterhin in web/public.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:25:43 +02:00
Scarriffle
5d3ae185ce iOS: Mindestbestand je Lagerort (Produkte) + Einkaufsliste je Ort
- Produkt-Detail: neuer Editor „Mindestbestand je Lagerort" (Zeilen: Lagerort +
  Menge in Artikeleinheiten), zusaetzlich zum Gesamt-Mindestbestand.
- Einkaufsliste: Abschnitte „Bedarf: <Lagerort>" mit Produkten und Gruppen, die
  am Ort unter dem dort hinterlegten Mindestbestand liegen.
- Models/Client: LocationMinStock(+In), LocationNeeds*, setProductLocationMinStock,
  shoppingByLocation; Product um location_min_stocks erweitert.
(Gruppen-Bedarf je Ort ist vorerst nur im Web pflegbar, wird aber in der iOS-
 Einkaufsliste mit angezeigt.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:23:12 +02:00
Scarriffle
dff7677e50 Web: Mindestbestand je Lagerort + Einkaufsliste je Ort
- Neuer LocationMinStock-Editor (Zeilen: Lagerort + Menge, separat speichern).
- Produktformular: Abschnitt „Mindestbestand je Lagerort" (Menge in Artikel-
  einheit), zusaetzlich zum Gesamt-Mindestbestand.
- Gruppen: je Gruppe ein Editor „Mindestbestand je Lagerort".
- Einkaufsliste: Abschnitt „Bedarf: <Lagerort>" mit Produkten und Gruppen, die
  am Ort unter dem dort hinterlegten Mindestbestand liegen.
- API-Methoden set*LocationMinStock und shoppingByLocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:12:59 +02:00
Scarriffle
67bc2dd87d Backend: Preiserkennung präziser (nicht mehr jede Zahl)
Nur noch Beträge, die nach Preis aussehen: direkt hinter einem Summen-Stichwort
oder mit Währung (CHF/EUR/€/…) in der Nähe. Ohne solchen Hinweis kein Vorschlag.
Datumsangaben (31.12.2027) werden per Lookaround nicht mehr als Betrag (31.12)
missgelesen. Kandidatenliste enthaelt entsprechend nur plausible Preise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 07:06:12 +02:00
Scarriffle
d0d854be5a Backend: Mindestbestand je Lagerort für Produkte und Gruppen
Zusaetzlich zum globalen Mindestbestand: je Produkt und je Gruppe laesst sich pro
Lagerort ein Mindestbestand (in Artikeleinheiten) hinterlegen.
- Neue Tabellen product_location_min_stock / group_location_min_stock.
- PUT /products/{id}/location-min-stock und /groups/{id}/location-min-stock
  ersetzen die Eintraege; Produkt-/Gruppen-Ausgabe liefert sie mit.
- Neue Einkaufsliste GET /shopping-list/by-location: Bedarfe je Ort (Produkte +
  Gruppen), Bestand-am-Ort gegen Mindestbestand-am-Ort.
- Helfer location_stock_base (Bestand je Ort). 4 neue Tests, Suite 144 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 06:58:08 +02:00
Scarriffle
2bc871f229 Preis-Vorschlag: Auswahl aus mehreren erkannten Beträgen
Findet die Beleg-Analyse mehrere Beträge, liefert der Upload jetzt eine
Kandidatenliste (bester Tipp zuerst). Web und iOS bieten dann ein Dropdown zur
Auswahl des richtigen Kaufpreises, statt nur den automatischen Tipp. 1 neuer
Test; Backend-Suite gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 06:39:03 +02:00
Scarriffle
761c131286 iOS: Scanner – Pinch-Zoom und schlaueres Blitzlicht
- Zwei-Finger-Pinch zoomt im Kamerabild naeher an einen Code heran (digital,
  auf Faktor 6 begrenzt).
- Blitz geht nach dem ersten erfolgreichen Scan wieder aus. Per Langdruck auf
  den Blitz-Knopf bleibt er dauerhaft an (gesperrt, gelb); Tippen schaltet aus
  und hebt die Sperre auf. Gilt fuer alle Scan-Bildschirme.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:42:12 +02:00
Scarriffle
944edbccd0 iOS: sichtbarer Fokus-Rahmen beim Antippen der Kamera
Tap-to-Focus war bereits verdrahtet, aber ohne sichtbare Rueckmeldung – daher
wirkte es, als passiere nichts. Beim Antippen erscheint jetzt kurz ein gelber
Fokus-Rahmen an der getippten Stelle (wie in der System-Kamera); die Kamera
stellt weiterhin gezielt dorthin scharf.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:27:52 +02:00
Scarriffle
2449f03e13 Web: "Ohne MHD"-Umschalter nur im Dashboard-Bearbeiten sichtbar
Der Haken zum Ein-/Ausblenden von "Ohne MHD" ist eine Karten-Einstellung und
erscheint jetzt nur, wenn das Dashboard bearbeitet wird (.dash-grid.editing).
In der normalen Ansicht bleibt er verborgen, die gewaehlte Einstellung wirkt
aber weiterhin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 22:07:39 +02:00
Scarriffle
d993a48711 Web: "Ablauf-Anteile" – "Ohne MHD" ein-/ausblendbar
Die Ablauf-Ring-Karte bekommt einen Umschalter, ob der Anteil "Ohne MHD"
(Gegenstaende/undatierte Chargen) mitgezaehlt wird. Aus zeigt der Ring nur
In Ordnung/Bald/Abgelaufen mit entsprechend neu berechneten Prozenten. Die Wahl
wird in localStorage gemerkt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:46:18 +02:00
Scarriffle
3453471716 Web: Etiketten-Export signalisiert mitexportierte Unterkategorien
Waehlt man beim P-touch-Export eine Kategorie, schliesst der Server ihre
Unterkategorien mit ein – das war im UI nicht sichtbar. Jetzt erscheinen die
Unterkategorien einer gewaehlten Kategorie als „enthalten" (angehakt,
deaktiviert, gedimmt); gewaehlte Oberkategorien mit Unterkategorien tragen
„inkl. Unterkategorien", nicht gewaehlte einen „+ Unterkategorien"-Hinweis. Der
Einleitungstext erklaert es zusaetzlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:32:41 +02:00
Scarriffle
3befaad850 Web: Foto-Knöpfe auf gleiche Höhe
"Foto machen"/"Galerie" sind <label> und erbten das margin-bottom des globalen
label-Stils, wodurch sie hoeher sassen als der "Entfernen"-<button>. margin:0
an den Foto-Knoepfen stellt alle drei auf eine Hoehe.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:26:13 +02:00
Scarriffle
41e8cce612 Web: Produktformular-Feinschliff (Barcode-Buttons, Foto-Entfernen, Typ-Abstand)
- Barcode-Zeile: Eingabefeld fuellt die Breite, die beiden Knoepfe (Nachschlagen
  + Vergleichen) stehen nebeneinander statt gestapelt – keine Luecke mehr rechts.
- Foto: "Entfernen" ist jetzt ein normaler Button wie "Foto machen"/"Galerie"
  (gleiche Hoehe/Ausrichtung) statt reiner Text.
- Umschalter "Typ"/"Verwaltung": Abstand zwischen Beschriftung und Segmenten.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:23:36 +02:00
Scarriffle
40e12a574e Web: Einzelstück-Detail ohne eigene Färbung, als eine Ansicht
Die blaue Hinterlegung der Detailzeile (Preis/Belege) entfernt; Stück-Zeile und
Detailzeile bilden jetzt eine zusammenhaengende Ansicht ohne Trennlinie
dazwischen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:18:26 +02:00
Scarriffle
e0e0323f99 Web: Einzelstücke – Preis/Belege immer sichtbar, Anlegen als Button
Die Anlege-Maske war ein grosses, dauerhaft sichtbares Formular. Jetzt klappt
sie hinter einen "Einzelstück anlegen"-Knopf. Dafuer werden Kaufpreis und Belege
je Stueck nicht mehr ueber einen Aufklapp-Knopf versteckt, sondern direkt unter
jeder Zeile angezeigt (leicht abgesetzte Detailzeile).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:14:51 +02:00
Scarriffle
f8c0cec918 iOS: Kaufpreis + Belege am Einzelstück
ItemEditView bekommt einen Kaufpreis (Betrag + CHF/EUR) und eine Beleg-Sektion:
Rechnung/Garantieschein als Bild (Fotoauswahl) oder PDF (Datei-Import)
hochladen, ansehen (QuickLook) und loeschen. Nach einem PDF-Upload schlaegt der
Server "Garantie bis" und Kaufpreis vor – per Knopf uebernehmbar. Item-Model um
priceCents/currency/documents erweitert; Client-Methoden fuer Upload, Download
und Loeschen der Belege.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 21:02:58 +02:00
Scarriffle
b9709d29bf Web: Kaufpreis + Belege an Einzelstücken
Je Einzelstück eine ausklappbare Detailzeile: Kaufpreis (Betrag + CHF/EUR) und
Belege (Rechnung/Garantieschein) hochladen, ansehen, löschen. Nach dem Upload
eines PDFs schlägt der Server „Garantie bis" und Kaufpreis vor – per Knopf
übernehmbar. Belege werden mit Anmeldung geladen (kein offener Zugriff).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:57:30 +02:00
Scarriffle
ba65e48d47 Backend: Kaufpreis aus Beleg-PDF schätzen
guess_price_cents zieht den Kaufpreis aus dem Belegtext: bevorzugt den Betrag
nach einem Summen-Stichwort (Gesamtbetrag/Total/…), sonst den groessten Betrag.
Versteht Tausendertrenner (1'299.00) und deutsches Format (1.299,00). Der
Upload liefert den Vorschlag als suggested_price_cents mit. 5 neue Tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:57:30 +02:00
Scarriffle
61270e6d00 Backend: Kaufpreis + Belege an Einzelstücken, PDF-Garantie-Vorschlag
- Item: price_cents + currency (Kaufpreis rappen-/centgenau). Migration ergaenzt.
- Neue Tabelle item_documents (mehrere Belege je Stueck, PDF oder Bild); Blob
  wird verzoegert geladen, damit Item-Listen leicht bleiben.
- Endpunkte: Upload (POST), Ansehen/Download (GET), Loeschen (DELETE) je Stueck.
  Dateiname beim Download gehaertet (keine Header-Injektion).
- services/warranty.py: schaetzt aus PDF-Text lokal ein Garantieende
  (Zeitraum + Kaufdatum, oder Datum nahe Garantie-Stichwort); pypdf ergaenzt.
  Der Upload liefert den Vorschlag zurueck. 10 neue Tests, Suite 136 gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 20:49:08 +02:00
Scarriffle
9a11e3cfc6 iOS: Lagerorte umhängen, Unterkategorien anlegen, eigene Felder verwalten
- Lagerorte: LocationEditor hängt bestehende Orte jetzt um (Elternort auch beim
  Bearbeiten, eigene Unterorte ausgeschlossen); Client updateLocation +
  LocationUpdateRequest (parent_id wird immer gesendet, auch null).
- Kategorien: beim Anlegen optionale Oberkategorie wählbar (Unterkategorie);
  mit Eltern erbt sie deren Verwaltungsart, sonst wird sie gewählt.
- Eigene Felder: neue CategoryFieldsView + FieldEditor (anlegen/bearbeiten/
  löschen von Feldern je Kategorie), erreichbar aus dem Kategorie-Editor;
  Client ownFieldDefinitions/create/update/deleteFieldDefinition + Models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:10:14 +02:00
Scarriffle
f88a459e12 Web: Etiketten-CSV gegen Formel-Injektion härten
Zellen, die mit = + - @ (oder Tab/CR) beginnen, werden mit einem vorangestellten
Apostroph zu Text entschaerft. So fuehrt ein Produktname wie "=HYPERLINK(...)"
beim Oeffnen der Etiketten-CSV in Excel/P-touch nicht mehr als Formel aus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 15:58:54 +02:00
Scarriffle
b49546b297 Lagerorte umhängen (Backend + Web)
Bestehende Lagerorte lassen sich jetzt nachträglich einem anderen Elternort
zuordnen oder auf die oberste Ebene holen.
- Backend: LocationUpdate um parent_id (Name jetzt optional); update_location
  haengt um, mit Schutz gegen Ringe (weder auf sich selbst noch auf einen
  eigenen Unterort) und Existenzpruefung des Ziels. 6 Tests.
- Web: Lagerorte-Seite bekommt je Ort Bearbeiten (Name + Elternort ueber den
  CategorySelect-Baum, eigene Unterorte ausgeschlossen); api.updateLocation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 15:58:54 +02:00
Scarriffle
3f47e273ad iOS: Produktliste zeigt Artikelbild statt Kästchen-Symbol
Hat ein Artikel ein Bild, zeigt die Produktliste jetzt ein Vorschaubild statt
des neutralen shippingbox-Symbols; ohne Bild bleibt das Symbol. Ein gemeinsamer
ProductImageCache verhindert, dass beim Scrollen dieselben Bilder immer wieder
geladen werden, und merkt sich auch "kein Bild". ProductPhotoSection haelt den
Cache bei Laden/Upload/Entfernen synchron.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 14:40:51 +02:00
Scarriffle
272609e70a Web: eigene Felder nachträglich bearbeiten
Backend und API-Client konnten Felddefinitionen laengst aendern (PATCH), nur die
Oberflaeche bot bisher nur Anlegen und Loeschen. In der Felder-Verwaltung einer
Kategorie gibt es jetzt je Feld einen Bearbeiten-Knopf: er laedt Name, Typ,
Einheit, Auswahlmoeglichkeiten und Pflicht ins Formular; Speichern aktualisiert
das Feld (oder Abbrechen). Geerbte und eingebaute Felder bleiben unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:11:29 +02:00
Scarriffle
dc66a6d7ff Web: alle Kategorie-Auswahlen als aufklappbarer Baum
Statt der nativen "— —"-Dropdowns nutzen jetzt auch Produktformular
(Kategorie + Inline-"Neue Kategorie"-Elternwahl), Einlagern-Maske und
Produkte-Filter den CategorySelect-Baum. CategorySelect um optionale
Zusatz-Eintraege (extraOptions) erweitert, damit der Produkte-Filter
"Alle Kategorien"/"Ohne Kategorie" weiter anbietet.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:07:36 +02:00
Scarriffle
956a472c04 iOS: Kategorie-Typfilter + Lagerort-Hierarchie (aufklappbarer Baum)
Gemeinsame TreeMasterView fuer hierarchische Stammdaten (Ein-/Ausklappen,
Anlegen, Bearbeiten, Loeschen):
- Kategorien: Segment-Filter Alle/Lebensmittel/Gegenstaende (wie im Web); in
  der Alle-Ansicht zeigt jede Zeile ihre Art. Anlegen/Bearbeiten waehlt die
  Verwaltungsart (neu: createCategory mit tracking, updateCategory).
- Lagerorte: werden jetzt als aufklappbarer Baum gezeigt statt flach. Beim
  Anlegen laesst sich ein Elternort waehlen (Hierarchie aufbauen); Umhaengen
  unterstuetzt der Server nicht, Bearbeiten bleibt daher Umbenennen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:01:28 +02:00
Scarriffle
949c395fbf iOS: Kategorien als ein-/ausklappbarer Baum
Die Stammdaten-Kategorien waren nur eingerueckt und immer komplett offen. Jetzt
lassen sich Unterkategorien per Pfeil ein- und ausklappen (wie im Web): zugeklappt
zeigt die Zeile "N ausgeblendet". Anlegen, Umbenennen (Antippen) und Loeschen
(wischen) bleiben unveraendert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 12:48:03 +02:00
Scarriffle
6b597313d5 iOS: Shops (Bezugsquellen) verwalten
Die iOS-Stammdaten hatten keinen Punkt fuer Shops – die "gekauft bei"-Liste war
nur im Web pflegbar. Jetzt gibt es unter Verwaltung > Stammdaten "Shops" mit
Anlegen, Bearbeiten (Name + optionale Website) und Loeschen, analog zum Web.
Client um createShop/updateShop/deleteShop samt Request-Models ergaenzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 12:32:17 +02:00
Scarriffle
2a507851d9 Web: "Entfernen" nur zeigen, wenn ein Artikelbild vorliegt
Der Entfernen-Knopf stand auch ohne Bild da. ProduktBild meldet jetzt via
onLoaded, ob ein Bild geladen wurde; das Produktformular blendet "Entfernen"
entsprechend ein oder aus. "Foto machen" und "Galerie" bleiben immer sichtbar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 12:28:02 +02:00
Scarriffle
dbdd977bad Web: Stammdaten geschlossen unter Verwaltung (wie iOS)
Kategorien und Gruppen standen in der Alltags-Navigation, obwohl sie – wie
Lagerorte, Shops, Einheiten und Gebinde – nur von Admins gepflegte Katalog-
Stammdaten sind. Jetzt sind alle Stammdaten zusammen im Admin-Bereich, der in
zwei Abschnitte gegliedert ist:
- Stammdaten: Kategorien, Gruppen, Lagerorte, Shops, Einheiten, Gebinde
- System: Benutzer, Import/Export, Einstellungen
Normale Nutzer sehen nur noch den Alltag (Uebersicht, Ein-/Auslagern, Produkte,
Einkaufsliste, Verlauf). /categories und /groups sind jetzt Admin-Routen. Das
entspricht der Gliederung der iOS-App.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 12:25:25 +02:00
Scarriffle
a70a99b387 Dashboard zaehlt Einzelstuecke mit; Baum-Dropdown scrollbar
Auswertungen (by_category, expiry_split, stats) iterierten nur Lots. Gegenstaende
mit Menge-je-Lagerort liegen als Lots und tauchten auf – Einzelstuecke liegen
aber als Items und fehlten dadurch komplett (Dashboard zeigte z.B. nur die
Powerbank, nicht Kamera/Kabel). Neuer Helfer _bestand_beitraege deckt beide
Speicherformen ab: jedes Item zaehlt als ein Stueck ohne MHD. Regressionstest
ergaenzt; volle Suite gruen (120).

CategorySelect: Scrollen IM aufklappbaren Menue schloss es, weil jeder Scroll als
Seiten-Scroll gewertet wurde. Jetzt schliesst nur ein Scroll ausserhalb des
Menues; die lange Liste laesst sich scrollen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 12:09:25 +02:00
Scarriffle
f35126cb7b Web: Kategorie-Elternwahl als aufklappbarer Baum statt "— —"-Select
Die uebergeordnete Kategorie wird nicht mehr ueber ein <select> mit
"— —"-Einrueckung gewaehlt, sondern ueber einen aufklappbaren Baum-Picker
(CategorySelect): echte Einrueckung, Aeste per Pfeil auf-/zuklappbar wie in der
Liste. Das Menue haengt per Portal am <body>, damit der horizontal scrollende
Tabellenrahmen es nicht abschneidet. Ersetzt beide Stellen (Zeilen-Elternwahl
und Anlege-Formular); die kompakten Knoepfe machen die Zeile schmaler, sodass
die Tabelle nicht mehr seitwaerts ueber den Block hinauslaeuft.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:44:03 +02:00
Scarriffle
914118e33a iOS: gescanntes Einzelstueck oeffnet sich (Abbruch behoben)
Der Abruf schlug mit „Abgebrochen" (URLError.cancelled) fehl, nicht mit 404:
openItemUid wurde zu Beginn des .task(id:) auf nil gesetzt, wodurch SwiftUI die
laufende Anfrage abbrach. Jetzt wird openItemUid erst NACH dem Laden
zurueckgesetzt – das Stueck oeffnet sich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:23:27 +02:00
Scarriffle
dcee7cab6c iOS: Shop nur am Nicht-Einzelstück; QR-Fehler nennt den Server
- „Gekauft bei" (Shop) gehoert bei Einzelstuecken zum einzelnen Stueck, nicht
  ans Modell. Wie im Web wird der Shop-Picker am Produkt jetzt ausgeblendet,
  sobald „Einzelstuecke" aktiv ist; der Produktlink (beschreibt das Produkt
  allgemein) bleibt. Beim Speichern wird fuer Einzelstuecke kein Shop ans
  Modell geschrieben.
- Wird ein gescanntes Einzelstueck nicht gefunden, nennt die Meldung jetzt den
  abgefragten Server und den Original-Fehler. So ist sofort erkennbar, ob der
  aktive Server ein anderer ist als der im QR-Code (haeufigste Ursache bei
  korrekter UID) oder ob ein Netz-/Serverfehler vorliegt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 11:15:38 +02:00
Scarriffle
1a5a5bc16c iOS: Export-Compliance-Rueckfrage dauerhaft abschalten
ITSAppUsesNonExemptEncryption=false in die Info.plist. Die App nutzt nur die
Standard-Verschluesselung des Systems (HTTPS), faellt damit unter die Ausnahme
und die "App Encryption Documentation"-Frage entfaellt bei jedem Upload.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:31:31 +02:00
Scarriffle
2d7cfa7fcb iOS: gescannter Einzelstück-Link öffnet wirklich das Stück
Universal Link oeffnete die App, sprang aber nicht aufs Stueck. Jetzt robuster:
- Kaltstart wird abgefangen (AppDelegate application(_:continue:) + pending-URL),
  zusaetzlich zu den SwiftUI-Hooks.
- Beim Link wird auf das Server-Profil gewechselt, dessen Adresse zum Host passt
  (sonst wuerde die UID am falschen Server gesucht).
- Schlaegt das Aufloesen fehl (nicht angemeldet, UID unbekannt), erscheint jetzt
  eine Meldung statt stiller Nichtreaktion.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 10:24:05 +02:00
Scarriffle
b4b203a117 Etiketten-Export: Trennzeichen waehlbar (Komma/Semikolon/Tab), gemerkt
P-touch will Komma, wir hatten fest Semikolon - jetzt einstellbar und in
localStorage gemerkt, sodass man es nur einmal setzt. Standard ist Komma.
Anfuehrungszeichen werden passend zum gewaehlten Trennzeichen gesetzt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:54:35 +02:00
Scarriffle
2ebdf94b16 Web: QR-Etiketten-Export (CSV für P-touch & Co.)
Auf "Import / Export" neue Karte: Kategorien auswaehlen (inkl. Unterkategorien)
und je Einzelstueck eine CSV-Zeile herunterladen - Spalten QR-Inhalt (…/i/<UID>),
UID, Produkt, Marke, Kategorie, Lagerort. In der Etiketten-Software als Datenbank
verknuepfen, dann entsteht QR + Text automatisch - kein einzelnes Bild-Download
und Zuordnen mehr.

Backend: GET /export/labels?category_ids=... liefert die Zeilen (JSON); den
QR-Inhalt setzt das Web mit window.location.origin dazu.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 09:22:03 +02:00
Scarriffle
7b2d4965ff iOS: Einzelstück-Ansicht zeigt Produktbild statt QR-Code
Beim Nachschlagen/Öffnen eines Einzelstuecks stand oben das QR-Bild - unnoetig,
den hat man ja gerade gescannt. Jetzt oben das Produktbild plus Name/Marke; die
stueckspezifischen Angaben (UID, Kaufdatum, Garantie, Ort, Notiz) bleiben.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 08:27:27 +02:00
Scarriffle
e8471bd951 Universal Links: gescannter Einzelstück-Link öffnet die App
Server liefert /.well-known/apple-app-site-association (nur /i/* beansprucht;
/l/* bleibt im Browser). iOS: Associated-Domains-Entitlement, Universal Link
(https://…/i/<UID>) wird abgefangen (onContinueUserActivity) und öffnet das
Einzelstück direkt.

Hinweis: Damit das Signieren durchläuft, muss die Capability "Associated
Domains" einmalig im Apple-Developer-Portal für die App-ID aktiv sein.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 08:12:45 +02:00
Scarriffle
7f9895245c Lagerort-QR + Zuordnen per Scan
Web: druckbare QR-Etiketten fuer Lagerorte (Locations-Seite) und Route /l/<id>
(zeigt den Ort im Browser). Geteilter QR-Helfer (web/src/qr.jsx).

iOS: neuer Ablauf "Ort zuordnen" - erst Einzelstueck-QR (…/i/<UID>) scannen, dann
Lagerort-QR (…/l/<ID>); das Stueck wird dem Ort zugewiesen, danach gleich das
naechste. Plus die zuvor gebaute "Nachschlagen"-Kachel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 08:09:00 +02:00
Scarriffle
953e0aa483 iOS: "Nachschlagen"-Scan (nur ansehen)
Neue Kachel im Scannen-Tab: Barcode -> Artikel, Einzelstueck-QR -> genau das
Stueck (Kaufdatum, Garantie, Ort ...), rein lesend ohne Ein-/Auslagern. Praktisch
mit Einzelstuecken, um schnell nachzuschauen, welches Exemplar man in der Hand
haelt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 08:04:56 +02:00
Scarriffle
7f7129c87a iOS: Einzelstücke (UID/QR) + Scan öffnet das Stück; Seed ohne Modell-Kaufdatum
iOS-Parität zum Web: Item-Modell + API, Einzelstueck-Liste in der Produkt-
Detailansicht (anlegen, je-Stueck Lagerort/Kaufdatum/Garantie/Bezugsquelle/Notiz
aendern, entfernen mit Grund, QR je Stueck via CoreImage). Anlegen-Formular hat
den Schalter "Einzelstuecke". Der Scanner erkennt Einzelstueck-QR (…/i/<UID>) und
oeffnet direkt das Stueck.

Seed: "Kaufdatum"/"Garantie bis" nicht mehr als Modell-Felder auf Elektronik -
diese Angaben gehoeren je Einzelstueck ans Item, nicht ans Modell.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 07:48:25 +02:00
Scarriffle
6fb5c47de6 Web: Einzelstücke mit QR – anlegen, je-Stück-Felder, Etiketten, /i/<uid>
Gegenstands-Produkte haben jetzt den Umschalter "Menge je Lagerort / Einzelstücke".
Bei Einzelstücken erscheint (volle Breite) eine Liste je Stueck mit eigener UID,
QR-Code, Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz - direkt inline
aenderbar, mit Entfernen (Grund) und druckbaren QR-Etiketten. Ein Scan oeffnet
ueber die Route /i/<uid> das passende Stueck. QR via qrcode (gebundelt, offline).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:36:26 +02:00
Scarriffle
5ce4411d1e Backend: Einzelstücke (Items) mit UID je Gegenstand
Gegenstands-Produkte koennen als Einzelstuecke gefuehrt werden (Product.individual):
jedes physische Stueck ist ein Item mit eigener kurzer UID (fuer QR), eigenem
Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz. So laesst sich dasselbe
Modell mehrfach getrennt fuehren (Powerbank 2024 + 2025).

Neu: models.Item + Product.individual (+Migration), Schemas, services/items.py
(UID-Erzeugung, Anreicherung), routers/items.py (CRUD, /items/by-uid/{uid} zur
QR-Aufloesung, /items/{id}/remove mit Grund als Bewegung). current_stock zaehlt
bei Einzelstueck-Produkten die Items. Tests + HTTP-Smoke gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:26:15 +02:00
Scarriffle
68e27c1868 Web: Kategorie-Tabelle bekommt mehr Breite (kein Seitwaerts-Scrollen)
Durch die neue Spalte "Uebergeordnet" wurde die Kategorie-Tabelle im halben Grid
zu breit und scrollte seitwaerts. Die Kategorien-Seite nutzt jetzt das
wide-aside-Layout; dessen Block daneben ist zudem schmal gedeckelt (clamp), damit
die Tabelle den restlichen Platz bekommt. Gilt auch fuer die Gruppen-Seite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:07:23 +02:00
Scarriffle
928342d352 Web: Unterkategorien umsortieren (uebergeordnete Kategorie aenderbar)
In der Kategorien-Verwaltung gibt es je Zeile eine Spalte "Uebergeordnet" mit
Auswahl - so laesst sich eine (Unter-)Kategorie unter eine andere haengen oder
auf die oberste Ebene holen. Zielauswahl auf denselben Typ beschraenkt und um die
eigenen Unterkategorien bereinigt; das Backend verhindert Ringe zusaetzlich.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 00:01:32 +02:00
Scarriffle
5edb830afd Web: Produkte und Kategorien nach Typ filtern (Lebensmittel/Gegenstand)
Zum leichteren Unterscheiden gibt es in der Produkte-Liste und in der
Kategorien-Verwaltung einen Umschalter Alle/Lebensmittel/Gegenstaende. Er filtert
die jeweilige Liste und in der Produktsuche auch die Kategorieauswahl auf den Typ.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:55:27 +02:00
Scarriffle
c532492286 iOS: Umschalter Lebensmittel/Gegenstand im Artikelformular
Gleiche Idee wie im Web: ein Typ-Umschalter filtert die Kategorieauswahl, statt
Werkzeug & Co. zwischen den Lebensmittel-Kategorien zu suchen. Im Anlege-Formular
blenden sich zudem die Lebensmittel-Felder (Gruppe, Einheit, MHD) aus, wenn
"Gegenstand" gewaehlt ist. Die Detailansicht filtert die Kategorieliste auf den
Typ des Artikels. CategoryPicker bekam dafuer einen optionalen Typ-Filter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:40:32 +02:00
Scarriffle
8d75ad63ce Web-Artikelformular: Umschalter Lebensmittel/Gegenstand
Statt die Verwaltungsart nur implizit ueber die Kategorie zu waehlen (und dabei
Werkzeug & Co. zwischen 50 Lebensmittel-Kategorien zu suchen), gibt es jetzt oben
einen klaren Umschalter. Die Kategorieauswahl zeigt nur Kategorien des gewaehlten
Typs; neu angelegte Kategorien erben den Typ automatisch. Neuanlage startet als
Gegenstand, beim Bearbeiten kommt der Modus aus dem Artikel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:37:16 +02:00
Scarriffle
2a7f98d61c iOS: Artikel ohne Barcode anlegen offensichtlich machen
Anlegen ohne EAN war nur versteckt im Scan-Screen erreichbar. Jetzt zwei klare
Wege: ein "+" in der Produkte-Liste und eine Kachel "Artikel anlegen" im
Scannen-Tab (jeweils fuer Admins). Beide oeffnen das Anlege-Formular ohne
Barcode; ein Abbrechen-Knopf schliesst es.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:33:18 +02:00
Scarriffle
65b0792148 iOS-Scan: Text nennt beide Produkt-Datenbanken
Der Barcode-Lookup fragt schon immer beide Quellen ab (Open Food Facts und
Open Products Facts, ueber /products/lookup). Die Scan-Ansicht sprach aber nur
von "Open Food Facts" und liess es so wirken, als wuerde nur dort gesucht.
Texte neutralisiert bzw. beide Quellen genannt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:27:10 +02:00
Scarriffle
5c8b46a0d5 Foto-Upload: 413 beheben und Kamera stabilisieren
- nginx client_max_body_size auf 12m (Standard 1 MB loeste beim Bild-Upload
  einen 413 aus, bevor das Backend den Upload sah).
- Backend-Bildlimit auf 8 MB angehoben, bleibt unter dem nginx-Limit, damit
  Uebergroesse eine klare 400-Meldung statt eines nackten 413 ergibt.
- Web: Foto vor dem Upload clientseitig verkleinern (max 1600px, JPEG) -
  schneller, kleiner in der DB, zuverlaessig unter dem Limit.
- iOS: Kamera ueber fullScreenCover statt sheet und Schliessen deterministisch
  ueber ein Binding - behebt das "Kamera geht direkt wieder zu"-Flackern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 23:11:59 +02:00
Scarriffle
a664410ac0 Vergleichs-Beschriftung folgt dem Modus (OFF vs. Open Products Facts)
Bei Gegenstaenden fragt das Backend Open Products Facts ab, bei Lebensmitteln
Open Food Facts. Die sichtbaren Texte (Vergleichs-Button, Toasts, das
Gegenueberstellungs-Panel) nannten aber immer "Open Food Facts". Jetzt richtet
sich die Beschriftung nach der Verwaltungsart der Kategorie.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 21:50:59 +02:00
Scarriffle
044f63446f Inline-Anlage (Kategorien/Felder), Artikelfoto und CSV-Spalte "art"
- Web: Kategorien/Unterkategorien und eigene Felder direkt im Artikelformular
  anlegen (ohne Umweg über die Kategorien-Seite).
- Artikelfoto per Kamera oder Galerie hochladen – Backend-Endpunkt
  (PUT/DELETE /products/{id}/image), Web (Foto machen / Galerie) und iOS
  (Kamera + PhotosPicker, inline in der Produktansicht angezeigt).
- CSV-Import: neue Spalte "art" (Gegenstand/Lebensmittel) steuert den Modus
  automatisch angelegter Kategorien; Export schreibt sie mit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 16:18:36 +02:00
Scarriffle
5b952524d7 Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln
Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 15:33:56 +02:00
Scarriffle
580afcc133 iOS: eigene, gestaltbare App-Uebersicht mit nativen Diagrammen
Der Start-Tab spiegelte bisher fest die Web-Dashboards - dieselbe gespeicherte
Anordnung, und Diagramme gab es nur den Hinweis "im Web". Das war der Punkt,
der geaergert hat.

Jetzt ist der Modus je Server umschaltbar: "Web-Uebersicht spiegeln" (weiter
Standard, damit nach dem Update nichts ueberrascht) oder "Eigene
App-Uebersicht". Die eigene Uebersicht liegt nur auf dem Geraet (pro Server),
unabhaengig vom Web und nicht synchronisiert. Beim ersten Umschalten entsteht
gleich eine sinnvolle Startanordnung.

Sie funktioniert wie im Web, nur fuers Telefon: mehrere Dashboards, Karten
einspaltig untereinander, im Bearbeiten-Modus hinzufuegen, per Ziehen sortieren
und loeschen; Artikelkarten bekommen ihren Artikel zugewiesen. Anzeigen und
Bearbeiten sind getrennt - so laufen Sortieren und Loeschen sauber, statt an
Sektionsgrenzen zu haken.

Die Diagramme sind nativ mit Swift Charts: Ablauf- und Kategorien-Anteile
(Ring ab iOS 17, sonst ein 100-%-Balken - das Ziel ist iOS 16, deshalb kein
stilles Anheben), Kategorien nach Zustand als gestapelte Balken, Bestands- und
Artikelverlauf als Linie, Ein-/Auslagerungen als gruppierte Balken. Dieselben
Endpunkte wie das Web-Dashboard, kein Backend-Eingriff. Der "gibt es nur im
Web"-Hinweis faellt damit weg - auch beim Spiegeln werden die Diagramme jetzt
nativ gezeichnet.

Geprueft: Build; alle vier Diagramm-Modelle gegen echte Serverantworten
dekodiert (auch der Zeitstempel mit Z und Sekundenbruchteilen); refactorte
DashboardCardView ohne alte Aufrufstellen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:29:37 +02:00
Scarriffle
96172c6fa0 iOS: Ablauf-Benachrichtigungen, je Server einstellbar
Die App zeigte "Bald ablaufend" nur beim Oeffnen. Jetzt erinnert sie aktiv vor
dem Ablauf - mit frei zusammenstellbaren Regeln, pro Server getrennt.

Eine Regel ist "X Tage/Wochen vor Ablauf", optional nur fuer eine Kategorie
(inklusive deren Unterkategorien), einmalig oder taeglich wiederkehrend. Eine
Kategorie-Regel ersetzt fuer ihre Produkte die allgemeine - so kann Molkerei
frueher warnen als Chips. Dazu am Ablauftag eine letzte, deutliche Warnung fuer
alle Produkte (eigener Schalter). Die Meldungen kommen als Tages-Sammelmeldung,
je Server eine, zur pro Server eingestellten Uhrzeit.

Weil Vorrania im Heimnetz laeuft und von unterwegs oft nicht erreichbar ist,
kann die App nicht zuverlaessig im Hintergrund abfragen - und iOS verlangt
ohnehin, dass lokale Benachrichtigungen im Voraus geplant werden. Also: aus den
Ablaufdaten kuenftige Termine berechnen und einplanen; aufgefrischt beim
Oeffnen, im Vordergrund und best-effort per Hintergrund-Aktualisierung. Geplant
wird fuer alle eingerichteten Server, jeder mit seinem Token aus dem Keychain;
unerreichbare werden uebersprungen. iOS erlaubt 64 ausstehende Meldungen je App,
deshalb die naechstliegenden zuerst und bei 60 gekappt.

Einstellungen sitzen hinter der Glocke je Server in der Server-Verwaltung (nicht
admin-beschraenkt, es ist eine Geraete-Einstellung). Getippt wechselt die
Meldung auf den betroffenen Server und oeffnet "Bald ablaufend". Keine
Backend-Aenderung: die Kategorie kommt aus /products, der Ablauf aus /expiring.

Die Rechenlogik (ExpiryPlanner) ist bewusst netz- und iOS-frei und mit einem
eigenstaendigen Swift-Harnisch geprueft: Kategorie-Vorrang, Unterkategorien,
Rueckfall auf die allgemeine Regel, einmalig vs. wiederkehrend und die letzte
Warnung. Modelle gegen echte /expiring- und /products-Antworten abgeglichen,
App startet ohne Absturz (auch die BGTask-Registrierung).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:15:09 +02:00
Scarriffle
85ac3dfa50 Schnellbuchungs-Karten: gueltige Einheit statt "article"
Die Ein-/Auslagern-Karten schickten unit: "article" - eine Einheit, die der
Server nicht kennt (services/units.py akzeptiert nur package/packung bzw. die
Basiseinheiten g/ml/stück). Jede Buchung ueber die Karte scheiterte damit an
"Unbekannte Einheit: article", in der Web-Oberflaeche wie in der App.

Jetzt wird die Menge in Artikeleinheiten gebucht: gibt es ein Gebinde, ist das
eine Packung, sonst die Basiseinheit des Produkts. Gegen ein laufendes Backend
geprueft (package und Basiseinheit je 200).

Aufgefallen beim Testen der Ablauf-Benachrichtigungen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-23 21:14:50 +02:00
151 changed files with 22693 additions and 1462 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 selbst gehosteter Dienst, um sowohl deinen **Lebensmittelvorrat** als auch
Ein-/Auslagern per Barcode, MHD-/Ablaufverwaltung mit Chargen, Mindestbestände, allgemeine **Gegenstände** (Haushalt, Elektronik, Kleidung, Verbrauchsmaterial …)
automatische Einkaufsliste mit **Web-UI** und (in Vorbereitung) **nativer iOS-App**. 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 ## Was Vorrania verwaltet
> [docs/ROADMAP.md](docs/ROADMAP.md).
## Features (Schritt 1) **Lebensmittel** laufen als **Chargen** mit Mindesthaltbarkeitsdatum. Beim
- **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) mit Auslagern wird automatisch die zuerst ablaufende Charge entnommen (**FEFO**).
lokalem Fallback (unbekannte Produkte selbst anlegen).
- **Chargen mit MHD:** Jedes Einlagern erzeugt eine Charge mit eigenem **Gegenstände** kennen drei Arten:
Mindesthaltbarkeitsdatum. Beim Auslagern wird automatisch die zuerst ablaufende - **Menge je Lagerort** reines Zählen (z. B. Unterhosen, Batterien).
Charge zuerst entnommen (**FEFO** First Expired, First Out). - **Einzelstücke** jedes Exemplar mit eigener **UID/QR**, Kaufdatum, Garantie,
- **Einheiten:** Basiseinheit Stück / Gramm / Milliliter plus optionale Bezugsquelle und Beleg (z. B. Powerbank, Kamera).
Packungsgröße → Ein-/Auslagern in Packungen **oder** Teilmengen (z. B. 200 g). - **Verbrauchsgegenstand** wie ein Lebensmittel als Charge mit Menge und
- **Mindestbestände** pro Produkt → automatische **Einkaufsliste**. Einheit geführt (z. B. Sonnencreme in ml), nur ohne eindeutigen Code.
- **Ablaufwarnung** für bald ablaufende Chargen (Frist konfigurierbar).
- **Lagerorte** (flach; Unterlagerorte folgen in Schritt 3). ## Features
- **Mehrbenutzer mit Rollen:** - **Barcode-Lookup** über [Open Food Facts](https://world.openfoodfacts.org) und
- **Admin** verwaltet Produkte, Lagerorte, Benutzer, Mindestbestände. Open Products Facts mit lokalem Fallback; unbekannte Produkte selbst anlegen,
- **Nutzer** nur ein-/auslagern und ansehen. Bild wird automatisch geholt.
- Bewegungen werden protokolliert (wer hat wann was ein-/ausgelagert). - **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 ## Architektur
| Teil | Technik | | Teil | Technik |
|---------|--------------------------------------| |---------|----------------------------------------|
| Backend | Python · FastAPI · SQLAlchemy | | Backend | Python · FastAPI · SQLAlchemy |
| DB | PostgreSQL | | DB | PostgreSQL (SQLite für Tests/lokal) |
| Web-UI | React · Vite (via nginx ausgeliefert)| | Web-UI | React · Vite (via nginx ausgeliefert) |
| iOS | SwiftUI (Schritt 2) | | iOS | Native SwiftUI-App (VisionKit-Scanner) |
| Deploy | Docker Compose · `install.sh` | | Deploy | Docker Compose · `install.sh` |
``` ```
backend/ FastAPI-App + Tests backend/ FastAPI-App + Tests
web/ React/Vite SPA 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 docs/ Roadmap
``` ```
Das Schema wird beim Start per idempotenter `ALTER TABLE`-Migration in
`backend/app/main.py` nachgezogen (kein Alembic).
## Installation (selfhosted, Linux) ## Installation (selfhosted, Linux)
Voraussetzung: eine Linux-Maschine (Server, NAS, Raspberry Pi …). Docker wird bei Voraussetzung: eine Linux-Maschine (Server, NAS, Raspberry Pi …). Docker wird bei
Bedarf automatisch installiert. Bedarf automatisch installiert.
@@ -59,6 +93,8 @@ Nach dem Start:
- Web-UI: `http://<server-ip>:8080` (Port konfigurierbar) - Web-UI: `http://<server-ip>:8080` (Port konfigurierbar)
- Anmeldung mit dem im Installer angezeigten Admin-Benutzer/Passwort. - Anmeldung mit dem im Installer angezeigten Admin-Benutzer/Passwort.
Die iOS-App wird mit derselben Adresse verbunden (`http://<server-ip>:8080`).
## Aktualisieren ## Aktualisieren
```bash ```bash
cd vorrania/deploy cd vorrania/deploy
@@ -99,13 +135,14 @@ npm install
npm run dev # http://localhost:5173, proxyt /api → localhost:8000 npm run dev # http://localhost:5173, proxyt /api → localhost:8000
``` ```
**iOS:** siehe [ios/README.md](ios/README.md) (XcodeGen-Projekt, `xcodegen generate`).
**Tests:** **Tests:**
```bash ```bash
cd backend cd backend
pip install -r requirements.txt pip install -r requirements.txt
pytest # prüft FEFO-Abbuchung und Einheiten-Umrechnung DATABASE_URL="sqlite://" pytest
``` ```
## Nächste Schritte ## Weiteres
Native iOS-App (Schritt 2) und fortgeschrittene Features (Gruppen-Intelligenz, Fahrplan und Ideen: [docs/ROADMAP.md](docs/ROADMAP.md).
Unterlagerorte, Push-Benachrichtigungen …) siehe [docs/ROADMAP.md](docs/ROADMAP.md).

View File

@@ -18,8 +18,10 @@ class Settings(BaseSettings):
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "changeme" admin_password: str = "changeme"
# Open Food Facts # Open Food Facts (Lebensmittel)
off_base_url: str = "https://world.openfoodfacts.org" off_base_url: str = "https://world.openfoodfacts.org"
# Open Products Facts (allgemeine Produkte / Gegenstände) gleiche API-Struktur.
opf_base_url: str = "https://world.openproductsfacts.org"
off_timeout_seconds: float = 8.0 off_timeout_seconds: float = 8.0
# Behaviour # Behaviour

View File

@@ -2,15 +2,85 @@
from __future__ import annotations from __future__ import annotations
from collections import defaultdict
from datetime import date from datetime import date
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Barcode, Lot, Product from .models import (
from .schemas import BarcodeOut, ProductOut Barcode,
from .services.conversion import KIND_OF_BASE, display_unit_info BaseUnit,
from .services.stock import current_stock Category,
CategoryTracking,
Item,
Lot,
Product,
ProductImage,
)
from .schemas import BarcodeOut, LocationMinStockOut, ProductOut
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:
"""Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet.
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)
return cat.tracking if cat and cat.tracking else CategoryTracking.food.value
def product_to_out(db: Session, product: Product) -> ProductOut: def product_to_out(db: Session, product: Product) -> ProductOut:
@@ -31,22 +101,137 @@ 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() 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.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 = (
db.query(ProductImage.updated_at)
.filter(ProductImage.product_id == product.id)
.scalar()
)
out.image_version = int(bild_ts.timestamp()) if bild_ts is not None else None
out.tracking = CategoryTracking(product_tracking(db, product))
out.shop_name = product.shop.name if product.shop else None
out.kind = KIND_OF_BASE[product.base_unit].value out.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product) name, factor = display_unit_info(product)
out.unit_name = name out.unit_name = name
out.unit_factor = factor out.unit_factor = factor
# Mindestbestand in der Einheit anzeigen, in der er erfasst wurde. # „Ueberall" (Ort NULL) ist der frueher separate Gesamt-Mindestbestand.
if product.min_stock is not None: # ``out.min_stock`` bleibt im Vertrag Dashboards, Einkaufsliste und App
if product.min_stock_in_packages and product.package_size: # lesen es unveraendert weiter, es kommt nur aus einer anderen Quelle.
out.min_stock_display = product.min_stock / product.package_size out.min_stock = lies_ueberall(product.location_min_stocks)
out.min_stock_unit_label = "Pkg" if out.min_stock is not None:
elif product.min_stock_unit is not None: out.min_stock_display, out.min_stock_unit_label = _min_anzeige(
out.min_stock_display = product.min_stock / product.min_stock_unit.factor product, out.min_stock, name, factor
out.min_stock_unit_label = product.min_stock_unit.name )
else: _zweit_felder(out, product)
out.min_stock_display = product.min_stock / factor
out.min_stock_unit_label = name out.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=(
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)
)
]
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 return out

View File

@@ -12,12 +12,15 @@ from .routers import (
branding, branding,
categories, categories,
dashboard, dashboard,
field_definitions,
groups, groups,
items,
locations, locations,
maintenance, maintenance,
package_types, package_types,
products, products,
settings as settings_router, settings as settings_router,
shops,
stock, stock,
transfer, transfer,
units, units,
@@ -28,13 +31,105 @@ from .seed import (
ensure_builtin_categories, ensure_builtin_categories,
ensure_builtin_package_types, ensure_builtin_package_types,
ensure_builtin_units, ensure_builtin_units,
ensure_example_object_categories,
ensure_first_admin, ensure_first_admin,
) )
from .services.group_codes import backfill as backfill_group_codes 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() settings = get_settings()
def _migrate_locations_to_code(conn) -> None:
"""Einmalige Umstellung: Lagerort-ID von fortlaufender Zahl auf 10-Zeichen-Code.
Läuft nur, solange ``locations.id`` noch eine Integer-Spalte ist danach ist
die Umstellung erledigt und der Block überspringt sich selbst. Alles passiert
innerhalb der umgebenden Transaktion: Bricht ein Schritt ab, wird komplett
zurückgerollt und die Datenbank bleibt auf dem alten (funktionierenden) Stand.
"""
id_type = conn.execute(text(
"SELECT data_type FROM information_schema.columns "
"WHERE table_name = 'locations' AND column_name = 'id'"
)).scalar()
if id_type not in ("integer", "bigint", "smallint"):
return # frische Installation (schon VARCHAR) oder bereits umgestellt
from .models import generate_location_code
# 1. Jedem bestehenden Lagerort einen eindeutigen Code geben (id bleibt vorerst).
conn.execute(text("ALTER TABLE locations ADD COLUMN IF NOT EXISTS code VARCHAR(10)"))
vergeben: set[str] = set()
for (lid,) in conn.execute(text("SELECT id FROM locations")).all():
code = generate_location_code()
while code in vergeben:
code = generate_location_code()
vergeben.add(code)
conn.execute(text("UPDATE locations SET code = :c WHERE id = :i"), {"c": code, "i": lid})
# 2. Fremdschlüssel-Spalten (Integer) auf den Code umziehen. Beim DROP COLUMN
# fallen die alten FK-/Unique-Constraints automatisch mit weg.
kinder = [
# (Tabelle, ON DELETE, NOT NULL danach, Unique-Constraint danach)
("lots", "SET NULL", False, None),
("movements", "SET NULL", False, None),
("items", "SET NULL", False, None),
("product_location_min_stock", "CASCADE", True, ("uq_prod_loc_min", "product_id")),
("group_location_min_stock", "CASCADE", True, ("uq_group_loc_min", "group_id")),
]
vorhanden = []
for tabelle, ond, nn, uq in kinder:
spalte_da = conn.execute(text(
"SELECT 1 FROM information_schema.columns "
"WHERE table_name = :t AND column_name = 'location_id'"
), {"t": tabelle}).scalar()
if spalte_da is None:
continue # Spalte existiert (noch) nicht nichts umzuziehen
vorhanden.append((tabelle, ond, nn, uq))
conn.execute(text(f"ALTER TABLE {tabelle} ADD COLUMN location_code VARCHAR(10)"))
conn.execute(text(
f"UPDATE {tabelle} t SET location_code = l.code "
f"FROM locations l WHERE t.location_id = l.id"
))
conn.execute(text(f"ALTER TABLE {tabelle} DROP COLUMN location_id"))
conn.execute(text(f"ALTER TABLE {tabelle} RENAME COLUMN location_code TO location_id"))
# 2b. Selbstverweis parent_id ebenso auf den Code umziehen.
conn.execute(text("ALTER TABLE locations ADD COLUMN parent_code VARCHAR(10)"))
conn.execute(text(
"UPDATE locations c SET parent_code = p.code FROM locations p WHERE c.parent_id = p.id"
))
conn.execute(text("ALTER TABLE locations DROP COLUMN parent_id"))
# 3. Integer-PK durch den Code ersetzen (DROP COLUMN zieht PK und Sequenz mit).
conn.execute(text("ALTER TABLE locations DROP COLUMN id"))
conn.execute(text("ALTER TABLE locations RENAME COLUMN code TO id"))
conn.execute(text("ALTER TABLE locations ALTER COLUMN id SET NOT NULL"))
conn.execute(text("ALTER TABLE locations ADD PRIMARY KEY (id)"))
conn.execute(text("ALTER TABLE locations RENAME COLUMN parent_code TO parent_id"))
conn.execute(text(
"ALTER TABLE locations ADD CONSTRAINT locations_parent_id_fkey "
"FOREIGN KEY (parent_id) REFERENCES locations(id) ON DELETE SET NULL"
))
# 4. Fremdschlüssel (und Pflicht-/Unique-Bedingungen) neu setzen jetzt auf den Code.
for tabelle, ond, nn, uq in vorhanden:
if nn:
# Verwaiste Mindestbestände (Lagerort gelöscht) vorher entfernen.
conn.execute(text(f"DELETE FROM {tabelle} WHERE location_id IS NULL"))
conn.execute(text(f"ALTER TABLE {tabelle} ALTER COLUMN location_id SET NOT NULL"))
conn.execute(text(
f"ALTER TABLE {tabelle} ADD CONSTRAINT {tabelle}_location_id_fkey "
f"FOREIGN KEY (location_id) REFERENCES locations(id) ON DELETE {ond}"
))
if uq is not None:
name, andere = uq
conn.execute(text(
f"ALTER TABLE {tabelle} ADD CONSTRAINT {name} UNIQUE ({andere}, location_id)"
))
def _ensure_schema() -> None: def _ensure_schema() -> None:
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen. """Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
@@ -50,6 +145,10 @@ def _ensure_schema() -> None:
"REFERENCES units(id) ON DELETE SET NULL", "REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER " "ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL", "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 " "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL", "REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN " "ALTER TABLE products ADD COLUMN IF NOT EXISTS min_stock_in_packages BOOLEAN "
@@ -66,8 +165,55 @@ def _ensure_schema() -> None:
"NOT NULL DEFAULT 'Übersicht'", "NOT NULL DEFAULT 'Übersicht'",
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER " "ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
"NOT NULL DEFAULT 0", "NOT NULL DEFAULT 0",
# Gegenstands-Verwaltung: Verwaltungsart je Kategorie. Bestehende
# (reine Lebensmittel-)Kategorien werden dabei auf "food" gesetzt.
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) "
"NOT NULL DEFAULT 'food'",
# Bezugsquelle und Onlineshop-Link am Artikel (nur für Gegenstände genutzt).
"ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_id INTEGER "
"REFERENCES shops(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
# location_id ist ein Lagerort-Code (VARCHAR(10)), siehe
# _migrate_locations_to_code deshalb hier gleich als VARCHAR anlegen.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id VARCHAR(10) "
"REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
# 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: with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
# damit die folgenden ADD-COLUMN-Verweise auf die neue VARCHAR-id passen.
_migrate_locations_to_code(conn)
for stmt in stmts: for stmt in stmts:
conn.execute(text(stmt)) conn.execute(text(stmt))
# Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer. # Solange user_id eindeutig war, ging genau ein Dashboard je Benutzer.
@@ -93,9 +239,16 @@ async def lifespan(app: FastAPI):
ensure_builtin_units(db) ensure_builtin_units(db)
ensure_builtin_package_types(db) ensure_builtin_package_types(db)
ensure_builtin_categories(db) ensure_builtin_categories(db)
ensure_example_object_categories(db)
ensure_first_admin(db) ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen. # Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db) 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: finally:
db.close() db.close()
yield yield
@@ -136,3 +289,6 @@ app.include_router(branding.router)
app.include_router(categories.router) app.include_router(categories.router)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(shops.router)
app.include_router(field_definitions.router)
app.include_router(items.router)

View File

@@ -1,20 +1,25 @@
from __future__ import annotations from __future__ import annotations
import enum import enum
import secrets
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
Boolean, Boolean,
Column,
Date, Date,
DateTime, DateTime,
Enum, Enum,
Float, Float,
ForeignKey, ForeignKey,
Index,
Integer, Integer,
LargeBinary, LargeBinary,
String, String,
Table,
Text, Text,
UniqueConstraint, UniqueConstraint,
text,
) )
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
@@ -60,6 +65,39 @@ class UnitKind(str, enum.Enum):
volume = "volume" # Basis: Milliliter volume = "volume" # Basis: Milliliter
class CategoryTracking(str, enum.Enum):
"""Wie Artikel einer Kategorie verwaltet werden.
food = Lebensmittel: Chargen mit MHD und FEFO (bestehende Logik, unverändert).
object = Gegenstand: nur Menge pro Lagerort, kein MHD, keine Chargen.
Bewusst als kurzer String gespeichert (wie DatePrecision), damit sich die
Spalte per ADD COLUMN nachziehen lässt, ohne einen Postgres-Enumtyp anzulegen.
"""
food = "food"
object = "object"
class FieldType(str, enum.Enum):
"""Art eines selbst definierten Feldes an einer Kategorie."""
text = "text" # einzeilig
textarea = "textarea" # mehrzeilig
number = "number" # Zahl, optional mit Einheit
date = "date" # Datum (z.B. Kaufdatum)
select = "select" # Auswahlliste (options = JSON-Liste)
boolean = "boolean" # Ja/Nein
class RemovalReason(str, enum.Enum):
"""Grund einer Gegenstands-Entnahme aus dem Bestand."""
lost = "lost" # verloren
broken = "broken" # kaputt
given_away = "given_away" # verschenkt
sold = "sold" # verkauft
used_up = "used_up" # aufgebraucht
other = "other" # sonstiges
class Unit(Base): class Unit(Base):
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis. """Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
@@ -85,6 +123,27 @@ class User(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) 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): class Group(Base):
__tablename__ = "groups" __tablename__ = "groups"
@@ -95,19 +154,69 @@ class Group(Base):
min_stock_unit_id: Mapped[int | None] = mapped_column( min_stock_unit_id: Mapped[int | None] = mapped_column(
ForeignKey("units.id", ondelete="SET NULL"), nullable=True 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") products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship() min_stock_unit: Mapped[Unit | None] = relationship()
location_min_stocks: Mapped[list["GroupLocationMinStock"]] = relationship(
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
# praktisch nie, und der Code IST zugleich der Inhalt des QR /l/<code>.
# Alphabet ohne 0/O/1/I/L wie bei den Einzelstück-UIDs gut ablesbar.
_LOCATION_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def generate_location_code() -> str:
"""Zufälliger 10-Zeichen-Code für einen Lagerort (Kollision vernachlässigbar)."""
return "".join(secrets.choice(_LOCATION_CODE_ALPHABET) for _ in range(10))
class Location(Base): class Location(Base):
__tablename__ = "locations" __tablename__ = "locations"
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[str] = mapped_column(
String(10), primary_key=True, default=generate_location_code
)
name: Mapped[str] = mapped_column(String(120), nullable=False) name: Mapped[str] = mapped_column(String(120), nullable=False)
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat. # Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
parent_id: Mapped[int | None] = mapped_column( parent_id: Mapped[str | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
) )
@@ -130,6 +239,15 @@ class Category(Base):
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
) )
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Verwaltungsart der Artikel dieser Kategorie: "food" (Chargen+MHD) oder
# "object" (Menge je Lagerort). Neue Kategorien sind Gegenstände; bestehende
# (reine Lebensmittel-Installationen) werden bei der Migration auf "food"
# gesetzt. Als kurzer String wie date_precision, damit per ADD COLUMN nachziehbar.
tracking: Mapped[str] = mapped_column(
String(16), nullable=False,
default=CategoryTracking.object.value,
server_default=CategoryTracking.food.value,
)
class Product(Base): class Product(Base):
@@ -154,6 +272,18 @@ class Product(Base):
package_size: Mapped[float | None] = mapped_column(Float, nullable=True) package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
# Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", … # Bezeichnung eines Gebindes: "Packung", "Glas", "Tüte", "Flasche", …
package_label: Mapped[str | None] = mapped_column(String(32), nullable=True) 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 # Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die
# Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt). # Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt).
date_precision: Mapped[str] = mapped_column( date_precision: Mapped[str] = mapped_column(
@@ -181,15 +311,43 @@ class Product(Base):
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
# Nur für Gegenstände: Bezugsquelle ("gekauft bei") und Link zum Onlineshop.
shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
)
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# Gegenstände: als Einzelstücke (Items mit eigener UID/QR) statt als Menge je
# 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) 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") group: Mapped[Group | None] = relationship(back_populates="products")
category: Mapped[Category | None] = relationship() category: Mapped[Category | None] = relationship()
shop: Mapped[Shop | None] = relationship()
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id]) display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id]) min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
lots: Mapped[list[Lot]] = relationship( lots: Mapped[list[Lot]] = relationship(
back_populates="product", cascade="all, delete-orphan" back_populates="product", cascade="all, delete-orphan"
) )
field_values: Mapped[list[ProductFieldValue]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
location_min_stocks: Mapped[list["ProductLocationMinStock"]] = relationship(
cascade="all, delete-orphan"
)
class ApiToken(Base): class ApiToken(Base):
@@ -249,8 +407,8 @@ class Lot(Base):
String(8), nullable=False, default=DatePrecision.day.value, String(8), nullable=False, default=DatePrecision.day.value,
server_default=DatePrecision.day.value, server_default=DatePrecision.day.value,
) )
location_id: Mapped[int | None] = mapped_column( location_id: Mapped[str | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
) )
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -274,6 +432,14 @@ class Movement(Base):
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
note: Mapped[str | None] = mapped_column(String(255), nullable=True) note: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -369,3 +535,207 @@ class Setting(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True) key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(255), nullable=False) value: Mapped[str] = mapped_column(String(255), nullable=False)
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
Vom Admin verwaltbare Liste (wie Lagerorte). Am Artikel optional "unbekannt"
oder "mehrere Herkünfte" bleibt einfach leer.
"""
__tablename__ = "shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
website: Mapped[str | None] = mapped_column(String(1024), nullable=True)
class FieldDefinition(Base):
"""Selbst definiertes Feld an einer Kategorie (z.B. „Kapazität“ in mAh).
Gilt für Artikel dieser Kategorie und über die Vererbung im Kategorie-Baum
auch für deren Unterkategorien. Der konkrete Wert je Artikel steht in
:class:`ProductFieldValue`.
"""
__tablename__ = "field_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[str] = mapped_column(String(120), nullable=False)
# Maschinenlesbarer Schlüssel (aus dem Label abgeleitet) für Export/Anzeige.
key: Mapped[str] = mapped_column(String(120), nullable=False)
field_type: Mapped[str] = mapped_column(
String(16), nullable=False, default=FieldType.text.value
)
# Einheit für Zahlenfelder (z.B. "mAh", "g", "W").
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
# JSON-Liste der Auswahlmöglichkeiten für field_type == "select".
options: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ProductFieldValue(Base):
"""Wert eines selbst definierten Feldes für einen konkreten Artikel."""
__tablename__ = "product_field_values"
__table_args__ = (
UniqueConstraint(
"product_id", "field_definition_id", name="uq_pfv_product_field"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
field_definition_id: Mapped[int] = mapped_column(
ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=False
)
# Immer als Text gespeichert; typgerecht interpretiert wird beim Lesen/Schreiben.
value: Mapped[str | None] = mapped_column(Text, nullable=True)
product: Mapped[Product] = relationship(back_populates="field_values")
field_definition: Mapped[FieldDefinition] = relationship()
class Item(Base):
"""Ein physisches Einzelstück eines Gegenstands (Instanz) mit eigener UID/QR.
Nur für Gegenstands-Produkte mit Einzelstück-Verwaltung (``Product.individual``).
Je Stück ein eigener Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz
so lässt sich dasselbe Modell mehrfach getrennt führen (Powerbank 2024 + 2025).
Der QR-Code trägt die UID; ein Scan öffnet genau dieses Stück.
"""
__tablename__ = "items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Kurzer, gut lesbarer Code für den QR (ohne verwechselbare Zeichen).
uid: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
location_id: Mapped[str | None] = mapped_column(
String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
)
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
# Kaufpreis in kleinster Einheit (Rappen/Cent), damit keine Float-Rundung
# entsteht; Währung getrennt (z.B. CHF, EUR).
price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship()
location: Mapped[Location | None] = relationship()
shop: Mapped[Shop | None] = relationship()
documents: Mapped[list["ItemDocument"]] = relationship(
cascade="all, delete-orphan", order_by="ItemDocument.id"
)
class ItemDocument(Base):
"""Beleg zu einem Einzelstück: Rechnung oder Garantieschein (PDF oder Bild).
Eigene Tabelle wie :class:`ProductImage`, damit die Binärdaten nicht bei
jeder Item-Abfrage mitgezogen werden. Mehrere Belege je Stück sind erlaubt.
"""
__tablename__ = "item_documents"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
item_id: Mapped[int] = mapped_column(
ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True
)
filename: Mapped[str] = mapped_column(String(255), nullable=False)
content_type: Mapped[str] = mapped_column(String(64), nullable=False)
# Nur bei Bedarf laden (Download): sonst zoege jede Item-Liste die Belege mit.
data: Mapped[bytes] = mapped_column(LargeBinary, nullable=False, deferred=True)
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ProductLocationMinStock(Base):
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
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"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True
)
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 | None] = relationship()
__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 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"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
group_id: Mapped[int] = mapped_column(
ForeignKey("groups.id", ondelete="CASCADE"), nullable=False, index=True
)
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 | None] = relationship()
__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 json
import re import re
from typing import NamedTuple
import httpx import httpx
@@ -24,33 +25,79 @@ _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) _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]: def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
"""Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'. """Ermittelt Basiseinheit und Packungsgröße aus dem OFF-Feld 'quantity'.
Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000), Beispiele: '500 g' -> ('gram', 500), '1 kg' -> ('gram', 1000),
'1,5 l' -> ('milliliter', 1500), '6 Stück' -> ('piece', None). '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: menge = parse_gebinde(quantity)
return "piece", None return menge.base_unit, menge.package_size
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)
def lookup_barcode(barcode: str) -> dict | None: def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
"""Fragt Open Food Facts nach einem Barcode. """Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode.
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden. Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die
Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None.
""" """
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0 # v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404, # (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
# weshalb wir bewusst v0 nutzen. # weshalb wir bewusst v0 nutzen.
url = f"{settings.off_base_url}/api/v0/product/{barcode}.json" url = f"{base_url}/api/v0/product/{barcode}.json"
try: try:
resp = httpx.get( resp = httpx.get(
url, url,
@@ -84,18 +131,46 @@ def lookup_barcode(barcode: str) -> dict | None:
categories = product.get("categories") or "" categories = product.get("categories") or ""
category_tags = product.get("categories_tags") or [] category_tags = product.get("categories_tags") or []
base_unit, package_size = parse_quantity(product.get("quantity")) menge = parse_gebinde(product.get("quantity"))
return { return {
"barcode": barcode, "barcode": barcode,
"name": name, "name": name,
"brand": (product.get("brands") or "").strip() or None, "brand": (product.get("brands") or "").strip() or None,
"image_url": product.get("image_front_url") or product.get("image_url") or None, "image_url": product.get("image_front_url") or product.get("image_url") or None,
"base_unit": base_unit, "base_unit": menge.base_unit,
"package_size": package_size, "package_size": menge.package_size,
"quantity_text": product.get("quantity"), "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_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags, "category_tags": category_tags,
"source": "off", "source": source,
"off_raw": json.dumps(product)[:20000], "off_raw": json.dumps(product)[:20000],
} }
def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None:
"""Sucht einen Barcode in den offenen Datenbanken.
Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen
der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte
(Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach
vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen.
"""
off = (settings.off_base_url, "off")
opf = (settings.opf_base_url, "opf")
reihenfolge = [opf, off] if prefer == "object" else [off, opf]
for base_url, source in reihenfolge:
if not base_url:
continue
treffer = _lookup_at(barcode, base_url, source)
if treffer is not None:
return treffer
return None

View File

@@ -10,8 +10,17 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Category, Product, User from ..models import (
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate Category,
CategoryTracking,
FieldDefinition,
Product,
ProductFieldValue,
User,
)
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
from ..services.fields import effective_field_definitions
from .field_definitions import to_out as field_to_out
router = APIRouter(prefix="/categories", tags=["categories"]) router = APIRouter(prefix="/categories", tags=["categories"])
@@ -61,9 +70,19 @@ def create_category(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> CategoryOut: ) -> CategoryOut:
if payload.parent_id is not None and db.get(Category, payload.parent_id) is None: parent = None
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden") if payload.parent_id is not None:
category = Category(name=payload.name, parent_id=payload.parent_id) parent = db.get(Category, payload.parent_id)
if parent is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
# Modus: ausdrücklich gewählt > vom Elternteil geerbt > neue Oberkategorie = Gegenstand.
if payload.tracking is not None:
tracking = payload.tracking.value
elif parent is not None:
tracking = parent.tracking
else:
tracking = CategoryTracking.object.value
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
db.add(category) db.add(category)
db.commit() db.commit()
db.refresh(category) db.refresh(category)
@@ -95,6 +114,9 @@ def update_category(
"Unterkategorien untergeordnet werden.", "Unterkategorien untergeordnet werden.",
) )
# Modus als kurzen String ablegen (Spalte ist String, nicht Enum-Typ).
if "tracking" in data and data["tracking"] is not None:
data["tracking"] = CategoryTracking(data["tracking"]).value
for field, value in data.items(): for field, value in data.items():
setattr(category, field, value) setattr(category, field, value)
db.commit() db.commit()
@@ -120,6 +142,35 @@ def delete_category(
db.query(Category).filter(Category.parent_id == category_id).update( db.query(Category).filter(Category.parent_id == category_id).update(
{Category.parent_id: None} {Category.parent_id: None}
) )
# Eigene Felder dieser Kategorie samt aller erfassten Werte entfernen.
feld_ids = [
fid
for (fid,) in db.query(FieldDefinition.id)
.filter(FieldDefinition.category_id == category_id)
.all()
]
if feld_ids:
db.query(ProductFieldValue).filter(
ProductFieldValue.field_definition_id.in_(feld_ids)
).delete(synchronize_session=False)
db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id
).delete(synchronize_session=False)
db.delete(category) db.delete(category)
db.commit() db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{category_id}/fields", response_model=list[FieldDefinitionOut])
def category_fields(
category_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[FieldDefinitionOut]:
"""Alle für eine Kategorie geltenden Felder inkl. der von Oberkategorien geerbten."""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
return [
field_to_out(fd, inherited=inherited)
for fd, inherited in effective_field_definitions(db, category_id)
]

View File

@@ -24,6 +24,7 @@ from ..models import (
Category, Category,
DashboardLayout, DashboardLayout,
Group, Group,
Item,
Lot, Lot,
Movement, Movement,
MovementType, MovementType,
@@ -46,6 +47,7 @@ from ..schemas import (
from ..services.conversion import article_unit from ..services.conversion import article_unit
from ..services.stock import current_stock from ..services.stock import current_stock
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
router = APIRouter(prefix="/dashboard", tags=["dashboard"]) router = APIRouter(prefix="/dashboard", tags=["dashboard"])
@@ -300,6 +302,35 @@ def _lots_mit_produkt(db: Session) -> list[tuple[Lot, Product]]:
) )
def _bestand_beitraege(
db: Session, heute: date, warnfrist: int
) -> list[tuple[Product, float, str]]:
"""Alle Bestands-Beiträge als (Produkt, Artikeleinheiten, Ablaufzustand).
Deckt beide Speicherformen ab, damit die Auswertungen vollständig sind:
- Chargen (Lebensmittel und Mengen-Gegenstände) je Lot mit ihrem MHD-Zustand.
- Einzelstücke: jedes Item zählt als ein Stück ohne MHD ("no_date"). Diese
liegen als Items und nicht als Lots vor ohne sie fehlten Einzelstück-
Produkte (z.B. eine Kamera) in jeder Kategorie-/Ablauf-Auswertung.
"""
beitraege: list[tuple[Product, float, str]] = []
for lot, product in _lots_mit_produkt(db):
# Einzelstücke werden über ihre Items gezählt (siehe unten), nie über Lots.
if product.individual:
continue
faktor, _label = article_unit(product)
menge = lot.quantity / (faktor or 1.0)
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
for product in db.query(Product).filter(Product.individual.is_(True)).all():
anzahl = db.query(Item).filter(Item.product_id == product.id).count()
if anzahl <= 0:
continue
faktor, _label = article_unit(product)
beitraege.append((product, anzahl / (faktor or 1.0), "no_date"))
return beitraege
@router.get("/stats", response_model=DashboardStats) @router.get("/stats", response_model=DashboardStats)
def stats( def stats(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -310,29 +341,18 @@ def stats(
einheiten = 0.0 einheiten = 0.0
bestand_produkte: set[int] = set() bestand_produkte: set[int] = set()
bald = abgelaufen = 0 bald = abgelaufen = 0
for lot, product in _lots_mit_produkt(db): for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
faktor, _label = article_unit(product) einheiten += menge
einheiten += lot.quantity / faktor if faktor else 0.0
bestand_produkte.add(product.id) bestand_produkte.add(product.id)
zustand = _zustand(lot.best_before, heute, warnfrist)
if zustand == "expired": if zustand == "expired":
abgelaufen += 1 abgelaufen += 1
elif zustand == "soon": elif zustand == "soon":
bald += 1 bald += 1
# Einkaufsbedarf: Produkte und Gruppen unter Mindestbestand. # Einkaufsbedarf: so viele Zeilen, wie auf der Einkaufsliste stehen
bedarf = 0 # inklusive der Verrechnung zwischen „Überall", Lagerorten und Unter-
for product in db.query(Product).filter(Product.min_stock.isnot(None), Product.min_stock > 0): # gruppen. Frueher wurde hier eigenstaendig gezaehlt und wich deshalb ab.
if current_stock(db, product.id) < product.min_stock: bedarf = len(einkaufsliste_artikel(db)) + len(einkaufsliste_gruppen(db))
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
return DashboardStats( return DashboardStats(
products_in_stock=len(bestand_produkte), products_in_stock=len(bestand_produkte),
@@ -351,29 +371,54 @@ def expiry_split(
heute = date.today() heute = date.today()
warnfrist = get_expiry_warning_days(db) warnfrist = get_expiry_warning_days(db)
summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0} summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0}
for lot, product in _lots_mit_produkt(db): for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
faktor, _label = article_unit(product) summen[zustand] += menge
summen[_zustand(lot.best_before, heute, warnfrist)] += lot.quantity / (faktor or 1.0)
return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()}) return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()})
@router.get("/by-category", response_model=list[CategoryShare]) @router.get("/by-category", response_model=list[CategoryShare])
def by_category( 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]: ) -> 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() heute = date.today()
warnfrist = get_expiry_warning_days(db) 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} 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) eimer: dict[int | None, dict] = defaultdict(leer)
for lot, product in _lots_mit_produkt(db): for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist):
faktor, _label = article_unit(product) topf = eimer[rollup(product.category_id)]
menge = lot.quantity / (faktor or 1.0)
topf = eimer[product.category_id]
topf["article_units"] += menge topf["article_units"] += menge
topf[_zustand(lot.best_before, heute, warnfrist)] += menge topf[zustand] += menge
ergebnis = [ ergebnis = [
CategoryShare( CategoryShare(

View File

@@ -0,0 +1,143 @@
"""Verwaltung der selbst definierten Felder je Kategorie."""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue, User
from ..schemas import FieldDefinitionCreate, FieldDefinitionOut, FieldDefinitionUpdate
from ..services.fields import options_list, slugify
router = APIRouter(prefix="/field-definitions", tags=["field-definitions"])
def to_out(fd: FieldDefinition, inherited: bool = False) -> FieldDefinitionOut:
return FieldDefinitionOut(
id=fd.id,
category_id=fd.category_id,
label=fd.label,
key=fd.key,
field_type=FieldType(fd.field_type),
unit=fd.unit,
options=options_list(fd),
required=fd.required,
position=fd.position,
is_builtin=fd.is_builtin,
inherited=inherited,
)
def _unique_key(db: Session, category_id: int, label: str, exclude_id: int | None = None) -> str:
"""Eindeutigen Schlüssel je Kategorie erzeugen (kapazitaet, kapazitaet_2, …)."""
basis = slugify(label)
kandidat = basis
n = 1
while True:
query = db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id, FieldDefinition.key == kandidat
)
if exclude_id is not None:
query = query.filter(FieldDefinition.id != exclude_id)
if query.first() is None:
return kandidat
n += 1
kandidat = f"{basis}_{n}"
def _options_json(field_type: FieldType, options: list[str] | None) -> str | None:
if field_type != FieldType.select:
return None
return json.dumps([o.strip() for o in (options or []) if o.strip()])
@router.get("", response_model=list[FieldDefinitionOut])
def list_field_definitions(
category_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[FieldDefinitionOut]:
"""Felder auflisten ohne category_id alle, sonst nur die dieser Kategorie."""
query = db.query(FieldDefinition)
if category_id is not None:
query = query.filter(FieldDefinition.category_id == category_id)
rows = query.order_by(
FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id
).all()
return [to_out(fd) for fd in rows]
@router.post("", response_model=FieldDefinitionOut, status_code=status.HTTP_201_CREATED)
def create_field_definition(
payload: FieldDefinitionCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> FieldDefinitionOut:
if db.get(Category, payload.category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
fd = FieldDefinition(
category_id=payload.category_id,
label=payload.label.strip(),
key=_unique_key(db, payload.category_id, payload.label),
field_type=payload.field_type.value,
unit=(payload.unit or None),
options=_options_json(payload.field_type, payload.options),
required=bool(payload.required),
position=payload.position,
)
db.add(fd)
db.commit()
db.refresh(fd)
return to_out(fd)
@router.patch("/{field_id}", response_model=FieldDefinitionOut)
def update_field_definition(
field_id: int,
payload: FieldDefinitionUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> FieldDefinitionOut:
fd = db.get(FieldDefinition, field_id)
if fd is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "label" in data and data["label"]:
fd.label = data["label"].strip()
fd.key = _unique_key(db, fd.category_id, fd.label, exclude_id=fd.id)
if "field_type" in data and data["field_type"] is not None:
fd.field_type = data["field_type"].value
if "unit" in data:
fd.unit = data["unit"] or None
if "required" in data and data["required"] is not None:
fd.required = bool(data["required"])
if "position" in data and data["position"] is not None:
fd.position = data["position"]
# Optionen immer passend zum (ggf. neuen) Typ ablegen.
if "options" in data or "field_type" in data:
neuer_typ = FieldType(fd.field_type)
optionen = data["options"] if "options" in data else options_list(fd)
fd.options = _options_json(neuer_typ, optionen)
db.commit()
db.refresh(fd)
return to_out(fd)
@router.delete("/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_field_definition(
field_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Löscht das Feld samt aller dazu erfassten Werte (ON DELETE CASCADE)."""
fd = db.get(FieldDefinition, field_id)
if fd is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
db.query(ProductFieldValue).filter(
ProductFieldValue.field_definition_id == field_id
).delete()
db.delete(fd)
db.commit()

View File

@@ -3,7 +3,7 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Barcode, Group, Product, User from ..models import Barcode, Group, GroupLocationMinStock, Location, Product, User
from ..schemas import ( from ..schemas import (
BarcodeCreate, BarcodeCreate,
BarcodeNoteUpdate, BarcodeNoteUpdate,
@@ -11,18 +11,57 @@ from ..schemas import (
GroupCreate, GroupCreate,
GroupOut, GroupOut,
GroupUpdate, GroupUpdate,
LocationMinStockIn,
LocationMinStockOut,
ProductBarcodeOut, ProductBarcodeOut,
) )
from ..services.conversion import BASE_OF_KIND from ..services import gruppen as gruppen_graph
from ..services.stock import current_stock 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"]) 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: def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group) 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() 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 # Zu welchem Artikel gehoert ein Code? Der Gruppen-Code entsteht beim
# Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben. # Zuordnen automatisch; die Herkunft soll trotzdem sichtbar bleiben.
artikel_zu_code: dict[str, Product] = {} artikel_zu_code: dict[str, Product] = {}
@@ -65,17 +104,44 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
) )
out.product_barcodes = product_codes 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 unit = group.min_stock_unit
if unit is not None: 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_name = unit.name
out.min_stock_unit_factor = unit.factor
out.kind = unit.kind.value 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=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),
)
# „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 return out
@@ -87,6 +153,43 @@ def list_groups(
return [_group_to_out(db, g) for g in groups] return [_group_to_out(db, g) for g in groups]
@router.put("/{group_id}/location-min-stock", response_model=GroupOut)
def set_group_location_min_stock(
group_id: int,
payload: list[LocationMinStockIn],
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> GroupOut:
"""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")
db.query(GroupLocationMinStock).filter(
GroupLocationMinStock.group_id == group_id
).delete()
# 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 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(
group_id=group_id,
location_id=eintrag.location_id,
min_stock=eintrag.min_stock,
))
db.commit()
db.refresh(group)
return _group_to_out(db, group)
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED) @router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
def create_group( def create_group(
payload: GroupCreate, payload: GroupCreate,
@@ -97,10 +200,21 @@ def create_group(
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group( group = Group(
name=payload.name, name=payload.name,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id, 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) 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.commit()
db.refresh(group) db.refresh(group)
return _group_to_out(db, group) return _group_to_out(db, group)
@@ -117,6 +231,12 @@ def update_group(
if group is None: if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
data = payload.model_dump(exclude_unset=True) 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"]: if "name" in data and data["name"]:
clash = ( clash = (
db.query(Group) db.query(Group)
@@ -125,8 +245,17 @@ def update_group(
) )
if clash: if clash:
raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben") raise HTTPException(status.HTTP_409_CONFLICT, "Name bereits vergeben")
for field, value in data.items(): for field, value in data.items():
setattr(group, field, value) 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.commit()
db.refresh(group) db.refresh(group)
return _group_to_out(db, group) return _group_to_out(db, group)
@@ -247,5 +376,13 @@ def delete_group(
group = db.get(Group, group_id) group = db.get(Group, group_id)
if group is None: if group is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") 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.delete(group)
db.commit() db.commit()

View File

@@ -0,0 +1,348 @@
"""Einzelstücke (Items) eines Gegenstands: anlegen, ändern, entfernen, per UID finden.
Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
(Lagerort, Kaufdatum, Garantie, Bezugsquelle, Notiz). Nur sinnvoll für
Gegenstands-Produkte mit ``Product.individual``.
"""
import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import (
Item,
ItemDocument,
Location,
Movement,
MovementType,
Product,
Shop,
User,
)
from ..schemas import (
DocumentSuggestions,
ItemCreate,
ItemDocumentUploadOut,
ItemOut,
ItemRemove,
ItemUpdate,
)
from ..services.items import generate_uid, item_to_out
from ..services.warranty import (
extract_pdf_text,
guess_acquired_on,
guess_price_candidates,
guess_price_cents,
guess_shop,
guess_warranty_until,
)
router = APIRouter(tags=["items"])
# Belege sind meist kleine PDFs; die Grenze schuetzt vor Ausreissern und passt
# zur nginx-Grenze (client_max_body_size 12m).
DOC_MAX_BYTES = 10 * 1024 * 1024
def _product_or_404(db: Session, product_id: int) -> Product:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
return product
def _item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
return item
def _check_refs(db: Session, shop_id: int | None, location_id: str | None) -> None:
if shop_id is not None and db.get(Shop, shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
if location_id is not None and db.get(Location, location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
@router.get("/products/{product_id}/items", response_model=list[ItemOut])
def list_items(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ItemOut]:
_product_or_404(db, product_id)
rows = db.query(Item).filter(Item.product_id == product_id).order_by(Item.id).all()
return [item_to_out(i) for i in rows]
@router.post(
"/products/{product_id}/items", response_model=list[ItemOut],
status_code=status.HTTP_201_CREATED,
)
def create_items(
product_id: int,
payload: ItemCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> list[ItemOut]:
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
product = _product_or_404(db, product_id)
_check_refs(db, payload.shop_id, payload.location_id)
created: list[Item] = []
for _ in range(payload.count):
item = Item(
uid=generate_uid(db),
product_id=product.id,
location_id=payload.location_id,
shop_id=payload.shop_id,
acquired_on=payload.acquired_on,
warranty_until=payload.warranty_until,
note=payload.note,
price_cents=payload.price_cents,
currency=payload.currency,
)
db.add(item)
db.flush()
created.append(item)
db.commit()
for item in created:
db.refresh(item)
return [item_to_out(i) for i in created]
@router.get("/items", response_model=list[ItemOut])
def list_all_items(
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ItemOut]:
"""Alle Einzelstücke über alle Produkte Grundlage der Einzelstück-Liste.
Angereichert mit Produkt-, Kategorie-, Lagerort- und Shop-Angaben; die
Beziehungen werden vorgeladen, damit die Liste nicht in N+1-Abfragen zerfällt.
"""
rows = (
db.query(Item)
.options(
joinedload(Item.product).joinedload(Product.category),
joinedload(Item.location),
joinedload(Item.shop),
joinedload(Item.documents),
)
.order_by(Item.id)
.all()
)
return [item_to_out(i) for i in rows]
@router.get("/items/by-uid/{uid}", response_model=ItemOut)
def item_by_uid(
uid: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ItemOut:
"""QR-Auflösung: UID → Einzelstück (mit Produktangaben)."""
item = db.query(Item).filter(Item.uid == uid.strip().upper()).first()
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Einzelstück mit dieser UID")
return item_to_out(item)
@router.get("/items/{item_id}", response_model=ItemOut)
def get_item(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ItemOut:
return item_to_out(_item_or_404(db, item_id))
@router.patch("/items/{item_id}", response_model=ItemOut)
def update_item(
item_id: int,
payload: ItemUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemOut:
item = _item_or_404(db, item_id)
data = payload.model_dump(exclude_unset=True)
_check_refs(db, data.get("shop_id"), data.get("location_id"))
for field, value in data.items():
setattr(item, field, value)
db.commit()
db.refresh(item)
return item_to_out(item)
@router.post("/items/{item_id}/remove", status_code=status.HTTP_204_NO_CONTENT)
def remove_item(
item_id: int,
payload: ItemRemove,
db: Session = Depends(get_db),
user: User = Depends(require_admin),
) -> None:
"""Einzelstück mit Grund entfernen als Bewegung protokolliert (Statistik je Modell)."""
item = _item_or_404(db, item_id)
db.add(
Movement(
product_id=item.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.out,
quantity=1,
unit_used="Stück",
note=(payload.note or f"Einzelstück {item.uid}"),
location_id=item.location_id,
reason=payload.reason.value,
)
)
db.delete(item)
db.commit()
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Einzelstück ohne Grund löschen (Korrektur)."""
db.delete(_item_or_404(db, item_id))
db.commit()
# --------------------------------------------------------------------------
# Belege (Rechnung/Garantieschein) je Einzelstück
# --------------------------------------------------------------------------
def _document_or_404(db: Session, item_id: int, doc_id: int) -> ItemDocument:
doc = (
db.query(ItemDocument)
.filter(ItemDocument.id == doc_id, ItemDocument.item_id == item_id)
.first()
)
if doc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Beleg nicht gefunden")
return doc
def _safe_filename(name: str | None) -> str:
"""Steuerzeichen/Anführungszeichen raus sonst Header-Injektion im Download."""
cleaned = re.sub(r'[\r\n"]', "", (name or "").strip())
return cleaned[:255] or "beleg"
def _read_document(file: UploadFile, data: bytes) -> str:
"""Validiert Größe/Typ eines hochgeladenen Belegs und gibt den Content-Type."""
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Leere Datei")
if len(data) > DOC_MAX_BYTES:
raise HTTPException(
status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
f"Datei zu groß (max. {DOC_MAX_BYTES // (1024 * 1024)} MB).",
)
content_type = file.content_type or "application/octet-stream"
if content_type != "application/pdf" and not content_type.startswith("image/"):
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE, "Nur PDF oder Bild erlaubt."
)
return content_type
def _doc_suggestions(
db: Session, data: bytes, content_type: str, item_acquired_on=None
) -> DocumentSuggestions:
"""Garantie, Preis, Kaufdatum und Shop aus einem PDF schätzen (Bilder: leer)."""
if content_type != "application/pdf":
return DocumentSuggestions()
text = extract_pdf_text(data)
shop_id, shop_name = guess_shop(text, [(s.id, s.name) for s in db.query(Shop).all()])
return DocumentSuggestions(
suggested_warranty_until=guess_warranty_until(text, acquired_on=item_acquired_on),
suggested_price_cents=guess_price_cents(text),
suggested_price_candidates=guess_price_candidates(text),
suggested_acquired_on=guess_acquired_on(text),
suggested_shop_id=shop_id,
suggested_shop_name=shop_name,
)
@router.post(
"/items/{item_id}/documents",
response_model=ItemDocumentUploadOut,
status_code=status.HTTP_201_CREATED,
)
async def upload_item_document(
item_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemDocumentUploadOut:
"""Beleg (PDF oder Bild) hochladen. Bei PDF werden Garantie, Preis, Kaufdatum
und Shop vorgeschlagen."""
item = _item_or_404(db, item_id)
data = await file.read()
content_type = _read_document(file, data)
doc = ItemDocument(
item_id=item.id,
filename=_safe_filename(file.filename),
content_type=content_type,
data=data,
)
db.add(doc)
db.commit()
db.refresh(doc)
vorschlag = _doc_suggestions(db, data, content_type, item.acquired_on)
return ItemDocumentUploadOut(
id=doc.id,
filename=doc.filename,
content_type=doc.content_type,
uploaded_at=doc.uploaded_at,
**vorschlag.model_dump(),
)
@router.post("/items/analyze-document", response_model=DocumentSuggestions)
async def analyze_item_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> DocumentSuggestions:
"""Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen."""
data = await file.read()
content_type = _read_document(file, data)
return _doc_suggestions(db, data, content_type)
@router.get("/items/{item_id}/documents/{doc_id}")
def get_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> Response:
"""Beleg anzeigen/herunterladen."""
doc = _document_or_404(db, item_id, doc_id)
return Response(
content=doc.data,
media_type=doc.content_type,
headers={"Content-Disposition": f'inline; filename="{doc.filename}"'},
)
@router.delete(
"/items/{item_id}/documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT
)
def delete_item_document(
item_id: int,
doc_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
db.delete(_document_or_404(db, item_id, doc_id))
db.commit()

View File

@@ -10,6 +10,20 @@ from ..schemas import LocationCreate, LocationOut, LocationUpdate
router = APIRouter(prefix="/locations", tags=["locations"]) router = APIRouter(prefix="/locations", tags=["locations"])
def _descendant_ids(db: Session, location_id: str) -> set[str]:
"""Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen, sonst
entstünde ein Ring."""
result: set[str] = set()
stack = [location_id]
while stack:
cur = stack.pop()
for kid in db.query(Location).filter(Location.parent_id == cur).all():
if kid.id not in result:
result.add(kid.id)
stack.append(kid.id)
return result
@router.get("", response_model=list[LocationOut]) @router.get("", response_model=list[LocationOut])
def list_locations( def list_locations(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -32,26 +46,46 @@ def create_location(
@router.patch("/{location_id}", response_model=LocationOut) @router.patch("/{location_id}", response_model=LocationOut)
def update_location( def update_location(
location_id: int, location_id: str,
payload: LocationUpdate, payload: LocationUpdate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> Location: ) -> Location:
"""Umbenennen. Chargen haengen an der ID, behalten ihren Lagerort also.""" """Umbenennen und/oder umhängen. Chargen haengen an der ID, behalten ihren
Lagerort also."""
loc = db.get(Location, location_id) loc = db.get(Location, location_id)
if loc is None: if loc is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
name = payload.name.strip() data = payload.model_dump(exclude_unset=True)
doppelt = (
db.query(Location) if "name" in data and data["name"]:
.filter(func.lower(Location.name) == name.lower(), Location.id != location_id) name = data["name"].strip()
.first() doppelt = (
) db.query(Location)
if doppelt is not None: .filter(func.lower(Location.name) == name.lower(), Location.id != location_id)
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits") .first()
)
if doppelt is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Lagerort gibt es bereits")
loc.name = name
# parent_id nur anfassen, wenn ausdrücklich mitgeschickt (None = oberste Ebene).
if "parent_id" in data:
neu = data["parent_id"]
if neu is not None:
if db.get(Location, neu) is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Übergeordneter Lagerort nicht gefunden"
)
if neu == location_id or neu in _descendant_ids(db, location_id):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Ein Lagerort kann nicht sich selbst oder einem seiner Unterorte "
"untergeordnet werden.",
)
loc.parent_id = neu
loc.name = name
db.commit() db.commit()
db.refresh(loc) db.refresh(loc)
return loc return loc
@@ -59,7 +93,7 @@ def update_location(
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_location( def delete_location(
location_id: int, location_id: str,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> None: ) -> None:

View File

@@ -24,6 +24,7 @@ from ..models import (
Barcode, Barcode,
Category, Category,
Group, Group,
group_parents,
Location, Location,
Lot, Lot,
Movement, Movement,
@@ -108,6 +109,10 @@ def reset_all(
_delete_products(db) _delete_products(db)
# Restliche EAN-Codes (die an Gruppen hängen) mitnehmen. # Restliche EAN-Codes (die an Gruppen hängen) mitnehmen.
db.query(Barcode).delete(synchronize_session=False) 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(Group).delete(synchronize_session=False)
db.query(Category).delete(synchronize_session=False) db.query(Category).delete(synchronize_session=False)
db.query(Location).delete(synchronize_session=False) db.query(Location).delete(synchronize_session=False)

View File

@@ -1,21 +1,129 @@
from fastapi import APIRouter, Depends, HTTPException, status import difflib
from fastapi.responses import Response import re
from sqlalchemy.orm import Session
from ..crud import product_to_out from fastapi import APIRouter, BackgroundTasks, Depends, File, HTTPException, UploadFile, status
from ..database import get_db 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 ..deps import get_current_user, require_admin
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User from ..models import (
Barcode,
BaseUnit,
Category,
CategoryTracking,
Group,
Location,
Movement,
MovementType,
Product,
ProductImage,
ProductLocationMinStock,
RemovalReason,
Shop,
User,
)
from ..off import lookup_barcode from ..off import lookup_barcode
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate from ..schemas import (
BarcodeCreate,
LocationMinStockIn,
LookupResult,
MatchCandidate,
MatchLine,
MatchRequest,
ProductCreate,
ProductOut,
ProductUpdate,
RemovalHistoryItem,
RemovalStat,
RemovalSummary,
)
from ..services import images from ..services import images
from ..services.categories import suggest_category from ..services.categories import suggest_category
from .categories import descendant_ids from .categories import descendant_ids
from .settings import get_receipt_match_threshold
from ..services.conversion import ConversionError, resolve_product_unit 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.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"]) 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]) @router.get("", response_model=list[ProductOut])
def list_products( def list_products(
@@ -30,7 +138,16 @@ def list_products(
Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will
auch "Schokolade" sehen. 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: if q:
like = f"%{q}%" like = f"%{q}%"
query = query.filter(Product.name.ilike(like)) query = query.filter(Product.name.ilike(like))
@@ -39,7 +156,7 @@ def list_products(
elif category_id is not None: elif category_id is not None:
query = query.filter(Product.category_id.in_(descendant_ids(db, category_id))) query = query.filter(Product.category_id.in_(descendant_ids(db, category_id)))
products = query.order_by(Product.name).all() 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) @router.get("/lookup", response_model=LookupResult)
@@ -92,6 +209,88 @@ def get_product(
return product_to_out(db, product) return product_to_out(db, product)
@router.put("/{product_id}/location-min-stock", response_model=ProductOut)
def set_product_location_min_stock(
product_id: int,
payload: list[LocationMinStockIn],
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt)."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
db.query(ProductLocationMinStock).filter(
ProductLocationMinStock.product_id == product_id
).delete()
gesehen: set[int] = 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:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
gesehen.add(eintrag.location_id)
db.add(ProductLocationMinStock(
product_id=product_id,
location_id=eintrag.location_id,
min_stock=eintrag.min_stock,
))
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.get("/{product_id}/removals", response_model=RemovalSummary)
def product_removals(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> RemovalSummary:
"""Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
stats = [
RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"])
for grund, v in removal_stats(db, product_id).items()
]
stats.sort(key=lambda s: s.quantity, reverse=True)
rows = (
db.query(Movement)
.filter(
Movement.product_id == product_id,
Movement.type == MovementType.out,
Movement.reason.isnot(None),
)
.order_by(Movement.created_at.desc())
.limit(50)
.all()
)
loc_names = {loc.id: loc.name for loc in db.query(Location).all()}
user_ids = {m.user_id for m in rows if m.user_id}
users = (
{u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()}
if user_ids
else {}
)
history = [
RemovalHistoryItem(
reason=RemovalReason(m.reason),
quantity=m.quantity,
location_id=m.location_id,
location_name=loc_names.get(m.location_id),
note=m.note,
username=users.get(m.user_id),
created_at=m.created_at,
)
for m in rows
]
return RemovalSummary(stats=stats, history=history)
@router.get("/{product_id}/off", response_model=LookupResult) @router.get("/{product_id}/off", response_model=LookupResult)
def off_vergleich( def off_vergleich(
product_id: int, product_id: int,
@@ -123,8 +322,10 @@ def off_vergleich(
"Dieser Artikel hat keinen Barcode ohne den kann Open Food Facts nichts finden.", "Dieser Artikel hat keinen Barcode ohne den kann Open Food Facts nichts finden.",
) )
# Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF.
prefer = "object" if product_tracking(db, product) == "object" else None
for code in codes: for code in codes:
suggestion = lookup_barcode(code) suggestion = lookup_barcode(code, prefer=prefer)
if suggestion: if suggestion:
category = suggest_category(db, suggestion) category = suggest_category(db, suggestion)
return LookupResult( return LookupResult(
@@ -164,9 +365,102 @@ def get_product_image(
) )
@router.put("/{product_id}/image", response_model=ProductOut)
async def upload_product_image(
product_id: int,
file: UploadFile = File(...),
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ProductOut:
"""Eigenes Foto hochladen (Kamera oder Galerie) und beim Artikel ablegen.
``source_url = None`` markiert das Bild als selbst hochgeladen so ersetzt
es der Bildabgleich beim Speichern nicht durch ein Bild aus einer Adresse
(solange keine Bildadresse gesetzt ist).
"""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
if file.content_type not in images.ALLOWED_TYPES:
erlaubt = ", ".join(sorted(t.split("/")[-1] for t in images.ALLOWED_TYPES))
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Dieses Bildformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.",
)
data = await file.read()
if not data:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.")
if len(data) > images.MAX_BYTES:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
f"Das Bild ist zu groß ({len(data) // 1024} KB). "
f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.",
)
bild = db.get(ProductImage, product.id)
if bild is None:
bild = ProductImage(
product_id=product.id, content_type=file.content_type, data=data, source_url=None
)
db.add(bild)
else:
bild.content_type, bild.data, bild.source_url = file.content_type, data, None
db.commit()
db.refresh(product)
return product_to_out(db, product)
@router.delete("/{product_id}/image", status_code=status.HTTP_204_NO_CONTENT)
def delete_product_image(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Foto entfernen. Eine gesetzte Bildadresse bleibt bestehen."""
bild = db.get(ProductImage, product_id)
if bild is not None:
db.delete(bild)
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) @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
def create_product( def create_product(
payload: ProductCreate, payload: ProductCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> ProductOut: ) -> ProductOut:
@@ -183,6 +477,11 @@ def create_product(
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id) base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
except ConversionError as exc: except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc 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( product = Product(
barcode=payload.barcode or None, barcode=payload.barcode or None,
name=payload.name, name=payload.name,
@@ -192,23 +491,36 @@ def create_product(
display_unit_id=display_unit_id, display_unit_id=display_unit_id,
package_size=payload.package_size, package_size=payload.package_size,
package_label=payload.package_label or None, 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, date_precision=payload.date_precision.value,
group_id=payload.group_id, group_id=payload.group_id,
category_id=payload.category_id, category_id=payload.category_id,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id, min_stock_unit_id=payload.min_stock_unit_id,
min_stock_in_packages=bool(payload.min_stock_in_packages), 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", source="manual",
) )
db.add(product) db.add(product)
db.flush() # product.id fuer den Gruppen-Code 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) sync_group_code(db, product)
if product.image_url: try:
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler: apply_field_values(db, product, payload.field_values)
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern. except FieldError as exc:
images.store(db, product, product.image_url) raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit() db.commit()
db.refresh(product) 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) return product_to_out(db, product)
@@ -224,6 +536,8 @@ def update_product(
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
# Feldwerte sind keine Spalte, sondern eigene Zeilen getrennt behandeln.
field_values = data.pop("field_values", None)
if "barcode" in data and data["barcode"]: if "barcode" in data and data["barcode"]:
clash = ( clash = (
db.query(Product) db.query(Product)
@@ -234,6 +548,8 @@ def update_product(
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits" status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
) )
if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit. # Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
if "unit_id" in data: if "unit_id" in data:
unit_id = data.pop("unit_id") unit_id = data.pop("unit_id")
@@ -245,6 +561,27 @@ def update_product(
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id) product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
except ConversionError as exc: except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from 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: if data.get("min_stock_in_packages") is None:
data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL
if data.get("date_precision") is None: if data.get("date_precision") is None:
@@ -253,6 +590,13 @@ def update_product(
data["date_precision"] = data["date_precision"].value data["date_precision"] = data["date_precision"].value
for field, value in data.items(): for field, value in data.items():
setattr(product, field, value) 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)
except FieldError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen. # Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
sync_group_code(db, product) sync_group_code(db, product)
if "image_url" in data: if "image_url" in data:

View File

@@ -10,6 +10,10 @@ from ..schemas import SettingOut
router = APIRouter(prefix="/settings", tags=["settings"]) router = APIRouter(prefix="/settings", tags=["settings"])
EXPIRY_WARNING_KEY = "expiry_warning_days" 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: 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 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]) @router.get("", response_model=list[SettingOut])
def list_settings( def list_settings(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
@@ -29,6 +44,7 @@ def list_settings(
rows = db.query(Setting).all() rows = db.query(Setting).all()
known = {r.key: r.value for r in rows} known = {r.key: r.value for r in rows}
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db))) 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()] return [SettingOut(key=k, value=v) for k, v in known.items()]

View File

@@ -0,0 +1,88 @@
"""Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ (nur Gegenstände)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Product, Shop, User
from ..schemas import ShopCreate, ShopOut, ShopUpdate
router = APIRouter(prefix="/shops", tags=["shops"])
def _to_out(db: Session, shop: Shop) -> ShopOut:
out = ShopOut.model_validate(shop)
out.product_count = db.query(Product).filter(Product.shop_id == shop.id).count()
return out
@router.get("", response_model=list[ShopOut])
def list_shops(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShopOut]:
return [_to_out(db, s) for s in db.query(Shop).order_by(Shop.name).all()]
@router.post("", response_model=ShopOut, status_code=status.HTTP_201_CREATED)
def create_shop(
payload: ShopCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ShopOut:
name = payload.name.strip()
if db.query(Shop).filter(func.lower(Shop.name) == name.lower()).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
shop = Shop(name=name, website=(payload.website or None))
db.add(shop)
db.commit()
db.refresh(shop)
return _to_out(db, shop)
@router.patch("/{shop_id}", response_model=ShopOut)
def update_shop(
shop_id: int,
payload: ShopUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ShopOut:
shop = db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "name" in data and data["name"]:
name = data["name"].strip()
doppelt = (
db.query(Shop)
.filter(func.lower(Shop.name) == name.lower(), Shop.id != shop_id)
.first()
)
if doppelt is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
shop.name = name
if "website" in data:
shop.website = data["website"] or None
db.commit()
db.refresh(shop)
return _to_out(db, shop)
@router.delete("/{shop_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shop(
shop_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Artikel bleiben bestehen, sie verlieren nur die Bezugsquelle (SET NULL)."""
shop = db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
# SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich.
db.query(Product).filter(Product.shop_id == shop_id).update(
{Product.shop_id: None}
)
db.delete(shop)
db.commit()

View File

@@ -1,32 +1,44 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session, joinedload
from ..crud import resolve_product from ..crud import product_tracking, resolve_product
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Lot, Movement, MovementType, User from ..models import CategoryTracking, Location, Lot, Movement, MovementType, Product, User
from ..schemas import ( from ..schemas import (
BatchCheckInRequest, BatchCheckInRequest,
BatchCheckInResponse, BatchCheckInResponse,
BulkLotLocation,
CheckInRequest, CheckInRequest,
CheckInResponse, CheckInResponse,
CheckOutRequest, CheckOutRequest,
CheckOutResponse, CheckOutResponse,
LotOut, LotOut,
LotRow,
LotSplit,
LotUpdate, 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.dates import clean_precision, normalize_best_before
from ..services.stock import ( from ..services.stock import (
StockError, StockError,
check_in, check_in,
check_out, check_out,
check_out_lot, check_out_lot,
consolidate_lot_group,
current_stock, current_stock,
object_add,
object_relocate,
object_remove,
) )
router = APIRouter(tags=["stock"]) router = APIRouter(tags=["stock"])
OBJECT = CategoryTracking.object.value
@router.post("/stock/checkin", response_model=CheckInResponse) @router.post("/stock/checkin", response_model=CheckInResponse)
def stock_checkin( def stock_checkin(
@@ -35,6 +47,21 @@ def stock_checkin(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> CheckInResponse: ) -> CheckInResponse:
product = resolve_product(db, payload.product_id, payload.barcode) product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
# Gegenstände: Menge am Lagerort erhöhen, kein MHD, keine Charge-Auswahl.
lot = object_add(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
user=user,
note=payload.note,
)
db.commit()
db.refresh(lot)
return CheckInResponse(
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
)
try: try:
lot = check_in( lot = check_in(
db, db,
@@ -103,6 +130,12 @@ def stock_checkout(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> CheckOutResponse: ) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode) product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Gegenstände werden über „Entfernen“ (mit Grund) oder „Umlagern“ gebucht, "
"nicht ausgecheckt.",
)
try: try:
if payload.lot_id is not None: if payload.lot_id is not None:
lot = db.get(Lot, payload.lot_id) lot = db.get(Lot, payload.lot_id)
@@ -138,6 +171,63 @@ def stock_checkout(
) )
@router.post("/stock/relocate", response_model=StockActionResponse)
def stock_relocate(
payload: RelocateRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge von einem Lagerort zum anderen umbuchen (ohne Grund)."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Umlagern gibt es nur für Gegenstände."
)
try:
object_relocate(
db,
product=product,
quantity=payload.quantity,
from_location_id=payload.from_location_id,
to_location_id=payload.to_location_id,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.post("/stock/remove", response_model=StockActionResponse)
def stock_remove(
payload: RemoveRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge mit Pflicht-Grund aus dem Bestand entfernen."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entfernen mit Grund gibt es nur für Gegenstände.",
)
try:
object_remove(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
reason=payload.reason.value,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.get("/lots", response_model=list[LotOut]) @router.get("/lots", response_model=list[LotOut])
def list_lots( def list_lots(
product_id: int | None = None, product_id: int | None = None,
@@ -150,6 +240,122 @@ def list_lots(
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all() 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) @router.patch("/lots/{lot_id}", response_model=LotOut)
def update_lot( def update_lot(
lot_id: int, lot_id: int,
@@ -193,9 +399,15 @@ def update_lot(
note="Charge korrigiert", 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.commit()
db.refresh(lot) db.refresh(survivor)
return lot return survivor
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT) @router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)

View File

@@ -11,36 +11,70 @@ import io
import json import json
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin 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.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.group_codes import sync as sync_group_code
from ..services.min_stock import lies_ueberall, schreibe_ueberall
from ..models import ( from ..models import (
BaseUnit, BaseUnit,
Category, Category,
CategoryTracking,
DatePrecision, DatePrecision,
FieldDefinition,
Group, Group,
GroupLocationMinStock,
Item,
Location, Location,
Lot, Lot,
Movement, Movement,
MovementType, MovementType,
Product, Product,
ProductLocationMinStock,
Shop,
Unit, Unit,
UnitKind, UnitKind,
User, User,
) )
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
from ..services.fields import (
FieldError,
apply_field_values,
effective_field_definitions,
options_list,
slugify,
)
router = APIRouter(tags=["transfer"]) router = APIRouter(tags=["transfer"])
CSV_FIELDS = [ CSV_FIELDS = [
"barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde", "barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde",
"gruppe", "kategorie", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort", "gruppe", "kategorie", "art", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort",
] ]
def _tracking_from_art(text) -> str:
"""Spalte „art" → Verwaltungsart einer (neu angelegten) Kategorie.
Leer/unbekannt ⇒ Lebensmittel (bewahrt das Verhalten für zurückgespielte
Lebensmittel-Exporte).
"""
t = (str(text) if text is not None else "").strip().lower()
if t in ("gegenstand", "gegenstände", "object", "objekt", "non-food", "nonfood"):
return CategoryTracking.object.value
return CategoryTracking.food.value
def _art_label(product: Product) -> str:
if product.category and product.category.tracking == CategoryTracking.object.value:
return "Gegenstand"
return "Lebensmittel"
PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"} PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"}
@@ -85,7 +119,11 @@ def export_stock_csv(
product.package_label or "", product.package_label or "",
product.group.name if product.group else "", product.group.name if product.group else "",
_category_path(db, product.category), _category_path(db, product.category),
product.min_stock if product.min_stock is not None else "", _art_label(product),
# „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 = ( lots = (
db.query(Lot) db.query(Lot)
@@ -128,7 +166,10 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations} loc_name = {loc.id: loc.name for loc in locations}
data = { data = {
"version": 1, # 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": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"), "exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [ "units": [
@@ -138,8 +179,13 @@ def export_backup_json(
"groups": [ "groups": [
{ {
"name": g.name, "name": g.name,
"min_stock": g.min_stock,
"min_stock_unit": g.min_stock_unit.name if g.min_stock_unit else None, "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() for g in db.query(Group).order_by(Group.id).all()
], ],
@@ -147,6 +193,32 @@ def export_backup_json(
{"name": loc.name, "parent": loc_name.get(loc.parent_id)} {"name": loc.name, "parent": loc_name.get(loc.parent_id)}
for loc in locations for loc in locations
], ],
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
"categories": [
{"path": _category_path(db, c), "tracking": c.tracking}
for c in db.query(Category).order_by(Category.id).all()
],
# Bezugsquellen (nur für Gegenstände).
"shops": [
{"name": s.name, "website": s.website}
for s in db.query(Shop).order_by(Shop.id).all()
],
# Selbst definierte Felder je Kategorie.
"field_definitions": [
{
"category": _category_path(db, db.get(Category, fd.category_id)),
"label": fd.label,
"field_type": fd.field_type,
"unit": fd.unit,
"options": options_list(fd),
"required": fd.required,
"position": fd.position,
}
for fd in db.query(FieldDefinition)
.order_by(FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id)
.all()
],
"products": [], "products": [],
} }
@@ -165,9 +237,19 @@ def export_backup_json(
"date_precision": p.date_precision, "date_precision": p.date_precision,
"group": p.group.name if p.group else None, "group": p.group.name if p.group else None,
"category": _category_path(db, p.category), "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_unit": p.min_stock_unit.name if p.min_stock_unit else None,
"min_stock_in_packages": bool(p.min_stock_in_packages), "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,
"field_values": {
pfv.field_definition.label: pfv.value for pfv in p.field_values
},
"lots": [ "lots": [
{ {
"quantity": lot.quantity, "quantity": lot.quantity,
@@ -190,6 +272,71 @@ def export_backup_json(
) )
@router.get("/export/master-data")
def export_masterdata(
db: Session = Depends(get_db), _: User = Depends(require_admin)
) -> dict:
"""Stammdaten (Kategorien, Felder, Lagerorte, Einheiten, Gebinde) als JSON
mit IDs, für Sicherung/Umzug."""
return export_master_data(db)
@router.post("/import/master-data")
def import_masterdata(
payload: dict = Body(...),
mode: str = "skip",
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> dict:
"""Stammdaten aus JSON einspielen. mode=skip (Vorhandenes lassen) oder
overwrite (per ID überschreiben). IDs werden aus der Datei übernommen."""
try:
return import_master_data(db, payload, mode=mode)
except ValueError as e:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(e))
@router.get("/export/labels")
def export_labels(
category_ids: str | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[dict]:
"""Zeilen für den QR-Etiketten-Export (P-touch & Co.) je Einzelstück eine.
``category_ids`` (z.B. "1,3") grenzt auf Kategorien samt Unterkategorien ein;
leer = alle Einzelstücke. Den QR-Inhalt (…/i/<UID>) setzt die Weboberfläche
dazu, weil nur sie die öffentliche Adresse kennt.
"""
from .categories import descendant_ids
ids: set[int] | None = None
if category_ids:
ids = set()
for part in category_ids.split(","):
teil = part.strip()
if teil.isdigit():
ids |= descendant_ids(db, int(teil))
query = db.query(Item)
if ids is not None:
query = (
query.join(Product, Item.product_id == Product.id)
.filter(Product.category_id.in_(ids))
)
rows: list[dict] = []
for item in query.order_by(Item.id).all():
product = item.product
rows.append({
"uid": item.uid,
"product": product.name if product else "",
"brand": (product.brand if product else "") or "",
"category": _category_path(db, product.category) if product else "",
"location": item.location.name if item.location else "",
})
return rows
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
# Import (nur additiv) # Import (nur additiv)
# -------------------------------------------------------------------------- # --------------------------------------------------------------------------
@@ -307,8 +454,16 @@ def _category_path(db: Session, category: Category | None) -> str:
return " > ".join(reversed(teile)) return " > ".join(reversed(teile))
def _get_or_create_category(db: Session, path: str | None) -> Category | None: def _get_or_create_category(
"""Legt den ganzen Pfad an, falls Teile davon fehlen.""" db: Session, path: str | None, tracking: str = CategoryTracking.food.value
) -> Category | None:
"""Legt den ganzen Pfad an, falls Teile davon fehlen.
Importierte Kategorien sind standardmäßig „food": Backups stammen aus der
Lebensmittel-Ausgabe, und so verhalten sich wiederhergestellte Artikel wie
zuvor. Der Modus aus einem neueren Backup (Liste ``categories``) überschreibt
das anschließend.
"""
path = (path or "").strip() path = (path or "").strip()
if not path: if not path:
return None return None
@@ -320,13 +475,78 @@ def _get_or_create_category(db: Session, path: str | None) -> Category | None:
) )
node = query.first() node = query.first()
if node is None: if node is None:
node = Category(name=name, parent_id=parent.id if parent else None) node = Category(
name=name,
parent_id=parent.id if parent else None,
tracking=tracking,
)
db.add(node) db.add(node)
db.flush() db.flush()
parent = node parent = node
return parent return parent
def _get_or_create_shop(db: Session, name: str | None, website: str | None = None) -> Shop | None:
name = (name or "").strip()
if not name:
return None
shop = db.query(Shop).filter(Shop.name == name).first()
if shop is None:
shop = Shop(name=name, website=(website or None))
db.add(shop)
db.flush()
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: def _get_or_create_group(db: Session, name: str | None) -> Group | None:
name = (name or "").strip() name = (name or "").strip()
if not name: if not name:
@@ -371,7 +591,9 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc
if unit is None: if unit is None:
raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}") raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}")
group = _get_or_create_group(db, row.get("gruppe")) group = _get_or_create_group(db, row.get("gruppe"))
category = _get_or_create_category(db, row.get("kategorie")) category = _get_or_create_category(
db, row.get("kategorie"), tracking=_tracking_from_art(row.get("art"))
)
product = Product( product = Product(
barcode=barcode, barcode=barcode,
name=name, name=name,
@@ -388,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), date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None),
group_id=group.id if group else None, group_id=group.id if group else None,
category_id=category.id if category else None, category_id=category.id if category else None,
min_stock=_num(row.get("mindestbestand")),
source="import", source="import",
) )
db.add(product) db.add(product)
db.flush() 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 # Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen
# ueber die Oberflaeche. # ueber die Oberflaeche.
sync_group_code(db, product) sync_group_code(db, product)
@@ -510,7 +734,19 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
db.flush() db.flush()
for entry in data.get("groups", []): 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", []): for entry in data.get("locations", []):
_get_or_create_location(db, entry.get("name")) _get_or_create_location(db, entry.get("name"))
db.flush() db.flush()
@@ -524,6 +760,63 @@ 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: if child and parent and child.parent_id is None and child.id != parent.id:
child.parent_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", []):
cat = _get_or_create_category(
db,
entry.get("path"),
tracking=entry.get("tracking") or CategoryTracking.food.value,
)
if cat is not None and entry.get("tracking"):
cat.tracking = entry["tracking"]
for entry in data.get("shops", []):
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
db.flush()
for entry in data.get("field_definitions", []):
cat = _get_or_create_category(db, entry.get("category"))
label = (entry.get("label") or "").strip()
if cat is None or not label:
continue
if db.query(FieldDefinition).filter_by(category_id=cat.id, label=label).first():
continue
ftype = entry.get("field_type") or "text"
optionen = entry.get("options") or []
db.add(
FieldDefinition(
category_id=cat.id,
label=label,
key=slugify(label),
field_type=ftype,
unit=(entry.get("unit") or None),
options=json.dumps(optionen) if ftype == "select" and optionen else None,
required=bool(entry.get("required")),
position=int(entry.get("position") or 0),
)
)
db.flush()
for entry in data.get("products", []): for entry in data.get("products", []):
try: try:
row = { row = {
@@ -543,6 +836,36 @@ 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 "", "mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
} }
product = _get_or_create_product(db, row, created_products) 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:
shop = _get_or_create_shop(db, shop_name)
product.shop_id = shop.id if shop else None
if entry.get("product_url") and not product.product_url:
product.product_url = entry["product_url"]
feldwerte = entry.get("field_values") or {}
if feldwerte and product.category_id:
nach_label = {
fd.label: fd
for fd, _ in effective_field_definitions(db, product.category_id)
}
for label, value in feldwerte.items():
fd = nach_label.get(label)
if fd is None:
continue
try:
apply_field_values(db, product, {fd.id: value})
except FieldError:
pass
if mode == "replace_listed" and product.id not in cleared: if mode == "replace_listed" and product.id not in cleared:
_clear_lots(db, product, user) _clear_lots(db, product, user)
cleared.add(product.id) cleared.add(product.id)

View File

@@ -1,44 +1,314 @@
import math
from collections import defaultdict
from collections.abc import Iterable
from datetime import date, timedelta from datetime import date, timedelta
from typing import Callable, TypeVar
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Group, Lot, Movement, Product, User from ..models import (
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem Group,
from ..services.conversion import BASE_OF_KIND, display_unit_info GroupLocationMinStock,
from ..services.stock import current_stock Item,
Location,
Lot,
Movement,
PackageType,
Product,
ProductLocationMinStock,
User,
)
from ..schemas import (
BaseUnit,
ExpiringItem,
GroupShoppingItem,
LocationContentEntry,
LocationContents,
LocationNeedGroup,
LocationNeedProduct,
LocationNeeds,
MovementOut,
ShoppingItem,
ShoppingListAll,
ShoppingNeed,
)
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 from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"]) 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]) @router.get("/shopping-list", response_model=list[ShoppingItem])
def shopping_list( def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]: ) -> list[ShoppingItem]:
"""Produkte, deren Bestand unter dem Mindestbestand liegt.""" return einkaufsliste_artikel(db)
items: list[ShoppingItem] = []
products = (
db.query(Product) def _gruppen_bedarfe(db: Session, ort_desc: dict[str, set[str]]) -> tuple[dict, dict, dict, dict, dict]:
.filter(Product.min_stock.isnot(None), Product.min_stock > 0) """Je (Gruppe, Ort) Mindestbestand, Bestand und verrechneter Bedarf.
.all()
) Hier wirken ZWEI Hierarchien zusammen: der Gruppen-Graph und der
for product in products: Lagerort-Baum. „1 kg Grillwurst in die Küche" deckt auch „Wurst in Lemgo".
stock = current_stock(db, product.id) Deshalb ist ein Schlüssel ein Paar, und ``(h, m)`` gilt als Nachfahre von
if stock < product.min_stock: ``(g, l)``, wenn h unter g und m unter l liegt (oder gleich ist).
items.append(
ShoppingItem( Verrechnet wird nur, wo die zählenden Artikel der Untergruppe eine Teilmenge
product_id=product.id, der Obergruppe sind: zählt „Wurst" in Kilogramm, ihre Untergruppe aber in
name=product.name, Stück, kommt ein Kauf dort oben gar nicht an (siehe die Einheiten-Filterung
base_unit=product.base_unit, in ``group_min_context``).
package_size=product.package_size,
stock=stock, Alle Mengen in Basiseinheiten nur darin lassen sich Gruppen mit
min_stock=product.min_stock, verschiedenen Erfassungseinheiten überhaupt gegeneinander verrechnen.
deficit=product.min_stock - stock, """
) 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) items.sort(key=lambda i: i.deficit, reverse=True)
return items return items
@@ -47,40 +317,206 @@ def shopping_list(
def group_shopping_list( def group_shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user) db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[GroupShoppingItem]: ) -> 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] = [] schluessel = list(minima)
groups = ( desc = {k: [d for d in schluessel if d != k and d in nachfahren(k)] for k in schluessel}
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all() 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: Artikel und Gruppen, denen AN DIESEM ORT etwas fehlt.
„Ü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)
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,
# 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,
),
))
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
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 = {
loc.id: loc.name
for loc in db.query(Location).filter(Location.id.in_(loc_ids)).all()
}
result: list[LocationNeeds] = []
for loc_id in sorted(loc_ids, key=lambda i: namen.get(i, "")):
result.append(LocationNeeds(
location_id=loc_id,
location_name=namen.get(loc_id, "?"),
products=sorted(prod_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
groups=sorted(group_needs.get(loc_id, []), key=lambda x: x.deficit, reverse=True),
))
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),
) )
for group in groups:
unit = group.min_stock_unit
if unit is not None: @router.get("/location/{code}", response_model=LocationContents)
base = BASE_OF_KIND[unit.kind] def location_contents(
products = [p for p in group.products if p.base_unit == base] code: str,
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor db: Session = Depends(get_db),
unit_name = unit.name _: User = Depends(get_current_user),
else: ) -> LocationContents:
products = list(group.products) """Alle Artikel, die an einem Lagerort liegen inklusive der Unterorte.
stock = float(sum(current_stock(db, p.id) for p in products))
unit_name = "" Ziel des Lagerort-QR (`/l/<code>`) beim Nachschlagen: einmal scannen und
if stock < group.min_stock: sehen, was im Regal/Fach (und allem darunter) steht. Zählt sowohl
items.append( Lot-Bestände (Lebensmittel/Objekte) als auch Einzelstücke.
GroupShoppingItem( """
group_id=group.id, loc = db.get(Location, code)
name=group.name, if loc is None:
stock=stock, raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
min_stock=group.min_stock,
deficit=group.min_stock - stock, ids = {code} | descendant_location_ids(db, code)
unit_name=unit_name, product_ids: set[int] = {
product_count=len(products), pid for (pid,) in db.query(Lot.product_id).filter(Lot.location_id.in_(ids)).distinct()
) }
) product_ids |= {
items.sort(key=lambda i: i.deficit, reverse=True) pid for (pid,) in db.query(Item.product_id).filter(Item.location_id.in_(ids)).distinct()
return items }
entries: list[LocationContentEntry] = []
for pid in product_ids:
product = db.get(Product, pid)
if product is None:
continue
faktor, label = article_unit(product)
stock = location_subtree_stock_base(db, product, code) / (faktor or 1.0)
if stock <= 0:
continue
entries.append(LocationContentEntry(
product_id=product.id, name=product.name, brand=product.brand,
stock=round(stock, 3), unit_label=label, individual=product.individual,
))
entries.sort(key=lambda e: e.name.lower())
return LocationContents(location_id=code, location_name=loc.name, products=entries)
@router.get("/expiring", response_model=list[ExpiringItem]) @router.get("/expiring", response_model=list[ExpiringItem])

View File

@@ -2,9 +2,17 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models import BaseUnit, DatePrecision, Role, UnitKind from .models import (
BaseUnit,
CategoryTracking,
DatePrecision,
FieldType,
RemovalReason,
Role,
UnitKind,
)
# ---- Units ---- # ---- Units ----
@@ -101,70 +109,179 @@ class UserUpdate(BaseModel):
# ---- Groups ---- # ---- Groups ----
class LocationMinStockIn(BaseModel):
"""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 | 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): class GroupOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str 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 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: # 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 product_count: int = 0
stock: float = 0.0 # Bestand in Basiseinheiten direct_product_count: int = 0
min_stock_unit_name: str | None = None 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 kind: str | None = None # Art der Mindestbestand-Einheit
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar. # EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
product_barcodes: list[ProductBarcodeOut] = [] product_barcodes: list[ProductBarcodeOut] = []
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
location_min_stocks: list[LocationMinStockOut] = []
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None 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): class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None 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 ---- # ---- Categories ----
class CategoryOut(BaseModel): class CategoryOut(BaseModel):
"""Reine Ordnungshilfe kein Bestand, kein Mindestbestand, keine EAN-Codes.""" """Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand)."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
parent_id: int | None = None parent_id: int | None = None
is_builtin: bool = False is_builtin: bool = False
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
tracking: CategoryTracking = CategoryTracking.food
product_count: int = 0 product_count: int = 0
class CategoryCreate(BaseModel): class CategoryCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
parent_id: int | None = None parent_id: int | None = None
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
tracking: CategoryTracking | None = None
class CategoryUpdate(BaseModel): class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: int | None = None parent_id: int | None = None
tracking: CategoryTracking | None = None
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
class ShopOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
website: str | None = None
product_count: int = 0
class ShopCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
class ShopUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
# ---- Selbst definierte Felder je Kategorie ----
class FieldDefinitionBase(BaseModel):
label: str = Field(min_length=1, max_length=120)
field_type: FieldType = FieldType.text
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None # nur für field_type == "select"
required: bool = False
position: int = 0
class FieldDefinitionCreate(FieldDefinitionBase):
category_id: int
class FieldDefinitionUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=120)
field_type: FieldType | None = None
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None
required: bool | None = None
position: int | None = None
class FieldDefinitionOut(BaseModel):
id: int
category_id: int
label: str
key: str
field_type: FieldType
unit: str | None = None
options: list[str] = []
required: bool = False
position: int = 0
is_builtin: bool = False
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
inherited: bool = False
# ---- Locations ---- # ---- Locations ----
class LocationOut(BaseModel): class LocationOut(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: str
name: str name: str
parent_id: int | None = None parent_id: str | None = None
class LocationCreate(BaseModel): class LocationCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
parent_id: int | None = None parent_id: str | None = None
class LocationUpdate(BaseModel): class LocationUpdate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: str | None = None
# ---- Gebinde (Packung, Glas, …) ---- # ---- Gebinde (Packung, Glas, …) ----
@@ -198,6 +315,12 @@ class ProductBase(BaseModel):
package_size: float | None = Field(default=None, gt=0) package_size: float | None = Field(default=None, gt=0)
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …) # Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
package_label: str | None = Field(default=None, max_length=32) 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). # Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
date_precision: DatePrecision = DatePrecision.day date_precision: DatePrecision = DatePrecision.day
group_id: int | None = None group_id: int | None = None
@@ -207,6 +330,15 @@ class ProductBase(BaseModel):
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde. # Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
min_stock_unit_id: int | None = None min_stock_unit_id: int | None = None
min_stock_in_packages: bool = False min_stock_in_packages: bool = False
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
shop_id: int | None = None
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
class ProductCreate(ProductBase): class ProductCreate(ProductBase):
@@ -222,12 +354,23 @@ class ProductUpdate(BaseModel):
unit_id: int | None = None unit_id: int | None = None
package_size: float | None = Field(default=None, gt=0) package_size: float | None = Field(default=None, gt=0)
package_label: str | None = Field(default=None, max_length=32) 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 date_precision: DatePrecision | None = None
group_id: int | None = None group_id: int | None = None
category_id: int | None = None category_id: int | None = None
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None min_stock_unit_id: int | None = None
min_stock_in_packages: bool | None = None min_stock_in_packages: bool | None = None
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
class ProductOut(BaseModel): class ProductOut(BaseModel):
@@ -241,18 +384,36 @@ class ProductOut(BaseModel):
display_unit_id: int | None = None display_unit_id: int | None = None
package_size: float | None package_size: float | None
package_label: str | None = 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 date_precision: DatePrecision = DatePrecision.day
group_id: int | None 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_id: int | None = None
category_name: str | None = None category_name: str | None = None
min_stock: float | None min_stock: float | None
min_stock_unit_id: int | None = None min_stock_unit_id: int | None = None
min_stock_in_packages: bool = False min_stock_in_packages: bool = False
source: str source: str
shop_id: int | None = None
product_url: str | None = None
individual: bool = False
bulk: bool = False
created_at: datetime created_at: datetime
updated_at: datetime | None = None
# angereichert: # angereichert:
stock: float = 0.0 stock: float = 0.0
expired_count: int = 0 expired_count: int = 0
# Epoch (Sekunden) der letzten Bildänderung, sonst None (= kein Bild). Der
# Client cacht Bilder danach und lädt nur bei geänderter Version neu.
image_version: int | None = None
kind: str = "" kind: str = ""
unit_name: str = "" unit_name: str = ""
unit_factor: float = 1.0 unit_factor: float = 1.0
@@ -261,6 +422,20 @@ class ProductOut(BaseModel):
min_stock_unit_label: str = "" min_stock_unit_label: str = ""
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode): # Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
barcodes: list[BarcodeOut] = [] barcodes: list[BarcodeOut] = []
# Verwaltungsart aus der Kategorie (food/object), Bezugsquelle und Feldwerte:
tracking: CategoryTracking = CategoryTracking.food
shop_name: str | None = None
field_values: dict[int, str | None] = {}
# Mindestbestand je Lagerort (in Artikeleinheiten), zusaetzlich zum globalen.
location_min_stocks: list[LocationMinStockOut] = []
@field_validator("field_values", mode="before")
@classmethod
def _field_values_from_orm(cls, v):
"""Beim Lesen aus der DB kommt eine Liste ProductFieldValue zu Map machen."""
if isinstance(v, list):
return {pfv.field_definition_id: pfv.value for pfv in v}
return v
class LookupResult(BaseModel): class LookupResult(BaseModel):
@@ -294,7 +469,7 @@ class CheckInRequest(BaseModel):
best_before: date | None = None best_before: date | None = None
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py). # "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
best_before_precision: DatePrecision = DatePrecision.day best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None = None location_id: str | None = None
note: str | None = None note: str | None = None
@@ -315,7 +490,7 @@ class LotOut(BaseModel):
quantity: float quantity: float
best_before: date | None best_before: date | None
best_before_precision: DatePrecision = DatePrecision.day best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None location_id: str | None
created_at: datetime created_at: datetime
@@ -324,7 +499,62 @@ class LotUpdate(BaseModel):
quantity: float | None = Field(default=None, gt=0) quantity: float | None = Field(default=None, gt=0)
best_before: date | None = None best_before: date | None = None
best_before_precision: DatePrecision | None = None best_before_precision: DatePrecision | None = None
location_id: int | None = None 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): class CheckInResponse(BaseModel):
@@ -337,7 +567,7 @@ class CheckInLine(BaseModel):
quantity: float = Field(gt=0) quantity: float = Field(gt=0)
best_before: date | None = None best_before: date | None = None
best_before_precision: DatePrecision = DatePrecision.day best_before_precision: DatePrecision = DatePrecision.day
location_id: int | None = None location_id: str | None = None
class BatchCheckInRequest(BaseModel): class BatchCheckInRequest(BaseModel):
@@ -358,12 +588,153 @@ class CheckOutResponse(BaseModel):
product_stock: float product_stock: float
# ---- Gegenstände: Umlagern und Entfernen mit Grund ----
class RelocateRequest(BaseModel):
"""Menge eines Gegenstands von einem Lagerort zu einem anderen umbuchen (ohne Grund)."""
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
from_location_id: str | None = None
to_location_id: str | None = None
note: str | None = None
class RemoveRequest(BaseModel):
"""Menge eines Gegenstands aus dem Bestand entfernen Grund ist Pflicht."""
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
location_id: str | None = None
reason: RemovalReason
note: str | None = None
class StockActionResponse(BaseModel):
product_stock: float
class RemovalStat(BaseModel):
"""Summe der Entnahmen je Grund (für die kleine Statistik am Artikel)."""
reason: RemovalReason
quantity: float
count: int
class RemovalHistoryItem(BaseModel):
reason: RemovalReason
quantity: float
location_id: str | None = None
location_name: str | None = None
note: str | None = None
username: str | None = None
created_at: datetime
class RemovalSummary(BaseModel):
stats: list[RemovalStat] = []
history: list[RemovalHistoryItem] = []
# ---- Einzelstücke (Items mit UID/QR) ----
class ItemCreate(BaseModel):
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
count: int = Field(default=1, ge=1, le=200)
location_id: str | None = None
shop_id: int | None = None
acquired_on: date | None = None
warranty_until: date | None = None
note: str | None = Field(default=None, max_length=255)
price_cents: int | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, max_length=3)
class ItemUpdate(BaseModel):
location_id: str | None = None
shop_id: int | None = None
acquired_on: date | None = None
warranty_until: date | None = None
note: str | None = Field(default=None, max_length=255)
price_cents: int | None = Field(default=None, ge=0)
currency: str | None = Field(default=None, max_length=3)
class ItemRemove(BaseModel):
reason: RemovalReason
note: str | None = Field(default=None, max_length=255)
class ItemDocumentOut(BaseModel):
"""Beleg-Metadaten (ohne Binärdaten)."""
model_config = ConfigDict(from_attributes=True)
id: int
filename: str
content_type: str
uploaded_at: datetime
class DocumentSuggestions(BaseModel):
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
suggested_warranty_until: date | None = None
suggested_price_cents: int | None = None
# Alle plausiblen Preise (bester zuerst) zur Auswahl, falls es mehrere gibt.
suggested_price_candidates: list[int] = []
suggested_acquired_on: date | None = None
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
suggested_shop_id: int | None = None
suggested_shop_name: str | None = None
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
"""Antwort nach dem Upload Beleg-Metadaten plus die geschätzten Werte."""
class ItemOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
uid: str
product_id: int
location_id: str | None = None
location_name: str | None = None
shop_id: int | None = None
shop_name: str | None = None
acquired_on: date | None = None
warranty_until: date | None = None
note: str | None = None
price_cents: int | None = None
currency: str | None = None
created_at: datetime
# Für QR-Auflösung/Anzeige mitgeliefert:
product_name: str | None = None
product_brand: str | None = None
category_id: int | None = None
category_name: str | None = None
documents: list[ItemDocumentOut] = []
# ---- Views ---- # ---- 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): class ShoppingItem(BaseModel):
product_id: int product_id: int
name: str name: str
base_unit: BaseUnit base_unit: BaseUnit
package_size: float | None = None package_size: float | None = None
need: ShoppingNeed | None = None
stock: float stock: float
min_stock: float min_stock: float
deficit: float deficit: float
@@ -393,6 +764,65 @@ class GroupShoppingItem(BaseModel):
deficit: float deficit: float
unit_name: str = "" unit_name: str = ""
product_count: int product_count: int
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
subgroup_count: int = 0
need: ShoppingNeed | None = None
class LocationNeedProduct(BaseModel):
"""Ein Produkt-Bedarf an einem Lagerort (Mengen in Artikeleinheiten)."""
product_id: int
name: str
unit_label: str = ""
stock: float
min_stock: float
deficit: float
need: ShoppingNeed | None = None
class LocationNeedGroup(BaseModel):
group_id: int
name: str
unit_name: str = ""
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):
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
location_id: str
location_name: str
products: list[LocationNeedProduct] = []
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
name: str
brand: str | None = None
stock: float
unit_label: str = ""
individual: bool = False
class LocationContents(BaseModel):
"""Alle Artikel, die an einem Lagerort liegen inklusive der Unterorte."""
location_id: str
location_name: str
products: list[LocationContentEntry] = []
class MovementOut(BaseModel): class MovementOut(BaseModel):

View File

@@ -1,10 +1,13 @@
"""Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten.""" """Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
import json
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import get_settings from .config import get_settings
from .models import Category, PackageType, Role, Unit, UnitKind, User from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User
from .security import hash_password from .security import hash_password
from .services.fields import slugify
# (Name, Art, Faktor zur kanonischen Basiseinheit) # (Name, Art, Faktor zur kanonischen Basiseinheit)
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [ BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
@@ -87,12 +90,98 @@ def ensure_builtin_categories(db: Session) -> None:
""" """
if db.query(Category).first() is not None: if db.query(Category).first() is not None:
return return
food = CategoryTracking.food.value
for name, children in BUILTIN_CATEGORIES: for name, children in BUILTIN_CATEGORIES:
parent = Category(name=name, is_builtin=True) parent = Category(name=name, is_builtin=True, tracking=food)
db.add(parent) db.add(parent)
db.flush() db.flush()
for child in children: for child in children:
db.add(Category(name=child, parent_id=parent.id, is_builtin=True)) db.add(Category(name=child, parent_id=parent.id, is_builtin=True, tracking=food))
db.commit()
# Beispiel-Kategorien für Gegenstände (Non-Food) mit ein paar sinnvollen Feldern.
# Frei änderbar; wer sie löscht, bekommt sie nicht zurück (siehe Guard unten).
OBJECT_EXAMPLES: list[dict] = [
{
"name": "Kleidung",
"fields": [
{"label": "Größe", "type": "select",
"options": ["XS", "S", "M", "L", "XL", "XXL"]},
{"label": "Farbe", "type": "text"},
],
"children": [
{"name": "Schuhe",
"fields": [{"label": "Schuhgröße", "type": "number", "unit": "EU"}]},
],
},
{
# Kaufdatum/Garantie sind je Einzelstück (Item), nicht am Modell deshalb
# hier bewusst keine solchen Felder. Modell-Felder = gemeinsame Merkmale.
"name": "Elektronik",
"fields": [],
"children": [
{"name": "Powerbank",
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
{"name": "Kabel", "fields": [
{"label": "Länge", "type": "number", "unit": "cm"},
{"label": "Anschluss", "type": "text"},
]},
],
},
{"name": "Werkzeug", "fields": []},
{"name": "Bücher & Medien", "fields": []},
]
def _add_fields(db: Session, category_id: int, felder: list[dict]) -> None:
for pos, f in enumerate(felder):
optionen = f.get("options")
db.add(
FieldDefinition(
category_id=category_id,
label=f["label"],
key=slugify(f["label"]),
field_type=f.get("type", "text"),
unit=f.get("unit"),
options=json.dumps(optionen) if optionen else None,
required=bool(f.get("required")),
position=pos,
is_builtin=True,
)
)
def ensure_example_object_categories(db: Session) -> None:
"""Legt Beispiel-Gegenstandskategorien mit Feldern an nur, wenn es noch keine gibt.
So sieht man das Gegenstands-Feature gleich in Aktion; wer die Beispiele
bewusst entfernt, bekommt sie beim nächsten Start nicht zurück.
"""
if (
db.query(Category)
.filter(Category.tracking == CategoryTracking.object.value)
.first()
is not None
):
return
for top in OBJECT_EXAMPLES:
parent = Category(
name=top["name"], tracking=CategoryTracking.object.value, is_builtin=True
)
db.add(parent)
db.flush()
_add_fields(db, parent.id, top.get("fields", []))
for child in top.get("children", []):
node = Category(
name=child["name"],
parent_id=parent.id,
tracking=CategoryTracking.object.value,
is_builtin=True,
)
db.add(node)
db.flush()
_add_fields(db, node.id, child.get("fields", []))
db.commit() db.commit()

View File

@@ -7,10 +7,13 @@ Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
from __future__ import annotations from __future__ import annotations
from typing import NamedTuple
from sqlalchemy import func from sqlalchemy import func
from sqlalchemy.orm import Session 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] = { BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
UnitKind.count: BaseUnit.piece, 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() 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: 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.""" """Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
if quantity <= 0: if quantity <= 0:
@@ -68,10 +115,18 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) ->
if unit is None: if unit is None:
raise ConversionError(f"Unbekannte Einheit: {unit_token}") raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product): if unit.kind != kind_of_product(product):
raise ConversionError( # Artfremde Einheit: geht nur über die Zweiteinheit des Artikels.
f"Einheit '{unit.name}' passt nicht zur Art des Produkts " # „250 g" bei einem in Stück geführten Artikel wird zu 3 Stück.
f"({BASE_LABEL[product.base_unit]})." # 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 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.""" """Rechnet eine Menge in Basiseinheiten in Artikeleinheiten um."""
factor, _ = article_unit(product) factor, _ = article_unit(product)
return quantity_base / factor if factor else quantity_base 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,139 @@
"""Selbst definierte Felder je Kategorie: Vererbung, Validierung, Werte.
Felder hängen an einer Kategorie und gelten über den Kategorie-Baum auch für
deren Unterkategorien. Der Wert je Artikel steht in ProductFieldValue (immer als
Text; typgerecht geprüft wird hier).
"""
from __future__ import annotations
import json
import re
import unicodedata
from datetime import date
from sqlalchemy.orm import Session
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue
class FieldError(ValueError):
"""Ungültige Feld-Definition oder ein Wert, der nicht zum Feldtyp passt."""
def slugify(label: str) -> str:
"""Maschinenlesbaren Schlüssel aus einem Label ableiten (z.B. „Kapazität“ → „kapazitaet“)."""
text = unicodedata.normalize("NFKD", label)
text = text.encode("ascii", "ignore").decode("ascii").lower()
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
return text or "feld"
def options_list(fd: FieldDefinition) -> list[str]:
"""Auswahlmöglichkeiten eines select-Felds als Liste (leer, wenn keine)."""
if not fd.options:
return []
try:
data = json.loads(fd.options)
except (ValueError, TypeError):
return []
return [str(o) for o in data] if isinstance(data, list) else []
def _ancestry(db: Session, category_id: int) -> list[Category]:
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
chain: list[Category] = []
seen: set[int] = set()
cid: int | None = category_id
while cid is not None and cid not in seen:
seen.add(cid)
cat = db.get(Category, cid)
if cat is None:
break
chain.append(cat)
cid = cat.parent_id
chain.reverse() # Wurzel zuerst, damit deren Felder oben stehen
return chain
def effective_field_definitions(
db: Session, category_id: int | None
) -> list[tuple[FieldDefinition, bool]]:
"""Alle für eine Kategorie geltenden Felder inkl. der geerbten.
Rückgabe: Liste aus (Felddefinition, geerbt?) geerbt=True, wenn das Feld
von einer Oberkategorie stammt. Reihenfolge: Oberkategorien zuerst.
"""
if category_id is None:
return []
result: list[tuple[FieldDefinition, bool]] = []
for cat in _ancestry(db, category_id):
defs = (
db.query(FieldDefinition)
.filter(FieldDefinition.category_id == cat.id)
.order_by(FieldDefinition.position, FieldDefinition.id)
.all()
)
for d in defs:
result.append((d, cat.id != category_id))
return result
def coerce_value(fd: FieldDefinition, raw) -> str | None:
"""Einen Rohwert typgerecht prüfen und als Text zurückgeben (oder None = leer)."""
if raw is None:
return None
text = str(raw).strip()
if text == "":
return None
if fd.field_type == FieldType.number.value:
try:
float(text.replace(",", "."))
except ValueError as exc:
raise FieldError(f"{fd.label}“ erwartet eine Zahl.") from exc
return text
if fd.field_type == FieldType.date.value:
try:
date.fromisoformat(text)
except ValueError as exc:
raise FieldError(f"{fd.label}“ erwartet ein Datum (JJJJ-MM-TT).") from exc
return text
if fd.field_type == FieldType.boolean.value:
return "true" if text.lower() in ("1", "true", "ja", "yes", "on") else "false"
if fd.field_type == FieldType.select.value:
opts = options_list(fd)
if opts and text not in opts:
raise FieldError(f"{text}“ ist keine gültige Auswahl für „{fd.label}“.")
return text
return text # text / textarea
def apply_field_values(
db: Session, product, values: dict[int, str | None] | None
) -> None:
"""Feldwerte eines Artikels setzen/ändern/löschen (nur die übergebenen Felder)."""
if not values:
return
existing = {
pfv.field_definition_id: pfv
for pfv in db.query(ProductFieldValue)
.filter(ProductFieldValue.product_id == product.id)
.all()
}
for fid, raw in values.items():
fd = db.get(FieldDefinition, fid)
if fd is None:
raise FieldError(f"Feld {fid} existiert nicht.")
val = coerce_value(fd, raw)
if fid in existing:
if val is None:
db.delete(existing[fid])
else:
existing[fid].value = val
elif val is not None:
db.add(
ProductFieldValue(
product_id=product.id, field_definition_id=fid, value=val
)
)

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

@@ -18,9 +18,11 @@ from sqlalchemy.orm import Session
from ..models import Product, ProductImage from ..models import Product, ProductImage
# Grosszuegig genug fuer ein Produktfoto, eng genug, dass niemand die Datenbank # Grosszuegig genug fuer ein Produktfoto (auch ein Handy-Foto), eng genug, dass
# mit einer versehentlich verlinkten Datei volllaeuft. # niemand die Datenbank mit einer versehentlich verlinkten Datei volllaeuft.
MAX_BYTES = 2 * 1024 * 1024 # Bleibt unter dem nginx-Limit (client_max_body_size, siehe web/nginx.conf),
# damit Uebergroesse eine klare Meldung statt eines nackten 413 ergibt.
MAX_BYTES = 8 * 1024 * 1024
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}

View File

@@ -0,0 +1,34 @@
"""Einzelstücke (Items): UID-Erzeugung und Anreicherung für die Ausgabe."""
from __future__ import annotations
import secrets
from sqlalchemy.orm import Session
from ..models import Item
from ..schemas import ItemOut
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def generate_uid(db: Session, length: int = 8) -> str:
"""Kurze, eindeutige UID erzeugen (bei Kollision neu würfeln)."""
while True:
code = "".join(secrets.choice(_ALPHABET) for _ in range(length))
if db.query(Item).filter(Item.uid == code).first() is None:
return code
def item_to_out(item: Item) -> ItemOut:
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
out = ItemOut.model_validate(item)
out.location_name = item.location.name if item.location else None
out.shop_name = item.shop.name if item.shop else None
out.product_name = item.product.name if item.product else None
out.product_brand = item.product.brand if item.product else None
kategorie = item.product.category if item.product else None
out.category_id = kategorie.id if kategorie else None
out.category_name = kategorie.name if kategorie else None
return out

View File

@@ -0,0 +1,182 @@
"""Export/Import der Stammdaten als JSON mit ID-Erhalt für Sicherung/Umzug.
Umfasst Kategorien (inkl. eigener Felder), Lagerorte, Einheiten und Gebinde. Die
IDs werden bewusst mitgeführt: nur so passen z.B. gedruckte Lagerort-QR-Codes
(`/l/<id>`) nach einem Wiederherstellen noch, und Verweise (parent_id,
category_id) bleiben gültig.
Beim Import entscheidet der Modus, was bei bereits vergebener ID passiert:
- ``skip`` vorhandene Einträge unangetastet lassen, nur neue anlegen.
- ``overwrite`` vorhandene per ID überschreiben, fehlende anlegen.
Einzelne Zeilen, die an einer Eindeutigkeit scheitern (z.B. gleicher Name unter
anderer ID), werden per Savepoint übersprungen statt den ganzen Import
abzubrechen.
"""
from __future__ import annotations
import json
from sqlalchemy import text
from sqlalchemy.orm import Session
from ..models import Category, FieldDefinition, Location, PackageType, Unit, UnitKind
from .fields import options_list
FORMAT_KEY = "vorrania_master_data"
FORMAT_VERSION = 1
# Nur Tabellen mit fortlaufender Integer-id brauchen die Sequenz-Korrektur.
# Lagerorte tragen einen zufälligen Code als id dort gibt es keine Sequenz.
_TABLES = ("units", "package_types", "categories", "field_definitions")
def export_master_data(db: Session) -> dict:
return {
FORMAT_KEY: FORMAT_VERSION,
"categories": [
{"id": c.id, "name": c.name, "parent_id": c.parent_id, "tracking": c.tracking}
for c in db.query(Category).order_by(Category.id).all()
],
"field_definitions": [
{
"id": f.id, "category_id": f.category_id, "label": f.label, "key": f.key,
"field_type": f.field_type, "unit": f.unit, "options": options_list(f),
"required": f.required, "position": f.position, "is_builtin": f.is_builtin,
}
for f in db.query(FieldDefinition).order_by(FieldDefinition.id).all()
],
"locations": [
{"id": l.id, "name": l.name, "parent_id": l.parent_id}
for l in db.query(Location).order_by(Location.name).all()
],
"units": [
{"id": u.id, "name": u.name, "kind": u.kind.value, "factor": u.factor,
"is_builtin": u.is_builtin}
for u in db.query(Unit).order_by(Unit.id).all()
],
"package_types": [
{"id": p.id, "singular": p.singular, "plural": p.plural, "is_builtin": p.is_builtin}
for p in db.query(PackageType).order_by(PackageType.id).all()
],
}
class _Report:
def __init__(self) -> None:
self.created: dict[str, int] = {}
self.updated: dict[str, int] = {}
self.skipped: dict[str, int] = {}
def add(self, bucket: dict[str, int], key: str) -> None:
bucket[key] = bucket.get(key, 0) + 1
def as_dict(self) -> dict:
return {"created": self.created, "updated": self.updated, "skipped": self.skipped}
def _fix_sequence(db: Session, table: str) -> None:
"""Postgres: die id-Sequenz auf MAX(id) heben, sonst kollidieren spätere
Auto-IDs mit importierten. SQLite braucht das nicht."""
if db.get_bind().dialect.name != "postgresql":
return
db.execute(text(
f"SELECT setval(pg_get_serial_sequence('{table}', 'id'), "
f"GREATEST((SELECT COALESCE(MAX(id), 1) FROM {table}), 1))"
))
def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
if not isinstance(data, dict) or FORMAT_KEY not in data:
raise ValueError("Das ist keine Vorrania-Stammdaten-Datei.")
overwrite = mode == "overwrite"
rep = _Report()
def upsert(model, row, key, build, *, is_tree=False, conflict=None) -> None:
rid = row.get("id")
existing = db.get(model, rid) if rid is not None else None
if existing is not None and not overwrite:
rep.add(rep.skipped, key)
return
# Vorab-Check statt IntegrityError abzufangen: würde die Zeile eine
# Eindeutigkeit unter ANDERER id verletzen (z.B. gleicher Name), lieber
# überspringen als den Import abbrechen.
if conflict is not None and conflict(row):
rep.add(rep.skipped, key)
return
if existing is not None:
for k, v in build(row).items():
setattr(existing, k, v)
db.flush()
rep.add(rep.updated, key)
return
kwargs = build(row)
if rid is not None:
kwargs["id"] = rid
if is_tree:
kwargs["parent_id"] = None # Eltern erst im 2. Durchgang
db.add(model(**kwargs))
db.flush()
rep.add(rep.created, key)
def set_parents(model, rows) -> None:
for row in rows or []:
rid = row.get("id")
obj = db.get(model, rid) if rid is not None else None
if obj is None:
continue
pid = row.get("parent_id")
ziel = db.get(model, pid) if pid is not None else None
obj.parent_id = ziel.id if ziel is not None else None
db.flush()
def name_clash(model, field, r):
val = r.get(field)
return db.query(model.id).filter(
getattr(model, field) == val, model.id != r.get("id")
).first() is not None
for row in data.get("units") or []:
upsert(Unit, row, "units", lambda r: {
"name": r["name"], "kind": UnitKind(r["kind"]),
"factor": float(r.get("factor", 1.0)),
"is_builtin": bool(r.get("is_builtin", False)),
}, conflict=lambda r: name_clash(Unit, "name", r))
for row in data.get("package_types") or []:
upsert(PackageType, row, "package_types", lambda r: {
"singular": r["singular"], "plural": r.get("plural") or r["singular"],
"is_builtin": bool(r.get("is_builtin", False)),
}, conflict=lambda r: name_clash(PackageType, "singular", r))
cats = data.get("categories") or []
for row in cats:
upsert(Category, row, "categories", lambda r: {
"name": r["name"], "tracking": r.get("tracking") or "food",
}, is_tree=True)
set_parents(Category, cats)
for row in data.get("field_definitions") or []:
if db.get(Category, row.get("category_id")) is None:
rep.add(rep.skipped, "field_definitions")
continue
upsert(FieldDefinition, row, "field_definitions", lambda r: {
"category_id": r["category_id"], "label": r["label"],
"key": r.get("key") or r["label"],
"field_type": r.get("field_type", "text"),
"unit": r.get("unit"),
"options": json.dumps(r.get("options") or []) if r.get("field_type") == "select" else None,
"required": bool(r.get("required", False)),
"position": int(r.get("position", 0)),
"is_builtin": bool(r.get("is_builtin", False)),
})
locs = data.get("locations") or []
for row in locs:
upsert(Location, row, "locations", lambda r: {"name": r["name"]}, is_tree=True)
set_parents(Location, locs)
db.commit()
for table in _TABLES:
_fix_sequence(db, table)
db.commit()
return rep.as_dict()

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,12 +2,22 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import Sequence
from datetime import date from datetime import date
from sqlalchemy import asc from sqlalchemy import asc, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import DatePrecision, Lot, Movement, MovementType, Product, User from ..models import (
DatePrecision,
Item,
Location,
Lot,
Movement,
MovementType,
Product,
User,
)
from .conversion import to_base from .conversion import to_base
from .dates import clean_precision, normalize_best_before from .dates import clean_precision, normalize_best_before
@@ -16,21 +26,330 @@ class StockError(ValueError):
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand).""" """Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
# ---------------------------------------------------------------------------
# Gegenstände (Non-Food): Menge je Lagerort statt Chargen mit MHD.
#
# Technisch wird dieselbe Lot-Tabelle genutzt je (Produkt, Lagerort) genau
# eine Zeile mit ``best_before = NULL``. So laufen Bestands-Summe, Bewegungslog
# und Export unverändert weiter; nur MHD/FEFO entfällt. Die Lebensmittel-Logik
# oben (check_in/check_out) bleibt davon unberührt.
# ---------------------------------------------------------------------------
def _object_lot(db: Session, product_id: int, location_id: str | None) -> Lot | None:
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
query = db.query(Lot).filter(
Lot.product_id == product_id, Lot.best_before.is_(None)
)
if location_id is None:
query = query.filter(Lot.location_id.is_(None))
else:
query = query.filter(Lot.location_id == location_id)
return query.first()
def object_add(
db: Session,
product: Product,
quantity: float,
location_id: str | None,
user: User | None,
note: str | None = None,
) -> Lot:
"""Erhöht die Menge eines Gegenstands an einem Lagerort."""
lot = _object_lot(db, product.id, location_id)
if lot is None:
lot = Lot(
product_id=product.id,
quantity=0.0,
best_before=None,
best_before_precision=DatePrecision.day.value,
location_id=location_id,
)
db.add(lot)
db.flush()
lot.quantity += quantity
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.in_,
quantity=quantity,
unit_used=product.base_unit.value,
note=note,
location_id=location_id,
)
)
return lot
def object_remove(
db: Session,
product: Product,
quantity: float,
location_id: str | None,
reason: str,
user: User | None,
note: str | None = None,
) -> None:
"""Entfernt eine Menge mit Grund (verloren/kaputt/…) aus dem Bestand."""
lot = _object_lot(db, product.id, location_id)
have = lot.quantity if lot else 0.0
if quantity > have + 1e-9:
raise StockError(
f"Am Lagerort sind nur {have:g} {product.base_unit.value} vorhanden "
f"(benötigt {quantity:g})."
)
lot.quantity -= quantity
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=quantity,
unit_used=product.base_unit.value,
note=note,
location_id=location_id,
reason=reason,
)
)
if lot.quantity <= 1e-9:
db.delete(lot)
def object_relocate(
db: Session,
product: Product,
quantity: float,
from_location_id: str | None,
to_location_id: str | None,
user: User | None,
note: str | None = None,
) -> None:
"""Bucht eine Menge von einem Lagerort zum anderen um (ohne Grund)."""
if from_location_id == to_location_id:
raise StockError("Quell- und Ziel-Lagerort sind identisch.")
src = _object_lot(db, product.id, from_location_id)
have = src.quantity if src else 0.0
if quantity > have + 1e-9:
raise StockError(
f"Am Quell-Lagerort sind nur {have:g} {product.base_unit.value} "
f"vorhanden (benötigt {quantity:g})."
)
beleg = note or "Umlagerung"
src.quantity -= quantity
# Als neutrale Korrektur (adjust) protokollieren, damit Umlagerungen die
# Ein-/Auslager-Statistiken nicht verfälschen.
db.add(
Movement(
product_id=product.id,
lot_id=src.id,
user_id=user.id if user else None,
type=MovementType.adjust,
quantity=-quantity,
unit_used=product.base_unit.value,
note=beleg,
location_id=from_location_id,
)
)
if src.quantity <= 1e-9:
db.delete(src)
dest = _object_lot(db, product.id, to_location_id)
if dest is None:
dest = Lot(
product_id=product.id,
quantity=0.0,
best_before=None,
best_before_precision=DatePrecision.day.value,
location_id=to_location_id,
)
db.add(dest)
db.flush()
dest.quantity += quantity
db.add(
Movement(
product_id=product.id,
lot_id=dest.id,
user_id=user.id if user else None,
type=MovementType.adjust,
quantity=quantity,
unit_used=product.base_unit.value,
note=beleg,
location_id=to_location_id,
)
)
def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
"""Entnahmen je Grund summieren: {reason: {quantity, count}}."""
rows = (
db.query(Movement)
.filter(
Movement.product_id == product_id,
Movement.type == MovementType.out,
Movement.reason.isnot(None),
)
.all()
)
stats: dict[str, dict] = {}
for m in rows:
eintrag = stats.setdefault(m.reason, {"quantity": 0.0, "count": 0})
eintrag["quantity"] += m.quantity
eintrag["count"] += 1
return stats
def current_stock(db: Session, product_id: int) -> float: def current_stock(db: Session, product_id: int) -> float:
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten).""" """Bestand eines Produkts (in Basiseinheiten).
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
die Lot-Mengen.
"""
product = db.get(Product, product_id)
if product is not None and product.individual:
return float(db.query(Item).filter(Item.product_id == product_id).count())
total = ( total = (
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all() db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
) )
return float(sum(q for (q,) in total)) 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).
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
Ortes summiert.
"""
if product.individual:
return float(
db.query(Item)
.filter(Item.product_id == product.id, Item.location_id == location_id)
.count()
)
total = (
db.query(Lot.quantity)
.filter(Lot.product_id == product.id, Lot.location_id == location_id)
.all()
)
return float(sum(q for (q,) in total))
def descendant_location_ids(db: Session, location_id: str) -> set[str]:
"""Alle Unter-Lagerorte (rekursiv) eines Lagerorts."""
result: set[str] = set()
stack = [location_id]
while stack:
cur = stack.pop()
for (lid,) in db.query(Location.id).filter(Location.parent_id == cur).all():
if lid not in result:
result.add(lid)
stack.append(lid)
return result
def location_subtree_stock_base(db: Session, product: Product, location_id: str) -> float:
"""Bestand an einem Lagerort INKL. aller Unter-Lagerorte (Basiseinheiten).
So gilt ein Mindestbestand auf „Hedingen" als gedeckt, wenn der Vorrat
irgendwo darunter liegt (z.B. „Hedingen → Keller").
"""
ids = {location_id} | descendant_location_ids(db, location_id)
return float(sum(location_stock_base(db, product, lid) for lid in ids))
def check_in( def check_in(
db: Session, db: Session,
product: Product, product: Product,
quantity: float, quantity: float,
unit: str, unit: str,
best_before: date | None, best_before: date | None,
location_id: int | None, location_id: str | None,
user: User | None, user: User | None,
note: str | None = None, note: str | None = None,
best_before_precision: str | None = DatePrecision.day.value, best_before_precision: str | None = DatePrecision.day.value,
@@ -65,7 +384,90 @@ def check_in(
note=note, 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( def check_out_lot(

View File

@@ -0,0 +1,361 @@
"""Aus einem Beleg-PDF ein mögliches Garantieende schätzen lokal, ohne KI.
Zwei Wege, in dieser Reihenfolge:
1. Garantiezeitraum ("24 Monate", "2 Jahre") + Kaufdatum (aus dem Beleg oder vom
Einzelstück) → Enddatum ausrechnen.
2. Ein Datum in der Nähe eines Garantie-Stichworts.
Bewusst konservativ: lieber nichts vorschlagen als etwas Falsches das Ergebnis
ist nur ein Vorschlag, den der Nutzer bestätigt. Reine Scan-PDFs ohne Textebene
liefern keinen Text und damit keinen Vorschlag (dafür bräuchte es OCR).
"""
from __future__ import annotations
import io
import re
from datetime import date
from dateutil.relativedelta import relativedelta
_KEYWORD_RE = re.compile(r"garantie|gew[äa]hrleistung|warranty", re.IGNORECASE)
_PERIOD_RE = re.compile(r"(\d{1,3})\s*(jahr|monat|year|month)", re.IGNORECASE)
_PURCHASE_KW = re.compile(
r"rechnungsdatum|rechnung|kaufdatum|bestelldatum|bestellt|belegdatum|"
r"datum|invoice|order date|purchase|receipt",
re.IGNORECASE,
)
# Monatsnamen (Deutsch + Englisch, mit gängigen Abkürzungen), damit z.B.
# Amazon-Rechnungen mit „29 April 2026" erkannt werden nicht nur 29.04.2026.
_MONTHS = {
"januar": 1, "jan": 1, "january": 1,
"februar": 2, "feb": 2, "february": 2,
"märz": 3, "maerz": 3, "mrz": 3, "mär": 3, "mar": 3, "march": 3,
"april": 4, "apr": 4,
"mai": 5, "may": 5,
"juni": 6, "jun": 6, "june": 6,
"juli": 7, "jul": 7, "july": 7,
"august": 8, "aug": 8,
"september": 9, "sept": 9, "sep": 9,
"oktober": 10, "okt": 10, "october": 10, "oct": 10,
"november": 11, "nov": 11,
"dezember": 12, "dez": 12, "december": 12, "dec": 12,
}
# Längere Namen zuerst, damit „januar" vor „jan" greift.
_MONTH_ALT = "|".join(sorted((re.escape(k) for k in _MONTHS), key=len, reverse=True))
# ISO 2024-03-31, numerisch 31.03.2024 / 31/03/24, oder mit Monatsname
# ("29 April 2026" bzw. "April 29, 2026").
_DATE_RE = re.compile(
r"(?P<iy>\d{4})-(?P<im>\d{1,2})-(?P<idd>\d{1,2})"
r"|(?P<d>\d{1,2})[.\/](?P<m>\d{1,2})[.\/](?P<y>\d{2,4})"
r"|(?P<nd>\d{1,2})\.?\s+(?P<nmon>" + _MONTH_ALT + r")\.?\s+(?P<ny>\d{4})"
r"|(?P<emon>" + _MONTH_ALT + r")\s+(?P<ed>\d{1,2})(?:th|st|nd|rd)?,?\s+(?P<ey>\d{4})",
re.IGNORECASE,
)
_WINDOW = 60 # Zeichen um ein Stichwort, in denen Zahl/Datum als zugehörig gelten
def extract_pdf_text(data: bytes) -> str:
"""Text aus einem PDF ziehen. Schlägt es fehl (kaputt, reiner Scan), ""."""
try:
from pypdf import PdfReader
reader = PdfReader(io.BytesIO(data))
return "\n".join((page.extract_text() or "") for page in reader.pages)
except Exception:
return ""
def _match_to_date(m: re.Match) -> date | None:
g = m.groupdict()
try:
if g.get("iy"): # ISO yyyy-mm-dd
y, mo, d = int(g["iy"]), int(g["im"]), int(g["idd"])
elif g.get("d"): # dd.mm.yyyy / dd/mm/yy
d, mo, y = int(g["d"]), int(g["m"]), int(g["y"])
if y < 100:
y += 2000
elif g.get("nd"): # 29 April 2026
d, mo, y = int(g["nd"]), _MONTHS[g["nmon"].lower()], int(g["ny"])
elif g.get("emon"): # April 29, 2026
d, mo, y = int(g["ed"]), _MONTHS[g["emon"].lower()], int(g["ey"])
else:
return None
return date(y, mo, d)
except (TypeError, ValueError, KeyError):
return None
def _all_dates(text: str) -> list[tuple[int, date]]:
out: list[tuple[int, date]] = []
for m in _DATE_RE.finditer(text):
d = _match_to_date(m)
if d is not None:
out.append((m.start(), d))
return out
def _find_period(text: str) -> tuple[int, str] | None:
"""(Anzahl, Einheit) in der Nähe eines Garantie-Stichworts beide Reihenfolgen."""
for kw in _KEYWORD_RE.finditer(text):
fenster = text[max(0, kw.start() - _WINDOW): kw.end() + _WINDOW]
p = _PERIOD_RE.search(fenster)
if p:
return int(p.group(1)), p.group(2).lower()
return None
# Eindeutige Kaufdatum-Stichworte (Vorrang) und Fälligkeits-/Zahlungsziel-Wörter,
# die KEIN Kaufdatum markieren (z.B. "Fälligkeitsdatum" enthält "datum").
_STRONG_DATE_KW = re.compile(
r"rechnungsdatum|bestelldatum|kaufdatum|belegdatum|bestellt am|kaufdatum|"
r"invoice date|order date|purchase date|date of purchase",
re.IGNORECASE,
)
_DUE_KW = re.compile(
r"fällig|faellig|zahlbar|zahlungsziel|valuta|due date|payable", re.IGNORECASE
)
def _closest_date(text: str, dates: list[tuple[int, date]], kw_re, window: int) -> date | None:
best: date | None = None
best_dist = 10**9
for kw in kw_re.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= window and dist < best_dist:
best, best_dist = d, dist
return best
def _near_due(text: str, pos: int) -> bool:
return bool(_DUE_KW.search(text[max(0, pos - 30): pos + 30]))
def _find_purchase_date(text: str) -> date | None:
dates = _all_dates(text)
if not dates:
return None
# 1) Eindeutiges Kaufdatum-Stichwort (Rechnungs-/Bestell-/Kaufdatum) gewinnt.
stark = _closest_date(text, dates, _STRONG_DATE_KW, window=60)
if stark is not None:
return stark
# 2) Generisches "datum/rechnung", aber NICHT neben einem Fälligkeits-Wort.
best: date | None = None
best_dist = 10**9
for kw in _PURCHASE_KW.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist and not _near_due(text, pos):
best, best_dist = d, dist
if best is not None:
return best
# 3) Kein Stichwort getroffen: frühestes Datum (liegt vor dem Garantieende).
return min(d for _, d in dates)
def _find_date_near_keyword(text: str) -> date | None:
dates = _all_dates(text)
best: date | None = None
best_dist = 10**9
for kw in _KEYWORD_RE.finditer(text):
for pos, d in dates:
dist = abs(pos - kw.start())
if dist <= _WINDOW and dist < best_dist:
best, best_dist = d, dist
return best
def _find_latest_date_near_keyword(text: str) -> date | None:
"""Spätestes Datum nahe einem Garantie-Stichwort das Ende eines ausdrücklich
genannten Zeitraums (z.B. „… - 06.10.2027")."""
dates = _all_dates(text)
latest: date | None = None
for kw in _KEYWORD_RE.finditer(text):
for pos, d in dates:
if abs(pos - kw.start()) <= _WINDOW and (latest is None or d > latest):
latest = d
return latest
def _add_period(base: date, n: int, unit: str) -> date:
if unit.startswith(("jahr", "year")):
return base + relativedelta(years=n)
return base + relativedelta(months=n)
def guess_warranty_until(text: str, acquired_on: date | None = None) -> date | None:
"""Bestes rät fürs Garantieende oder ``None``.
``acquired_on`` ist das am Einzelstück hinterlegte Kaufdatum es hat Vorrang
vor einem im Beleg gefundenen Datum.
"""
if not text:
return None
base = acquired_on or _find_purchase_date(text)
# Ein ausdrücklich genanntes Enddatum im Garantie-Umfeld hat Vorrang vor dem
# aus Zeitraum + Kaufdatum gerechneten (z.B. Beleg nennt "… - 06.10.2027").
explizit = _find_latest_date_near_keyword(text)
if explizit is not None and (base is None or explizit > base):
return explizit
period = _find_period(text)
if period and base:
return _add_period(base, period[0], period[1])
return _find_date_near_keyword(text)
def guess_acquired_on(text: str) -> date | None:
"""Kaufdatum aus dem Beleg (Datum nahe Rechnungs-/Kaufdatum-Stichwort)."""
return _find_purchase_date(text) if text else None
# --------------------------------------------------------------------------
# Shop / Bezugsquelle aus dem Beleg
# --------------------------------------------------------------------------
_LEGAL_RE = re.compile(
r"\b(GmbH|AG|SA|Sàrl|S\.?à r\.?l\.?|Ltd|Inc|SE|KG|OHG|e\.?K\.?|AS|BV|S\.p\.A\.)\b"
)
def _vendor_candidate(text: str) -> str | None:
"""Bester Rate-Name des Händlers meist im Kopf des Belegs. Best effort."""
lines = [z.strip() for z in text.splitlines() if z.strip()]
for z in lines[:20]:
if _LEGAL_RE.search(z):
return z[:60]
for z in lines[:8]:
buchstaben = sum(c.isalpha() for c in z)
if 3 <= len(z) <= 40 and buchstaben >= 3 and not re.search(
r"rechnung|invoice|quittung|beleg|kassenbon|datum|receipt|order|bestell",
z, re.IGNORECASE,
):
return z[:60]
return None
def guess_shop(text: str, shops: list[tuple[int, str]]) -> tuple[int | None, str | None]:
"""(shop_id, name).
Kommt der Name eines bereits hinterlegten Shops im Beleg vor, wird dieser
vorgeschlagen (längster Treffer gewinnt). Sonst ein Kandidatenname zum
Anlegen oder (None, None), wenn nichts Brauchbares gefunden wird.
"""
if not text:
return (None, None)
low = text.lower()
treffer: tuple[int, str] | None = None
best_len = 0
for sid, name in shops:
n = name.strip().lower()
if len(n) >= 3 and n in low and len(n) > best_len:
treffer = (sid, name)
best_len = len(n)
if treffer is not None:
return treffer
return (None, _vendor_candidate(text))
# --------------------------------------------------------------------------
# Kaufpreis aus dem Beleg schätzen
# --------------------------------------------------------------------------
_TOTAL_KW = re.compile(
r"gesamtbetrag|gesamtsumme|rechnungsbetrag|endbetrag|gesamt|summe|total|"
r"zu zahlen|amount due|grand total|total due",
re.IGNORECASE,
)
_CURRENCY_RE = re.compile(r"chf|eur|sfr|fr\.|rp\.|€|\$", re.IGNORECASE)
# Beträge mit genau 2 Nachkommastellen, mit/ohne Tausendertrenner
# (1'299.00 / 1.299,00 / 49,90). Die Lookarounds verhindern Treffer mitten in
# längeren Zahlen und in Datumsangaben (z.B. "31.12.2027" liefert kein "31.12").
_AMOUNT_RE = re.compile(
r"(?<![\d.,])(?:\d{1,3}(?:[.\s']\d{3})+[.,]\d{2}|\d+[.,]\d{2})(?![.,]?\d)"
)
def _amount_to_cents(raw: str) -> int | None:
""""1'299.00" / "1.299,00" / "49,90" -> Rappen/Cent."""
s = raw.replace(" ", "").replace("'", "")
m = re.search(r"[.,](\d{2})$", s) # letzter Trenner ist der Dezimalpunkt
if not m:
return None
dezimal = m.group(1)
ganz = re.sub(r"[.,]", "", s[: m.start()]) # Tausendertrenner entfernen
if not ganz.isdigit():
return None
return int(ganz) * 100 + int(dezimal)
def _has_currency_near(text: str, start: int, end: int, window: int = 6) -> bool:
return bool(_CURRENCY_RE.search(text[max(0, start - window): end + window]))
def _near_total(text: str, pos: int) -> bool:
"""Steht der Betrag kurz hinter einem Summen-Stichwort?"""
for kw in _TOTAL_KW.finditer(text):
if kw.start() <= pos <= kw.end() + 40:
return True
return False
def _price_hits(text: str) -> list[tuple[int, int, bool]]:
"""(Position, Betrag in Cent, Währung in der Nähe?) für alle Beträge.
Prozentangaben (MwSt-Satz „8.10 %") werden übersprungen das sind keine
Preise.
"""
hits: list[tuple[int, int, bool]] = []
for m in _AMOUNT_RE.finditer(text):
if text[m.end(): m.end() + 3].lstrip().startswith("%"):
continue
c = _amount_to_cents(m.group())
if c:
hits.append((m.start(), c, _has_currency_near(text, m.start(), m.end())))
return hits
def guess_price_cents(text: str) -> int | None:
"""Kaufpreis in Rappen/Cent aus dem Beleg oder ``None``.
Bewusst zurückhaltend, damit nicht irgendeine Zahl gewinnt:
1. Von den Beträgen nahe einem Summen-Stichwort (Gesamtbetrag/Total/…) der
GRÖSSTE der Endbetrag inkl. steht rechts und ist meist der höchste.
2. Sonst der größte Betrag MIT Währungszeichen (CHF/EUR/€/…) in der Nähe.
3. Ohne Hinweis (weder Summe noch Währung) lieber kein Vorschlag.
"""
if not text:
return None
hits = _price_hits(text)
if not hits:
return None
total_nah = [
c for pos, c, _cur in hits if _near_total(text, pos)
]
if total_nah:
return max(total_nah)
mit_waehrung = [c for _pos, c, cur in hits if cur]
return max(mit_waehrung) if mit_waehrung else None
def guess_price_candidates(text: str) -> list[int]:
"""Alle geldartigen Beträge (Rappen/Cent) zur Auswahl, bester Tipp zuerst.
Bei einer Rechnung mit mehreren Artikeln soll man auch den Zeilenpreis des
passenden Produkts wählen können deshalb kommen hier alle Beträge rein
(Zeilen- und Summenbeträge). Mengen, Artikelnummern und Prozentsätze fallen
schon in :func:`_price_hits` weg. Der automatische Tipp (Gesamtsumme) steht
vorne.
"""
if not text:
return []
werte = {c for _pos, c, _cur in _price_hits(text)}
werte.discard(0)
if not werte:
return []
kandidaten = sorted(werte, reverse=True)
best = guess_price_cents(text)
if best is not None and best in kandidaten:
kandidaten.remove(best)
kandidaten.insert(0, best)
return kandidaten[:12]

View File

@@ -9,4 +9,5 @@ bcrypt==4.2.1
python-multipart==0.0.20 python-multipart==0.0.20
httpx==0.28.1 httpx==0.28.1
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
pypdf==5.1.0
pytest==8.3.4 pytest==8.3.4

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

@@ -2,9 +2,10 @@ from datetime import date, timedelta # noqa: F401 (date fuer die Zustands-Test
import pytest import pytest
from app.models import BaseUnit, Category, Product, Role, User from app.models import BaseUnit, Category, Item, Product, Role, User
from app.routers import dashboard from app.routers import dashboard
from app.services.conversion import article_unit, article_units from app.services.conversion import article_unit, article_units
from app.services.items import generate_uid
from app.services.stock import check_in, check_out from app.services.stock import check_in, check_out
@@ -89,6 +90,31 @@ def test_by_category_bündelt_und_sortiert(db, user):
assert anteile[1].article_units == 2 assert anteile[1].article_units == 2
def test_einzelstuecke_zaehlen_in_auswertungen(db, user):
"""Regression: Einzelstueck-Produkte liegen als Items, nicht als Lots. Sie
muessen trotzdem in Kategorie-, Ablauf- und Bestandsauswertung erscheinen
sonst zeigte das Dashboard nur die Mengen-Gegenstaende (Bug: nur Powerbank)."""
kamera_kat = Category(name="Kamera", tracking="object")
db.add(kamera_kat)
db.commit()
kamera = Product(name="Alpha 7 IV", category_id=kamera_kat.id, individual=True)
db.add(kamera)
db.commit()
for _ in range(2):
db.add(Item(uid=generate_uid(db), product_id=kamera.id))
db.commit()
anteil = next(a for a in dashboard.by_category(db=db, _=user) if a.name == "Kamera")
assert anteil.article_units == 2
assert anteil.no_date == 2 # Einzelstuecke haben kein MHD
assert dashboard.expiry_split(db=db, _=user).no_date == 2
st = dashboard.stats(db=db, _=user)
assert st.article_units == 2
assert st.products_in_stock == 1
# ----------------------------------------------------------------- Verlauf # ----------------------------------------------------------------- Verlauf
def test_schrittweite_haengt_am_zeitraum(): def test_schrittweite_haengt_am_zeitraum():
"""Kurze Zeitraeume werden fein abgetastet, lange grob.""" """Kurze Zeitraeume werden fein abgetastet, lange grob."""

View File

@@ -0,0 +1,393 @@
"""Gegenstände (Non-Food): Menge je Lagerort, Umlagern, Entfernen mit Grund,
Kategorie-Modus und selbst definierte Felder.
Die Lebensmittel-Logik (Chargen/MHD/FEFO) bleibt davon unberührt dafür sorgt
test_fefo.py weiterhin.
"""
import json
import pytest
from app.crud import product_to_out, product_tracking, products_to_out_bulk
from app.models import (
BaseUnit,
Category,
CategoryTracking,
FieldDefinition,
Item,
Location,
Lot,
Movement,
Product,
ProductFieldValue,
RemovalReason,
)
from app.services.items import generate_uid
from app.routers.categories import create_category
from app.schemas import CategoryCreate
from app.services.fields import (
FieldError,
apply_field_values,
coerce_value,
effective_field_definitions,
)
from app.services.stock import (
StockError,
current_stock,
object_add,
object_relocate,
object_remove,
removal_stats,
)
def _object_product(db, name="Unterhose"):
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name=name, category_id=cat.id)
db.add(product)
db.commit()
db.refresh(product)
return product, cat
def _loc(db, name):
loc = Location(name=name)
db.add(loc)
db.commit()
db.refresh(loc)
return loc
# ---- Modus je Kategorie ----
def test_ohne_kategorie_bleibt_food(db):
product = Product(name="Altbestand")
db.add(product)
db.commit()
assert product_tracking(db, product) == "food"
def test_gegenstandskategorie_macht_object(db):
product, _ = _object_product(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
def test_unterkategorie_erbt_modus_vom_elternteil(db):
ober = create_category(
CategoryCreate(name="Lebensmittel", tracking=CategoryTracking.food), db, None
)
unter = create_category(CategoryCreate(name="Käse", parent_id=ober.id), db, None)
assert unter.tracking == CategoryTracking.food
# ---- Menge je Lagerort ----
def test_add_summiert_je_ort(db):
product, _ = _object_product(db)
a, b = _loc(db, "Schrank A"), _loc(db, "Schrank B")
object_add(db, product, 5, a.id, None)
object_add(db, product, 3, b.id, None)
db.commit()
assert current_stock(db, product.id) == 8
verteilung = {
lot.location_id: lot.quantity
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
}
assert verteilung == {a.id: 5, b.id: 3}
def test_add_am_gleichen_ort_erhoeht_dieselbe_zeile(db):
product, _ = _object_product(db)
a = _loc(db, "Schrank A")
object_add(db, product, 2, a.id, None)
object_add(db, product, 3, a.id, None)
db.commit()
lots = db.query(Lot).filter(Lot.product_id == product.id).all()
assert len(lots) == 1 and lots[0].quantity == 5
# ---- Umlagern (ohne Grund) ----
def test_umlagern_verschiebt_menge(db):
product, _ = _object_product(db)
a, b = _loc(db, "A"), _loc(db, "B")
object_add(db, product, 5, a.id, None)
db.commit()
object_relocate(db, product, 2, a.id, b.id, None)
db.commit()
verteilung = {
lot.location_id: lot.quantity
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
}
assert verteilung == {a.id: 3, b.id: 2}
assert current_stock(db, product.id) == 5
# Umlagern erzeugt keine Entnahme mit Grund.
assert db.query(Movement).filter(Movement.reason.isnot(None)).count() == 0
def test_umlagern_zu_wenig_bestand(db):
product, _ = _object_product(db)
a, b = _loc(db, "A"), _loc(db, "B")
object_add(db, product, 1, a.id, None)
db.commit()
with pytest.raises(StockError):
object_relocate(db, product, 5, a.id, b.id, None)
def test_umlagern_gleicher_ort_fehler(db):
product, _ = _object_product(db)
a = _loc(db, "A")
object_add(db, product, 3, a.id, None)
db.commit()
with pytest.raises(StockError):
object_relocate(db, product, 1, a.id, a.id, None)
# ---- Entfernen mit Grund + Statistik ----
def test_entfernen_mit_grund_und_statistik(db):
product, _ = _object_product(db)
a = _loc(db, "A")
object_add(db, product, 5, a.id, None)
db.commit()
object_remove(db, product, 2, a.id, RemovalReason.broken.value, None)
object_remove(db, product, 1, a.id, RemovalReason.broken.value, None)
object_remove(db, product, 1, a.id, RemovalReason.lost.value, None)
db.commit()
assert current_stock(db, product.id) == 1
stats = removal_stats(db, product.id)
assert stats["broken"] == {"quantity": 3, "count": 2}
assert stats["lost"] == {"quantity": 1, "count": 1}
def test_entfernen_zu_viel_bestand(db):
product, _ = _object_product(db)
a = _loc(db, "A")
object_add(db, product, 1, a.id, None)
db.commit()
with pytest.raises(StockError):
object_remove(db, product, 5, a.id, RemovalReason.lost.value, None)
# ---- Selbst definierte Felder ----
def test_felder_werden_vererbt_oberkategorie_zuerst(db):
ober = Category(name="Elektronik", tracking="object")
db.add(ober)
db.flush()
unter = Category(name="Powerbank", parent_id=ober.id, tracking="object")
db.add(unter)
db.commit()
db.add(FieldDefinition(category_id=ober.id, label="Kaufdatum", key="kaufdatum", field_type="date"))
db.add(
FieldDefinition(
category_id=unter.id, label="Kapazität", key="kapazitaet",
field_type="number", unit="mAh",
)
)
db.commit()
eff = effective_field_definitions(db, unter.id)
assert [fd.label for fd, _ in eff] == ["Kaufdatum", "Kapazität"]
geerbt = {fd.label: inh for fd, inh in eff}
assert geerbt["Kaufdatum"] is True and geerbt["Kapazität"] is False
def test_zahlfeld_prueft_typ(db):
fd = FieldDefinition(category_id=1, label="Kapazität", key="kap", field_type="number")
assert coerce_value(fd, "20000") == "20000"
assert coerce_value(fd, "3,5") == "3,5"
with pytest.raises(FieldError):
coerce_value(fd, "abc")
def test_auswahlfeld_prueft_optionen(db):
fd = FieldDefinition(
category_id=1, label="Größe", key="groesse", field_type="select",
options=json.dumps(["S", "M", "L"]),
)
assert coerce_value(fd, "M") == "M"
with pytest.raises(FieldError):
coerce_value(fd, "XXL")
def test_feldwert_setzen_und_leeren(db):
cat = Category(name="Elektronik", tracking="object")
db.add(cat)
db.flush()
fd = FieldDefinition(category_id=cat.id, label="Notiz", key="notiz", field_type="text")
db.add(fd)
db.commit()
product = Product(name="Gerät", category_id=cat.id)
db.add(product)
db.commit()
apply_field_values(db, product, {fd.id: "wichtig"})
db.commit()
assert (
db.query(ProductFieldValue).filter_by(product_id=product.id).one().value
== "wichtig"
)
# Leerer Wert löscht den Eintrag wieder.
apply_field_values(db, product, {fd.id: ""})
db.commit()
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
# ---- Einzelstücke (Items mit UID/QR) ----
def _individual_product(db, name="Powerbank"):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name=name, category_id=cat.id, individual=True)
db.add(product)
db.commit()
db.refresh(product)
return product
def test_uid_ist_eindeutig(db):
codes = {generate_uid(db) for _ in range(50)}
assert len(codes) == 50
# Keine verwechselbaren Zeichen (0/O/1/I/L) in der UID.
assert all(ch not in "01OIL" for code in codes for ch in code)
def test_einzelstueck_bestand_zaehlt_items(db):
product = _individual_product(db)
for _ in range(3):
db.add(Item(uid=generate_uid(db), product_id=product.id))
db.commit()
assert current_stock(db, product.id) == 3
def test_mengen_produkt_zaehlt_weiter_lots(db):
# Ein Nicht-Einzelstück-Produkt zählt weiterhin über Lots, nicht über Items.
product, _ = _object_product(db, name="Unterhose")
a = _loc(db, "Schrank A")
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

@@ -0,0 +1,86 @@
"""Einzelstücke: Kaufpreis und Belege (Metadaten in der Ausgabe, Dateinamen-Härtung)."""
import pytest
from app.models import (
Category,
CategoryTracking,
Item,
ItemDocument,
Location,
Product,
Role,
User,
)
from app.routers.items import _safe_filename, create_items, get_item, list_all_items
from app.schemas import ItemCreate
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _product(db):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Kamera", category_id=cat.id, individual=True)
db.add(product)
db.commit()
db.refresh(product)
return product
def test_item_kaufpreis_round_trip(db, admin):
product = _product(db)
out = create_items(
product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin
)
assert out[0].price_cents == 49900
assert out[0].currency == "CHF"
def test_item_out_listet_belege(db, admin):
product = _product(db)
item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id
db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf",
content_type="application/pdf", data=b"%PDF-1.4 x"))
db.commit()
fresh = get_item(item_id, db=db, _=admin)
assert len(fresh.documents) == 1
assert fresh.documents[0].filename == "rechnung.pdf"
assert fresh.documents[0].content_type == "application/pdf"
def test_list_all_items_angereichert(db, admin):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name="Kamera", brand="Sony", category_id=cat.id, individual=True)
db.add(product)
db.flush()
ort = Location(name="Keller")
db.add(ort)
db.commit()
create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, _=admin)
alle = list_all_items(db=db, _=admin)
assert len(alle) == 2
e = alle[0]
assert e.product_name == "Kamera"
assert e.product_brand == "Sony"
assert e.category_name == "Elektronik"
assert e.location_id == ort.id
assert e.location_name == "Keller"
def test_safe_filename_entfernt_header_zeichen():
assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf"
assert _safe_filename(" ") == "beleg"
assert _safe_filename(None) == "beleg"

View File

@@ -0,0 +1,64 @@
"""Lagerort-Inhalt: was liegt an einem Ort inklusive der Unterorte?"""
import pytest
from fastapi import HTTPException
from app.models import BaseUnit, Item, Location, Lot, Product, Role, User
from app.routers.views import location_contents
@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_zeigt_artikel_inkl_unterorten(db, user):
haus = Location(name="Hedingen")
db.add(haus)
db.flush()
keller = Location(name="Keller", parent_id=haus.id)
db.add(keller)
db.flush()
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Menge klar.
kaffee = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1)
bohrer = Product(name="Bohrer", base_unit=BaseUnit.piece, package_size=1, individual=True)
db.add_all([kaffee, bohrer])
db.flush()
# Lot im Unterort (Keller), Einzelstück direkt in Hedingen.
db.add(Lot(product_id=kaffee.id, quantity=3, location_id=keller.id))
db.add(Item(uid="AAAA111111", product_id=bohrer.id, location_id=haus.id))
db.commit()
inhalt = location_contents(code=haus.id, db=db, _=user)
assert inhalt.location_name == "Hedingen"
nach_name = {e.name: e for e in inhalt.products}
assert set(nach_name) == {"Kaffee", "Bohrer"} # Bestand im Unterort zaehlt mit
assert nach_name["Kaffee"].stock == 3
assert nach_name["Bohrer"].stock == 1
assert nach_name["Bohrer"].individual is True
def test_nur_der_gescannte_teilbaum(db, user):
a = Location(name="Ort A")
b = Location(name="Ort B")
db.add_all([a, b])
db.flush()
p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
db.add(Lot(product_id=p.id, quantity=2, location_id=b.id))
db.commit()
# In A liegt nichts der Bestand von B darf nicht auftauchen.
assert location_contents(code=a.id, db=db, _=user).products == []
def test_unbekannter_ort_404(db, user):
with pytest.raises(HTTPException) as ex:
location_contents(code="ZZZZZZZZZZ", db=db, _=user)
assert ex.value.status_code == 404

View File

@@ -0,0 +1,146 @@
"""Bedarfe (Mindestbestände) je Lagerort und die Einkaufsliste je Ort."""
import pytest
from app.models import (
BaseUnit,
Location,
Lot,
Product,
ProductLocationMinStock,
Role,
User,
)
from app.routers.views import shopping_list_by_location
@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_bedarf_je_lagerort(db, user):
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Rechnung klar.
p = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
zuhause = Location(name="Zuhause")
ferien = Location(name="Ferienhaus")
db.add_all([zuhause, ferien])
db.flush()
# 1 Stück zuhause, nichts im Ferienhaus.
db.add(Lot(product_id=p.id, quantity=1, location_id=zuhause.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
db.add(ProductLocationMinStock(product_id=p.id, location_id=ferien.id, min_stock=2))
db.commit()
needs = shopping_list_by_location(db=db, _=user)
nach_ort = {n.location_name: n for n in needs}
assert set(nach_ort) == {"Zuhause", "Ferienhaus"}
assert nach_ort["Zuhause"].products[0].deficit == 2 # 3 - 1
assert nach_ort["Ferienhaus"].products[0].deficit == 2 # 2 - 0
def test_gedeckter_ort_erscheint_nicht(db, user):
p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
zuhause = Location(name="Zuhause")
db.add(zuhause)
db.flush()
db.add(Lot(product_id=p.id, quantity=5, location_id=zuhause.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=zuhause.id, min_stock=3))
db.commit()
# Bestand (5) >= Mindestbestand (3) -> kein Bedarf, kein Eintrag.
assert shopping_list_by_location(db=db, _=user) == []
def test_bestand_in_unterlagerort_zaehlt_zum_oberort(db, user):
p = Product(name="Konserve", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
haus = Location(name="Hedingen")
db.add(haus)
db.flush()
keller = Location(name="Keller", parent_id=haus.id)
db.add(keller)
db.flush()
# Vorrat liegt im Unter-Lagerort; Mindestbestand ist auf dem Ober-Lagerort.
db.add(Lot(product_id=p.id, quantity=5, location_id=keller.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=haus.id, min_stock=3))
db.commit()
# 5 (im Keller) deckt die 3 auf Hedingen -> kein Bedarf.
assert shopping_list_by_location(db=db, _=user) == []
def test_unterlagerort_reicht_nicht_zeigt_restbedarf(db, user):
p = Product(name="Konserve", base_unit=BaseUnit.gram, package_size=1)
db.add(p)
db.flush()
haus = Location(name="Hedingen")
db.add(haus)
db.flush()
keller = Location(name="Keller", parent_id=haus.id)
db.add(keller)
db.flush()
db.add(Lot(product_id=p.id, quantity=2, location_id=keller.id))
db.add(ProductLocationMinStock(product_id=p.id, location_id=haus.id, min_stock=5))
db.commit()
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,82 @@
"""Lagerorte: Umbenennen und Umhängen (mit Schutz vor Ringen)."""
import pytest
from fastapi import HTTPException
from app.models import Location, Role, User
from app.routers.locations import update_location
from app.schemas import LocationUpdate
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
def _orte(db):
"""Keller → Regal → Fach."""
keller = Location(name="Keller")
db.add(keller)
db.flush()
regal = Location(name="Regal", parent_id=keller.id)
db.add(regal)
db.flush()
fach = Location(name="Fach", parent_id=regal.id)
db.add(fach)
db.commit()
return keller, regal, fach
def test_neuer_ort_bekommt_10_zeichen_code_als_id(db):
ort = Location(name="Speisekammer")
db.add(ort)
db.commit()
# Statt fortlaufender Zahl trägt der Lagerort einen zufälligen Code das
# macht Sicherung/Import zwischen zwei Instanzen konfliktfrei.
assert isinstance(ort.id, str)
assert len(ort.id) == 10
assert set(ort.id) <= set("ABCDEFGHJKMNPQRSTUVWXYZ23456789")
def test_umbenennen_ohne_parent_bleibt_hierarchie(db, admin):
keller, regal, _ = _orte(db)
out = update_location(regal.id, LocationUpdate(name="Regal links"), db=db, _=admin)
assert out.name == "Regal links"
assert out.parent_id == keller.id # Umhängen war nicht gemeint
def test_umhaengen_setzt_neuen_parent(db, admin):
keller, _, fach = _orte(db)
out = update_location(fach.id, LocationUpdate(parent_id=keller.id), db=db, _=admin)
assert out.parent_id == keller.id
def test_umhaengen_auf_oberste_ebene(db, admin):
_, regal, _ = _orte(db)
out = update_location(regal.id, LocationUpdate(parent_id=None), db=db, _=admin)
assert out.parent_id is None
def test_umhaengen_auf_sich_selbst_wird_abgelehnt(db, admin):
keller, _, _ = _orte(db)
with pytest.raises(HTTPException) as ex:
update_location(keller.id, LocationUpdate(parent_id=keller.id), db=db, _=admin)
assert ex.value.status_code == 400
def test_umhaengen_in_eigenen_unterort_wird_abgelehnt(db, admin):
keller, _, fach = _orte(db)
with pytest.raises(HTTPException) as ex:
update_location(keller.id, LocationUpdate(parent_id=fach.id), db=db, _=admin)
assert ex.value.status_code == 400
def test_umhaengen_auf_unbekannten_ort_ist_404(db, admin):
_, _, fach = _orte(db)
with pytest.raises(HTTPException) as ex:
update_location(fach.id, LocationUpdate(parent_id="ZZZZZZZZZZ"), db=db, _=admin)
assert ex.value.status_code == 404

View File

@@ -0,0 +1,91 @@
"""Stammdaten-Export/Import (JSON) mit ID-Erhalt und Konfliktmodi."""
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models import Category, FieldDefinition, Location, PackageType, Unit
from app.services.master_data import (
FORMAT_KEY,
export_master_data,
import_master_data,
)
def _fresh_db():
engine = create_engine(
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
)
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, autoflush=False, autocommit=False)()
def test_roundtrip_erhaelt_ids_und_baum(db):
ober = Category(name="Elektronik", tracking="object")
db.add(ober)
db.flush()
unter = Category(name="Kabel", parent_id=ober.id, tracking="object")
db.add(unter)
db.flush()
db.add(FieldDefinition(category_id=ober.id, label="Kapazität", key="kapazitaet",
field_type="number", unit="mAh"))
haus = Location(name="Hedingen")
db.add(haus)
db.flush()
keller = Location(name="Keller", parent_id=haus.id)
db.add(keller)
beutel = PackageType(singular="Beutel", plural="Beutel")
db.add(beutel)
db.commit()
beutel_id = beutel.id
data = export_master_data(db)
ziel = _fresh_db()
import_master_data(ziel, data, mode="skip")
assert ziel.get(Category, ober.id).name == "Elektronik"
assert ziel.get(Category, unter.id).parent_id == ober.id # Baum erhalten
assert ziel.get(Location, keller.id).parent_id == haus.id
assert ziel.query(FieldDefinition).filter_by(category_id=ober.id).first().label == "Kapazität"
assert ziel.get(PackageType, beutel_id).singular == "Beutel" # id erhalten
def test_skip_laesst_vorhandenes_unangetastet(db):
a = Location(name="Alt")
db.add(a)
db.commit()
data = {FORMAT_KEY: 1, "locations": [{"id": a.id, "name": "Neu"}]}
rep = import_master_data(db, data, mode="skip")
assert db.get(Location, a.id).name == "Alt"
assert rep["skipped"].get("locations") == 1
def test_overwrite_aktualisiert_per_id(db):
a = Location(name="Alt")
db.add(a)
db.commit()
data = {FORMAT_KEY: 1, "locations": [{"id": a.id, "name": "Neu"}]}
rep = import_master_data(db, data, mode="overwrite")
assert db.get(Location, a.id).name == "Neu"
assert rep["updated"].get("locations") == 1
def test_falsche_datei_wirft():
with pytest.raises(ValueError):
import_master_data(_fresh_db(), {"foo": 1})
def test_namenskonflikt_wird_uebersprungen_nicht_abgebrochen(db):
# Vorhandene Einheit unter id X; Import bringt gleichen Namen unter anderer id.
vorhanden = Unit(name="Spezialeinheit", kind="count", factor=1)
db.add(vorhanden)
db.commit()
data = {FORMAT_KEY: 1, "units": [{"id": vorhanden.id + 999, "name": "Spezialeinheit",
"kind": "count", "factor": 1}]}
rep = import_master_data(db, data, mode="skip")
# Gleicher Name unter neuer id -> Eindeutigkeit verletzt -> uebersprungen.
assert rep["skipped"].get("units") == 1
assert db.query(Unit).filter_by(name="Spezialeinheit").count() == 1

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(): def test_grams():
@@ -22,3 +22,31 @@ def test_unparsable_is_piece():
assert parse_quantity(None) == ("piece", None) assert parse_quantity(None) == ("piece", None)
assert parse_quantity("6 Stück") == ("piece", None) assert parse_quantity("6 Stück") == ("piece", None)
assert parse_quantity("") == ("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

@@ -17,6 +17,18 @@ def _artikel(db, url=None):
return p return p
def test_image_version_spiegelt_bildzeitstempel(db):
from app.crud import product_to_out
p = _artikel(db)
assert product_to_out(db, p).image_version is None # kein Bild
img = ProductImage(product_id=p.id, content_type="image/png", data=EIN_PIXEL)
db.add(img)
db.commit()
db.refresh(img)
assert product_to_out(db, p).image_version == int(img.updated_at.timestamp())
def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch): def test_ohne_bildadresse_gibt_es_nichts_zu_holen(db, monkeypatch):
monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError)) monkeypatch.setattr(images, "fetch", lambda url: (_ for _ in ()).throw(AssertionError))
assert images.ensure(db, _artikel(db)) is None assert images.ensure(db, _artikel(db)) 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,168 @@
"""Lokale Garantieende-Schätzung aus Belegtext (ohne echtes PDF)."""
from datetime import date
from app.services.warranty import (
guess_acquired_on,
guess_price_candidates,
guess_price_cents,
guess_shop,
guess_warranty_until,
)
def test_zeitraum_plus_kaufdatum_vom_stueck():
# Beleg nennt nur den Zeitraum; das Kaufdatum kommt vom Einzelstück.
text = "Vielen Dank für Ihren Einkauf. 24 Monate Garantie auf dieses Gerät."
assert guess_warranty_until(text, acquired_on=date(2024, 3, 31)) == date(2026, 3, 31)
def test_jahre_werden_verstanden():
text = "2 Jahre Gewährleistung"
assert guess_warranty_until(text, acquired_on=date(2023, 1, 15)) == date(2025, 1, 15)
def test_zeitraum_plus_rechnungsdatum_aus_dem_beleg():
text = "Rechnungsdatum: 05.06.2024\nGarantie: 12 Monate"
assert guess_warranty_until(text) == date(2025, 6, 5)
def test_warranty_keyword_before_period_english():
text = "Warranty period is 36 months from purchase."
assert guess_warranty_until(text, acquired_on=date(2022, 2, 1)) == date(2025, 2, 1)
def test_explizites_datum_neben_stichwort():
text = "Garantie gültig bis 31.12.2027."
assert guess_warranty_until(text) == date(2027, 12, 31)
def test_ohne_hinweise_kein_vorschlag():
assert guess_warranty_until("Nur ein Kassenbon ohne alles.") is None
def test_leerer_text_ist_none():
assert guess_warranty_until("", acquired_on=date(2024, 1, 1)) is None
# ---- Preis ----
def test_preis_nahe_total_stichwort():
text = "Artikel 49.00\nVersand 5.90\nGesamtbetrag: 54.90 CHF"
assert guess_price_cents(text) == 5490
def test_preis_mit_tausendertrenner_apostroph():
text = "Total CHF 1'299.00"
assert guess_price_cents(text) == 129900
def test_preis_deutsches_format():
text = "Rechnungsbetrag 1.299,00 EUR"
assert guess_price_cents(text) == 129900
def test_preis_ohne_waehrung_und_ohne_stichwort_ist_none():
# Reine Zahlen ohne Waehrung/Summen-Stichwort werden NICHT als Preis geraten.
assert guess_price_cents("Position A 12,90\nPosition B 199,00\nDanke.") is None
def test_preis_mit_waehrung_ohne_stichwort_nimmt_groessten():
text = "Kabel 12.90 CHF\nGeraet 199.00 CHF"
assert guess_price_cents(text) == 19900
def test_datum_wird_nicht_als_preis_gelesen():
assert guess_price_cents("Garantie gültig bis 31.12.2027.") is None
assert guess_price_candidates("Kaufdatum 05.06.2024, Garantie bis 31.12.2027") == []
def test_preis_ohne_betrag_ist_none():
assert guess_price_cents("Kein Preis hier.") is None
assert guess_price_cents("") is None
def test_preis_kandidaten_bester_zuerst_ohne_dubletten():
text = "Artikel 49.00 CHF\nVersand 5.90 CHF\nGesamtbetrag: 54.90 CHF\nnochmal 49.00 CHF"
kandidaten = guess_price_candidates(text)
assert kandidaten[0] == 5490 # bester Tipp (Summe) vorne
assert set(kandidaten) == {5490, 4900, 590} # ohne Dubletten
assert guess_price_candidates("") == []
# ---- Kaufdatum & Shop ----
def test_kaufdatum_aus_beleg():
assert guess_acquired_on("Rechnungsdatum: 05.06.2024") == date(2024, 6, 5)
assert guess_acquired_on("nichts hier") is None
def test_kaufdatum_amazon_monatsname():
# Amazon nennt das Kaufdatum als "29 April 2026" (Monatsname statt 29.04.).
text = "Bestelldatum 29 April 2026\nBestellnummer 028-6796274-2062709"
assert guess_acquired_on(text) == date(2026, 4, 29)
def test_kaufdatum_englischer_monatsname():
assert guess_acquired_on("Invoice date: April 29, 2026") == date(2026, 4, 29)
def test_shop_erkennt_bekannten_namen():
shops = [(1, "Digitec"), (2, "Galaxus")]
assert guess_shop("Rechnung von Digitec AG, Zürich", shops) == (1, "Digitec")
def test_shop_schlaegt_neuen_namen_vor():
sid, name = guess_shop("ACME Electronics GmbH\nRechnung\nDatum 01.01.2024", [(1, "Galaxus")])
assert sid is None
assert name == "ACME Electronics GmbH"
def test_shop_ohne_text_leer():
assert guess_shop("", [(1, "Digitec")]) == (None, None)
# ---- Realer Galaxus-Beleg (vereinfacht) ----
GALAXUS = """Kaufbeleg / Rechnung Auftrag 161498087
Bestell- / Rechnungsdatum 05.10.2025
Fälligkeitsdatum 26.10.2025
Währung CHF
Delock USB2.0-Ladekabel 10079671 2 8.10% 16.56 17.90 33.12 35.80
24 Monate Garantie (06.10.2025 - 06.10.2027)
Logitech G Pro X 13489068 1 8.10% 102.68 111.00 102.68 111.00
24 Monate Garantie (06.10.2025 - 06.10.2027)
Gesamtbetrag 135.80 146.80
Gesamtbetrag enthält folgende Mehrwertsteuer:
MwSt. 8.10% 11.00
Total aller Lieferungen und Leistungen CHF 146.80
03.11.2025 Zahlung geleistet -146.80
Offener Betrag 0.00
"""
def test_galaxus_kaufdatum_nicht_faelligkeit():
assert guess_acquired_on(GALAXUS) == date(2025, 10, 5)
def test_galaxus_preis_ist_gesamt_inkl():
assert guess_price_cents(GALAXUS) == 14680
def test_galaxus_mwst_satz_ist_kein_preis():
kandidaten = guess_price_candidates(GALAXUS)
assert 810 not in kandidaten # 8.10 % ist der Steuersatz, kein Preis
assert kandidaten[0] == 14680 # bester Tipp: Gesamt inkl.
def test_galaxus_garantieende_aus_zeitraum():
assert guess_warranty_until(GALAXUS, acquired_on=date(2025, 10, 5)) == date(2027, 10, 6)
def test_galaxus_zeilenpreise_sind_kandidaten():
# Bei mehreren Artikeln soll man den Zeilenpreis wählen können.
kandidaten = guess_price_candidates(GALAXUS)
assert 14680 in kandidaten # Gesamt inkl.
assert 11100 in kandidaten # Logitech, Betrag/Preis inkl.
assert 3580 in kandidaten # Kabel, Betrag inkl.
assert 810 not in kandidaten # MwSt-Satz weiterhin draußen

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)

27
brand/README.md Normal file
View File

@@ -0,0 +1,27 @@
# Vorrania Markenzeichen
Das Zeichen ist die sechseckige „box"-Silhouette (aus `web/src/components/Icon.jsx`,
Name `box`) in Weiß auf der Akzentfläche.
## Dateien
- **`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).
## Farben
- Akzent (hell): `#4d5cd4`
- Akzent (dunkel, nur Favicon): `#7c8ae8`
- Zeichen: `#ffffff`
## Als PNG exportieren (falls ein Werkzeug PNG verlangt)
```sh
# benötigt librsvg (brew install librsvg)
rsvg-convert -w 1024 -h 1024 app-icon.svg -o app-icon-1024.png
```

11
brand/app-icon.svg Normal file
View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024" role="img" aria-label="Vorrania">
<!-- App-Icon: dasselbe "box"-Zeichen wie Favicon/Seitenleiste, aber vollflächig
(keine abgerundeten Ecken iOS/macOS maskieren selbst) und mit fester
Farbe (kein Dark-Mode-Umschalten). 1024×1024 für App Store / Icon Composer. -->
<rect width="1024" height="1024" fill="#4d5cd4" />
<g transform="translate(192 192) scale(26.6667)"
fill="none" stroke="#ffffff" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 736 B

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

@@ -2,7 +2,7 @@
"fill-specializations" : [ "fill-specializations" : [
{ {
"value" : { "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" : { "orientation" : {
"start" : { "start" : {
"x" : 0.5, "x" : 0.5,
@@ -18,7 +18,7 @@
{ {
"appearance" : "dark", "appearance" : "dark",
"value" : { "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" : { "orientation" : {
"start" : { "start" : {
"x" : 0.5, "x" : 0.5,
@@ -38,23 +38,21 @@
{ {
"fill-specializations" : [ "fill-specializations" : [
{ {
"appearance" : "dark",
"value" : { "value" : {
"automatic-gradient" : "display-p3:0.42536,0.31875,0.88861,1.00000" "solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
} }
}, },
{ {
"appearance" : "tinted", "appearance" : "dark",
"value" : { "value" : {
"automatic-gradient" : "extended-gray:0.25000,1.00000" "solid" : "display-p3:0.99316,0.99316,0.99316,1.00000"
} }
} }
], ],
"glass" : false, "image-name" : "hexagon.svg",
"image-name" : "barcode-gelesen.svg", "name" : "hexagon",
"name" : "barcode-gelesen",
"position" : { "position" : {
"scale" : 1.4, "scale" : 1.18,
"translation-in-points" : [ "translation-in-points" : [
0, 0,
0 0

View File

@@ -1,12 +1,16 @@
# Vorrania iOS-App # 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. REST-API wie die Web-Oberfläche.
> **Stand:** Läuft auf dem Gerät. Login, Scanner, Einlagern mit mehreren > **Stand:** Voll nutzbar. Login; Scannen von Barcode, MHD/Datum (per
> Chargen/MHDs, MHD per Texterkennung, Auslagern mit Chargenauswahl, Artikel > Texterkennung, auch nur Tag+Monat ohne Jahr), Lagerort-QR und Name/Marke;
> anlegen aus Open Food Facts, Einkaufsliste, Ablaufliste sowie Produkte > Ein-/Auslagern mit mehreren Chargen bzw. Menge je Lagerort; Einzelstücke mit
> ansehen und bearbeiten. > 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 ## 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) | | `ScannerView.swift` | Kamera + Barcode-Erkennung (EAN-8/13, UPC-E, Code128, QR) |
| `RootView.swift` | Startbildschirm und Routing | | `RootView.swift` | Startbildschirm und Routing |
| `LoginView.swift` | Server + Anmeldung | | `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 | | `CheckOutView.swift` | Scan → Menge, Chargenauswahl oder FEFO |
| `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung) | | `ProductViews.swift` | Artikelsuche und Anlegen (mit OFF-Vorbefüllung, Foto, Text-Scan) |
| `ProductDetailView.swift` | Produkt bearbeiten, Chargen korrigieren und löschen | | `ProductDetailView.swift` | Produkt bearbeiten, Verwaltungsart ändern, Chargen korrigieren |
| `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste | | `ObjectStockView.swift` | Gegenstände: Menge je Lagerort (hinzufügen/umlagern/entfernen) |
| `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen | | `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 | | `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server |
| `CategoryPicker.swift` | Kategorie-Auswahl und Gruppe anlegen | | `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 Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Beim Anlegen
schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor. schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor.
## Noch offen ## Weiteres
- Push-Benachrichtigungen bei ablaufenden Produkten Push-Benachrichtigungen bei Ablauf und Lagerort je Charge beim Einlagern sind
- Lagerort je Charge beim Einlagern wählbar umgesetzt. Weitere Ideen stehen in [../docs/ROADMAP.md](../docs/ROADMAP.md).

View File

@@ -104,6 +104,11 @@ actor APIClient {
try await send(try makeRequest("/products/\(id)"), as: Product.self) try await send(try makeRequest("/products/\(id)"), as: Product.self)
} }
/// Alles, was an einem Lagerort (inkl. Unterorte) liegt Ziel des /l/-QR beim Nachschlagen.
func locationContents(code: String) async throws -> LocationContents {
try await send(try makeRequest("/location/\(code)"), as: LocationContents.self)
}
func lookup(barcode: String) async throws -> LookupResult { func lookup(barcode: String) async throws -> LookupResult {
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self) return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)
@@ -121,6 +126,132 @@ actor APIClient {
return try await send(request, as: Product.self) return try await send(request, as: Product.self)
} }
/// Mindestbestände je Lagerort ersetzen (Menge in Artikeleinheiten).
func setProductLocationMinStock(id: Int, _ list: [LocationMinStockIn]) async throws -> Product {
var request = try makeRequest("/products/\(id)/location-min-stock", method: "PUT")
try jsonBody(&request, list)
return try await send(request, as: Product.self)
}
/// Aktuelles Artikelfoto laden (mit Anmeldung). Gibt nil bei 404 zurück.
func productImage(id: Int) async throws -> Data? {
let request = try makeRequest("/products/\(id)/image")
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, http.statusCode == 404 { return nil }
try check(response, data: data)
return data
}
/// Eigenes Foto (Kamera/Galerie) als multipart hochladen.
func uploadProductImage(id: Int, data: Data, contentType: String) async throws -> Product {
var request = try makeRequest("/products/\(id)/image", method: "PUT")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
let ext = contentType == "image/png" ? "png" : "jpg"
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"foto.\(ext)\"\r\n"
.data(using: .utf8)!)
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
return try await send(request, as: Product.self)
}
func deleteProductImage(id: Int) async throws {
try await sendNoContent(try makeRequest("/products/\(id)/image", method: "DELETE"))
}
// MARK: - Einzelstücke (Items mit UID/QR)
func items(productId: Int) async throws -> [Item] {
try await send(try makeRequest("/products/\(productId)/items"), as: [Item].self)
}
/// Alle Einzelstücke über alle Produkte für die Einzelstück-Liste.
func allItems() async throws -> [Item] {
try await send(try makeRequest("/items"), as: [Item].self)
}
func createItems(productId: Int, _ payload: ItemCreateRequest) async throws -> [Item] {
var request = try makeRequest("/products/\(productId)/items", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: [Item].self)
}
/// QR-Auflösung: UID Einzelstück.
func itemByUid(uid: String) async throws -> Item {
let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid
return try await send(try makeRequest("/items/by-uid/\(escaped)"), as: Item.self)
}
func updateItem(id: Int, _ payload: ItemUpdateRequest) async throws -> Item {
var request = try makeRequest("/items/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: Item.self)
}
func removeItem(id: Int, _ payload: ItemRemoveRequest) async throws {
var request = try makeRequest("/items/\(id)/remove", method: "POST")
try jsonBody(&request, payload)
try await sendNoContent(request)
}
func uploadItemDocument(itemId: Int, data: Data, filename: String,
contentType: String) async throws -> ItemDocumentUpload {
var request = try makeRequest("/items/\(itemId)/documents", method: "POST")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
let safeName = filename.replacingOccurrences(of: "\"", with: "")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
.data(using: .utf8)!)
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
return try await send(request, as: ItemDocumentUpload.self)
}
/// Beleg nur analysieren (nichts speichern) zum Vorbefüllen beim Anlegen.
func analyzeItemDocument(data: Data, filename: String,
contentType: String) async throws -> DocumentSuggestions {
var request = try makeRequest("/items/analyze-document", method: "POST")
let boundary = "Boundary-\(UUID().uuidString)"
request.setValue("multipart/form-data; boundary=\(boundary)",
forHTTPHeaderField: "Content-Type")
let safeName = filename.replacingOccurrences(of: "\"", with: "")
var body = Data()
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"file\"; filename=\"\(safeName)\"\r\n"
.data(using: .utf8)!)
body.append("Content-Type: \(contentType)\r\n\r\n".data(using: .utf8)!)
body.append(data)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
request.httpBody = body
return try await send(request, as: DocumentSuggestions.self)
}
func itemDocumentData(itemId: Int, docId: Int) async throws -> Data {
let request = try makeRequest("/items/\(itemId)/documents/\(docId)")
let (data, response) = try await URLSession.shared.data(for: request)
try check(response, data: data)
return data
}
func deleteItemDocument(itemId: Int, docId: Int) async throws {
try await sendNoContent(
try makeRequest("/items/\(itemId)/documents/\(docId)", method: "DELETE"))
}
func deleteItem(id: Int) async throws {
try await sendNoContent(try makeRequest("/items/\(id)", method: "DELETE"))
}
func groups() async throws -> [GroupItem] { func groups() async throws -> [GroupItem] {
try await send(try makeRequest("/groups"), as: [GroupItem].self) try await send(try makeRequest("/groups"), as: [GroupItem].self)
} }
@@ -149,6 +280,10 @@ actor APIClient {
try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self) try await send(try makeRequest("/shopping-list/groups"), as: [GroupShoppingItem].self)
} }
func shoppingByLocation() async throws -> [LocationNeeds] {
try await send(try makeRequest("/shopping-list/by-location"), as: [LocationNeeds].self)
}
func expiring(days: Int? = nil) async throws -> [ExpiringItem] { func expiring(days: Int? = nil) async throws -> [ExpiringItem] {
let path = days.map { "/expiring?days=\($0)" } ?? "/expiring" let path = days.map { "/expiring?days=\($0)" } ?? "/expiring"
return try await send(try makeRequest(path), as: [ExpiringItem].self) return try await send(try makeRequest(path), as: [ExpiringItem].self)
@@ -166,6 +301,13 @@ actor APIClient {
return try await send(request, as: StockResponse.self) 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 { func checkOut(_ payload: CheckOutRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkout", method: "POST") var request = try makeRequest("/stock/checkout", method: "POST")
try jsonBody(&request, payload) try jsonBody(&request, payload)
@@ -188,6 +330,82 @@ actor APIClient {
try check(response, data: data) try check(response, data: data)
} }
// MARK: - Gegenstaende (Non-Food)
func shops() async throws -> [ShopItem] {
try await send(try makeRequest("/shops"), as: [ShopItem].self)
}
func createShop(_ payload: NewShopRequest) async throws -> ShopItem {
var request = try makeRequest("/shops", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: ShopItem.self)
}
func updateShop(id: Int, _ payload: ShopUpdateRequest) async throws -> ShopItem {
var request = try makeRequest("/shops/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: ShopItem.self)
}
func deleteShop(id: Int) async throws {
try await sendNoContent(try makeRequest("/shops/\(id)", method: "DELETE"))
}
/// Effektive (vererbte) Felder einer Kategorie fuer das Artikelformular.
func categoryFields(categoryId: Int) async throws -> [FieldDefinition] {
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
}
/// Nur die *eigenen* Felder einer Kategorie (ohne vererbte) fuer die
/// Feldverwaltung, wo man sie anlegt, bearbeitet und loescht.
func ownFieldDefinitions(categoryId: Int) async throws -> [FieldDefinition] {
try await send(try makeRequest("/field-definitions?category_id=\(categoryId)"),
as: [FieldDefinition].self)
}
func createFieldDefinition(_ payload: FieldDefinitionCreate) async throws -> FieldDefinition {
var request = try makeRequest("/field-definitions", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: FieldDefinition.self)
}
func updateFieldDefinition(id: Int, _ payload: FieldDefinitionUpdate) async throws -> FieldDefinition {
var request = try makeRequest("/field-definitions/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: FieldDefinition.self)
}
func deleteFieldDefinition(id: Int) async throws {
try await sendNoContent(try makeRequest("/field-definitions/\(id)", method: "DELETE"))
}
/// Menge eines Gegenstands an einem Lagerort erhoehen.
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge von einem Lagerort zum anderen umbuchen (ohne Grund).
func relocate(_ payload: RelocateRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/relocate", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
func removeStock(_ payload: RemoveRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/remove", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Entnahme-Statistik (Summe je Grund) und die juengsten Entnahmen.
func productRemovals(id: Int) async throws -> RemovalSummary {
try await send(try makeRequest("/products/\(id)/removals"), as: RemovalSummary.self)
}
// MARK: - Uebersicht und Verlauf // MARK: - Uebersicht und Verlauf
func dashboardStats() async throws -> DashboardStats { func dashboardStats() async throws -> DashboardStats {
@@ -199,6 +417,27 @@ actor APIClient {
try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self) try await send(try makeRequest("/dashboard/layouts"), as: DashboardList.self)
} }
// MARK: - Diagrammdaten
func expirySplit() async throws -> ExpirySplit {
try await send(try makeRequest("/dashboard/expiry-split"), as: ExpirySplit.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] {
var path = "/dashboard/timeline?days=\(days)"
if let productId { path += "&product_id=\(productId)" }
return try await send(try makeRequest(path), as: [TimelinePoint].self)
}
func activity(days: Int) async throws -> [ActivityPoint] {
try await send(try makeRequest("/dashboard/activity?days=\(days)"), as: [ActivityPoint].self)
}
/// Neueste zuerst. `productId` filtert auf einen Artikel. /// Neueste zuerst. `productId` filtert auf einen Artikel.
func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] { func movements(limit: Int = 100, productId: Int? = nil) async throws -> [Movement] {
var path = "/movements?limit=\(limit)" var path = "/movements?limit=\(limit)"
@@ -214,13 +453,19 @@ actor APIClient {
return try await send(request, as: StorageLocation.self) return try await send(request, as: StorageLocation.self)
} }
func renameLocation(id: Int, name: String) async throws -> StorageLocation { func renameLocation(id: String, name: String) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH") var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name)) try jsonBody(&request, RenameRequest(name: name))
return try await send(request, as: StorageLocation.self) return try await send(request, as: StorageLocation.self)
} }
func deleteLocation(id: Int) async throws { func updateLocation(id: String, _ payload: LocationUpdateRequest) async throws -> StorageLocation {
var request = try makeRequest("/locations/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: StorageLocation.self)
}
func deleteLocation(id: String) async throws {
try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE")) try await sendNoContent(try makeRequest("/locations/\(id)", method: "DELETE"))
} }
@@ -272,10 +517,30 @@ actor APIClient {
return try await send(request, as: CategoryItem.self) return try await send(request, as: CategoryItem.self)
} }
func updateCategory(id: Int, _ payload: CategoryUpdateRequest) async throws -> CategoryItem {
var request = try makeRequest("/categories/\(id)", method: "PATCH")
try jsonBody(&request, payload)
return try await send(request, as: CategoryItem.self)
}
func deleteCategory(id: Int) async throws { func deleteCategory(id: Int) async throws {
try await sendNoContent(try makeRequest("/categories/\(id)", method: "DELETE")) 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 { func renameGroup(id: Int, name: String) async throws -> GroupItem {
var request = try makeRequest("/groups/\(id)", method: "PATCH") var request = try makeRequest("/groups/\(id)", method: "PATCH")
try jsonBody(&request, RenameRequest(name: name)) try jsonBody(&request, RenameRequest(name: name))

View File

@@ -0,0 +1,130 @@
import Foundation
/// Eine **eigene** Übersicht der App - unabhängig von den Web-Dashboards und nur
/// auf diesem Gerät gespeichert. Je Server wählbar, ob die Web-Dashboards
/// gespiegelt werden oder diese lokale Übersicht gilt.
/// Modus je Server.
enum OverviewMode: String, Codable {
case mirror // die Web-Dashboards spiegeln (Standard)
case local // eigene App-Übersicht
}
/// Eine Karte der lokalen Übersicht. Einspaltig - die Reihenfolge im Array
/// bestimmt die Anordnung, deshalb kein Raster.
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,
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
}
}
struct AppDashboard: Codable, Identifiable, Equatable {
let id: UUID
var name: String
var cards: [AppCard]
init(id: UUID = UUID(), name: String, cards: [AppCard] = []) {
self.id = id
self.name = name
self.cards = cards
}
/// Sinnvolle Startkarten, wenn jemand die eigene Übersicht zum ersten Mal
/// anlegt - eine brauchbare Zusammenstellung statt einer leeren Seite.
static func standard() -> AppDashboard {
AppDashboard(name: "Übersicht", cards: [
AppCard(type: "status"),
AppCard(type: "checkin-quick"),
AppCard(type: "shopping"),
AppCard(type: "expiry-all"),
AppCard(type: "movements"),
])
}
}
/// Was ein Server lokal an Übersicht hat: der Modus und die eigenen Dashboards.
struct AppOverviewConfig: Codable, Equatable {
var mode: OverviewMode = .mirror
var dashboards: [AppDashboard] = []
}
/// Ablage je Profil-UUID in den UserDefaults, wie `NotificationStore`.
enum AppDashboardStore {
static let key = "app_overviews"
static func loadAll() -> [String: AppOverviewConfig] {
guard let data = UserDefaults.standard.data(forKey: key),
let dict = try? JSONDecoder().decode([String: AppOverviewConfig].self, from: data)
else { return [:] }
return dict
}
static func config(for profileID: UUID) -> AppOverviewConfig {
loadAll()[profileID.uuidString] ?? AppOverviewConfig()
}
static func save(_ config: AppOverviewConfig, for profileID: UUID) {
var alle = loadAll()
alle[profileID.uuidString] = config
guard let data = try? JSONEncoder().encode(alle) else { return }
UserDefaults.standard.set(data, forKey: key)
}
static func remove(for profileID: UUID) {
var alle = loadAll()
alle.removeValue(forKey: profileID.uuidString)
guard let data = try? JSONEncoder().encode(alle) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}
/// Verfügbare Kartenarten für die lokale Übersicht - Grundlage des Hinzufügen-Menüs.
struct CardKind: Identifiable {
let type: String
let needsProduct: Bool
var id: String { type }
var title: String { CardCatalog.titel(type) }
var isChart: Bool { CardCatalog.istDiagramm(type) }
}
enum CardCatalogList {
/// Reihenfolge wie sie im Hinzufügen-Menü erscheinen soll.
static let all: [CardKind] = [
CardKind(type: "status", needsProduct: false),
CardKind(type: "kpi-products", needsProduct: false),
CardKind(type: "kpi-units", needsProduct: false),
CardKind(type: "checkin-quick", needsProduct: false),
CardKind(type: "checkout-quick", needsProduct: false),
CardKind(type: "actions", needsProduct: false),
CardKind(type: "shopping", needsProduct: false),
CardKind(type: "expiry-all", needsProduct: false),
CardKind(type: "expiring", needsProduct: false),
CardKind(type: "expired", needsProduct: false),
CardKind(type: "movements", needsProduct: false),
CardKind(type: "product-stock", needsProduct: true),
CardKind(type: "product-timeline", needsProduct: true),
CardKind(type: "expiry-donut", needsProduct: false),
CardKind(type: "category-donut", needsProduct: false),
CardKind(type: "category-bars", needsProduct: false),
CardKind(type: "stock-timeline", needsProduct: false),
CardKind(type: "activity-timeline", needsProduct: false),
]
static func kind(for type: String) -> CardKind? { all.first { $0.type == type } }
}

View File

@@ -0,0 +1,148 @@
import SwiftUI
/// Zuordnen per Scan: erst ein Einzelstück (/i/<UID>) scannen, dann einen
/// Lagerort (/l/<ID>) das Stück wird dem Ort zugewiesen. Danach gleich das
/// nächste Stück, ohne Tippen.
struct AssignScanView: View {
@Environment(\.dismiss) private var dismiss
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@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 {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await handle(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
if let pending {
VStack(spacing: 4) {
Text("Einzelstück \(pending.uid)").font(.headline)
Text("Jetzt den Lagerort-QR scannen")
.font(.caption).foregroundStyle(.secondary)
Button("Abbrechen") { self.pending = nil }
.font(.caption).padding(.top, 2)
}
.padding().frame(maxWidth: .infinity)
.background(Color.marke.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 12)).padding(.horizontal)
} else {
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 {
Text(status).font(.callout).foregroundStyle(.green)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
}
if let error {
Text(error).font(.callout).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal)
}
}
.padding(.vertical)
}
.navigationTitle("Ort zuordnen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
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 {
// Lagerort-QR (/l/<Code>)? Der Code ist eine 10-stellige Zeichenkette.
if let r = code.range(of: "/l/") {
let locCode = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if !locCode.isEmpty {
guard let it = pending else {
error = "Erst ein Einzelstück scannen."; status = nil; return
}
do {
_ = try await APIClient.shared.updateItem(id: it.id, ItemUpdateRequest(
locationId: locCode, shopId: it.shopId,
acquiredOn: it.acquiredOn, warrantyUntil: it.warrantyUntil, note: it.note))
status = "\(it.uid) zugeordnet. Nächstes Stück scannen."
error = nil
pending = nil
} catch { self.error = error.localizedDescription }
return
}
}
// Einzelstück-QR (/i/<UID>)?
if let r = code.range(of: "/i/") {
let uid = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
if !uid.isEmpty, let it = try? await APIClient.shared.itemByUid(uid: uid) {
pending = it; status = nil; error = nil
return
}
}
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{1,2})[./-](\d{4})"#, ["m", "y"], "month"),
(#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"), (#"(\d{4})[./-](\d{1,2})(?!\d)"#, ["y", "m"], "month"),
(#"(\d{1,2})[./-](\d{2})(?!\d)"#, ["m", "Y"], "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. /// Alle plausiblen Daten aus einem Textstueck, in Fundreihenfolge und ohne Dubletten.
@@ -73,6 +77,18 @@ enum BestBeforeText {
jahr = vier jahr = vier
} else if let zwei = teile["Y"] { } else if let zwei = teile["Y"] {
jahr = 2000 + zwei 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 { } else {
return nil return nil
} }
@@ -92,3 +108,9 @@ enum BestBeforeText {
return Calendar.current.date(from: komponenten) 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

@@ -7,6 +7,8 @@ import SwiftUI
struct CategoryPicker: View { struct CategoryPicker: View {
let categories: [CategoryItem] let categories: [CategoryItem]
@Binding var selection: Int? @Binding var selection: Int?
/// Nur Kategorien dieses Typs zeigen ("food"/"object"); nil = alle.
var tracking: String? = nil
var body: some View { var body: some View {
Picker("Kategorie", selection: $selection) { Picker("Kategorie", selection: $selection) {
@@ -18,17 +20,23 @@ struct CategoryPicker: View {
} }
} }
private var gefiltert: [CategoryItem] {
guard let tracking else { return categories }
return categories.filter { $0.tracking == tracking }
}
private func flattened() -> [(item: CategoryItem, depth: Int)] { private func flattened() -> [(item: CategoryItem, depth: Int)] {
let ids = Set(categories.map(\.id)) let quelle = gefiltert
let ids = Set(quelle.map(\.id))
var result: [(CategoryItem, Int)] = [] var result: [(CategoryItem, Int)] = []
func walk(_ node: CategoryItem, _ depth: Int) { func walk(_ node: CategoryItem, _ depth: Int) {
result.append((node, depth)) result.append((node, depth))
for child in categories.filter({ $0.parentId == node.id }) { for child in quelle.filter({ $0.parentId == node.id }) {
walk(child, depth + 1) walk(child, depth + 1)
} }
} }
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { for root in quelle.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0) walk(root, 0)
} }
return result return result

View File

@@ -0,0 +1,312 @@
import SwiftUI
import Charts
/// Native Diagrammkarten der Übersicht (Swift Charts). Ersetzt den früheren
/// gibt es nur im Web"-Hinweis.
///
/// Ringe (SectorMark) gibt es erst ab iOS 17; auf iOS 16 tritt an ihre Stelle
/// ein 100-%-Balken, der dieselben Anteile zeigt. Balken und Linien laufen auf
/// beiden.
/// Farben der Ablaufzustände - überall gleich, damit Grün/Orange/Rot dasselbe
/// bedeuten.
enum ExpiryColor {
static let ok = Color.green
static let soon = Color.orange
static let expired = Color.red
static let noDate = Color.gray
}
/// Ein Anteil für Ring bzw. 100-%-Balken.
private struct Segment: Identifiable {
let id = UUID()
let label: String
let value: Double
let color: Color
}
/// Ring (iOS 17+) oder 100-%-Balken (iOS 16) plus Legende.
private struct ProportionChart: View {
let segments: [Segment]
let einheit: String
private var sichtbar: [Segment] { segments.filter { $0.value > 0 } }
var body: some View {
if sichtbar.isEmpty {
Text("Keine Daten.").font(.caption).foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
if #available(iOS 17.0, *) {
Chart(sichtbar) { seg in
SectorMark(angle: .value("Anteil", seg.value),
innerRadius: .ratio(0.62), angularInset: 1.5)
.foregroundStyle(seg.color)
}
.frame(height: 160)
} else {
Chart(sichtbar) { seg in
BarMark(x: .value("Anteil", seg.value), y: .value("", "Bestand"))
.foregroundStyle(seg.color)
}
.chartXAxis(.hidden).chartYAxis(.hidden)
.frame(height: 28)
}
legende
}
}
}
private var legende: some View {
VStack(alignment: .leading, spacing: 3) {
ForEach(sichtbar) { seg in
HStack(spacing: 6) {
Circle().fill(seg.color).frame(width: 8, height: 8)
Text(seg.label).font(.caption)
Spacer()
Text("\(formatAmount(seg.value)) \(einheit)")
.font(.caption).foregroundStyle(.secondary)
}
}
}
}
}
/// Waehlt anhand der Art die passende Diagrammkarte (ohne Artikelbezug).
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, 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)
default:
Text("Unbekanntes Diagramm „\(type)“.").font(.caption).foregroundStyle(.secondary)
}
}
}
// MARK: - Ladehülle
/// Nimmt die wiederkehrende Lade-/Fehlerlogik der Diagrammkarten ab.
private struct ChartLoader<Data, Inhalt: View>: View {
let laden: () async throws -> Data
let stand: Int
@ViewBuilder let inhalt: (Data) -> Inhalt
@State private var daten: Data?
@State private var fehler: String?
var body: some View {
Group {
if let fehler {
Text(fehler).font(.caption).foregroundStyle(.red)
} else if let daten {
inhalt(daten)
} else {
Text("Lädt…").font(.caption).foregroundStyle(.secondary)
}
}
.task(id: stand) {
do { daten = try await laden(); fehler = nil }
catch { self.fehler = error.localizedDescription }
}
}
}
// MARK: - Ablauf-Anteile
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
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),
]
if mitOhneMhd {
segmente.append(Segment(label: "Ohne MHD", value: s.noDate, color: ExpiryColor.noDate))
}
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
}
}
}
// MARK: - Kategorien-Anteile
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(depth: tiefe) }, stand: stand) { liste in
let sortiert = liste.filter { $0.articleUnits > 0 }
.sorted { $0.articleUnits > $1.articleUnits }
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
}
return ProportionChart(segments: segmente, einheit: "Artikeleinheiten")
}
}
}
// MARK: - Kategorien nach Zustand (gestapelte Balken)
struct CategoryBarsCard: View {
let stand: Int
private struct Zeile: Identifiable {
let id = UUID()
let kategorie: String
let zustand: String
let wert: Double
let farbe: Color
}
var body: some View {
ChartLoader(laden: { try await APIClient.shared.byCategory() }, stand: stand) { liste in
let top = liste.filter { $0.articleUnits > 0 }
.sorted { $0.articleUnits > $1.articleUnits }.prefix(8)
let zeilen: [Zeile] = top.flatMap { c in
[
Zeile(kategorie: c.name, zustand: "In Ordnung", wert: c.ok, farbe: ExpiryColor.ok),
Zeile(kategorie: c.name, zustand: "Bald", wert: c.soon, farbe: ExpiryColor.soon),
Zeile(kategorie: c.name, zustand: "Abgelaufen", wert: c.expired, farbe: ExpiryColor.expired),
Zeile(kategorie: c.name, zustand: "Ohne MHD", wert: c.noDate, farbe: ExpiryColor.noDate),
]
}
return Chart(zeilen) { z in
BarMark(x: .value("Artikeleinheiten", z.wert),
y: .value("Kategorie", z.kategorie))
.foregroundStyle(z.farbe)
}
.chartLegend(.hidden)
.frame(height: max(120, CGFloat(top.count) * 34))
}
}
}
// MARK: - Bestandsverlauf (Linie)
struct StockTimelineCard: View {
let stand: Int
var body: some View {
ChartLoader(laden: { try await APIClient.shared.timeline(days: 90) }, stand: stand) { punkte in
LinienDiagramm(punkte: punkte)
}
}
}
/// Verlauf **eines** Artikels - dieselbe Linie, nur gefiltert.
struct ProductTimelineChart: View {
let productId: Int?
let stand: Int
var body: some View {
if let productId {
ChartLoader(laden: { try await APIClient.shared.timeline(days: 90, productId: productId) },
stand: stand) { punkte in
LinienDiagramm(punkte: punkte)
}
} else {
Text("Kein Artikel gewählt beim Bearbeiten einstellen.")
.font(.caption).foregroundStyle(.secondary)
}
}
}
private struct LinienDiagramm: View {
let punkte: [TimelinePoint]
var body: some View {
if punkte.isEmpty {
Text("Keine Daten.").font(.caption).foregroundStyle(.secondary)
} else {
Chart(punkte) { p in
if let datum = DisplaySettings.parseTimestamp(p.at) {
LineMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
.foregroundStyle(Color.marke)
AreaMark(x: .value("Zeit", datum), y: .value("Bestand", p.articleUnits))
.foregroundStyle(Color.marke.opacity(0.12))
}
}
.frame(height: 170)
}
}
}
// MARK: - Ein- und Auslagerungen (gruppierte Balken)
struct ActivityTimelineCard: View {
let stand: Int
private struct Balken: Identifiable {
let id = UUID()
let datum: Date
let richtung: String
let wert: Int
}
var body: some View {
ChartLoader(laden: { try await APIClient.shared.activity(days: 30) }, stand: stand) { punkte in
let balken: [Balken] = punkte.compactMap { p in
DisplaySettings.parseTimestamp(p.at).map { d in [
Balken(datum: d, richtung: "Eingelagert", wert: p.checkedIn),
Balken(datum: d, richtung: "Ausgelagert", wert: p.checkedOut),
] } ?? []
}.flatMap { $0 }
return Chart(balken) { b in
BarMark(x: .value("Tag", b.datum, unit: .day), y: .value("Anzahl", b.wert))
.foregroundStyle(by: .value("Richtung", b.richtung))
.position(by: .value("Richtung", b.richtung))
}
.chartForegroundStyleScale([
"Eingelagert": Color.green, "Ausgelagert": Color.orange,
])
.frame(height: 170)
}
}
}

View File

@@ -35,7 +35,10 @@ struct LabeledField: View {
Spacer(minLength: 12) Spacer(minLength: 12)
TextField(label, text: $text) TextField(label, text: $text)
.keyboardType(keyboard) .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) .fieldBox(width: 190)
} }
} }
@@ -138,6 +141,7 @@ struct CheckInFormView: View {
var onDone: (String) -> Void var onDone: (String) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings
struct Line: Identifiable { struct Line: Identifiable {
let id = UUID() let id = UUID()
@@ -153,14 +157,26 @@ struct CheckInFormView: View {
/// Charge, fuer die gerade das MHD abgescannt wird. /// Charge, fuer die gerade das MHD abgescannt wird.
@State private var scanLineId: LineRef? @State private var scanLineId: LineRef?
@State private var unit: String = "" @State private var unit: String = ""
@State private var locationId: Int? @State private var locationId: String?
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
// Lagerort per QR (/l/<Code>) setzen, statt in der Liste zu suchen.
@State private var locScanShown = false
@State private var locScanPaused = false
@State private var locTorchOn = false
@State private var locTorchLocked = false
@State private var locScanError: String?
@State private var locScanHinweis: String?
/// Einheiten der passenden Art plus das Gebinde des Artikels. /// Einheiten der passenden Art plus das Gebinde des Artikels.
private var unitOptions: [UnitOption] { 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 var options = units
.filter { $0.kind == product.kind } .filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) } .map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 { if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package". // Angezeigt wird "Glas"/"Dose", geschickt wird "package".
@@ -189,13 +205,31 @@ struct CheckInFormView: View {
Picker("Einheit", selection: $unit) { Picker("Einheit", selection: $unit) {
ForEach(unitOptions) { Text($0.label).tag($0.value) } ForEach(unitOptions) { Text($0.label).tag($0.value) }
} }
if !locations.isEmpty { }
if !locations.isEmpty {
Section {
Picker("Lagerort", selection: $locationId) { Picker("Lagerort", selection: $locationId) {
Text(" keiner ").tag(Int?.none) Text(" keiner ").tag(String?.none)
ForEach(locations) { location in ForEach(locations) { location in
Text(location.name).tag(Int?.some(location.id)) Text(location.path(in: locations)).tag(String?.some(location.id))
} }
} }
Button {
locScanError = nil
locScanPaused = false
locScanShown = true
} label: {
Label("Lagerort-QR scannen", systemImage: "qrcode.viewfinder")
}
if let locScanHinweis {
Label(locScanHinweis, systemImage: "checkmark.circle.fill")
.font(.caption).foregroundStyle(.green)
}
} header: {
Text("Lagerort")
} footer: {
Text("Den QR am Regal/Fach scannen, statt den Ort in der Liste zu suchen.")
} }
} }
@@ -281,6 +315,7 @@ struct CheckInFormView: View {
} }
} }
} }
.fullScreenCover(isPresented: $locScanShown) { locationScannerCover }
.onAppear { .onAppear {
// Gibt es ein Gebinde, ist das die naheliegende Eingabe. // Gibt es ein Gebinde, ist das die naheliegende Eingabe.
unit = hasPackage ? UnitOption.packageValue : product.unitName unit = hasPackage ? UnitOption.packageValue : product.unitName
@@ -289,6 +324,60 @@ struct CheckInFormView: View {
} }
} }
// MARK: - Lagerort scannen
private var locationScannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await handleLocationScan(code) } },
isPaused: $locScanPaused, torchOn: $locTorchOn, torchLocked: $locTorchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
VStack(spacing: 6) {
if let locScanError {
Text(locScanError)
.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") { locScanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $locTorchOn, locked: $locTorchLocked)
}
}
}
.onAppear { locScanPaused = false; locScanError = nil }
}
/// Aus einem gescannten Lagerort-QR (/l/<Code>) den Zielort setzen.
private func handleLocationScan(_ code: String) async {
guard let r = code.range(of: "/l/") else {
locScanError = "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 }) {
locationId = treffer.id
locScanHinweis = treffer.name
locScanShown = false
} else {
locScanError = "Dieser Lagerort ist hier nicht bekannt."
}
}
private func format(_ value: Double) -> String { private func format(_ value: Double) -> String {
value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value)
} }
@@ -323,7 +412,8 @@ struct CheckInFormView: View {
BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines) BatchCheckInRequest(productId: product.id, unit: unit, lines: payloadLines)
) )
let total = response.productStock / product.articleUnitFactor 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() dismiss()
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription

View File

@@ -1,14 +1,22 @@
import SwiftUI import SwiftUI
import Vision
import PhotosUI
import UIKit
struct CheckInView: View { struct CheckInView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
// Kamera bewusst nur auf Wunsch: Einlagern öffnet ein Formular, kein Sucher.
@State private var scanShown = false
@State private var paused = false @State private var paused = false
@State private var torchOn = false @State private var torchOn = false
@State private var torchLocked = false
@State private var status: String? @State private var status: String?
@State private var error: String? @State private var error: String?
@State private var product: Product? @State private var product: Product?
@State private var scannedItem: Item?
@State private var suggestion: LookupResult.Suggestion? @State private var suggestion: LookupResult.Suggestion?
@State private var suggestedGroupId: Int? @State private var suggestedGroupId: Int?
@State private var suggestedGroupName: String? @State private var suggestedGroupName: String?
@@ -17,68 +25,103 @@ struct CheckInView: View {
@State private var unknownCode: String? @State private var unknownCode: String?
@State private var manualCodeShown = false @State private var manualCodeShown = false
@State private var manualCode = "" @State private var manualCode = ""
// Manuelle Artikelsuche (statt Scanzwang).
@State private var query = ""
@State private var results: [Product] = []
@State private var searching = false
@State private var units: [Unit] = [] @State private var units: [Unit] = []
@State private var locations: [StorageLocation] = [] @State private var locations: [StorageLocation] = []
var body: some View { var body: some View {
// Kamera bewusst nur als Feld oben statt bildschirmfuellend - der Form {
// Sucher ueber die ganze Flaeche wirkte erschlagend. if let status { Section { banner(status, color: .green) } }
ScrollView { if let error { Section { banner(error, color: .red) } }
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode vor die Kamera halten") Section {
.font(.caption).foregroundStyle(.secondary) Button {
error = nil; status = nil
scanShown = true
} label: {
Label("EAN scannen", systemImage: "barcode.viewfinder")
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
if let status { banner(status, color: .green) } Button {
if let error { banner(error, color: .red) } manualCode = ""
manualCodeShown = true
if let suggestion { } label: {
suggestionCard(suggestion) Label("EAN von Hand eingeben", systemImage: "keyboard")
} else if let unknownCode {
unknownCard(unknownCode)
} }
VStack(spacing: 10) { NavigationLink {
Button { ProductFormView(prefillBarcode: nil, groupId: nil) { created in
paused = true product = created
manualCodeShown = true
} label: {
Label("EAN manuell", systemImage: "keyboard")
.frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) } label: {
Label("Neuen Artikel anlegen", systemImage: "plus")
NavigationLink {
ProductFormView(prefillBarcode: nil, groupId: nil) { created in
product = created
}
} label: {
Label("Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
}
.buttonStyle(.bordered)
} }
.padding(.horizontal) } header: {
Text("Neu erfassen")
} footer: {
Text("Die Kamera öffnet sich erst beim Tippen auf „EAN scannen“ kein Zwang.")
}
if let suggestion {
suggestionSection(suggestion)
} else if let unknownCode {
unknownSection(unknownCode)
}
Section {
HStack {
Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
TextField("Name oder Marke …", text: $query)
.autocorrectionDisabled()
if !query.isEmpty {
Button { query = "" } label: {
Image(systemName: "xmark.circle.fill").foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
if searching {
HStack { ProgressView(); Text("Suche …").foregroundStyle(.secondary) }
}
ForEach(results) { p in
Button {
error = nil; status = nil
product = p
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(p.name).foregroundStyle(.primary)
let unter = [p.brand, "Bestand: \(bestandText(p))"]
.compactMap { $0 }.joined(separator: " · ")
Text(unter).font(.caption).foregroundStyle(.secondary)
}
}
}
if !query.trimmingCharacters(in: .whitespaces).isEmpty && results.isEmpty && !searching {
Text("Kein Artikel gefunden oben neu anlegen.")
.foregroundStyle(.secondary).font(.callout)
}
} header: {
Text("Vorhandenen Artikel wählen")
} }
.padding(.vertical)
} }
.navigationTitle("Einlagern") .navigationTitle("Einlagern")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
Button("Fertig") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) }
} }
.task { .task {
units = (try? await APIClient.shared.units()) ?? [] units = (try? await APIClient.shared.units()) ?? []
locations = (try? await APIClient.shared.locations()) ?? [] locations = (try? await APIClient.shared.locations()) ?? []
} }
.task(id: query) { await search() }
.fullScreenCover(isPresented: $scanShown) { scannerCover }
.alert("EAN eingeben", isPresented: $manualCodeShown) { .alert("EAN eingeben", isPresented: $manualCodeShown) {
TextField("z.B. 8076809572569", text: $manualCode) TextField("z.B. 8076809572569", text: $manualCode)
.keyboardType(.numberPad) .keyboardType(.numberPad)
@@ -87,30 +130,87 @@ struct CheckInView: View {
manualCode = "" manualCode = ""
Task { await resolve(code) } Task { await resolve(code) }
} }
Button("Abbrechen", role: .cancel) { paused = false } Button("Abbrechen", role: .cancel) { }
} }
.sheet(item: $product) { item in .sheet(item: $product) { item in
NavigationStack { NavigationStack {
CheckInFormView(product: item, units: units, locations: locations) { message in if item.isIndividual {
status = message // Einzelstücke werden nicht als Charge eingelagert, sondern als
product = nil // physische Exemplare (UID/QR) angelegt die zählen den Bestand.
paused = false 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." })
} }
} }
} }
.sheet(item: $scannedItem) { it in
NavigationStack { ItemEditView(item: it) }
}
}
// MARK: - Scanner (nur auf Wunsch)
private var scannerCover: some View {
NavigationStack {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.ignoresSafeArea(edges: .bottom)
.overlay(alignment: .bottom) {
Text("Barcode vor die Kamera halten")
.font(.callout)
.padding(10)
.background(.ultraThinMaterial, in: Capsule())
.padding(.bottom, 24)
}
.navigationTitle("EAN scannen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { scanShown = false }
}
ToolbarItem(placement: .topBarTrailing) {
TorchButton(isOn: $torchOn, locked: $torchLocked)
}
}
}
.onAppear { paused = false }
} }
// MARK: - Bausteine // MARK: - Bausteine
private func banner(_ text: String, color: Color) -> some View { private func banner(_ text: String, color: Color) -> some View {
Text(text) // Erste Zeile = Titel (fett), Rest = Detail ergibt eine saubere,
.font(.callout) // rundum eingerückte Melde-Pille statt eines randlosen Balkens.
.padding(10) let zeilen = text.components(separatedBy: "\n")
.frame(maxWidth: .infinity, alignment: .leading) return VStack(alignment: .leading, spacing: 2) {
.background(color.opacity(0.9)) Text(zeilen.first ?? text).font(.headline)
.foregroundStyle(.white) if zeilen.count > 1 {
.clipShape(RoundedRectangle(cornerRadius: 10)) Text(zeilen.dropFirst().joined(separator: "\n")).font(.subheadline)
.padding(.horizontal) }
}
.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 {
let wert = p.stockInArticleUnits
let zahl = wert == wert.rounded() ? String(Int(wert)) : String(format: "%.2f", wert)
return "\(zahl) \(p.articleUnitLabel)"
} }
/// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum. /// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum.
@@ -119,7 +219,7 @@ struct CheckInView: View {
if let kategorie = suggestedCategoryName { if let kategorie = suggestedCategoryName {
noteRow(symbol: "square.grid.2x2", noteRow(symbol: "square.grid.2x2",
title: "Wird der Kategorie „\(kategorie)“ zugeordnet", title: "Wird der Kategorie „\(kategorie)“ zugeordnet",
detail: "Aus der Einordnung bei Open Food Facts im Formular änderbar.") detail: "Aus der Produkt-Datenbank vorgeschlagen im Formular änderbar.")
} }
if let name = suggestedGroupName { if let name = suggestedGroupName {
noteRow(symbol: "tag", noteRow(symbol: "tag",
@@ -136,64 +236,73 @@ struct CheckInView: View {
Text(detail).font(.caption2).foregroundStyle(.secondary) Text(detail).font(.caption2).foregroundStyle(.secondary)
} }
} }
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.accentColor.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 8))
} }
private func suggestionCard(_ item: LookupResult.Suggestion) -> some View { @ViewBuilder
VStack(alignment: .leading, spacing: 8) { private func suggestionSection(_ item: LookupResult.Suggestion) -> some View {
Text(item.name).font(.headline) Section {
Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · ")) VStack(alignment: .leading, spacing: 8) {
.font(.caption).foregroundStyle(.secondary) Text(item.name).font(.headline)
Text("Bei Open Food Facts gefunden, noch nicht im Katalog.") Text([item.brand, item.quantityText].compactMap { $0 }.joined(separator: " · "))
.font(.caption2).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
groupNote() Text("In einer Produkt-Datenbank gefunden, noch nicht im Katalog.")
NavigationLink { .font(.caption2).foregroundStyle(.secondary)
ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, groupNote()
categoryId: suggestedCategoryId, suggestion: item) { created in NavigationLink {
suggestion = nil ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId,
product = created categoryId: suggestedCategoryId, suggestion: item) { created in
suggestion = nil
product = created
}
} label: {
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
} }
} label: { .buttonStyle(.borderedProminent)
Label("Anlegen & einlagern", systemImage: "plus.circle.fill")
.frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) } header: {
Text("Vorschlag zum Scan")
} }
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
} }
private func unknownCard(_ code: String) -> some View { @ViewBuilder
VStack(alignment: .leading, spacing: 8) { private func unknownSection(_ code: String) -> some View {
Text("Unbekannter Code \(code)").font(.headline) Section {
Text("Weder im Katalog noch bei Open Food Facts.") VStack(alignment: .leading, spacing: 8) {
.font(.caption).foregroundStyle(.secondary) Text("Unbekannter Code \(code)").font(.headline)
groupNote() Text("Weder im Katalog noch in Open Food / Products Facts.")
NavigationLink { .font(.caption).foregroundStyle(.secondary)
ProductFormView(prefillBarcode: code, groupId: suggestedGroupId, groupNote()
categoryId: suggestedCategoryId) { created in NavigationLink {
unknownCode = nil ProductFormView(prefillBarcode: code, groupId: suggestedGroupId,
product = created categoryId: suggestedCategoryId) { created in
unknownCode = nil
product = created
}
} label: {
Label("Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
} }
} label: { .buttonStyle(.borderedProminent)
Label("Artikel anlegen", systemImage: "plus")
.frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) } header: {
Text("Zum Scan")
} }
.padding()
.background(.thinMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal)
} }
// MARK: - Logik // MARK: - Logik
private func search() async {
let q = query.trimmingCharacters(in: .whitespaces)
guard q.count >= 2 else { results = []; searching = false; return }
// Kurze Verzögerung, damit nicht bei jedem Tastendruck abgefragt wird.
try? await Task.sleep(nanoseconds: 250_000_000)
if Task.isCancelled { return }
searching = true
defer { searching = false }
results = (try? await APIClient.shared.searchProducts(q)) ?? []
}
private func resolve(_ code: String) async { private func resolve(_ code: String) async {
paused = true paused = true
error = nil error = nil
@@ -201,6 +310,17 @@ struct CheckInView: View {
suggestion = nil suggestion = nil
unknownCode = nil unknownCode = nil
do { do {
// Einzelstück-QR (/i/<UID>) direkt das Stück öffnen statt Barcode-Suche.
if let r = code.range(of: "/i/") {
let uid = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ "))
.uppercased()
if !uid.isEmpty, let item = try? await APIClient.shared.itemByUid(uid: uid) {
scannedItem = item
scanShown = false
return
}
}
let result = try await APIClient.shared.lookup(barcode: code) let result = try await APIClient.shared.lookup(barcode: code)
suggestedGroupId = result.groupId suggestedGroupId = result.groupId
suggestedGroupName = result.groupName suggestedGroupName = result.groupName
@@ -213,9 +333,448 @@ struct CheckInView: View {
} else { } else {
unknownCode = code unknownCode = code
} }
scanShown = false
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
paused = false scanShown = false
}
}
}
// 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

@@ -5,6 +5,7 @@ struct CheckOutView: View {
@State private var paused = false @State private var paused = false
@State private var torchOn = false @State private var torchOn = false
@State private var torchLocked = false
@State private var status: String? @State private var status: String?
@State private var error: String? @State private var error: String?
@State private var product: Product? @State private var product: Product?
@@ -18,7 +19,7 @@ struct CheckOutView: View {
ScrollView { ScrollView {
VStack(spacing: 14) { VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } }, ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn) isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.aspectRatio(4.0 / 3.0, contentMode: .fit) .aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal) .padding(.horizontal)
@@ -56,7 +57,7 @@ struct CheckOutView: View {
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } } ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn) } ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
} }
.task { units = (try? await APIClient.shared.units()) ?? [] } .task { units = (try? await APIClient.shared.units()) ?? [] }
.alert("EAN eingeben", isPresented: $manualCodeShown) { .alert("EAN eingeben", isPresented: $manualCodeShown) {
@@ -135,8 +136,12 @@ struct CheckOutFormView: View {
@State private var error: String? @State private var error: String?
private var unitOptions: [UnitOption] { 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 var options = units
.filter { $0.kind == product.kind } .filter { arten.contains($0.kind) }
.map { UnitOption(value: $0.name, label: $0.name) } .map { UnitOption(value: $0.name, label: $0.name) }
if let size = product.packageSize, size > 0 { if let size = product.packageSize, size > 0 {
// Angezeigt wird "Glas"/"Dose", geschickt wird "package". // Angezeigt wird "Glas"/"Dose", geschickt wird "package".
@@ -214,7 +219,7 @@ struct CheckOutFormView: View {
CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId) CheckOutRequest(productId: product.id, quantity: amount, unit: unit, lotId: lotId)
) )
let total = response.productStock / product.articleUnitFactor 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() dismiss()
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription

View File

@@ -81,7 +81,7 @@ struct QuickBookingCard: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.tint(einlagern ? Color.accentColor : .orange) .tint(einlagern ? Color.marke : .orange)
.disabled(!bereit) .disabled(!bereit)
} }
@@ -108,17 +108,18 @@ struct QuickBookingCard: View {
fehler = nil fehler = nil
meldung = nil meldung = nil
do { do {
// "article" ist die Einheit, in der der Artikel gefuehrt wird - // Menge in Artikeleinheiten: Gebinde -> Packung, sonst Basiseinheit.
// genau die, die in der Karte steht. // Der Server kennt kein "article".
let unit = (artikel.packageSize ?? 0) > 0 ? "package" : artikel.baseUnit
if einlagern { if einlagern {
let zeile = CheckInLine(quantity: wert, bestBefore: nil, let zeile = CheckInLine(quantity: wert, bestBefore: nil,
bestBeforePrecision: "day", locationId: nil) bestBeforePrecision: "day", locationId: nil)
_ = try await APIClient.shared.checkInBatch( _ = try await APIClient.shared.checkInBatch(
BatchCheckInRequest(productId: artikel.id, unit: "article", lines: [zeile])) BatchCheckInRequest(productId: artikel.id, unit: unit, lines: [zeile]))
} else { } else {
_ = try await APIClient.shared.checkOut( _ = try await APIClient.shared.checkOut(
CheckOutRequest(productId: artikel.id, quantity: wert, CheckOutRequest(productId: artikel.id, quantity: wert,
unit: "article", lotId: nil)) unit: unit, lotId: nil))
} }
meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")." meldung = "\(formatAmount(wert)) \(einlagern ? "eingelagert" : "ausgelagert")."
menge = "1" menge = "1"
@@ -170,10 +171,10 @@ struct StatusCard: View {
switch art { switch art {
case "kpi-products": case "kpi-products":
Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand", Kennzahl(wert: String(stats.productsInStock), label: "Artikel mit Bestand",
detail: "von \(stats.productsTotal)", tint: .accentColor) detail: "von \(stats.productsTotal)", tint: .marke)
case "kpi-units": case "kpi-units":
Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten", Kennzahl(wert: formatAmount(stats.articleUnits), label: "Artikeleinheiten",
detail: "", tint: .accentColor) detail: "", tint: .marke)
default: default:
HStack(alignment: .top, spacing: 12) { HStack(alignment: .top, spacing: 12) {
Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend", Kennzahl(wert: String(stats.expiringSoon), label: "Bald ablaufend",
@@ -294,7 +295,7 @@ struct ExpiryCard: View {
.font(.caption2).foregroundStyle(.secondary) .font(.caption2).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(item.daysLeft < 0 ? "abgelaufen" : "\(item.daysLeft) Tage") Text(item.restlaufzeitText)
.font(.caption).bold() .font(.caption).bold()
.foregroundStyle(item.daysLeft < 0 ? .red : .orange) .foregroundStyle(item.daysLeft < 0 ? .red : .orange)
} }
@@ -329,7 +330,13 @@ struct MovementsCard: View {
} else { } else {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
ForEach(movements.prefix(5)) { movement in 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) NavigationLink("Ganzer Verlauf") { HistoryView() }.font(.caption)
} }

View File

@@ -1,83 +1,123 @@
import SwiftUI import SwiftUI
/// Zeigt die Dashboards, die auch die Web-Oberflaeche kennt. /// Der Start-Tab. Je Server wird entweder die Web-Übersicht gespiegelt oder eine
/// /// eigene, lokale App-Übersicht gezeigt - umschaltbar über das Menü oben.
/// Das Raster (x/y/w/h) laesst sich auf einem Telefon nicht sinnvoll
/// nachbilden - hier stehen die Karten deshalb untereinander, in der
/// Lesereihenfolge des Rasters (erst Zeile, dann Spalte). Zusammengestellt
/// werden Dashboards weiterhin am Rechner; die App zeigt sie.
struct DashboardView: View { struct DashboardView: View {
@EnvironmentObject private var session: Session @EnvironmentObject private var session: Session
@State private var mode: OverviewMode = .mirror
@State private var manageShown = false
private var profileID: UUID? { session.activeProfileID }
var body: some View {
NavigationStack {
Group {
if mode == .local, let profileID {
LocalOverview(profileID: profileID)
} else {
MirrorOverview()
}
}
.navigationTitle(session.activeProfile?.name ?? "Übersicht")
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Menu {
Picker("Übersicht", selection: Binding(
get: { mode },
set: { neu in setzeModus(neu) }
)) {
Label("Web-Übersicht spiegeln", systemImage: "arrow.triangle.2.circlepath")
.tag(OverviewMode.mirror)
Label("Eigene App-Übersicht", systemImage: "square.grid.2x2")
.tag(OverviewMode.local)
}
} label: {
Image(systemName: "rectangle.3.group")
}
}
ToolbarItem(placement: .topBarTrailing) {
ServerMenu(manageShown: $manageShown)
}
}
.sheet(isPresented: $manageShown) { ServerListView() }
}
.task(id: profileID) { ladeModus() }
}
private func ladeModus() {
guard let profileID else { mode = .mirror; return }
mode = AppDashboardStore.config(for: profileID).mode
}
private func setzeModus(_ neu: OverviewMode) {
guard let profileID else { return }
var config = AppDashboardStore.config(for: profileID)
// Beim ersten Umschalten auf eigen" gleich eine sinnvolle Übersicht anlegen.
if neu == .local && config.dashboards.isEmpty {
config.dashboards = [AppDashboard.standard()]
}
config.mode = neu
AppDashboardStore.save(config, for: profileID)
mode = neu
}
}
// MARK: - Web-Übersicht spiegeln
/// Zeigt die Dashboards der Web-Oberfläche (schreibgeschützt). Das Raster
/// (x/y/w/h) lässt sich auf dem Telefon nicht nachbilden - die Karten stehen
/// deshalb untereinander, in der Lesereihenfolge des Rasters.
struct MirrorOverview: View {
@EnvironmentObject private var session: Session
@State private var dashboards: [Dashboard] = [] @State private var dashboards: [Dashboard] = []
@State private var auswahl: Int? @State private var auswahl: Int?
@State private var busy = true @State private var busy = true
@State private var fehler: String? @State private var fehler: String?
@State private var manageShown = false
/// Zaehler, der die Karten nach einer Buchung neu laden laesst.
@State private var stand = 0 @State private var stand = 0
private var aktives: Dashboard? { private var aktives: Dashboard? {
dashboards.first { $0.id == auswahl } ?? dashboards.first dashboards.first { $0.id == auswahl } ?? dashboards.first
} }
/// Rasterreihenfolge: erst von oben nach unten, dann von links nach rechts.
private var karten: [DashboardCard] { private var karten: [DashboardCard] {
(aktives?.layout ?? []).sorted { (aktives?.layout ?? []).sorted { $0.y == $1.y ? $0.x < $1.x : $0.y < $1.y }
$0.y == $1.y ? $0.x < $1.x : $0.y < $1.y
}
} }
var body: some View { var body: some View {
NavigationStack { List {
List { if let fehler {
if let fehler { Section { Text(fehler).foregroundStyle(.red).font(.callout) }
Section { Text(fehler).foregroundStyle(.red).font(.callout) } }
} if dashboards.count > 1 {
Section {
// Mehrere Dashboards: oben umschalten. Bei einem einzigen waere Picker("Dashboard", selection: Binding(
// die Auswahl nur Beiwerk. get: { aktives?.id ?? 0 }, set: { auswahl = $0 }
if dashboards.count > 1 { )) {
Section { ForEach(dashboards) { Text($0.name).tag($0.id) }
Picker("Dashboard", selection: Binding(
get: { aktives?.id ?? 0 },
set: { auswahl = $0 }
)) {
ForEach(dashboards) { d in
Text(d.name).tag(d.id)
}
}
.pickerStyle(.menu)
} }
} .pickerStyle(.menu)
ForEach(karten) { karte in
Section {
DashboardCardView(karte: karte, stand: stand) { stand += 1 }
} header: {
Text(titel(fuer: karte))
}
}
if karten.isEmpty && !busy {
Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
.foregroundStyle(.secondary)
} }
} }
.navigationTitle(aktives?.name ?? session.activeProfile?.name ?? "Übersicht") ForEach(karten) { karte in
.toolbar { Section {
ToolbarItem(placement: .topBarTrailing) { DashboardCardView(type: karte.type, productId: karte.props?.productId,
ServerMenu(manageShown: $manageShown) 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))
} }
} }
.sheet(isPresented: $manageShown) { ServerListView() } if karten.isEmpty && !busy {
.refreshable { await laden() } Text("Dieses Dashboard hat keine Karten. Zusammenstellen lässt es sich in der Web-Oberfläche.")
.task(id: session.activeProfileID) { await laden() } .foregroundStyle(.secondary)
}
} }
} .refreshable { await laden() }
.task(id: session.activeProfileID) { await laden() }
private func titel(fuer karte: DashboardCard) -> String {
CardCatalog.titel(karte.type)
} }
private func laden() async { private func laden() async {
@@ -130,14 +170,22 @@ enum CardCatalog {
} }
} }
/// Waehlt anhand der Kartenart den passenden Inhalt. /// Waehlt anhand der Kartenart den passenden Inhalt. Wird sowohl beim Spiegeln
/// der Web-Dashboards als auch in der eigenen App-Uebersicht verwendet - daher
/// nur ueber Art und (fuer Artikelkarten) Produkt-Id parametrisiert.
struct DashboardCardView: View { struct DashboardCardView: View {
let karte: DashboardCard let type: String
let productId: Int?
let stand: 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 let onChange: () -> Void
var body: some View { var body: some View {
switch karte.type { switch type {
case "checkin-quick": case "checkin-quick":
QuickBookingCard(richtung: .ein, onChange: onChange) QuickBookingCard(richtung: .ein, onChange: onChange)
case "checkout-quick": case "checkout-quick":
@@ -145,23 +193,24 @@ struct DashboardCardView: View {
case "actions": case "actions":
ActionsCard() ActionsCard()
case "status", "kpi-products", "kpi-units": case "status", "kpi-products", "kpi-units":
StatusCard(art: karte.type, stand: stand) StatusCard(art: type, stand: stand)
case "shopping": case "shopping":
ShoppingCard(stand: stand) ShoppingCard(stand: stand)
case "expiring", "expired", "expiry-all": case "expiring", "expired", "expiry-all":
ExpiryCard(modus: karte.type, stand: stand) ExpiryCard(modus: type, stand: stand)
case "movements": case "movements":
MovementsCard(stand: stand) MovementsCard(stand: stand)
case "product-stock", "product-timeline": case "product-stock":
ProductCard(productId: karte.props?.productId, stand: stand) ProductCard(productId: productId, stand: stand)
case "product-timeline":
ProductTimelineChart(productId: productId, stand: stand)
case "expiry-donut", "category-donut", "category-bars",
"stock-timeline", "activity-timeline":
ChartCardView(type: type, stand: stand,
detail: detail, tiefe: tiefe, mitOhneMhd: mitOhneMhd)
default: default:
if CardCatalog.istDiagramm(karte.type) { Text("Unbekannte Karte „\(type)“.")
Label("Diagramme gibt es in der Web-Oberfläche.", systemImage: "chart.xyaxis.line") .font(.caption).foregroundStyle(.secondary)
.font(.caption).foregroundStyle(.secondary)
} else {
Text("Unbekannte Karte „\(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. /// Duenne Huelle um den Text-Scanner von VisionKit.
private struct TextScannerRepresentable: UIViewControllerRepresentable { private struct TextScannerRepresentable: UIViewControllerRepresentable {
var onText: (String) -> Void var onText: (String) -> Void

View File

@@ -13,18 +13,39 @@ final class DisplaySettings: ObservableObject {
static let fallbackFormat = "de" static let fallbackFormat = "de"
@Published private(set) var dateFormat = DisplaySettings.fallbackFormat @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() {} 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 /// deutschen Standard - an einer fehlenden Einstellung darf die Anzeige
/// nicht scheitern. /// nicht scheitern.
func load() async { func load() async {
guard let entries = try? await APIClient.shared.settings() else { return } if let entries = try? await APIClient.shared.settings(),
if let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }), let entry = entries.first(where: { $0.key == DisplaySettings.dateFormatKey }),
!entry.value.isEmpty { !entry.value.isEmpty {
dateFormat = entry.value 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 // 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 ForEach(tage, id: \.key) { tag in
Section(display.formatDay(tag.eintraege[0].createdAt)) { Section(display.formatDay(tag.eintraege[0].createdAt)) {
ForEach(tag.eintraege) { movement in 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

@@ -28,16 +28,30 @@
<string>1.0</string> <string>1.0</string>
<key>CFBundleVersion</key> <key>CFBundleVersion</key>
<string>1</string> <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
Documentation"-Rueckfrage bei jedem Upload nach App Store Connect. -->
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict/> <dict/>
<!-- Am iPad verlangt das System alle Ausrichtungen, solange die App nicht <!-- Am iPhone bewusst nur Hochkant - damit sich beim Drehen nichts
auf Vollbild besteht. Am iPhone bleibt Hochkant plus Querformat. --> verstellt. Am iPad verlangt das System alle Ausrichtungen, solange die
App nicht auf Vollbild besteht. -->
<key>UISupportedInterfaceOrientations</key> <key>UISupportedInterfaceOrientations</key>
<array> <array>
<string>UIInterfaceOrientationPortrait</string> <string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array> </array>
<key>UISupportedInterfaceOrientations~ipad</key> <key>UISupportedInterfaceOrientations~ipad</key>
<array> <array>
@@ -58,6 +72,17 @@
<true/> <true/>
</dict> </dict>
<!-- Hintergrund-Aktualisierung: die App frischt gelegentlich den Plan der
Ablauf-Benachrichtigungen auf. Zeitpunkt bestimmt iOS. -->
<key>UIBackgroundModes</key>
<array>
<string>fetch</string>
</array>
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>com.scarriffle.vorrania.refresh</string>
</array>
<!-- Schnellaktionen beim langen Druck auf das App-Symbol --> <!-- Schnellaktionen beim langen Druck auf das App-Symbol -->
<key>UIApplicationShortcutItems</key> <key>UIApplicationShortcutItems</key>
<array> <array>

View File

@@ -0,0 +1,182 @@
import SwiftUI
/// Liste aller Einzelstücke (physische Exemplare) über alle Produkte anders als
/// die Produktliste, die nur die Katalog-Typen zeigt. Such- und Filterfunktion,
/// v.a. nach Lagerort. Tippen öffnet das Einzelstück.
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: Int? // Kategorie-ID (Unterkategorien zählen mit)
@State private var busy = true
@State private var error: String?
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, !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]
.compactMap { $0 }.joined(separator: " ").lowercased()
if !heu.contains(q) { return false }
}
return true
}
}
private var hatFilter: Bool { locationFilter != nil || categoryFilter != nil }
var body: some View {
List {
if !busy && gefiltert.isEmpty {
Text(items.isEmpty ? "Noch keine Einzelstücke." : "Nichts gefunden.")
.foregroundStyle(.secondary)
}
ForEach(gefiltert) { it in
NavigationLink { ItemEditView(item: it) } label: {
ItemRow(item: it, locations: locations)
}
}
}
.searchable(text: $query, prompt: "Produkt, UID oder Notiz")
.navigationTitle("Einzelstücke")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarTrailing) { filterMenu } }
.overlay { if busy && items.isEmpty { ProgressView() } }
.refreshable { 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 ?? "") }
}
private var filterMenu: some View {
Menu {
Menu("Lagerort") {
check("Alle", locationFilter == nil) { locationFilter = nil }
ForEach(locations) { loc in
check(loc.path(in: locations), locationFilter == loc.id) { locationFilter = loc.id }
}
}
if !filterKategorien.isEmpty {
Menu("Kategorie") {
check("Alle", categoryFilter == nil) { categoryFilter = nil }
ForEach(filterKategorien) { e in
check(e.label, categoryFilter == e.item.id) { categoryFilter = e.item.id }
}
}
}
if hatFilter {
Divider()
Button(role: .destructive) { locationFilter = nil; categoryFilter = nil } label: {
Label("Filter zurücksetzen", systemImage: "xmark.circle")
}
}
} label: {
Image(systemName: hatFilter
? "line.3.horizontal.decrease.circle.fill"
: "line.3.horizontal.decrease.circle")
}
}
@ViewBuilder
private func check(_ title: String, _ selected: Bool, _ action: @escaping () -> Void) -> some View {
Button(action: action) {
if selected { Label(title, systemImage: "checkmark") } else { Text(title) }
}
}
private func load() async {
busy = true
defer { busy = false }
do {
items = try await APIClient.shared.allItems()
locations = (try? await APIClient.shared.locations()) ?? []
categories = (try? await APIClient.shared.categories()) ?? []
} catch {
self.error = error.localizedDescription
}
}
}
/// Eine Einzelstück-Zeile: Produktbild, Produktname, UID und Lagerort-Pfad.
struct ItemRow: View {
let item: Item
let locations: [StorageLocation]
var body: some View {
HStack(spacing: 12) {
ProductThumb(productId: item.productId)
VStack(alignment: .leading, spacing: 2) {
Text(item.productName ?? "")
let pfad = locationPath(item.locationId, in: locations)
HStack(spacing: 6) {
Text(item.uid).font(.caption).foregroundStyle(.secondary)
if !pfad.isEmpty {
Text("· \(pfad)").font(.caption).foregroundStyle(.secondary).lineLimit(1)
}
}
}
Spacer()
}
}
}

691
ios/Sources/ItemViews.swift Normal file
View File

@@ -0,0 +1,691 @@
import SwiftUI
import CoreImage
import PhotosUI
import QuickLook
import UniformTypeIdentifiers
/// QR-Code als Bild (CoreImage). Inhalt ist der Link auf das Einzelstück, damit
/// ein Scan auch mit der Systemkamera die App/Weboberfläche öffnet.
enum QRImage {
static func make(_ text: String, scale: CGFloat = 8) -> UIImage? {
guard let data = text.data(using: .utf8),
let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil }
filter.setValue(data, forKey: "inputMessage")
filter.setValue("M", forKey: "inputCorrectionLevel")
guard let ci = filter.outputImage?
.transformed(by: CGAffineTransform(scaleX: scale, y: scale)) else { return nil }
let context = CIContext()
guard let cg = context.createCGImage(ci, from: ci.extent) else { return nil }
return UIImage(cgImage: cg)
}
}
func qrLink(forUid uid: String) -> String {
let base = Session.shared.baseURL?.absoluteString
.trimmingCharacters(in: CharacterSet(charactersIn: "/")) ?? ""
return base.isEmpty ? uid : "\(base)/i/\(uid)"
}
private func dateToString(_ d: Date) -> String {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"; return f.string(from: d)
}
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
/// Detail-/Bearbeiten-Ansicht.
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
var body: some View {
Section("Einzelstücke (\(items.count))") {
ForEach(items) { it in
NavigationLink {
ItemEditView(item: it) { Task { await refresh() } }
} label: {
VStack(alignment: .leading, spacing: 2) {
Text(it.uid).font(.body.monospaced()).bold()
Text(untertitel(it)).font(.caption).foregroundStyle(.secondary)
}
}
}
if items.isEmpty {
Text("Noch keine Einzelstücke.").foregroundStyle(.secondary)
}
Button { showAdd = true } label: {
Label("Einzelstücke anlegen", systemImage: "plus")
}
}
.task(id: product.id) { await reload() }
.sheet(isPresented: $showAdd) {
NavigationStack {
ItemAddSheet(productId: product.id, onDone: { await refresh() },
onFinish: onFinish.map { finish in { showAdd = false; finish() } })
}
}
}
private func untertitel(_ it: Item) -> String {
[it.locationName, it.acquiredOn.map { "gekauft \($0)" }, it.shopName]
.compactMap { $0 }.joined(separator: " · ")
}
private func reload() async {
items = (try? await APIClient.shared.items(productId: product.id)) ?? []
}
private func refresh() async { await reload(); await onChanged() }
}
// 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
@Environment(\.dismiss) private var dismiss
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var photo: Data?
@State private var locationId: String?
@State private var shopId: Int?
@State private var hasAcquired = false
@State private var acquired = Date()
@State private var hasWarranty = false
@State private var warranty = Date()
@State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var documents: [ItemDocument] = []
@State private var suggWarranty: String?
@State private var suggPrice: Int?
@State private var suggPriceCandidates: [Int] = []
@State private var suggAcquired: String?
@State private var suggShopId: Int?
@State private var suggShopName: String?
@State private var busy = false
@State private var busyDoc = false
@State private var error: String?
@State private var showRemove = false
@State private var reason = "broken"
@State private var removeNote = ""
@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 {
Section {
if let photo, let ui = UIImage(data: photo) {
Image(uiImage: ui)
.resizable().scaledToFit()
.frame(maxWidth: .infinity).frame(maxHeight: 220)
.clipShape(RoundedRectangle(cornerRadius: 8))
}
if let name = item.productName {
LabeledContent("Artikel", value: name)
}
if let brand = item.productBrand, !brand.isEmpty {
LabeledContent("Marke", value: brand)
}
LabeledContent("UID", value: item.uid)
}
Section {
Picker("Lagerort", selection: $locationId) {
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 { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
if hasWarranty { DateRow(label: "Garantie bis", date: $warranty) }
LabeledField(label: "Notiz", text: $note)
}
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section("Belege (Rechnung/Garantieschein)") {
if hatVorschlag {
VStack(alignment: .leading, spacing: 6) {
Text("Im Beleg erkannt:").font(.caption).foregroundStyle(.secondary)
if let a = suggAcquired { Text("Gekauft am \(a)") }
if let w = suggWarranty { Text("Garantie bis \(w)") }
if suggPriceCandidates.count > 1 {
// Mehrere Beträge gefunden Auswahl anbieten.
Picker("Kaufpreis", selection: Binding(
get: { suggPrice ?? suggPriceCandidates.first ?? 0 },
set: { suggPrice = $0 }
)) {
ForEach(suggPriceCandidates, id: \.self) { c in
Text("\(ItemEditView.formatCents(c)) \(currency)").tag(c)
}
}
.pickerStyle(.menu)
} else if let p = suggPrice {
Text("Kaufpreis \(ItemEditView.formatCents(p)) \(currency)")
}
if let sid = suggShopId {
Text("Shop: \(shops.first(where: { $0.id == sid })?.name ?? "bekannt")")
} else if let name = suggShopName {
Text("Shop anlegen: \(name)")
}
HStack {
Button("Übernehmen") { Task { await applySuggestion() } }
Spacer()
Button("Verwerfen") { verwerfeVorschlag() }
.foregroundStyle(.secondary)
}
.font(.callout)
}
}
ForEach(documents) { d in
Button { Task { await openDocument(d) } } label: {
Label(d.filename, systemImage: "doc.text")
}
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { Task { await deleteDoc(d) } }
}
}
if documents.isEmpty {
Text("Noch keine Belege.").foregroundStyle(.secondary)
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild hochladen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF hochladen", systemImage: "doc.badge.plus")
}
if busyDoc { ProgressView() }
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }.disabled(busy)
Button("Entfernen (mit Grund)", role: .destructive) { showRemove = true }
}
}
.navigationTitle("Einzelstück")
.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 }
}
.fileImporter(isPresented: $showFileImporter, allowedContentTypes: [.pdf]) { result in
if case .success(let url) = result {
Task {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await upload(data: data, filename: url.lastPathComponent,
contentType: "application/pdf")
}
}
} else if case .failure(let err) = result {
error = err.localizedDescription
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await upload(data: data, filename: "foto.jpg", contentType: "image/jpeg")
}
pickerItem = nil
}
}
.quickLookPreview($previewURL)
}
private var removeSheet: some View {
Form {
Picker("Grund", selection: $reason) {
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
}
LabeledField(label: "Notiz", text: $removeNote)
Section {
Button("Entfernen", role: .destructive) { Task { await remove() } }
}
}
.navigationTitle("Entfernen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { showRemove = false } } }
}
private func load() async {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
photo = try? await APIClient.shared.productImage(id: item.productId)
locationId = item.locationId
shopId = item.shopId
note = item.note ?? ""
documents = item.documents
currency = item.currency ?? "CHF"
if let p = item.priceCents { priceText = ItemEditView.formatCents(p) }
if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true }
if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true }
}
private func save() async {
busy = true; defer { busy = false }; error = nil
let cents = ItemEditView.parseCents(priceText)
do {
_ = try await APIClient.shared.updateItem(id: item.id, ItemUpdateRequest(
locationId: locationId, shopId: shopId,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription }
}
// MARK: - Belege
private func upload(data: Data, filename: String, contentType: String) async {
busyDoc = true
defer { busyDoc = false }
do {
let res = try await APIClient.shared.uploadItemDocument(
itemId: item.id, data: data, filename: filename, contentType: contentType)
let s = res.suggestions
suggWarranty = s.suggestedWarrantyUntil
suggPrice = s.suggestedPriceCents
suggPriceCandidates = s.suggestedPriceCandidates
suggAcquired = s.suggestedAcquiredOn
suggShopId = s.suggestedShopId
suggShopName = s.suggestedShopName
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private func reloadDocuments() async {
if let fresh = try? await APIClient.shared.itemByUid(uid: item.uid) {
documents = fresh.documents
}
}
private func openDocument(_ d: ItemDocument) async {
do {
let data = try await APIClient.shared.itemDocumentData(itemId: item.id, docId: d.id)
let name = d.filename.isEmpty ? "beleg" : d.filename
let url = FileManager.default.temporaryDirectory.appendingPathComponent(name)
try data.write(to: url)
previewURL = url
} catch { self.error = error.localizedDescription }
}
private func deleteDoc(_ d: ItemDocument) async {
do {
try await APIClient.shared.deleteItemDocument(itemId: item.id, docId: d.id)
await reloadDocuments()
} catch { self.error = error.localizedDescription }
}
private var hatVorschlag: Bool {
suggWarranty != nil || suggPrice != nil || suggAcquired != nil
|| suggShopId != nil || suggShopName != nil
}
private func applySuggestion() async {
if let a = suggAcquired, let d = stringToDate(a) { acquired = d; hasAcquired = true }
if let w = suggWarranty, let d = stringToDate(w) { warranty = d; hasWarranty = true }
if let p = suggPrice { priceText = ItemEditView.formatCents(p) }
if let sid = suggShopId {
shopId = sid
} else if let name = suggShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
if !shops.contains(where: { $0.id == shop.id }) { shops.append(shop) }
shopId = shop.id
}
verwerfeVorschlag()
}
private func verwerfeVorschlag() {
suggWarranty = nil
suggPrice = nil
suggPriceCandidates = []
suggAcquired = nil
suggShopId = nil
suggShopName = nil
}
/// Eingabe in Hauptwährungseinheit Rappen/Cent.
static func parseCents(_ s: String) -> Int? {
let t = s.trimmingCharacters(in: .whitespaces).replacingOccurrences(of: ",", with: ".")
guard !t.isEmpty, let v = Double(t) else { return nil }
return Int((v * 100).rounded())
}
static func formatCents(_ c: Int) -> String {
String(format: "%.2f", Double(c) / 100)
}
private func remove() async {
do {
try await APIClient.shared.removeItem(id: item.id, ItemRemoveRequest(
reason: reason, note: removeNote.isEmpty ? nil : removeNote))
showRemove = false
onChanged?()
dismiss()
} catch { self.error = error.localizedDescription; showRemove = false }
}
}
// MARK: - Anlegen
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
@State private var count = 1
@State private var locations: [StorageLocation] = []
@State private var shops: [ShopItem] = []
@State private var locationId: String?
@State private var shopId: Int?
@State private var hasAcquired = false
@State private var acquired = Date()
@State private var hasWarranty = false
@State private var warranty = Date()
@State private var note = ""
@State private var priceText = ""
@State private var currency = "CHF"
@State private var docData: Data?
@State private var docName = ""
@State private var docType = ""
@State private var newShopName: String?
@State private var addInfo: String?
@State private var showFileImporter = false
@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 {
Section {
Stepper("Anzahl: \(count)", value: $count, in: 1...200)
Picker("Lagerort", selection: $locationId) {
Text(" ohne ").tag(String?.none)
ForEach(locations) { l in Text(l.path(in: locations)).tag(String?.some(l.id)) }
}
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 { DateRow(label: "Gekauft am", date: $acquired) }
Toggle("Garantie", isOn: $hasWarranty)
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.")
}
Section("Kaufpreis") {
HStack {
TextField("0.00", text: $priceText).keyboardType(.decimalPad)
Picker("", selection: $currency) {
Text("CHF").tag("CHF")
Text("EUR").tag("EUR")
}
.pickerStyle(.segmented).frame(width: 130)
}
}
Section {
if docData != nil { Text(docName).font(.callout) }
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Bild wählen", systemImage: "photo")
}
Button { showFileImporter = true } label: {
Label("PDF wählen", systemImage: "doc.badge.plus")
}
if let addInfo { Text(addInfo).font(.caption).foregroundStyle(.secondary) }
if let newShopName {
Text("Neuer Shop „\(newShopName)“ wird beim Anlegen erstellt.")
.font(.caption).foregroundStyle(.secondary)
}
} header: {
Text("Beleg (Rechnung/Garantieschein)")
} footer: {
Text("Der Beleg wird an das erste angelegte Stück gehängt; aus einem PDF werden Kaufdatum, Garantie, Preis und Shop vorgeschlagen.")
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
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 {
let scoped = url.startAccessingSecurityScopedResource()
defer { if scoped { url.stopAccessingSecurityScopedResource() } }
if let data = try? Data(contentsOf: url) {
await analyze(data, name: url.lastPathComponent, type: "application/pdf")
}
}
}
}
.onChange(of: pickerItem) { neu in
guard let neu else { return }
Task {
if let data = try? await neu.loadTransferable(type: Data.self) {
await analyze(data, name: "foto.jpg", type: "image/jpeg")
}
pickerItem = nil
}
}
.task {
locations = (try? await APIClient.shared.locations()) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
}
}
/// Beleg beim Anlegen analysieren und Felder vorbefüllen (nur bei PDF liefert
/// der Server Treffer).
private func analyze(_ data: Data, name: String, type: String) async {
docData = data
docName = name
docType = type
addInfo = nil
newShopName = nil
guard let s = try? await APIClient.shared.analyzeItemDocument(
data: data, filename: name, contentType: type) else { return }
var teile: [String] = []
if let a = s.suggestedAcquiredOn, let d = stringToDate(a) {
acquired = d; hasAcquired = true; teile.append("Kaufdatum")
}
if let w = s.suggestedWarrantyUntil, let d = stringToDate(w) {
warranty = d; hasWarranty = true; teile.append("Garantie")
}
if let p = s.suggestedPriceCents {
priceText = ItemEditView.formatCents(p); teile.append("Preis")
}
if let sid = s.suggestedShopId {
shopId = sid; teile.append("Shop")
} else if let sn = s.suggestedShopName {
newShopName = sn
}
addInfo = teile.isEmpty ? "Beleg erkannt keine Automatik-Treffer."
: "Übernommen: \(teile.joined(separator: ", "))."
}
private func create(finish: Bool) async {
busy = true; defer { busy = false }; error = nil
do {
var sid = shopId
if sid == nil, let name = newShopName,
let shop = try? await APIClient.shared.createShop(
NewShopRequest(name: name, website: nil)) {
sid = shop.id
}
let cents = ItemEditView.parseCents(priceText)
let created = try await APIClient.shared.createItems(productId: productId, ItemCreateRequest(
count: count, locationId: locationId, shopId: sid,
acquiredOn: hasAcquired ? dateToString(acquired) : nil,
warrantyUntil: hasWarranty ? dateToString(warranty) : nil,
note: note.isEmpty ? nil : note,
priceCents: cents,
currency: cents == nil ? nil : currency))
if let data = docData, let first = created.first {
_ = try? await APIClient.shared.uploadItemDocument(
itemId: first.id, data: data,
filename: docName.isEmpty ? "beleg" : docName, contentType: docType)
}
await onDone()
if finish, let onFinish { onFinish() } else { dismiss() }
} catch { self.error = error.localizedDescription }
}
}

View File

@@ -50,6 +50,7 @@ struct ShoppingListView: View {
@State private var items: [ShoppingItem] = [] @State private var items: [ShoppingItem] = []
@State private var groups: [GroupShoppingItem] = [] @State private var groups: [GroupShoppingItem] = []
@State private var byLocation: [LocationNeeds] = []
@State private var busy = true @State private var busy = true
@State private var error: String? @State private var error: String?
@@ -75,12 +76,34 @@ struct ShoppingListView: View {
ListRow( ListRow(
systemImage: "square.stack.3d.up", systemImage: "square.stack.3d.up",
title: group.name, 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() } ) { GroupBadge() }
} }
} }
} }
if items.isEmpty && groups.isEmpty && !busy { ForEach(byLocation) { ort in
Section("Bedarf: \(ort.locationName)") {
ForEach(ort.products) { p in
ListRow(
systemImage: "shippingbox",
title: p.name,
subtitle: "fehlt \(formatAmount(p.deficit)) \(p.unitLabel)"
) { EmptyView() }
}
ForEach(ort.groups) { g in
ListRow(
systemImage: "square.stack.3d.up",
title: g.name,
subtitle: "fehlt \(formatAmount(g.deficit)) \(g.unitName)"
) { GroupBadge() }
}
}
}
if items.isEmpty && groups.isEmpty && byLocation.isEmpty && !busy {
Text("Nichts einzukaufen alle Mindestbestände sind gedeckt.") Text("Nichts einzukaufen alle Mindestbestände sind gedeckt.")
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} }
@@ -97,6 +120,7 @@ struct ShoppingListView: View {
do { do {
items = try await APIClient.shared.shoppingList() items = try await APIClient.shared.shoppingList()
groups = try await APIClient.shared.shoppingGroups() groups = try await APIClient.shared.shoppingGroups()
byLocation = try await APIClient.shared.shoppingByLocation()
error = nil error = nil
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
@@ -126,7 +150,7 @@ struct ExpiringView: View {
.font(.caption).foregroundStyle(.secondary) .font(.caption).foregroundStyle(.secondary)
} }
Spacer() Spacer()
Text(restText(item.daysLeft)) Text(item.restlaufzeitText)
.font(.caption).bold() .font(.caption).bold()
.foregroundStyle(item.daysLeft < 0 ? .red : .orange) .foregroundStyle(item.daysLeft < 0 ? .red : .orange)
} }
@@ -161,22 +185,237 @@ struct ExpiringView: View {
return "\(formatAmount(item.quantity / faktor)) \(item.unitName)" return "\(formatAmount(item.quantity / faktor)) \(item.unitName)"
} }
private func restText(_ days: Int) -> String { }
if days < 0 { return "abgelaufen" }
if days == 0 { return "heute" } // MARK: - Mindestbestände
return days == 1 ? "1 Tag" : "\(days) Tage"
/// Ü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 // MARK: - Produktliste
struct ProductListView: View { 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 query = ""
@State private var products: [Product] = [] @State private var products: [Product] = []
@State private var categories: [CategoryItem] = [] @State private var categories: [CategoryItem] = []
/// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit). /// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit).
@State private var categoryId: Int? @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 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 { private var filterLabel: String {
if categoryId == 0 { return "Ohne Kategorie" } if categoryId == 0 { return "Ohne Kategorie" }
@@ -203,30 +442,73 @@ struct ProductListView: View {
Text(filterLabel).foregroundStyle(.secondary) 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 { Section {
ForEach(products) { product in ForEach(sortiert) { product in
NavigationLink { NavigationLink {
ProductDetailView(product: product) ProductDetailView(product: product)
} label: { } label: {
ProductRow(product: product) ProductRow(product: product)
} }
} }
if products.isEmpty && !busy { if sichtbar.isEmpty && !busy {
Text("Keine Treffer").foregroundStyle(.secondary) Text("Keine Treffer").foregroundStyle(.secondary)
} }
} }
} }
.searchable(text: $query, prompt: "Artikel suchen") .searchable(text: $query, prompt: "Artikel suchen")
.onChange(of: query) { _ in Task { await load() } } .onChange(of: query) { _ in Task { await load() } }
.navigationTitle("Produkte") .navigationTitle(titel)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.refreshable { await load() } .refreshable { await load() }
.task { .toolbar {
categories = (try? await APIClient.shared.categories()) ?? [] if session.isAdmin {
await load() ToolbarItem(placement: .topBarTrailing) {
Button { showNew = true } label: {
Label("Artikel anlegen", systemImage: "plus")
}
}
}
} }
.sheet(isPresented: $showNew) {
NavigationStack {
// Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste
// vorbelegen. Nach dem Anlegen schließt sich das Formular und die
// Liste wird aufgefrischt.
ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in
showNew = false
Task { await load() }
}
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { showNew = false }
}
}
}
}
.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?) { private func pick(_ id: Int?) {
@@ -243,17 +525,18 @@ struct ProductListView: View {
} }
private func categoryTree() -> [TreeEntry] { private func categoryTree() -> [TreeEntry] {
let ids = Set(categories.map(\.id)) let liste = typKategorien
let ids = Set(liste.map(\.id))
var result: [TreeEntry] = [] var result: [TreeEntry] = []
func walk(_ node: CategoryItem, _ depth: Int) { func walk(_ node: CategoryItem, _ depth: Int) {
result.append(TreeEntry(item: node, depth: depth)) 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) walk(child, depth + 1)
} }
} }
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene. // 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) walk(root, 0)
} }
return result return result
@@ -262,7 +545,11 @@ struct ProductListView: View {
private func load() async { private func load() async {
busy = true busy = true
defer { busy = false } defer { busy = false }
products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? [] let geladen = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? []
products = geladen
// Bilder im Hintergrund sicherstellen nur fehlende/geänderte werden geholt,
// damit die Thumbnails auch nach App-Neustart sofort aus dem Cache kommen.
Task { await ProductImageCache.shared.preload(geladen) }
} }
} }
@@ -272,8 +559,7 @@ struct ProductRow: View {
var body: some View { var body: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
Image(systemName: "shippingbox") ProductThumb(productId: product.id)
.font(.body).foregroundStyle(.secondary).frame(width: 24)
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(product.name) Text(product.name)
Text(bestand) Text(bestand)

View File

@@ -0,0 +1,324 @@
import SwiftUI
/// Die eigene, lokale App-Übersicht eines Servers: mehrere Dashboards, Karten
/// untereinander, frei zusammenstellbar. Nur auf diesem Gerät gespeichert.
///
/// Anzeige und Bearbeitung sind getrennt: Beim Bearbeiten zeigt eine kompakte,
/// sortierbare Liste die Karten (so funktionieren Ziehen und Löschen sauber),
/// beim Ansehen die vollen Karten.
struct LocalOverview: View {
let profileID: UUID
@State private var config = AppOverviewConfig()
@State private var auswahl: UUID?
@State private var bearbeiten = false
@State private var addShown = false
@State private var produkte: [Product] = []
@State private var stand = 0
@State private var renameText = ""
@State private var renameShown = false
private var aktives: AppDashboard? {
config.dashboards.first { $0.id == auswahl } ?? config.dashboards.first
}
var body: some View {
Group {
if bearbeiten {
editierListe
} else {
anzeige
}
}
// 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) }
}
.alert("Dashboard umbenennen", isPresented: $renameShown) {
TextField("Name", text: $renameText)
Button("Sichern") { speichereName() }
Button("Abbrechen", role: .cancel) {}
}
.task(id: profileID) {
config = AppDashboardStore.config(for: profileID)
if auswahl == nil || !config.dashboards.contains(where: { $0.id == auswahl }) {
auswahl = config.dashboards.first?.id
}
produkte = (try? await APIClient.shared.searchProducts("")) ?? []
}
}
// MARK: - Anzeige
private var anzeige: some View {
List {
if config.dashboards.count > 1 {
Section { dashboardPicker }
}
if let aktives {
ForEach(aktives.cards) { karte in
Section {
DashboardCardView(type: karte.type, productId: karte.productId,
stand: stand,
detail: karte.detail ?? false,
tiefe: karte.tiefe,
mitOhneMhd: karte.mitOhneMhd ?? true) { stand += 1 }
} header: {
Text(titel(karte))
}
}
if aktives.cards.isEmpty {
Text("Noch keine Karten. Über „Bearbeiten“ lassen sich welche hinzufügen.")
.foregroundStyle(.secondary)
}
}
}
.refreshable { stand += 1 }
}
// MARK: - Bearbeiten
private var editierListe: some View {
List {
if config.dashboards.count > 1 {
Section { dashboardPicker }
}
Section {
ForEach(aktives?.cards ?? []) { karte in
VStack(alignment: .leading, spacing: 6) {
Text(CardCatalog.titel(karte.type))
if CardCatalogList.kind(for: karte.type)?.needsProduct == true {
artikelWahl(fuer: karte)
}
diagrammEinstellungen(fuer: karte)
}
}
.onDelete(perform: loesche)
.onMove(perform: verschiebe)
} header: {
Text("Karten ziehen zum Sortieren")
}
}
.environment(\.editMode, .constant(.active))
}
private var dashboardPicker: some View {
Picker("Dashboard", selection: Binding(
get: { aktives?.id ?? UUID() }, set: { auswahl = $0 }
)) {
ForEach(config.dashboards) { Text($0.name).tag($0.id) }
}
.pickerStyle(.menu)
}
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")
}
}
}
.padding(.horizontal)
.padding(.vertical, 10)
}
.background(.bar)
}
private func titel(_ karte: AppCard) -> String {
if let pid = karte.productId, let p = produkte.first(where: { $0.id == pid }) {
return "\(CardCatalog.titel(karte.type)): \(p.name)"
}
return CardCatalog.titel(karte.type)
}
private func artikelWahl(fuer karte: AppCard) -> some View {
Menu {
Button(" kein Artikel ") { setzeProdukt(karte, nil) }
ForEach(produkte) { p in
Button(p.name) { setzeProdukt(karte, p.id) }
}
} label: {
Text(produkte.first { $0.id == karte.productId }?.name ?? "Artikel wählen")
.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()
}
}
// MARK: - Datenänderungen (alle sofort gespeichert)
private func mutiere(_ block: (inout AppDashboard) -> Void) {
guard let id = aktives?.id,
let index = config.dashboards.firstIndex(where: { $0.id == id }) else { return }
block(&config.dashboards[index])
AppDashboardStore.save(config, for: profileID)
}
private func fuegeHinzu(_ karte: AppCard) { mutiere { $0.cards.append(karte) } }
/// Ä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 }) {
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) } }
private func neuesDashboard() {
let neu = AppDashboard(name: "Neues Dashboard")
config.dashboards.append(neu)
AppDashboardStore.save(config, for: profileID)
auswahl = neu.id
}
private func loescheDashboard() {
guard let id = aktives?.id else { return }
config.dashboards.removeAll { $0.id == id }
AppDashboardStore.save(config, for: profileID)
auswahl = config.dashboards.first?.id
}
private func speichereName() {
let name = renameText.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { return }
mutiere { $0.name = name }
}
}
/// Auswahl einer neuen Karte. Diagramme sind eigens gekennzeichnet.
struct CardPickerView: View {
let onPick: (AppCard) -> Void
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section("Karten") {
ForEach(CardCatalogList.all.filter { !$0.isChart }) { kind in
zeile(kind)
}
}
Section("Diagramme") {
ForEach(CardCatalogList.all.filter { $0.isChart }) { kind in
zeile(kind)
}
}
}
.navigationTitle("Karte hinzufügen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
}
}
}
private func zeile(_ kind: CardKind) -> some View {
Button {
onPick(AppCard(type: kind.type))
dismiss()
} label: {
HStack {
Text(kind.title)
if kind.needsProduct {
Text("· Artikel").font(.caption).foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "plus.circle").foregroundStyle(Color.marke)
}
}
.foregroundStyle(.primary)
}
}

View File

@@ -0,0 +1,163 @@
import SwiftUI
/// Nur ansehen: Barcode Artikel, Einzelstück-QR genau dieses Stück,
/// Lagerort-QR Liste aller Artikel an diesem Ort. Kein Ein-/Auslagern.
struct LookupScanView: View {
@Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings
@State private var paused = false
@State private var torchOn = false
@State private var torchLocked = false
@State private var error: String?
@State private var item: Item?
@State private var product: Product?
@State private var contents: LocationContents?
var body: some View {
ScrollView {
VStack(spacing: 14) {
ScannerView(onCode: { code in Task { await resolve(code) } },
isPaused: $paused, torchOn: $torchOn, torchLocked: $torchLocked)
.aspectRatio(4.0 / 3.0, contentMode: .fit)
.clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal)
Text("Barcode, Einzelstück- oder Lagerort-QR scannen nur ansehen")
.font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let error {
Text(error).font(.callout).foregroundStyle(.red)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
}
}
.padding(.vertical)
}
.navigationTitle("Nachschlagen")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Fertig") { dismiss() } }
ToolbarItem(placement: .topBarTrailing) { TorchButton(isOn: $torchOn, locked: $torchLocked) }
}
.sheet(item: $item, onDismiss: { paused = false }) { it in
NavigationStack { ItemEditView(item: it) }
}
.sheet(item: $product, onDismiss: { paused = false }) { p in
NavigationStack { ProductDetailView(product: p) }
.environmentObject(display)
}
.sheet(item: $contents, onDismiss: { paused = false }) { c in
NavigationStack { LocationContentsView(contents: c) }
.environmentObject(display)
}
}
private func resolve(_ code: String) async {
paused = true
error = nil
// Einzelstück-QR (/i/<UID>)?
if let r = code.range(of: "/i/") {
let uid = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
if !uid.isEmpty, let found = try? await APIClient.shared.itemByUid(uid: uid) {
item = found
return
}
}
// Lagerort-QR (/l/<Code>)? alles zeigen, was dort (inkl. Unterorte) liegt.
if let r = code.range(of: "/l/") {
let loc = String(code[r.upperBound...])
.trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased()
if !loc.isEmpty {
do {
contents = try await APIClient.shared.locationContents(code: loc)
} catch {
self.error = "Lagerort nicht gefunden: \(loc)"
paused = false
}
return
}
}
// Sonst als Barcode auflösen.
do {
let result = try await APIClient.shared.lookup(barcode: code)
if let existing = result.existingProduct {
product = existing
} else {
error = "Nicht im Katalog: \(code)"
paused = false
}
} catch {
self.error = error.localizedDescription
paused = false
}
}
}
/// Liste aller Artikel eines gescannten Lagerorts (inkl. Unterorte).
struct LocationContentsView: View {
let contents: LocationContents
@EnvironmentObject private var display: DisplaySettings
var body: some View {
List {
if contents.products.isEmpty {
Text("Hier ist nichts eingelagert.").foregroundStyle(.secondary)
} else {
Section {
ForEach(contents.products) { e in
NavigationLink {
ProductLoaderView(productId: e.productId).environmentObject(display)
} label: {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text(e.name)
if let brand = e.brand, !brand.isEmpty {
Text(brand).font(.caption).foregroundStyle(.secondary)
}
}
Spacer()
Text("\(mengeText(e.stock)) \(e.unitLabel)")
.foregroundStyle(.secondary)
}
}
}
} footer: {
Text("Bestand inkl. der Unterorte. Tippen öffnet den Artikel.")
}
}
}
.navigationTitle(contents.locationName)
.navigationBarTitleDisplayMode(.inline)
}
private func mengeText(_ v: Double) -> String {
v == v.rounded() ? String(Int(v)) : String(format: "%.2f", v)
}
}
/// Lädt einen Artikel per ID nach und zeigt dann seine Detailansicht.
struct ProductLoaderView: View {
let productId: Int
@EnvironmentObject private var display: DisplaySettings
@State private var product: Product?
@State private var error: String?
var body: some View {
Group {
if let product {
ProductDetailView(product: product).environmentObject(display)
} else if let error {
Text(error).foregroundStyle(.red).padding()
} else {
ProgressView()
}
}
.task {
do { product = try await APIClient.shared.product(id: productId) }
catch { self.error = error.localizedDescription }
}
}
}

View File

@@ -24,6 +24,9 @@ struct AdminTabView: View {
NavigationLink { GroupsView() } label: { NavigationLink { GroupsView() } label: {
Label("Gruppen", systemImage: "square.stack.3d.up") Label("Gruppen", systemImage: "square.stack.3d.up")
} }
NavigationLink { ShopsView() } label: {
Label("Shops", systemImage: "cart")
}
} }
Section("App") { Section("App") {
@@ -168,6 +171,185 @@ struct MasterDataListView<Editor: View>: View {
} }
} }
// MARK: - Aufklappbarer Baum (Kategorien, Lagerorte)
/// Ein-/ausklappbare Baumliste für hierarchische Stammdaten. Kümmert sich um
/// Baumreihenfolge, Ein-/Ausklappen, Anlegen, Bearbeiten und Löschen. Die
/// Besonderheiten (Kopfzeile mit Filter, Zeilen-Hinweis, Editor) kommen von
/// außen herein, damit Kategorien und Lagerorte dieselbe Mechanik teilen.
struct TreeMasterView<Item: TreeItem, Header: View, Editor: View>: View {
let title: String
let singular: String
let load: () async throws -> [Item]
let delete: (Item.ID) async throws -> Void
/// Optionaler kleiner Hinweis am Zeilenende (z. B. der Kategorie-Typ).
let badge: (Item) -> String?
/// Nur Einträge, die das erfüllen, werden gezeigt (z. B. der Typ-Filter).
let include: (Item) -> Bool
@ViewBuilder let header: () -> Header
@ViewBuilder let editor: (_ editing: Item?, _ all: [Item], _ done: @escaping () -> Void) -> Editor
@State private var items: [Item] = []
@State private var collapsed: Set<Item.ID> = []
@State private var busy = true
@State private var error: String?
@State private var editing: Item?
@State private var addShown = false
@State private var pendingDeletion: Item?
private struct Node: Identifiable {
let item: Item
let depth: Int
var id: Item.ID { item.id }
}
private var shown: [Item] { items.filter(include) }
/// Baumreihenfolge mit Tiefe. Waisen (Elternteil gefiltert/gelöscht) gelten
/// als oberste Ebene, damit nichts verschwindet.
private var nodes: [Node] {
let list = shown
let ids = Set(list.map(\.id))
var result: [Node] = []
func walk(_ n: Item, _ depth: Int) {
result.append(Node(item: n, depth: depth))
for child in list.filter({ $0.parentId == n.id }) { walk(child, depth + 1) }
}
for root in list.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0)
}
return result
}
private func childCount(_ id: Item.ID) -> Int { shown.filter { $0.parentId == id }.count }
private var visible: [Node] {
let parent = Dictionary(uniqueKeysWithValues: shown.map { ($0.id, $0.parentId) })
func hidden(_ id: Item.ID) -> Bool {
var p = parent[id] ?? nil
while let cur = p {
if collapsed.contains(cur) { return true }
p = parent[cur] ?? nil
}
return false
}
return nodes.filter { !hidden($0.item.id) }
}
var body: some View {
VStack(spacing: 0) {
header()
List {
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
ForEach(visible) { node in
row(node)
.swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { pendingDeletion = node.item }
}
}
if shown.isEmpty && !busy {
Text("Nichts vorhanden.").foregroundStyle(.secondary)
}
}
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") { addShown = true }
}
}
.sheet(isPresented: $addShown) {
editor(nil, items) { Task { await reload() } }
}
.sheet(item: $editing) { item in
editor(item, items) { Task { await reload() } }
}
.confirmationDialog(
pendingDeletion.map { "\($0.name)“ löschen?" } ?? "",
isPresented: Binding(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
if let item = pendingDeletion { Task { await remove(item) } }
pendingDeletion = nil
}
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
} message: {
Text("Wird \(singular.lowercased()) noch verwendet oder hat Untereinträge, entscheidet der Server.")
}
.refreshable { await reload() }
.task { await reload() }
}
@ViewBuilder
private func row(_ node: Node) -> some View {
let kinder = childCount(node.item.id)
let zu = collapsed.contains(node.item.id)
HStack(spacing: 8) {
if kinder > 0 {
Button {
withAnimation(.easeInOut(duration: 0.15)) { toggle(node.item.id) }
} label: {
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
.rotationEffect(.degrees(zu ? 0 : 90))
.frame(width: 18)
}
.buttonStyle(.plain)
} else {
Color.clear.frame(width: 18, height: 1)
}
Button {
editing = node.item
} label: {
HStack(spacing: 8) {
Text(node.item.name).foregroundStyle(.primary)
if zu && kinder > 0 {
Text("\(kinder) ausgeblendet").font(.caption2).foregroundStyle(.tertiary)
}
Spacer()
if let hinweis = badge(node.item) {
Text(hinweis).font(.caption2).foregroundStyle(.secondary)
}
}
.contentShape(Rectangle())
}
.buttonStyle(.plain)
}
.padding(.leading, CGFloat(node.depth) * 16)
}
private func toggle(_ id: Item.ID) {
if collapsed.contains(id) { collapsed.remove(id) } else { collapsed.insert(id) }
}
private func reload() async {
busy = true
defer { busy = false }
do {
items = try await load()
error = nil
} catch {
self.error = error.localizedDescription
}
}
private func remove(_ item: Item) async {
do {
try await delete(item.id)
error = nil
await reload()
} catch {
self.error = error.localizedDescription
}
}
}
/// Kleiner Editor fuer alles, was nur einen Namen hat. /// Kleiner Editor fuer alles, was nur einen Namen hat.
struct NameEditor: View { struct NameEditor: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -233,31 +415,127 @@ struct NameEditor: View {
struct LocationsView: View { struct LocationsView: View {
var body: some View { var body: some View {
MasterDataListView( TreeMasterView(
title: "Lagerorte", title: "Lagerorte",
singular: "Der Lagerort", singular: "Der Lagerort",
load: { load: { try await APIClient.shared.locations() },
try await APIClient.shared.locations().map { delete: { try await APIClient.shared.deleteLocation(id: $0) },
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false) badge: { _ in nil },
include: { _ in true },
header: { EmptyView() },
editor: { item, all, done in
LocationEditor(item: item, all: all, done: done)
}
)
}
}
/// Lagerort anlegen oder bearbeiten: Name **und** übergeordneter Ort. So lässt
/// sich die Hierarchie auch nachträglich ändern (umhängen). Beim Bearbeiten sind
/// der Ort selbst und seine Unterorte als Ziel ausgeschlossen (kein Ring).
struct LocationEditor: View {
@Environment(\.dismiss) private var dismiss
let item: StorageLocation?
let all: [StorageLocation]
let done: () -> Void
@State private var name: String
@State private var parentId: String?
@State private var error: String?
@State private var busy = false
init(item: StorageLocation?, all: [StorageLocation], done: @escaping () -> Void) {
self.item = item
self.all = all
self.done = done
_name = State(initialValue: item?.name ?? "")
_parentId = State(initialValue: item?.parentId)
}
private var parentOptions: [StorageLocation] {
guard let item else { return all }
let verboten = LocationEditor.descendants(of: item.id, in: all).union([item.id])
return all.filter { !verboten.contains($0.id) }
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("z. B. Keller", text: $name)
} }
}, Section {
delete: { try await APIClient.shared.deleteLocation(id: $0) } Picker("Übergeordnet", selection: $parentId) {
) { item, done in Text(" oberste Ebene ").tag(String?.none)
NameEditor( ForEach(parentOptions) { loc in
title: item == nil ? "Lagerort anlegen" : "Lagerort umbenennen", Text(LocationEditor.pfad(loc, in: all)).tag(String?.some(loc.id))
initial: item?.title ?? "", }
save: { name in
if let item {
_ = try await APIClient.shared.renameLocation(id: item.id, name: name)
} else {
_ = try await APIClient.shared.createLocation(
NewLocationRequest(name: name, parentId: nil))
} }
}, } header: {
done: done Text("Übergeordneter Lagerort (optional)")
) }
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(item == nil ? "Lagerort anlegen" : "Lagerort bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
} }
} }
private func submit() async {
busy = true
defer { busy = false }
let n = name.trimmingCharacters(in: .whitespaces)
do {
if let item {
_ = try await APIClient.shared.updateLocation(
id: item.id, LocationUpdateRequest(name: n, parentId: parentId))
} else {
_ = try await APIClient.shared.createLocation(
NewLocationRequest(name: n, parentId: parentId))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
/// Alle Unterorte (rekursiv) als Ziel beim Umhängen ausgeschlossen.
private static func descendants(of id: String, in all: [StorageLocation]) -> Set<String> {
var result: Set<String> = []
var stack = [id]
while let cur = stack.popLast() {
for kid in all where kid.parentId == cur {
if result.insert(kid.id).inserted { stack.append(kid.id) }
}
}
return result
}
/// "Keller Regal" der Pfad macht gleiche Namen im flachen Picker
/// unterscheidbar.
private static func pfad(_ loc: StorageLocation, in all: [StorageLocation]) -> String {
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
var teile = [loc.name]
var pid = loc.parentId
while let cur = pid, let parent = byId[cur] {
teile.insert(parent.name, at: 0)
pid = parent.parentId
}
return teile.joined(separator: "")
}
} }
struct UnitsView: View { struct UnitsView: View {
@@ -466,87 +744,482 @@ struct PackageTypeEditor: View {
} }
} }
/// Kategorien als ein-/ausklappbarer Baum (wie im Web) mit Typ-Filter
/// Lebensmittel/Gegenstände. Anlegen und Bearbeiten wählen die Verwaltungsart.
struct CategoriesView: View { struct CategoriesView: View {
// "" = alle, sonst "food"/"object".
@State private var typFilter = ""
var body: some View { var body: some View {
MasterDataListView( TreeMasterView(
title: "Kategorien", title: "Kategorien",
singular: "Die Kategorie", singular: "Die Kategorie",
load: { load: { try await APIClient.shared.categories() },
let alle = try await APIClient.shared.categories() delete: { try await APIClient.shared.deleteCategory(id: $0) },
// Baumreihenfolge mit Einrueckung, damit die Verschachtelung // Typ nur in der Alle"-Ansicht zeigen gefiltert wäre er redundant.
// sichtbar bleibt (wie in der Produktliste). badge: { typFilter == "" ? CategoriesView.typLabel($0.tracking) : nil },
return CategoriesView.tree(alle).map { include: { typFilter == "" || ($0.tracking ?? "food") == typFilter },
MasterDataItem(id: $0.item.id, header: {
title: String(repeating: " ", count: $0.depth) + $0.item.name, Picker("Typ", selection: $typFilter) {
subtitle: "", isBuiltin: false) Text("Alle").tag("")
Text("Lebensmittel").tag("food")
Text("Gegenstände").tag("object")
} }
.pickerStyle(.segmented)
.padding(.horizontal)
.padding(.vertical, 8)
}, },
delete: { try await APIClient.shared.deleteCategory(id: $0) } editor: { item, all, done in
) { item, done in CategoryEditor(item: item, all: all, done: done)
NameEditor( }
title: item == nil ? "Kategorie anlegen" : "Kategorie umbenennen", )
initial: item?.title.trimmingCharacters(in: .whitespaces) ?? "", }
save: { name in
if let item { static func typLabel(_ tracking: String?) -> String {
_ = try await APIClient.shared.renameCategory(id: item.id, name: name) tracking == "object" ? "Gegenstand" : "Lebensmittel"
} else { }
_ = try await APIClient.shared.createCategory( }
NewCategoryRequest(name: name, parentId: nil))
/// Kategorie anlegen oder bearbeiten: Name, Verwaltungsart und beim Anlegen
/// eine optionale Oberkategorie (Unterkategorie). Bei bestehenden Kategorien
/// führt ein Link zur Verwaltung der eigenen Felder.
struct CategoryEditor: View {
@Environment(\.dismiss) private var dismiss
let item: CategoryItem?
let all: [CategoryItem]
let done: () -> Void
@State private var name: String
@State private var tracking: String
@State private var parentId: Int? // nur beim Anlegen
@State private var error: String?
@State private var busy = false
init(item: CategoryItem?, all: [CategoryItem], done: @escaping () -> Void) {
self.item = item
self.all = all
self.done = done
_name = State(initialValue: item?.name ?? "")
_tracking = State(initialValue: item?.tracking ?? "object")
_parentId = State(initialValue: nil)
}
// Mit gewählter Oberkategorie erbt die neue Kategorie deren Art dann ist
// die Art-Auswahl gegenstandslos.
private var erbtVonEltern: Bool { item == nil && parentId != nil }
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("z. B. Elektronik", text: $name)
}
if item == nil {
Section {
Picker("Übergeordnet", selection: $parentId) {
Text(" oberste Ebene ").tag(Int?.none)
ForEach(all) { c in
Text(CategoryEditor.pfad(c, in: all)).tag(Int?.some(c.id))
}
}
} header: {
Text("Übergeordnete Kategorie (optional)")
} footer: {
Text(erbtVonEltern
? "Erbt die Verwaltungsart der Oberkategorie."
: "Ohne Oberkategorie unten die Verwaltungsart wählen.")
} }
}, }
done: done
)
}
}
private struct Node { if !erbtVonEltern {
let item: CategoryItem Section {
let depth: Int Picker("Art", selection: $tracking) {
} Text("Lebensmittel").tag("food")
Text("Gegenstand").tag("object")
}
.pickerStyle(.segmented)
} header: {
Text("Verwaltungsart")
} footer: {
Text("Lebensmittel: Chargen mit Mindesthaltbarkeit. Gegenstand: Menge je Lagerort bzw. Einzelstücke. Unterkategorien erben die Art.")
}
}
private static func tree(_ categories: [CategoryItem]) -> [Node] { if let item {
let ids = Set(categories.map(\.id)) Section {
var result: [Node] = [] NavigationLink {
CategoryFieldsView(categoryId: item.id, categoryName: item.name)
} label: {
Label("Eigene Felder verwalten", systemImage: "tag")
}
}
}
func walk(_ node: CategoryItem, _ depth: Int) { if let error {
result.append(Node(item: node, depth: depth)) Section { Text(error).foregroundStyle(.red).font(.callout) }
for child in categories.filter({ $0.parentId == node.id }) { }
walk(child, depth + 1) }
.navigationTitle(item == nil ? "Kategorie anlegen" : "Kategorie bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
} }
} }
// Waisen (Oberkategorie geloescht) gelten als oberste Ebene. }
for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) {
walk(root, 0) private func submit() async {
busy = true
defer { busy = false }
let n = name.trimmingCharacters(in: .whitespaces)
do {
if let item {
_ = try await APIClient.shared.updateCategory(
id: item.id, CategoryUpdateRequest(name: n, tracking: tracking))
} else {
// Mit Oberkategorie die Art erben (tracking = nil), sonst die
// gewählte Art.
_ = try await APIClient.shared.createCategory(
NewCategoryRequest(name: n, parentId: parentId,
tracking: parentId == nil ? tracking : nil))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
} }
return result }
private static func pfad(_ c: CategoryItem, in all: [CategoryItem]) -> String {
let byId = Dictionary(uniqueKeysWithValues: all.map { ($0.id, $0) })
var teile = [c.name]
var pid = c.parentId
while let cur = pid, let parent = byId[cur] {
teile.insert(parent.name, at: 0)
pid = parent.parentId
}
return teile.joined(separator: "")
} }
} }
struct GroupsView: View { // MARK: - Eigene Felder je Kategorie
/// Eigene Felder einer Kategorie verwalten (anlegen, bearbeiten, löschen). Zeigt
/// nur die *eigenen* Felder geerbte werden in der Oberkategorie gepflegt.
struct CategoryFieldsView: View {
let categoryId: Int
let categoryName: String
@State private var fields: [FieldDefinition] = []
@State private var busy = true
@State private var error: String?
@State private var editing: FieldDefinition?
@State private var addShown = false
@State private var pendingDeletion: FieldDefinition?
var body: some View { var body: some View {
MasterDataListView( List {
title: "Gruppen", if let error {
singular: "Die Gruppe", Section { Text(error).foregroundStyle(.red).font(.callout) }
load: { }
try await APIClient.shared.groups().map { ForEach(fields) { f in
MasterDataItem(id: $0.id, title: $0.name, subtitle: "", isBuiltin: false) Button { editing = f } label: {
} HStack {
}, VStack(alignment: .leading, spacing: 2) {
delete: { try await APIClient.shared.deleteGroup(id: $0) } Text(f.label).foregroundStyle(.primary)
) { item, done in Text(CategoryFieldsView.untertitel(f))
NameEditor( .font(.caption).foregroundStyle(.secondary)
title: item == nil ? "Gruppe anlegen" : "Gruppe umbenennen", }
initial: item?.title ?? "", Spacer()
save: { name in if f.required {
if let item { Text("Pflicht").font(.caption2).foregroundStyle(.orange)
_ = 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 .swipeActions(edge: .trailing) {
) Button("Löschen", role: .destructive) { pendingDeletion = f }
}
}
if fields.isEmpty && !busy {
Text("Noch keine eigenen Felder.").foregroundStyle(.secondary)
}
}
.navigationTitle("Felder: \(categoryName)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Hinzufügen", systemImage: "plus") { addShown = true }
}
}
.sheet(isPresented: $addShown) {
FieldEditor(categoryId: categoryId, field: nil) { Task { await reload() } }
}
.sheet(item: $editing) { f in
FieldEditor(categoryId: categoryId, field: f) { Task { await reload() } }
}
.confirmationDialog(
pendingDeletion.map { "\($0.label)“ löschen?" } ?? "",
isPresented: Binding(get: { pendingDeletion != nil },
set: { if !$0 { pendingDeletion = nil } }),
titleVisibility: .visible
) {
Button("Löschen", role: .destructive) {
if let f = pendingDeletion { Task { await remove(f) } }
pendingDeletion = nil
}
Button("Abbrechen", role: .cancel) { pendingDeletion = nil }
} message: {
Text("Die zu diesem Feld erfassten Werte gehen an allen Artikeln verloren.")
}
.refreshable { await reload() }
.task { await reload() }
}
private static func untertitel(_ f: FieldDefinition) -> String {
var teile = [FieldEditor.typLabel(f.fieldType)]
if f.fieldType == "number", let u = f.unit, !u.isEmpty { teile.append(u) }
if f.fieldType == "select", !f.options.isEmpty {
teile.append(f.options.joined(separator: ", "))
}
return teile.joined(separator: " · ")
}
private func reload() async {
busy = true
defer { busy = false }
do {
fields = try await APIClient.shared.ownFieldDefinitions(categoryId: categoryId)
error = nil
} catch {
self.error = error.localizedDescription
}
}
private func remove(_ f: FieldDefinition) async {
do {
try await APIClient.shared.deleteFieldDefinition(id: f.id)
error = nil
await reload()
} catch {
self.error = error.localizedDescription
}
}
}
/// Eigenes Feld anlegen oder bearbeiten: Name, Typ, (Einheit bei Zahl,
/// Auswahlmöglichkeiten bei Auswahlliste) und Pflicht.
struct FieldEditor: View {
@Environment(\.dismiss) private var dismiss
let categoryId: Int
let field: FieldDefinition?
let done: () -> Void
@State private var label: String
@State private var fieldType: String
@State private var unit: String
@State private var options: String // Komma-getrennt
@State private var required: Bool
@State private var error: String?
@State private var busy = false
static let typen: [(String, String)] = [
("text", "Text (einzeilig)"),
("textarea", "Text (mehrzeilig)"),
("number", "Zahl (mit Einheit)"),
("date", "Datum"),
("select", "Auswahlliste"),
("boolean", "Ja/Nein"),
]
static func typLabel(_ v: String) -> String { typen.first { $0.0 == v }?.1 ?? v }
init(categoryId: Int, field: FieldDefinition?, done: @escaping () -> Void) {
self.categoryId = categoryId
self.field = field
self.done = done
_label = State(initialValue: field?.label ?? "")
_fieldType = State(initialValue: field?.fieldType ?? "text")
_unit = State(initialValue: field?.unit ?? "")
_options = State(initialValue: (field?.options ?? []).joined(separator: ", "))
_required = State(initialValue: field?.required ?? false)
}
var body: some View {
NavigationStack {
Form {
Section("Feldname") {
TextField("z. B. Kapazität", text: $label)
}
Section("Typ") {
Picker("Typ", selection: $fieldType) {
ForEach(FieldEditor.typen, id: \.0) { Text($0.1).tag($0.0) }
}
}
if fieldType == "number" {
Section("Einheit (optional)") {
TextField("z. B. mAh", text: $unit)
}
}
if fieldType == "select" {
Section {
TextField("z. B. S, M, L, XL", text: $options)
} header: {
Text("Auswahlmöglichkeiten")
} footer: {
Text("Mit Komma trennen.")
}
}
Section {
Toggle("Pflichtfeld", isOn: $required)
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(field == nil ? "Feld anlegen" : "Feld bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
.disabled(busy || label.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func submit() async {
busy = true
defer { busy = false }
let l = label.trimmingCharacters(in: .whitespaces)
let u = fieldType == "number" ? unit.trimmingCharacters(in: .whitespaces) : ""
let opts = fieldType == "select"
? options.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }
.filter { !$0.isEmpty }
: []
do {
if let field {
_ = try await APIClient.shared.updateFieldDefinition(
id: field.id,
FieldDefinitionUpdate(label: l, fieldType: fieldType, unit: u,
options: opts, required: required))
} else {
_ = try await APIClient.shared.createFieldDefinition(
FieldDefinitionCreate(categoryId: categoryId, label: l, fieldType: fieldType,
unit: u, options: opts, required: required))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}
/// Bezugsquellen für gekauft bei" (nur Gegenstände). Wie im Web pflegbar
/// Name plus optionale Website.
struct ShopsView: View {
var body: some View {
MasterDataListView(
title: "Shops",
singular: "Der Shop",
load: {
try await APIClient.shared.shops().map {
// Die Website steht im Untertitel und wird beim Bearbeiten
// wieder daraus gelesen (wie bei den Gebinden die Mehrzahl).
MasterDataItem(id: $0.id, title: $0.name,
subtitle: $0.website ?? "", isBuiltin: false)
}
},
delete: { try await APIClient.shared.deleteShop(id: $0) }
) { item, done in
ShopEditor(id: item?.id,
name: item?.title ?? "",
website: item?.subtitle ?? "",
done: done)
}
}
}
/// Shops haben Name und optionale Website.
struct ShopEditor: View {
@Environment(\.dismiss) private var dismiss
let id: Int?
let done: () -> Void
@State private var name: String
@State private var website: String
@State private var error: String?
@State private var busy = false
init(id: Int?, name: String, website: String, done: @escaping () -> Void) {
self.id = id
self.done = done
_name = State(initialValue: name)
_website = State(initialValue: website)
}
var body: some View {
NavigationStack {
Form {
Section("Name") {
TextField("z. B. Digitec", text: $name)
}
Section {
TextField("https://…", text: $website)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Website (optional)")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle(id == nil ? "Shop anlegen" : "Shop bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Sichern…" : "Sichern") { Task { await submit() } }
.disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty)
}
}
}
}
private func submit() async {
busy = true
defer { busy = false }
let n = name.trimmingCharacters(in: .whitespaces)
// Leeres Feld bewusst als "" senden (nicht nil): der Server setzt die
// Website damit auf leer so lässt sie sich auch wieder entfernen.
let w = website.trimmingCharacters(in: .whitespaces)
do {
if let id {
_ = try await APIClient.shared.updateShop(id: id, ShopUpdateRequest(name: n, website: w))
} else {
_ = try await APIClient.shared.createShop(NewShopRequest(name: n, website: w))
}
done()
dismiss()
} catch {
self.error = error.localizedDescription
} }
} }
} }

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,260 @@
import Foundation
import UserNotifications
// MARK: - Reine Planung (netzfrei, testbar)
/// Eine fertig berechnete Meldung: an welchem Tag, welcher Art, mit welchen
/// Produkten. Bewusst ohne iOS-Typen, damit sich die Logik ohne Geraet pruefen
/// laesst.
struct PlannedNotification: Equatable {
enum Kind { case upcoming, expired }
let profileID: UUID
let serverName: String
/// Tagesbeginn des Meldetags.
let day: Date
let kind: Kind
/// Produktnamen fuer diesen Tag, ohne Dopplung, in stabiler Reihenfolge.
let products: [String]
}
/// Rechnet aus den Ablaufdaten eines Servers die Meldungen aus. Alle
/// Entscheidungen des Nutzers stecken hier - deshalb strikt getrennt vom
/// Netz- und iOS-Teil.
enum ExpiryPlanner {
static func plan(
profileID: UUID,
serverName: String,
config: ServerNotificationConfig,
snapshot: ServerFetch.Snapshot,
today: Date,
horizonDays: Int,
calendar: Calendar = .current
) -> [PlannedNotification] {
guard config.hasSomethingToSchedule else { return [] }
let heute = calendar.startOfDay(for: today)
guard let horizont = calendar.date(byAdding: .day, value: horizonDays, to: heute) else {
return []
}
// Produkt -> Kategorie, und der Kategorie-Baum fuer die Nachfahren.
let kategorieVon = Dictionary(
snapshot.products.map { ($0.id, $0.categoryId) },
uniquingKeysWith: { first, _ in first }
)
let nachfahren = descendantMap(snapshot.categories)
let allgemeine = config.rules.filter { $0.categoryId == nil }
let kategorieRegeln = config.rules.filter { $0.categoryId != nil }
// Tag -> Produktnamen, getrennt nach bald-ablaufend und abgelaufen.
var bald: [Date: [String]] = [:]
var abgelaufen: [Date: [String]] = [:]
func ergaenze(_ eimer: inout [Date: [String]], _ tag: Date, _ name: String) {
var liste = eimer[tag] ?? []
if !liste.contains(name) { liste.append(name) }
eimer[tag] = liste
}
for item in snapshot.expiring {
guard let mhd = parseTag(item.bestBefore, calendar: calendar) else { continue }
let mhdTag = calendar.startOfDay(for: mhd)
// Welche Regeln gelten fuer dieses Produkt? Kategorie ersetzt Allgemeine:
// greift eine Kategorie-Regel (Produktkategorie im Teilbaum), zaehlen
// nur diese, sonst die allgemeinen.
let produktKategorie = kategorieVon[item.productId] ?? nil
let passendeKategorie = kategorieRegeln.filter { regel in
guard let ziel = regel.categoryId, let pk = produktKategorie else { return false }
return pk == ziel || (nachfahren[ziel]?.contains(pk) ?? false)
}
let regeln = passendeKategorie.isEmpty ? allgemeine : passendeKategorie
for regel in regeln {
guard let fensterStart = calendar.date(
byAdding: .day, value: -regel.leadDays, to: mhdTag) else { continue }
// Erinnerungen liegen vor dem Ablauftag (der Ablauftag gehoert der
// letzten Warnung). Einmalig: nur der Starttag; wiederkehrend: jeder
// Tag im Fenster bis zum Vortag des Ablaufs.
let letzterTag = regel.recurring
? (calendar.date(byAdding: .day, value: -1, to: mhdTag) ?? fensterStart)
: fensterStart
var tag = fensterStart
while tag <= letzterTag {
if tag >= heute && tag <= horizont && tag < mhdTag {
ergaenze(&bald, tag, item.productName)
}
guard let naechster = calendar.date(byAdding: .day, value: 1, to: tag) else { break }
tag = naechster
}
}
// Letzte Warnung: am Ablauftag selbst, fuer alle Produkte, danach nie
// wieder (bereits abgelaufene Chargen lassen wir bewusst ruhen).
if config.lastWarningOnExpiry && mhdTag >= heute && mhdTag <= horizont {
ergaenze(&abgelaufen, mhdTag, item.productName)
}
}
var result: [PlannedNotification] = []
for (tag, namen) in bald {
result.append(PlannedNotification(
profileID: profileID, serverName: serverName, day: tag,
kind: .upcoming, products: namen))
}
for (tag, namen) in abgelaufen {
result.append(PlannedNotification(
profileID: profileID, serverName: serverName, day: tag,
kind: .expired, products: namen))
}
// Nach Datum, dann abgelaufen vor bald-ablaufend (dringender zuerst).
return result.sorted {
$0.day != $1.day ? $0.day < $1.day
: ($0.kind == .expired && $1.kind == .upcoming)
}
}
/// Kategorie-Id -> alle darunterliegenden Kategorie-Ids (ohne die eigene).
static func descendantMap(_ categories: [CategoryItem]) -> [Int: Set<Int>] {
var kinder: [Int: [Int]] = [:]
for c in categories {
if let p = c.parentId { kinder[p, default: []].append(c.id) }
}
var ergebnis: [Int: Set<Int>] = [:]
for c in categories {
var alle: Set<Int> = []
var stapel = kinder[c.id] ?? []
while let n = stapel.popLast() {
if alle.insert(n).inserted { stapel.append(contentsOf: kinder[n] ?? []) }
}
ergebnis[c.id] = alle
}
return ergebnis
}
/// yyyy-MM-dd" -> Date (Tagesbeginn). Monatsangaben liefert der Server bereits
/// als Monatsletzten, daher reicht das Tagesdatum.
static func parseTag(_ raw: String, calendar: Calendar) -> Date? {
let teile = raw.prefix(10).split(separator: "-")
guard teile.count == 3,
let y = Int(teile[0]), let m = Int(teile[1]), let d = Int(teile[2]) else { return nil }
return calendar.date(from: DateComponents(year: y, month: m, day: d))
}
}
// MARK: - iOS-Planung
/// Plant die berechneten Meldungen als lokale Benachrichtigungen ein.
@MainActor
final class NotificationScheduler {
static let shared = NotificationScheduler()
/// Wie weit im Voraus geplant wird und wie viele Meldungen hoechstens - iOS
/// erlaubt 64 ausstehende je App, ueber alle Server zusammen.
static let horizonDays = 60
static let maxScheduled = 60
private var laeuftGerade = false
/// Fragt die Erlaubnis, falls noch nicht geschehen. Gibt zurueck, ob erlaubt.
@discardableResult
func requestAuthorization() async -> Bool {
let center = UNUserNotificationCenter.current()
let status = await center.notificationSettings().authorizationStatus
switch status {
case .authorized, .provisional, .ephemeral:
return true
case .notDetermined:
return (try? await center.requestAuthorization(options: [.alert, .sound, .badge])) ?? false
default:
return false
}
}
/// Rechnet alle Server neu durch und ersetzt den geplanten Bestand.
func rescheduleAll() async {
guard !laeuftGerade else { return }
laeuftGerade = true
defer { laeuftGerade = false }
let profile = Session.shared.profiles
let relevant = profile.filter {
NotificationStore.config(for: $0.id).hasSomethingToSchedule
}
guard !relevant.isEmpty else {
UNUserNotificationCenter.current().removeAllPendingNotificationRequests()
return
}
guard await requestAuthorization() else { return }
var geplant: [PlannedNotification] = []
let heute = Date()
for prof in relevant {
guard let url = prof.url,
let token = Keychain.read(account: Session.keychainAccount(for: prof.id))
else { continue }
let config = NotificationStore.config(for: prof.id)
// 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.
geplant.sort { $0.day != $1.day ? $0.day < $1.day
: ($0.kind == .expired && $1.kind == .upcoming) }
let center = UNUserNotificationCenter.current()
center.removeAllPendingNotificationRequests()
for meldung in geplant.prefix(Self.maxScheduled) {
center.add(request(for: meldung), withCompletionHandler: nil)
}
}
private func request(for meldung: PlannedNotification) -> UNNotificationRequest {
let inhalt = UNMutableNotificationContent()
let namen = meldung.products
let anzahl = namen.count
let liste = namen.prefix(4).joined(separator: ", ")
let mehr = anzahl > 4 ? " u. a." : ""
switch meldung.kind {
case .upcoming:
inhalt.title = meldung.serverName
inhalt.body = anzahl == 1
? "\(liste) läuft bald ab."
: "\(anzahl) Produkte laufen bald ab: \(liste)\(mehr)"
case .expired:
inhalt.title = "ACHTUNG \(meldung.serverName)"
inhalt.body = anzahl == 1
? "\(liste) ist heute abgelaufen."
: "\(anzahl) Produkte sind abgelaufen: \(liste)\(mehr)"
}
inhalt.sound = .default
inhalt.userInfo = ["profileID": meldung.profileID.uuidString]
let config = NotificationStore.config(for: meldung.profileID)
var wann = Calendar.current.dateComponents([.year, .month, .day], from: meldung.day)
wann.hour = config.hour
wann.minute = config.minute
// Zwei Meldungen am selben Tag (bald + abgelaufen) brauchen verschiedene
// Kennungen, sonst ueberschreibt die zweite die erste.
let art = meldung.kind == .expired ? "expired" : "upcoming"
let tag = "\(wann.year ?? 0)-\(wann.month ?? 0)-\(wann.day ?? 0)"
let id = "vorrania.\(meldung.profileID.uuidString).\(tag).\(art)"
return UNNotificationRequest(
identifier: id,
content: inhalt,
trigger: UNCalendarNotificationTrigger(dateMatching: wann, repeats: false))
}
}

View File

@@ -0,0 +1,86 @@
import Foundation
/// Einstellungen fuer Ablauf-Benachrichtigungen - je Server eigene.
///
/// Rein auf dem Geraet gespeichert (UserDefaults), nicht am Server: Es ist eine
/// Einstellung dieser App auf diesem iPhone, kein geteilter Serverzustand.
/// Die Einheit, in der X vor Ablauf" gemeint ist.
enum LeadUnit: String, Codable, CaseIterable {
case days, weeks
var label: String { self == .weeks ? "Wochen" : "Tage" }
}
/// Eine Erinnerungsregel: X Tage/Wochen vor Ablauf", optional nur fuer eine
/// Kategorie, einmalig oder taeglich wiederkehrend.
struct NotificationRule: Codable, Identifiable, Equatable {
let id: UUID
var amount: Int
var unit: LeadUnit
/// nil = alle Kategorien (die allgemeine Regel).
var categoryId: Int?
/// true = das Produkt taucht jeden Tag im Fenster (ab X vorher" bis Ablauf)
/// in der Tagesmeldung auf; false = nur am Tag X vorher".
var recurring: Bool
init(id: UUID = UUID(), amount: Int = 3, unit: LeadUnit = .days,
categoryId: Int? = nil, recurring: Bool = false) {
self.id = id
self.amount = amount
self.unit = unit
self.categoryId = categoryId
self.recurring = recurring
}
/// Vorlaufzeit in Tagen - Wochen werden umgerechnet.
var leadDays: Int { unit == .weeks ? amount * 7 : amount }
}
/// Alle Benachrichtigungseinstellungen eines Servers.
struct ServerNotificationConfig: Codable, Equatable {
var enabled: Bool = false
/// Uhrzeit der Tagesmeldung.
var hour: Int = 9
var minute: Int = 0
/// Letzte Warnung am Ablauftag (ACHTUNG: abgelaufen"), fuer alle Produkte.
var lastWarningOnExpiry: Bool = true
var rules: [NotificationRule] = []
/// Ohne aktive Regel und ohne letzte Warnung gibt es nichts zu planen.
var hasSomethingToSchedule: Bool {
enabled && (!rules.isEmpty || lastWarningOnExpiry)
}
}
/// Liest und schreibt die Konfigurationen. Ein Woerterbuch je Profil-UUID,
/// analog zu `ProfileStore`.
enum NotificationStore {
static let key = "notification_configs"
static func loadAll() -> [String: ServerNotificationConfig] {
guard let data = UserDefaults.standard.data(forKey: key),
let dict = try? JSONDecoder().decode([String: ServerNotificationConfig].self, from: data)
else { return [:] }
return dict
}
static func config(for profileID: UUID) -> ServerNotificationConfig {
loadAll()[profileID.uuidString] ?? ServerNotificationConfig()
}
static func save(_ config: ServerNotificationConfig, for profileID: UUID) {
var alle = loadAll()
alle[profileID.uuidString] = config
guard let data = try? JSONEncoder().encode(alle) else { return }
UserDefaults.standard.set(data, forKey: key)
}
/// Beim Loeschen eines Profils die zugehoerige Konfiguration mit entfernen.
static func remove(for profileID: UUID) {
var alle = loadAll()
alle.removeValue(forKey: profileID.uuidString)
guard let data = try? JSONEncoder().encode(alle) else { return }
UserDefaults.standard.set(data, forKey: key)
}
}

View File

@@ -0,0 +1,231 @@
import SwiftUI
import UserNotifications
/// Benachrichtigungs-Einstellungen eines Servers: Ein/Aus, Uhrzeit, letzte
/// Warnung und die Erinnerungsregeln.
struct NotificationSettingsView: View {
let profileID: UUID
let serverName: String
@Environment(\.dismiss) private var dismiss
@State private var config = ServerNotificationConfig()
@State private var categories: [CategoryItem] = []
@State private var editingRule: NotificationRule?
@State private var addShown = false
@State private var berechtigungFehlt = false
private var uhrzeit: Binding<Date> {
Binding(
get: {
Calendar.current.date(from: DateComponents(
hour: config.hour, minute: config.minute)) ?? Date()
},
set: { neu in
let teile = Calendar.current.dateComponents([.hour, .minute], from: neu)
config.hour = teile.hour ?? 9
config.minute = teile.minute ?? 0
speichern()
}
)
}
var body: some View {
Form {
Section {
Toggle("Benachrichtigungen aktiv", isOn: Binding(
get: { config.enabled },
set: { an in Task { await setzeAktiv(an) } }
))
} footer: {
Text("Erinnert vor dem Ablauf von Produkten auf „\(serverName)“. Nur auf diesem iPhone, unabhängig von den anderen Servern.")
}
if berechtigungFehlt {
Section {
Label("In den iOS-Einstellungen sind Mitteilungen für Vorrania aus. Ohne sie kommen keine Erinnerungen.",
systemImage: "exclamationmark.triangle")
.font(.callout).foregroundStyle(.orange)
Button("iOS-Einstellungen öffnen") {
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
}
}
if config.enabled {
Section("Uhrzeit der Meldung") {
DatePicker("Täglich um", selection: uhrzeit, displayedComponents: .hourAndMinute)
}
Section {
Toggle("Letzte Warnung am Ablauftag", isOn: Binding(
get: { config.lastWarningOnExpiry },
set: { config.lastWarningOnExpiry = $0; speichern() }
))
} footer: {
Text("Am Tag des Ablaufs eine deutliche Warnung für alle Produkte, unabhängig von den Regeln unten.")
}
Section {
ForEach(config.rules) { regel in
Button {
editingRule = regel
} label: {
regelZeile(regel)
}
.foregroundStyle(.primary)
}
.onDelete { indexSet in
config.rules.remove(atOffsets: indexSet)
speichern()
}
Button {
addShown = true
} label: {
Label("Regel hinzufügen", systemImage: "plus")
}
} header: {
Text("Regeln")
} footer: {
Text("Jede Regel erinnert „X vor Ablauf“. Eine Regel für eine Kategorie gilt auch für ihre Unterkategorien und ersetzt für diese Produkte die allgemeine Regel.")
}
}
}
.navigationTitle("Benachrichtigungen")
.navigationBarTitleDisplayMode(.inline)
.task {
config = NotificationStore.config(for: profileID)
await ladeKategorien()
await pruefeBerechtigung()
}
.sheet(isPresented: $addShown) {
RuleEditView(rule: nil, categories: categories) { neu in
config.rules.append(neu)
speichern()
}
}
.sheet(item: $editingRule) { regel in
RuleEditView(rule: regel, categories: categories) { geaendert in
if let index = config.rules.firstIndex(where: { $0.id == geaendert.id }) {
config.rules[index] = geaendert
speichern()
}
}
}
}
private func regelZeile(_ regel: NotificationRule) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text("\(regel.amount) \(regel.unit.label) vorher")
Text("\(kategorieName(regel.categoryId)) · \(regel.recurring ? "täglich" : "einmalig")")
.font(.caption).foregroundStyle(.secondary)
}
}
private func kategorieName(_ id: Int?) -> String {
guard let id else { return "Alle Kategorien" }
return categories.first { $0.id == id }?.name ?? "Kategorie \(id)"
}
// MARK: - Aktionen
private func setzeAktiv(_ an: Bool) async {
if an {
let erlaubt = await NotificationScheduler.shared.requestAuthorization()
berechtigungFehlt = !erlaubt
guard erlaubt else { return }
}
config.enabled = an
speichern()
}
private func speichern() {
NotificationStore.save(config, for: profileID)
Task { await NotificationScheduler.shared.rescheduleAll() }
}
private func ladeKategorien() async {
// Kategorien vom Server dieses Profils - auch wenn es nicht der aktive ist.
guard let profile = Session.shared.profiles.first(where: { $0.id == profileID }),
let url = profile.url,
let token = Keychain.read(account: Session.keychainAccount(for: profileID))
else { return }
categories = (try? await ServerFetch.categories(baseURL: url, token: token)) ?? []
}
private func pruefeBerechtigung() async {
guard config.enabled else { berechtigungFehlt = false; return }
let status = await UNUserNotificationCenter.current().notificationSettings().authorizationStatus
berechtigungFehlt = !(status == .authorized || status == .provisional || status == .ephemeral)
}
}
/// Anlegen und Bearbeiten einer Regel.
struct RuleEditView: View {
let rule: NotificationRule?
let categories: [CategoryItem]
let onSave: (NotificationRule) -> Void
@Environment(\.dismiss) private var dismiss
@State private var amount: Int
@State private var unit: LeadUnit
@State private var categoryId: Int?
@State private var recurring: Bool
init(rule: NotificationRule?, categories: [CategoryItem],
onSave: @escaping (NotificationRule) -> Void) {
self.rule = rule
self.categories = categories
self.onSave = onSave
_amount = State(initialValue: rule?.amount ?? 3)
_unit = State(initialValue: rule?.unit ?? .days)
_categoryId = State(initialValue: rule?.categoryId)
_recurring = State(initialValue: rule?.recurring ?? false)
}
var body: some View {
NavigationStack {
Form {
Section("Vorlauf") {
Stepper("\(amount) \(unit.label)", value: $amount, in: 1...99)
Picker("Einheit", selection: $unit) {
ForEach(LeadUnit.allCases, id: \.self) { Text($0.label).tag($0) }
}
.pickerStyle(.segmented)
}
Section {
CategoryPicker(categories: categories, selection: $categoryId)
} header: {
Text("Kategorie")
} footer: {
Text("„Alle Kategorien“ ist die allgemeine Regel. Eine bestimmte Kategorie schließt ihre Unterkategorien ein.")
}
Section {
Toggle("Täglich wiederholen", isOn: $recurring)
} footer: {
Text(recurring
? "Erinnert jeden Tag ab „\(amount) \(unit.label) vorher“ bis zum Ablauf."
: "Erinnert einmal, genau \(amount) \(unit.label) vor dem Ablauf.")
}
}
.navigationTitle(rule == nil ? "Regel hinzufügen" : "Regel bearbeiten")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Abbrechen") { dismiss() }
}
ToolbarItem(placement: .topBarTrailing) {
Button("Sichern") {
onSave(NotificationRule(
id: rule?.id ?? UUID(), amount: amount, unit: unit,
categoryId: categoryId, recurring: recurring))
dismiss()
}
}
}
}
}
}

View File

@@ -0,0 +1,328 @@
import SwiftUI
/// Bestand eines Gegenstands je Lagerort das Gegenstück zur Chargen-Liste der
/// 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]
let removals: RemovalSummary?
@Binding var sheet: ObjectStockSheet?
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
private func ortName(_ id: String?) -> String {
guard let id else { return "Ohne Lagerort" }
let pfad = locationPath(id, in: locations)
return pfad.isEmpty ? "Ort \(id)" : pfad
}
var body: some View {
Group {
Section("Bestand je Lagerort") {
ForEach(lots) { lot in
HStack {
Text(ortName(lot.locationId))
Spacer()
Text("\(formatAmount(lot.quantity)) \(einheit)")
.foregroundStyle(.secondary)
}
}
if lots.isEmpty {
Text("Noch kein Bestand.").foregroundStyle(.secondary)
}
HStack {
Button { sheet = .add } label: { Label("Hinzufügen", systemImage: "plus") }
Spacer()
Button { sheet = .relocate(from: lots.first?.locationId) } label: {
Label("Umlagern", systemImage: "arrow.left.arrow.right")
}
.disabled(lots.isEmpty)
Spacer()
Button(role: .destructive) { sheet = .remove(loc: lots.first?.locationId) } label: {
Label("Entfernen", systemImage: "trash")
}
.disabled(lots.isEmpty)
}
.buttonStyle(.borderless)
.font(.callout)
}
if let removals, !removals.stats.isEmpty {
Section("Bereits entnommen") {
ForEach(removals.stats) { s in
LabeledContent(RemovalReasons.label(s.reason),
value: "\(formatAmount(s.quantity)) (\(s.count)×)")
}
}
}
}
}
}
/// Eine Eingabe für ein selbst definiertes Feld, passend zum Feldtyp.
struct ObjectFieldRow: View {
let def: FieldDefinition
@Binding var value: String
private var titel: String { def.label + (def.required ? " *" : "") }
private var dateBinding: Binding<Date> {
Binding(
get: {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
return f.date(from: value) ?? Date()
},
set: {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
value = f.string(from: $0)
}
)
}
private var boolBinding: Binding<Bool> {
Binding(get: { value == "true" }, set: { value = $0 ? "true" : "false" })
}
var body: some View {
switch def.fieldType {
case "textarea":
VStack(alignment: .leading, spacing: 4) {
Text(titel).font(.caption).foregroundStyle(.secondary)
TextEditor(text: $value).frame(minHeight: 60)
}
case "number":
HStack {
Text(titel)
Spacer()
TextField("", text: $value)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 120)
if let u = def.unit, !u.isEmpty { Text(u).foregroundStyle(.secondary) }
}
case "date":
HStack {
DatePicker(titel, selection: dateBinding, displayedComponents: .date)
if !value.isEmpty {
Button { value = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.borderless).foregroundStyle(.secondary)
}
}
case "select":
Picker(titel, selection: $value) {
Text(" keine ").tag("")
ForEach(def.options, id: \.self) { Text($0).tag($0) }
}
case "boolean":
Toggle(titel, isOn: boolBinding)
default:
HStack {
Text(titel)
Spacer()
TextField("", text: $value).multilineTextAlignment(.trailing)
}
}
}
}
/// Auswahl eines Lagerorts (oder ohne").
private struct LocationPicker: View {
let title: String
let locations: [StorageLocation]
@Binding var selection: String?
var body: some View {
Picker(title, selection: $selection) {
Text(" ohne Lagerort ").tag(String?.none)
ForEach(locations) { loc in Text(loc.path(in: locations)).tag(String?.some(loc.id)) }
}
}
}
/// Menge an einem Lagerort hinzufügen.
struct ObjectAddSheet: View {
let product: Product
let locations: [StorageLocation]
let einheit: String
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var locationId: String?
@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) } }
Section {
Button(busy ? "Speichern…" : "Hinzufügen") { Task { await save() } }
.disabled(busy)
}
}
.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 {
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."; return
}
busy = true; defer { busy = false }
do {
_ = try await APIClient.shared.objectCheckIn(
ObjectCheckInRequest(productId: product.id, quantity: menge,
unit: einheit, locationId: locationId))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
/// Menge von einem Lagerort zum anderen umbuchen.
struct ObjectRelocateSheet: View {
let product: Product
let locations: [StorageLocation]
let initialFrom: String?
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var fromId: String?
@State private var toId: String?
@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) } }
Section {
Button(busy ? "Umlagern…" : "Umlagern") { Task { await save() } }.disabled(busy)
}
}
.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 }
}
private func save() async {
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."; return
}
busy = true; defer { busy = false }
do {
_ = try await APIClient.shared.relocate(
RelocateRequest(productId: product.id, quantity: menge,
fromLocationId: fromId, toLocationId: toId))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
struct ObjectRemoveSheet: View {
let product: Product
let locations: [StorageLocation]
let einheit: String
let initialLoc: String?
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var locationId: String?
@State private var quantity = ""
@State private var reason = "broken"
@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) }
}
LabeledField(label: "Notiz", text: $note)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Entfernen…" : "Entfernen") { Task { await save() } }
.disabled(busy)
.foregroundStyle(.red)
}
}
.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 }
}
private func save() async {
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."; return
}
busy = true; defer { busy = false }
do {
_ = try await APIClient.shared.removeStock(
RemoveRequest(productId: product.id, quantity: menge, locationId: locationId,
reason: reason, note: note.isEmpty ? nil : note))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}

View File

@@ -5,8 +5,10 @@ struct ProductDetailView: View {
let product: Product let product: Product
@EnvironmentObject private var display: DisplaySettings @EnvironmentObject private var display: DisplaySettings
@Environment(\.dismiss) private var dismiss
@State private var current: Product @State private var current: Product
@State private var showLeaveConfirm = false
@State private var lots: [Lot] = [] @State private var lots: [Lot] = []
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
@@ -18,12 +20,31 @@ struct ProductDetailView: View {
@State private var packageSize = "" @State private var packageSize = ""
@State private var packageLabel = "" @State private var packageLabel = ""
@State private var datePrecision = "day" @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 groupId: Int?
@State private var categoryId: 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 groups: [GroupItem] = []
@State private var categories: [CategoryItem] = [] @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? @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"] private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
@@ -32,6 +53,36 @@ struct ProductDetailView: View {
_current = State(initialValue: product) _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 { var body: some View {
Form { Form {
if let status { if let status {
@@ -41,12 +92,28 @@ struct ProductDetailView: View {
Section { Text(error).foregroundStyle(.red).font(.callout) } Section { Text(error).foregroundStyle(.red).font(.callout) }
} }
Section("Bestand") { ProductPhotoSection(productId: current.id)
LabeledContent("Vorrat",
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") if current.isObject && current.isIndividual {
if current.expiredCount > 0 { ProductItemsSection(product: current, onChanged: { await reload() },
LabeledContent("Abgelaufen", value: "\(current.expiredCount)") onFinish: { dismiss() })
.foregroundStyle(.red) } else if current.isObject && !current.isBulk {
// Menge je Lagerort (Verbrauchsgegenstände laufen als Charge, s.u.).
ObjectStockSection(product: current, locations: locations, lots: lots,
removals: objectRemovals, sheet: $objectSheet)
} else {
// Lebensmittel und Verbrauchsgegenstände: Vorrat aus den Chargen.
Section("Bestand") {
// 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)
}
} }
} }
@@ -55,33 +122,146 @@ struct ProductDetailView: View {
Section("Artikel") { Section("Artikel") {
LabeledField(label: "Name", text: $name) LabeledField(label: "Name", text: $name)
LabeledField(label: "Marke", text: $brand) LabeledField(label: "Marke", text: $brand)
QuantityField(label: "Packungsgröße", text: $packageSize,
suffix: display.baseUnitLabel(current.baseUnit))
Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
}
Picker("MHD-Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day")
Text("nur Monat/Jahr").tag("month")
}
} }
Section { // Alles zur Einheit steht zusammen und liest sich als Satz: das
CategoryPicker(categories: categories, selection: $categoryId) // Gebinde zuerst, weil die Zeile darunter es beim Namen nennt.
} footer: { if current.foodLike {
Text("Kategorie: nur für den Überblick in der Artikelliste.") Section("Einheit und Menge") {
} Picker("Gebinde", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
Section { }
Picker("Gruppe", selection: $groupId) { QuantityField(label: "1 \(gebindeName) sind", text: $packageSize,
Text(" keine ").tag(Int?.none) suffix: display.baseUnitLabel(current.baseUnit))
ForEach(groups) { gruppe in Picker("MHD-Angabe", selection: $datePrecision) {
Text(gruppe.name).tag(Int?.some(gruppe.id)) 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 {
CategoryPicker(categories: categories, selection: $categoryId,
tracking: current.tracking)
} footer: { } footer: {
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " Text(current.isObject
+ "wandert bei einem Wechsel mit.") ? "Nur Gegenstands-Kategorien. Bestimmt die eigenen Felder."
: "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
// beschreibt das Produkt allgemein und bleibt.
Section(current.isIndividual ? "Produktlink" : "Gekauft bei") {
if !current.isIndividual {
Picker("Shop", selection: $shopId) {
Text(" unbekannt / mehrere ").tag(Int?.none)
ForEach(shops) { shop in Text(shop.name).tag(Int?.some(shop.id)) }
}
}
LabeledField(label: "Produktlink", text: $productUrl)
if !productUrl.isEmpty, let url = URL(string: productUrl) {
Link("Im Onlineshop öffnen", destination: url)
}
}
}
// 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)
// 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 Artikel zusammen. Der EAN-Code "
+ "wandert bei einem Wechsel mit.")
}
}
// 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.")
}
} }
Section("Erkennung") { Section("Erkennung") {
@@ -96,28 +276,33 @@ struct ProductDetailView: View {
.disabled(busy || name.isEmpty) .disabled(busy || name.isEmpty)
} }
Section("Chargen") { if current.foodLike {
ForEach(lots) { lot in Section("Chargen") {
Button { ForEach(lots) { lot in
editLot = lot Button {
} label: { editLot = lot
HStack { } label: {
VStack(alignment: .leading, spacing: 2) { HStack {
Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") VStack(alignment: .leading, spacing: 2) {
Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))")
.font(.caption).foregroundStyle(.secondary) // 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()
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
} }
Spacer()
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
} }
.foregroundStyle(.primary)
}
.onDelete { indexSet in
Task { await deleteLots(at: indexSet) }
}
if lots.isEmpty {
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
} }
.foregroundStyle(.primary)
}
.onDelete { indexSet in
Task { await deleteLots(at: indexSet) }
}
if lots.isEmpty {
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
} }
} }
@@ -131,17 +316,137 @@ struct ProductDetailView: View {
} }
.navigationTitle(current.name) .navigationTitle(current.name)
.navigationBarTitleDisplayMode(.inline) .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 .sheet(item: $editLot) { lot in
NavigationStack { NavigationStack {
LotEditView(lot: lot, product: current) { LotEditView(lot: lot, product: current, locations: locations) {
Task { await reload() } 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 { .task {
fillForm() fillForm()
await reload() await reload()
} }
.onChange(of: categoryId) { newId in
// Bei Wechsel auf eine Gegenstands-Kategorie deren Felder nachladen.
Task {
if let cid = newId, categories.first(where: { $0.id == cid })?.isObject == true {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
}
}
}
/// 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() { private func fillForm() {
@@ -150,17 +455,65 @@ struct ProductDetailView: View {
packageSize = current.packageSize.map { formatAmount($0) } ?? "" packageSize = current.packageSize.map { formatAmount($0) } ?? ""
packageLabel = current.packageLabel ?? "" packageLabel = current.packageLabel ?? ""
datePrecision = current.datePrecision == "month" ? "month" : "day" datePrecision = current.datePrecision == "month" ? "month" : "day"
zweitBasis = current.secondaryBase ?? ""
zweitAnzahl = current.secondaryCount.map { formatAmount($0) } ?? ""
zweitMenge = current.secondaryAmount.map { formatAmount($0) } ?? ""
groupId = current.groupId groupId = current.groupId
categoryId = current.categoryId categoryId = current.categoryId
shopId = current.shopId
productUrl = current.productUrl ?? ""
objMode = currentObjMode
fieldValues = Self.stringValues(current.fieldValues)
}
private static func stringValues(_ dict: [String: String?]?) -> [String: String] {
guard let dict else { return [:] }
var out: [String: String] = [:]
for (schluessel, wert) in dict { if let wert { out[schluessel] = wert } }
return out
}
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
let key = String(def.id)
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 { private func reload() async {
groups = (try? await APIClient.shared.groups()) ?? [] groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? [] categories = (try? await APIClient.shared.categories()) ?? []
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? [] lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
if let frisch = try? await APIClient.shared.product(id: current.id) { if let frisch = try? await APIClient.shared.product(id: current.id) {
current = frisch current = frisch
} }
if current.isObject, let cid = current.categoryId {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} 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 { private func save() async {
@@ -169,9 +522,11 @@ struct ProductDetailView: View {
busy = true busy = true
defer { busy = false } defer { busy = false }
do { do {
current = try await APIClient.shared.updateProduct( if current.foodLike {
id: current.id, // Lebensmittel ODER Verbrauchsgegenstand: als Charge mit Einheit/
ProductUpdateRequest( // Packung/MHD/Gruppe. Verbrauchsgegenstände (isObject) tragen
// zusätzlich eigene Felder und einen Mindestbestand.
var req = ProductUpdateRequest(
name: name, name: name,
brand: brand.isEmpty ? nil : brand, brand: brand.isEmpty ? nil : brand,
packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")),
@@ -180,7 +535,52 @@ struct ProductDetailView: View {
groupId: groupId, groupId: groupId,
categoryId: categoryId 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)] ?? "" }
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)
}
status = "Gespeichert." status = "Gespeichert."
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription
@@ -200,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 { struct LotEditView: View {
let lot: Lot let lot: Lot
let product: Product let product: Product
let locations: [StorageLocation]
var onSaved: () -> Void var onSaved: () -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -212,6 +649,8 @@ struct LotEditView: View {
@State private var hasDate = false @State private var hasDate = false
@State private var bestBefore = Date() @State private var bestBefore = Date()
@State private var precision = "day" @State private var precision = "day"
@State private var locationId: String?
@State private var locScanShown = false
@State private var busy = false @State private var busy = false
@State private var error: String? @State private var error: String?
@@ -237,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 { if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) } Section { Text(error).foregroundStyle(.red).font(.callout) }
} }
@@ -251,11 +703,16 @@ struct LotEditView: View {
.toolbar { .toolbar {
ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } }
} }
.fullScreenCover(isPresented: $locScanShown) {
LocationScannerView(locations: locations) { ort in locationId = ort.id }
.ignoresSafeArea()
}
.onAppear(perform: fill) .onAppear(perform: fill)
} }
private func fill() { private func fill() {
quantity = formatAmount(lot.quantity / product.articleUnitFactor) quantity = formatAmount(lot.quantity / product.articleUnitFactor)
locationId = lot.locationId
precision = lot.bestBeforePrecision == "month" ? "month" : "day" precision = lot.bestBeforePrecision == "month" ? "month" : "day"
if let raw = lot.bestBefore { if let raw = lot.bestBefore {
let formatter = DateFormatter() let formatter = DateFormatter()
@@ -287,7 +744,8 @@ struct LotEditView: View {
LotUpdateRequest( LotUpdateRequest(
quantity: menge * product.articleUnitFactor, quantity: menge * product.articleUnitFactor,
bestBefore: hasDate ? formatter.string(from: datum) : nil, bestBefore: hasDate ? formatter.string(from: datum) : nil,
bestBeforePrecision: precision bestBeforePrecision: precision,
locationId: locationId
) )
) )
onSaved() onSaved()
@@ -297,3 +755,108 @@ struct LotEditView: View {
} }
} }
} }
// MARK: - Mindestbestand je Lagerort
/// Bedarf eines Produkts je Lagerort bearbeiten (zusätzlich zum globalen
/// Mindestbestand). Menge in Artikeleinheiten.
struct ProductLocationMinView: View {
let product: Product
var onChanged: (() async -> Void)? = nil
@Environment(\.dismiss) private var dismiss
private struct MinRow: Identifiable {
let id = UUID()
var locationId: String?
var amount: String
}
@State private var locations: [StorageLocation] = []
@State private var rows: [MinRow] = []
@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("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("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) {
rows.removeAll { $0.id == row.id }
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
}
}
Button {
rows.append(MinRow(locationId: UEBERALL_ID, amount: ""))
} label: {
Label("Mindestbestand hinzufügen", systemImage: "plus")
}
if let error {
Section { Text(error).foregroundStyle(.red).font(.callout) }
}
}
.navigationTitle("Mindestbestand")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button(busy ? "Speichern…" : "Speichern") { Task { await save() } }
.disabled(busy)
}
}
.task { await load() }
}
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 ?? UEBERALL_ID,
amount: formatAmount($0.minStock / dispFaktor))
}
}
private func save() async {
busy = true
defer { busy = false }
var list: [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 Anzeigeeinheit Basiseinheiten (so gespeichert).
list.append(LocationMinStockIn(
locationId: loc == UEBERALL_ID ? nil : loc,
minStock: wert * dispFaktor))
}
}
do {
_ = try await APIClient.shared.setProductLocationMinStock(id: product.id, list)
await onChanged?()
dismiss()
} catch {
self.error = error.localizedDescription
}
}
}

View File

@@ -0,0 +1,280 @@
import SwiftUI
import PhotosUI
/// Zwischenspeicher für Artikelbilder im Speicher **und auf der Platte**, damit
/// die Bilder einen App-Neustart überleben und in Listen sofort da sind. Zu jedem
/// Bild wird die Version (`image_version` des Produkts) gemerkt; so lädt `preload`
/// nur fehlende oder geänderte Bilder neu. Kein Bild" wird ebenfalls gemerkt.
final class ProductImageCache {
static let shared = ProductImageCache()
private struct Index: Codable { var versions: [Int: Int] = [:]; var empty: [Int] = [] }
private let memory = NSCache<NSNumber, UIImage>()
private let lock = NSLock()
private let dir: URL
private var index = Index()
private init() {
let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
dir = base.appendingPathComponent("product-images", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
if let data = try? Data(contentsOf: indexURL),
let decoded = try? JSONDecoder().decode(Index.self, from: data) {
index = decoded
}
}
private var indexURL: URL { dir.appendingPathComponent("index.json") }
private func fileURL(_ id: Int) -> URL { dir.appendingPathComponent("\(id).img") }
private func saveIndex() {
if let data = try? JSONEncoder().encode(index) {
try? data.write(to: indexURL, options: .atomic)
}
}
/// Bild aus dem Speicher, sonst von der Platte (überlebt Neustart).
func image(for id: Int) -> UIImage? {
if let ui = memory.object(forKey: NSNumber(value: id)) { return ui }
guard let data = try? Data(contentsOf: fileURL(id)), let ui = UIImage(data: data) else { return nil }
memory.setObject(ui, forKey: NSNumber(value: id))
return ui
}
func isKnownEmpty(_ id: Int) -> Bool {
lock.lock(); defer { lock.unlock() }
return index.empty.contains(id)
}
/// Bild speichern. `version` = `image_version` des Produkts (nil, wenn unbekannt,
/// z.B. beim Lazy-Nachladen einzelner Thumbnails).
func store(_ image: UIImage, data: Data, version: Int?, for id: Int) {
memory.setObject(image, forKey: NSNumber(value: id))
try? data.write(to: fileURL(id), options: .atomic)
lock.lock()
index.empty.removeAll { $0 == id }
if let version { index.versions[id] = version }
saveIndex()
lock.unlock()
}
func markEmpty(_ id: Int) {
memory.removeObject(forKey: NSNumber(value: id))
try? FileManager.default.removeItem(at: fileURL(id))
lock.lock()
index.versions[id] = nil
if !index.empty.contains(id) { index.empty.append(id) }
saveIndex()
lock.unlock()
}
private func isFresh(_ id: Int, _ serverVersion: Int) -> Bool {
lock.lock(); let known = index.versions[id]; lock.unlock()
return known == serverVersion && FileManager.default.fileExists(atPath: fileURL(id).path)
}
/// Alle Bilder einer Produktliste sicherstellen nur fehlende/geänderte werden geladen.
/// Beim Öffnen der Produkt-/Einzelstück-Liste aufrufen.
func preload(_ products: [Product]) async {
for p in products {
guard let sv = p.imageVersion else { markEmpty(p.id); continue }
if isFresh(p.id, sv) { continue }
if let data = try? await APIClient.shared.productImage(id: p.id),
let ui = UIImage(data: data) {
store(ui, data: data, version: sv, for: p.id)
} else {
markEmpty(p.id)
}
}
}
}
/// Kleines Vorschaubild für Listen: zeigt das Artikelbild, sonst das neutrale
/// Kästchen-Symbol wie bisher. Ergebnisse kommen aus dem gemeinsamen Cache.
struct ProductThumb: View {
let productId: Int
@State private var image: UIImage?
var body: some View {
content
.frame(width: 34, height: 34)
.task(id: productId) { await load() }
}
@ViewBuilder private var content: some View {
if let image {
Image(uiImage: image)
.resizable().scaledToFill()
.frame(width: 34, height: 34)
.clipShape(RoundedRectangle(cornerRadius: 6))
} else {
Image(systemName: "shippingbox")
.font(.body).foregroundStyle(.secondary)
}
}
private func load() async {
if let cached = ProductImageCache.shared.image(for: productId) {
image = cached
return
}
if ProductImageCache.shared.isKnownEmpty(productId) { return }
guard let data = try? await APIClient.shared.productImage(id: productId),
let ui = UIImage(data: data) else {
ProductImageCache.shared.markEmpty(productId)
return
}
ProductImageCache.shared.store(ui, data: data, version: nil, for: productId)
image = ui
}
}
/// Artikelfoto ansehen, per Kamera aufnehmen oder aus der Galerie wählen und
/// hochladen. Wird als Abschnitt in die Produkt-Detailansicht eingebettet.
struct ProductPhotoSection: View {
let productId: Int
@State private var imageData: Data?
@State private var loaded = false
@State private var busy = false
@State private var error: String?
@State private var showCamera = false
@State private var pickerItem: PhotosPickerItem?
var body: some View {
Section("Foto") {
if let imageData, let ui = UIImage(data: imageData) {
Image(uiImage: ui)
.resizable().scaledToFit()
.frame(maxWidth: .infinity)
.frame(maxHeight: 240)
.clipShape(RoundedRectangle(cornerRadius: 8))
} else if loaded {
Text("Noch kein Foto.").foregroundStyle(.secondary)
}
Button { showCamera = true } label: {
Label("Foto machen", systemImage: "camera")
}
PhotosPicker(selection: $pickerItem, matching: .images) {
Label("Aus Galerie", systemImage: "photo.on.rectangle")
}
if imageData != nil {
Button(role: .destructive) { Task { await removePhoto() } } label: {
Label("Foto entfernen", systemImage: "trash")
}
}
if busy { ProgressView() }
if let error { Text(error).foregroundStyle(.red).font(.callout) }
}
.fullScreenCover(isPresented: $showCamera) {
CameraPicker(isPresented: $showCamera) { image in Task { await upload(image) } }
.ignoresSafeArea()
}
.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) {
await upload(ui)
}
pickerItem = nil
}
}
.task { await load() }
}
private func load() async {
imageData = try? await APIClient.shared.productImage(id: productId)
// Listen-Cache mit der Wahrheit abgleichen.
if let imageData, let ui = UIImage(data: imageData) {
ProductImageCache.shared.store(ui, data: imageData, version: nil, for: productId)
} else {
ProductImageCache.shared.markEmpty(productId)
}
loaded = true
}
private func upload(_ image: UIImage) async {
guard let data = productImageJPEG(image) else { return }
busy = true
defer { busy = false }
error = nil
do {
_ = try await APIClient.shared.uploadProductImage(
id: productId, data: data, contentType: "image/jpeg")
imageData = data
if let ui = UIImage(data: data) { ProductImageCache.shared.store(ui, data: data, version: nil, for: productId) }
} catch {
self.error = error.localizedDescription
}
}
private func removePhoto() async {
busy = true
defer { busy = false }
error = nil
do {
try await APIClient.shared.deleteProductImage(id: productId)
imageData = nil
ProductImageCache.shared.markEmpty(productId)
} catch {
self.error = error.localizedDescription
}
}
}
/// 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.
/// Ohne Kamera (Simulator) fällt es auf die Fotoauswahl zurück.
struct CameraPicker: UIViewControllerRepresentable {
@Binding var isPresented: Bool
var onImage: (UIImage) -> Void
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
picker.sourceType = UIImagePickerController.isSourceTypeAvailable(.camera)
? .camera : .photoLibrary
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ picker: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator { Coordinator(self) }
// Schließen deterministisch über das Binding statt über die Umgebung
// das behebt das Flackern (Kamera geht direkt wieder zu), wenn die Ansicht
// während der Präsentation neu ausgewertet wird.
final class Coordinator: NSObject, UINavigationControllerDelegate, UIImagePickerControllerDelegate {
let parent: CameraPicker
init(_ parent: CameraPicker) { self.parent = parent }
func imagePickerController(_ picker: UIImagePickerController,
didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if let image = info[.originalImage] as? UIImage { parent.onImage(image) }
parent.isPresented = false
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
parent.isPresented = false
}
}
}

View File

@@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import PhotosUI
/// Artikel per Namenssuche auswählen (Alternative zum Scannen). /// Artikel per Namenssuche auswählen (Alternative zum Scannen).
struct ProductSearchView: View { struct ProductSearchView: View {
@@ -56,6 +57,9 @@ struct ProductFormView: View {
/// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie. /// Aus der Open-Food-Facts-Einordnung vorgeschlagene Kategorie.
var categoryId: Int? var categoryId: Int?
var suggestion: LookupResult.Suggestion? 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 var onCreated: (Product) -> Void
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@@ -70,61 +74,185 @@ struct ProductFormView: View {
@State private var datePrecision = "day" @State private var datePrecision = "day"
@State private var selectedGroupId: Int? @State private var selectedGroupId: Int?
@State private var selectedCategoryId: Int? @State private var selectedCategoryId: Int?
// Umschalter Lebensmittel/Gegenstand: filtert die Kategorieauswahl.
@State private var modus = "object"
// 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 groups: [GroupItem] = []
@State private var categories: [CategoryItem] = [] @State private var categories: [CategoryItem] = []
@State private var units: [Unit] = [] @State private var units: [Unit] = []
@State private var newGroupShown = false @State private var newGroupShown = false
@State private var busy = false @State private var busy = false
@State private var error: String? @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")] private let baseUnits = [("piece", "Stück"), ("gram", "Gramm"), ("milliliter", "Milliliter")]
// Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel. // Beschriftungen kommen sonst aus DisplaySettings.baseUnitLabel.
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] 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 { var body: some View {
Form { Form {
Section("Artikel") { Section("Artikel") {
LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad) LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad)
LabeledField(label: "Name", text: $name) LabeledField(label: "Name", text: $name)
if TextScanView.isSupported {
Button { scanTarget = .name } label: { Label("Name scannen", systemImage: "text.viewfinder") }
}
LabeledField(label: "Marke", text: $brand) LabeledField(label: "Marke", text: $brand)
} if TextScanView.isSupported {
Section { Button { scanTarget = .brand } label: { Label("Marke scannen", systemImage: "text.viewfinder") }
CategoryPicker(categories: categories, selection: $selectedCategoryId)
} footer: {
Text("Kategorie: nur für den Überblick in der Artikelliste.")
}
Section {
Picker("Gruppe", selection: $selectedGroupId) {
Text(" keine ").tag(Int?.none)
ForEach(groups) { gruppe in
Text(gruppe.name).tag(Int?.some(gruppe.id))
}
} }
if session.isAdmin { }
Button { Section {
newGroupShown = true Picker("Typ", selection: $modus) {
} label: { Text("Lebensmittel").tag("food")
Label("Neue Gruppe anlegen", systemImage: "plus") Text("Gegenstand").tag("object")
}
.pickerStyle(.segmented)
CategoryPicker(categories: categories, selection: $selectedCategoryId, tracking: modus)
if modus == "object" {
Picker("Verwaltung", selection: $objMode) {
Text("Menge je Lagerort").tag("count")
Text("Einzelstücke").tag("individual")
Text("Verbrauchsgegenstand").tag("bulk")
} }
} }
} footer: { } footer: {
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " Text(modus == "object"
+ "dieses Artikels erscheint danach automatisch bei der Gruppe.") ? (objMode == "individual"
? "Einzelstücke: jedes Stück mit eigener UID/QR, Kaufdatum, Garantie."
: 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.")
} }
Section("Einheit") { .onChange(of: modus) { neu in
Picker("Basiseinheit", selection: $baseUnit) { // Passt die gewählte Kategorie nicht mehr zum Typ, Auswahl leeren.
ForEach(baseUnits, id: \.0) { Text($0.1).tag($0.0) } if let cid = selectedCategoryId,
} categories.first(where: { $0.id == cid })?.tracking != neu {
QuantityField(label: "Packungsgröße", text: $packageSize, selectedCategoryId = nil
suffix: DisplaySettings.baseUnitLabel(baseUnit))
Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
} }
} }
Section("MHD") { // Gruppe: Lebensmittel, Verbrauchsgegenstand und Menge je Lagerort
Picker("Angabe", selection: $datePrecision) { // (nur Einzelstücke bleiben außen vor).
Text("Tagesdatum").tag("day") if showGroup {
Text("nur Monat/Jahr").tag("month") Section {
Picker("Gruppe", selection: $selectedGroupId) {
Text(" keine ").tag(Int?.none)
// 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 {
Button {
newGroupShown = true
} label: {
Label("Neue Gruppe anlegen", systemImage: "plus")
}
}
} footer: {
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) }
}
QuantityField(label: "Packungsgröße", text: $packageSize,
suffix: DisplaySettings.baseUnitLabel(baseUnit))
Picker("Bezeichnung", selection: $packageLabel) {
ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) }
}
}
Section("MHD") {
Picker("Angabe", selection: $datePrecision) {
Text("Tagesdatum").tag("day")
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 { if let error {
@@ -137,11 +265,47 @@ struct ProductFormView: View {
} }
.navigationTitle("Neuer Artikel") .navigationTitle("Neuer Artikel")
.navigationBarTitleDisplayMode(.inline) .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) .onAppear(perform: prefill)
.task { .task {
groups = (try? await APIClient.shared.groups()) ?? [] groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? [] categories = (try? await APIClient.shared.categories()) ?? []
units = (try? await APIClient.shared.units()) ?? [] units = (try? await APIClient.shared.units()) ?? []
// Kam eine Kategorie aus dem Barcode-Vorschlag, den Typ dazu setzen.
if let cid = selectedCategoryId,
let t = categories.first(where: { $0.id == cid })?.tracking {
modus = t
}
} }
.sheet(isPresented: $newGroupShown) { .sheet(isPresented: $newGroupShown) {
NavigationStack { NavigationStack {
@@ -153,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() { private func prefill() {
if let initialType { modus = initialType }
barcode = suggestion?.barcode ?? prefillBarcode ?? "" barcode = suggestion?.barcode ?? prefillBarcode ?? ""
// Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie // Aus dem gescannten Code vorgeschlagene Gruppe und Kategorie
// uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit // uebernehmen. Beides bleibt aenderbar - gespeichert wird erst mit
@@ -165,6 +385,14 @@ struct ProductFormView: View {
brand = suggestion.brand ?? "" brand = suggestion.brand ?? ""
baseUnit = suggestion.baseUnit ?? "piece" baseUnit = suggestion.baseUnit ?? "piece"
if let size = suggestion.packageSize { packageSize = String(size) } 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)
}
} }
} }
@@ -184,9 +412,23 @@ struct ProductFormView: View {
packageLabel: packageLabel.isEmpty ? nil : packageLabel, packageLabel: packageLabel.isEmpty ? nil : packageLabel,
datePrecision: datePrecision, datePrecision: datePrecision,
groupId: selectedGroupId, groupId: selectedGroupId,
categoryId: selectedCategoryId categoryId: selectedCategoryId,
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) onCreated(product)
dismiss() dismiss()
} catch { } catch {

View File

@@ -4,6 +4,9 @@ struct RootView: View {
@EnvironmentObject private var session: Session @EnvironmentObject private var session: Session
@EnvironmentObject private var router: Router @EnvironmentObject private var router: Router
@EnvironmentObject private var display: DisplaySettings @EnvironmentObject private var display: DisplaySettings
/// Über einen gescannten Universal Link geöffnetes Einzelstück.
@State private var linkedItem: Item?
@State private var linkError: String?
var body: some View { var body: some View {
Group { Group {
@@ -22,15 +25,52 @@ struct RootView: View {
await session.refreshMe() await session.refreshMe()
} }
} }
// Shortcut/URL öffnet den passenden Scan-Bildschirm direkt. // Shortcut/URL öffnet den passenden Scan-Bildschirm direkt; eine
// getippte Benachrichtigung öffnet Bald ablaufend".
.fullScreenCover(item: $router.route) { route in .fullScreenCover(item: $router.route) { route in
NavigationStack { NavigationStack {
switch route { switch route {
case .checkin: CheckInView() case .checkin: CheckInView()
case .checkout: CheckOutView() case .checkout: CheckOutView()
case .lookup: LookupScanView()
case .assign: AssignScanView()
case .expiring:
ExpiringView()
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { router.route = nil }
}
}
} }
} }
} }
.task(id: router.openItemUid) {
guard let uid = router.openItemUid else { return }
// openItemUid NICHT vor dem Abruf zurücksetzen: das ändert die
// .task-id und SwiftUI bricht dadurch die laufende Anfrage ab
// (URLError.cancelled). Erst nach dem Laden auf nil setzen.
let host = session.activeProfile?.url?.host ?? "?"
if session.isLoggedIn {
do {
linkedItem = try await APIClient.shared.itemByUid(uid: uid)
} catch {
linkError = "Einzelstück \(uid) auf Server \(host) nicht gefunden. "
+ "Zeigt der QR-Code auf denselben Server wie der oben gewählte? "
+ "(\(error.localizedDescription))"
}
} else {
linkError = "Bitte zuerst am Server \(host) anmelden, dann erneut scannen."
}
router.openItemUid = nil
}
.sheet(item: $linkedItem) { it in
NavigationStack { ItemEditView(item: it) }
}
.alert("Einzelstück öffnen", isPresented: Binding(
get: { linkError != nil }, set: { if !$0 { linkError = nil } }
)) {
Button("OK", role: .cancel) { linkError = nil }
} message: { Text(linkError ?? "") }
} }
} }
@@ -59,12 +99,25 @@ struct HomeView: View {
.tabItem { Label("Verwaltung", systemImage: "gearshape") } .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. /// Die beiden Kacheln, die frueher die Startseite waren.
struct ScanTabView: View { struct ScanTabView: View {
@EnvironmentObject private var router: Router @EnvironmentObject private var router: Router
@EnvironmentObject private var session: Session
@State private var showNew = false
@State private var showKassenzettel = false
var body: some View { var body: some View {
NavigationStack { NavigationStack {
@@ -83,10 +136,51 @@ struct ScanTabView: View {
ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen", ActionTile(title: "Auslagern", subtitle: "Barcode scannen und Menge entnehmen",
systemImage: "arrow.up.to.line") systemImage: "arrow.up.to.line")
} }
Button {
router.route = .lookup
} label: {
ActionTile(title: "Nachschlagen",
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 {
Button {
showNew = true
} label: {
ActionTile(title: "Artikel anlegen",
subtitle: "Ohne Barcode Name, Kategorie, eigene Felder",
systemImage: "plus")
}
Button {
router.route = .assign
} label: {
ActionTile(title: "Ort zuordnen",
subtitle: "Einzelstück-QR scannen, dann Lagerort-QR",
systemImage: "mappin.and.ellipse")
}
}
Spacer() Spacer()
} }
.buttonStyle(.plain)
.padding() .padding()
.navigationTitle("Scannen") .navigationTitle("Scannen")
.fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() }
.sheet(isPresented: $showNew) {
NavigationStack {
ProductFormView(prefillBarcode: nil, groupId: nil) { _ in
showNew = false
}
}
}
} }
} }
} }
@@ -95,11 +189,25 @@ struct ListsTabView: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
VStack(spacing: 16) { VStack(spacing: 16) {
// 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 { NavigationLink {
ProductListView() ItemListView()
} label: { } label: {
ActionTile(title: "Produkte", subtitle: "Suchen, ansehen und bearbeiten", ActionTile(title: "Einzelstücke", subtitle: "Alle Exemplare filterbar nach Lagerort",
systemImage: "shippingbox") systemImage: "tag")
} }
NavigationLink { NavigationLink {
ShoppingListView() ShoppingListView()
@@ -107,6 +215,12 @@ struct ListsTabView: View {
ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt", ActionTile(title: "Einkaufsliste", subtitle: "Was unter dem Mindestbestand liegt",
systemImage: "cart") systemImage: "cart")
} }
NavigationLink {
MinStockView()
} label: {
ActionTile(title: "Mindestbestände", subtitle: "Soll je Artikel ansehen und pflegen",
systemImage: "checklist")
}
NavigationLink { NavigationLink {
ExpiringView() ExpiringView()
} label: { } label: {
@@ -121,12 +235,36 @@ struct ListsTabView: View {
} }
Spacer() Spacer()
} }
.buttonStyle(.plain)
.padding() .padding()
.navigationTitle("Listen") .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 { struct ActionTile: View {
let title: String let title: String
let subtitle: String let subtitle: String
@@ -137,7 +275,7 @@ struct ActionTile: View {
Image(systemName: systemImage) Image(systemName: systemImage)
.font(.title2) .font(.title2)
.frame(width: 44, height: 44) .frame(width: 44, height: 44)
.background(Color.accentColor.opacity(0.15)) .background(Color.marke.opacity(0.15))
.clipShape(RoundedRectangle(cornerRadius: 10)) .clipShape(RoundedRectangle(cornerRadius: 10))
VStack(alignment: .leading, spacing: 2) { VStack(alignment: .leading, spacing: 2) {
Text(title).font(.headline) Text(title).font(.headline)

View File

@@ -7,6 +7,9 @@ struct ScannerView: UIViewControllerRepresentable {
var onCode: (String) -> Void var onCode: (String) -> Void
@Binding var isPaused: Bool @Binding var isPaused: Bool
@Binding var torchOn: Bool @Binding var torchOn: Bool
/// Per Langdruck gesperrter Blitz bleibt auch nach einem Scan an. Standard:
/// aus nach dem ersten Treffer.
@Binding var torchLocked: Bool
func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) } func makeCoordinator() -> Coordinator { Coordinator(onCode: onCode) }
@@ -18,12 +21,16 @@ struct ScannerView: UIViewControllerRepresentable {
func updateUIViewController(_ controller: ScannerViewController, context: Context) { func updateUIViewController(_ controller: ScannerViewController, context: Context) {
context.coordinator.isPaused = isPaused context.coordinator.isPaused = isPaused
context.coordinator.torchLocked = torchLocked
context.coordinator.torchBinding = $torchOn
controller.setTorch(on: torchOn) controller.setTorch(on: torchOn)
} }
final class Coordinator: NSObject, ScannerViewControllerDelegate { final class Coordinator: NSObject, ScannerViewControllerDelegate {
let onCode: (String) -> Void let onCode: (String) -> Void
var isPaused: Bool = false var isPaused: Bool = false
var torchLocked: Bool = false
var torchBinding: Binding<Bool>?
private var lastCode: String? private var lastCode: String?
private var lastTime: Date = .distantPast private var lastTime: Date = .distantPast
@@ -37,21 +44,42 @@ struct ScannerView: UIViewControllerRepresentable {
lastTime = Date() lastTime = Date()
AudioServicesPlaySystemSound(1057) AudioServicesPlaySystemSound(1057)
onCode(code) onCode(code)
// Blitz nach dem Scan ausschalten ausser er ist per Langdruck gesperrt.
if !torchLocked, torchBinding?.wrappedValue == true {
DispatchQueue.main.async { [weak self] in self?.torchBinding?.wrappedValue = false }
}
} }
} }
} }
/// Licht-Schalter fuer die Scan-Bildschirme. /// Licht-Schalter fuer die Scan-Bildschirme.
///
/// Tippen: an/aus angeschaltet geht der Blitz nach dem naechsten Scan wieder
/// aus. Langdruck: dauerhaft an (gesperrt, gelb), bleibt auch nach Scans an;
/// erneutes Tippen schaltet aus und hebt die Sperre auf.
struct TorchButton: View { struct TorchButton: View {
@Binding var isOn: Bool @Binding var isOn: Bool
@Binding var locked: Bool
var body: some View { var body: some View {
Button { Button {
locked = false
isOn.toggle() isOn.toggle()
} label: { } label: {
Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill") Image(systemName: isOn ? "bolt.fill" : "bolt.slash.fill")
.foregroundStyle(locked ? Color.yellow : Color.marke)
} }
.accessibilityLabel(isOn ? "Licht ausschalten" : "Licht einschalten") .simultaneousGesture(
LongPressGesture(minimumDuration: 0.4).onEnded { _ in
isOn = true
locked = true
}
)
.accessibilityLabel(
isOn
? (locked ? "Licht dauerhaft an, zum Ausschalten tippen" : "Licht aus")
: "Licht ein (bleibt bis zum nächsten Scan)"
)
} }
} }
@@ -65,6 +93,8 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
private let session = AVCaptureSession() private let session = AVCaptureSession()
private var preview: AVCaptureVideoPreviewLayer? private var preview: AVCaptureVideoPreviewLayer?
private var device: AVCaptureDevice? private var device: AVCaptureDevice?
/// Zoomfaktor beim Beginn einer Pinch-Geste (Ausgangspunkt fuers Skalieren).
private var zoomBasis: CGFloat = 1.0
/// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor, /// Auch krummere Symbologien mitnehmen: ITF-14 kommt auf Umkartons vor,
/// DataMatrix auf kleinen Aufklebern. /// DataMatrix auf kleinen Aufklebern.
@@ -77,6 +107,7 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
super.viewDidLoad() super.viewDidLoad()
view.backgroundColor = .black view.backgroundColor = .black
view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap))) view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(focusOnTap)))
view.addGestureRecognizer(UIPinchGestureRecognizer(target: self, action: #selector(zoomPinch)))
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
guard granted else { return } guard granted else { return }
DispatchQueue.main.async { self?.configure() } DispatchQueue.main.async { self?.configure() }
@@ -145,10 +176,13 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
} }
/// Antippen stellt gezielt auf den Code scharf - Rettungsanker fuer Codes, /// Antippen stellt gezielt auf den Code scharf - Rettungsanker fuer Codes,
/// die der Autofokus nicht von selbst findet. /// die der Autofokus nicht von selbst findet. Ein kurz aufblitzender Rahmen
/// zeigt, wohin fokussiert wird (wie in der System-Kamera).
@objc private func focusOnTap(_ gesture: UITapGestureRecognizer) { @objc private func focusOnTap(_ gesture: UITapGestureRecognizer) {
let location = gesture.location(in: view)
showFocusIndicator(at: location)
guard let device, let preview else { return } guard let device, let preview else { return }
let point = preview.captureDevicePointConverted(fromLayerPoint: gesture.location(in: view)) let point = preview.captureDevicePointConverted(fromLayerPoint: location)
guard (try? device.lockForConfiguration()) != nil else { return } guard (try? device.lockForConfiguration()) != nil else { return }
if device.isFocusPointOfInterestSupported, device.isFocusModeSupported(.autoFocus) { if device.isFocusPointOfInterestSupported, device.isFocusModeSupported(.autoFocus) {
device.focusPointOfInterest = point device.focusPointOfInterest = point
@@ -161,6 +195,44 @@ final class ScannerViewController: UIViewController, AVCaptureMetadataOutputObje
device.unlockForConfiguration() device.unlockForConfiguration()
} }
/// Zwei-Finger-Pinch zoomt naeher an einen Code heran (rein digital, auf ein
/// sinnvolles Maximum begrenzt zu viel Zoom macht den Code unscharf).
@objc private func zoomPinch(_ gesture: UIPinchGestureRecognizer) {
guard let device else { return }
if gesture.state == .began {
zoomBasis = device.videoZoomFactor
}
let maxZoom = min(device.activeFormat.videoMaxZoomFactor, 6.0)
let neu = max(1.0, min(zoomBasis * gesture.scale, maxZoom))
guard (try? device.lockForConfiguration()) != nil else { return }
device.videoZoomFactor = neu
device.unlockForConfiguration()
}
/// Kurzer gelber Fokus-Rahmen an der getippten Stelle als Rueckmeldung.
private func showFocusIndicator(at point: CGPoint) {
let seite: CGFloat = 72
let rahmen = UIView(frame: CGRect(x: 0, y: 0, width: seite, height: seite))
rahmen.center = point
rahmen.backgroundColor = .clear
rahmen.isUserInteractionEnabled = false
rahmen.layer.borderColor = UIColor.systemYellow.cgColor
rahmen.layer.borderWidth = 1.5
rahmen.layer.cornerRadius = 6
rahmen.alpha = 0
rahmen.transform = CGAffineTransform(scaleX: 1.4, y: 1.4)
view.addSubview(rahmen)
UIView.animate(withDuration: 0.2, animations: {
rahmen.alpha = 1
rahmen.transform = .identity
}, completion: { _ in
UIView.animate(withDuration: 0.35, delay: 0.6, options: [], animations: {
rahmen.alpha = 0
}, completion: { _ in rahmen.removeFromSuperview() })
})
}
/// Licht an: hebt den Kontrast bei matten oder farbigen Codes deutlich. /// Licht an: hebt den Kontrast bei matten oder farbigen Codes deutlich.
func setTorch(on: Bool) { func setTorch(on: Bool) {
guard let device, device.hasTorch, device.isTorchAvailable else { return } guard let device, device.hasTorch, device.isTorchAvailable else { return }

View File

@@ -0,0 +1,58 @@
import Foundation
/// Liest Daten von einem **bestimmten** Server, ohne den aktiven zu wechseln.
///
/// `APIClient` ist fest auf das aktive Profil verdrahtet (Session.shared).
/// Fuer die Benachrichtigungen muss die App aber jeden eingerichteten Server
/// abfragen - deshalb dieser parallele Pfad, der baseURL und Token ausdruecklich
/// bekommt. Modelle und Fehlerbedeutung sind dieselben wie im `APIClient`.
enum ServerFetch {
/// Alles, was der Planer je Server braucht: ablaufende Chargen im Horizont,
/// die Produkte (fuer die Kategoriezuordnung) und der Kategorie-Baum.
struct Snapshot {
let expiring: [ExpiringItem]
let products: [Product]
let categories: [CategoryItem]
}
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)
// 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>(
_ path: String, baseURL: URL, token: String
) async throws -> T {
guard let url = URL(string: "api" + path, relativeTo: baseURL) else {
throw APIError.notConfigured
}
var request = URLRequest(url: url)
request.httpMethod = "GET"
// Etwas grosszuegiger als der interaktive Client: Der Abruf laeuft im
// Hintergrund, ein langsamer Heimserver soll nicht sofort scheitern.
request.timeoutInterval = 20
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) {
if http.statusCode == 401 { throw APIError.unauthorized }
throw APIError.server("Serverfehler (\(http.statusCode))")
}
return try JSONDecoder().decode(T.self, from: data)
}
}

View File

@@ -46,17 +46,31 @@ struct ServerListView: View {
@State private var editing: ServerProfile? @State private var editing: ServerProfile?
@State private var addShown = false @State private var addShown = false
@State private var pendingDeletion: ServerProfile? @State private var pendingDeletion: ServerProfile?
@State private var notifyFor: ServerProfile?
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
ForEach(session.profiles) { profile in ForEach(session.profiles) { profile in
Button { HStack(spacing: 8) {
session.switchTo(profile.id) // Zeile wechselt den Server, die Glocke oeffnet die
dismiss() // Benachrichtigungen - zwei getrennte Ziele in einer Zeile.
} label: { Button {
row(for: profile) session.switchTo(profile.id)
dismiss()
} label: {
row(for: profile)
}
.buttonStyle(.plain)
Button {
notifyFor = profile
} label: {
Image(systemName: NotificationStore.config(for: profile.id).enabled
? "bell.fill" : "bell")
.foregroundStyle(Color.marke)
}
.buttonStyle(.borderless)
} }
.swipeActions(edge: .trailing) { .swipeActions(edge: .trailing) {
Button("Löschen", role: .destructive) { pendingDeletion = profile } Button("Löschen", role: .destructive) { pendingDeletion = profile }
@@ -64,7 +78,7 @@ struct ServerListView: View {
} }
} }
} footer: { } footer: {
Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab.") Text("Jeder Server merkt sich seine eigene Anmeldung. Ein Wechsel meldet dich nicht ab. Die Glocke stellt Ablauf-Erinnerungen ein.")
} }
if session.profiles.isEmpty { if session.profiles.isEmpty {
@@ -88,6 +102,16 @@ struct ServerListView: View {
.sheet(item: $editing) { profile in .sheet(item: $editing) { profile in
ServerEditView(profile: profile) ServerEditView(profile: profile)
} }
.sheet(item: $notifyFor) { profile in
NavigationStack {
NotificationSettingsView(profileID: profile.id, serverName: profile.name)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button("Fertig") { notifyFor = nil }
}
}
}
}
.confirmationDialog( .confirmationDialog(
pendingDeletion.map { "\($0.name)“ löschen?" } ?? "", pendingDeletion.map { "\($0.name)“ löschen?" } ?? "",
isPresented: Binding(get: { pendingDeletion != nil }, isPresented: Binding(get: { pendingDeletion != nil },
@@ -114,7 +138,7 @@ struct ServerListView: View {
} }
Spacer() Spacer()
if profile.id == session.activeProfileID { if profile.id == session.activeProfileID {
Image(systemName: "checkmark").foregroundStyle(Color.accentColor) Image(systemName: "checkmark").foregroundStyle(Color.marke)
} }
} }
.foregroundStyle(.primary) .foregroundStyle(.primary)

View File

@@ -122,6 +122,8 @@ final class Session: ObservableObject {
/// wenn der Server aus der Liste verschwindet. /// wenn der Server aus der Liste verschwindet.
func removeProfile(id: UUID) { func removeProfile(id: UUID) {
Keychain.delete(account: Session.keychainAccount(for: id)) Keychain.delete(account: Session.keychainAccount(for: id))
NotificationStore.remove(for: id)
AppDashboardStore.remove(for: id)
profiles.removeAll { $0.id == id } profiles.removeAll { $0.id == id }
ProfileStore.save(profiles) ProfileStore.save(profiles)
@@ -138,6 +140,17 @@ final class Session: ObservableObject {
loadTokenForActiveProfile() loadTokenForActiveProfile()
} }
/// Wechselt (falls vorhanden und noetig) auf das Profil, dessen Adresse zum
/// Host eines Links passt damit ein gescannter QR den richtigen Server
/// abfragt, auch wenn gerade ein anderer aktiv ist.
func switchToProfile(matching url: URL) {
guard let host = url.host?.lowercased() else { return }
if activeProfile?.url?.host?.lowercased() == host { return }
if let match = profiles.first(where: { $0.url?.host?.lowercased() == host }) {
switchTo(match.id)
}
}
// MARK: - Anmeldung // MARK: - Anmeldung
/// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil /// Legt die Adresse fuer die Anmeldung fest: aktualisiert das aktive Profil

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:vorrania-ch.scarriffle.com</string>
</array>
</dict>
</plist>

View File

@@ -1,31 +1,70 @@
import SwiftUI import SwiftUI
import UIKit import UIKit
import BackgroundTasks
import UserNotifications
/// Zielbildschirm, der über Home-Screen-Shortcut oder URL angesprungen wird. /// Zielbildschirm, der über Home-Screen-Shortcut, URL oder eine getippte
/// Benachrichtigung angesprungen wird.
enum Route: String { enum Route: String {
case checkin case checkin
case checkout case checkout
case expiring
case lookup
case assign
} }
final class Router: ObservableObject { final class Router: ObservableObject {
@Published var route: Route? @Published var route: Route?
/// Über einen Universal Link (/i/<UID>) zu öffnendes Einzelstück.
@Published var openItemUid: String?
func handle(shortcut type: String) { func handle(shortcut type: String) {
if type.hasSuffix("checkin") { route = .checkin } if type.hasSuffix("checkin") { route = .checkin }
if type.hasSuffix("checkout") { route = .checkout } if type.hasSuffix("checkout") { route = .checkout }
} }
/// vorrania://checkin bzw. vorrania://checkout /// Getippte Ablauf-Benachrichtigung: auf den betroffenen Server wechseln und
/// Bald ablaufend" oeffnen.
func handle(expiryProfile raw: String) {
if let id = UUID(uuidString: raw) { Session.shared.switchTo(id) }
route = .expiring
}
/// 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) { func handle(url: URL) {
guard url.scheme == "vorrania" else { return } if url.scheme == "vorrania" {
let target = (url.host ?? url.path.replacingOccurrences(of: "/", with: "")).lowercased() // Host + Pfad zu Bestandteilen ohne Trenner: ["i","ABC123"] bzw. ["checkin"].
if let route = Route(rawValue: target) { self.route = route } 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
}
// 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 {
Session.shared.switchToProfile(matching: url)
openItemUid = parts[idx + 1].uppercased()
}
} }
} }
@main @main
struct VorraniaApp: App { struct VorraniaApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
@StateObject private var session = Session.shared @StateObject private var session = Session.shared
@StateObject private var router = Router() @StateObject private var router = Router()
@StateObject private var display = DisplaySettings.shared @StateObject private var display = DisplaySettings.shared
@@ -37,29 +76,75 @@ struct VorraniaApp: App {
.environmentObject(router) .environmentObject(router)
.environmentObject(display) .environmentObject(display)
.onOpenURL { router.handle(url: $0) } .onOpenURL { router.handle(url: $0) }
.onContinueUserActivity(NSUserActivityTypeBrowsingWeb) { activity in
if let url = activity.webpageURL { router.handle(url: url) }
}
.onAppear { .onAppear {
if let type = AppDelegate.pendingShortcut { if let type = AppDelegate.pendingShortcut {
router.handle(shortcut: type) router.handle(shortcut: type)
AppDelegate.pendingShortcut = nil AppDelegate.pendingShortcut = nil
} }
// Kaltstart aus einer getippten Benachrichtigung.
if let profile = AppDelegate.pendingExpiryProfile {
router.handle(expiryProfile: profile)
AppDelegate.pendingExpiryProfile = nil
}
// Kaltstart aus einem gescannten Universal Link.
if let url = AppDelegate.pendingURL {
router.handle(url: url)
AppDelegate.pendingURL = nil
}
} }
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in .onReceive(NotificationCenter.default.publisher(for: AppDelegate.shortcutNotification)) { note in
if let type = note.object as? String { router.handle(shortcut: type) } if let type = note.object as? String { router.handle(shortcut: type) }
} }
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.expiryTapNotification)) { note in
if let profile = note.object as? String { router.handle(expiryProfile: profile) }
}
.onReceive(NotificationCenter.default.publisher(for: AppDelegate.urlNotification)) { note in
if let url = note.object as? URL { router.handle(url: url) }
}
}
// Im Vordergrund den Meldungsplan auffrischen und den naechsten
// Hintergrundlauf einplanen.
.onChange(of: scenePhase) { phase in
switch phase {
case .active:
Task { await NotificationScheduler.shared.rescheduleAll() }
case .background:
AppDelegate.scheduleBackgroundRefresh()
default:
break
}
} }
} }
} }
/// Nimmt Home-Screen-Quick-Actions entgegen (auch beim Kaltstart). /// Nimmt Home-Screen-Quick-Actions und getippte Benachrichtigungen entgegen
final class AppDelegate: NSObject, UIApplicationDelegate { /// (auch beim Kaltstart) und treibt die Hintergrund-Aktualisierung.
final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
static let shortcutNotification = Notification.Name("VorraniaShortcut") static let shortcutNotification = Notification.Name("VorraniaShortcut")
static let expiryTapNotification = Notification.Name("VorraniaExpiryTap")
static let urlNotification = Notification.Name("VorraniaOpenURL")
static var pendingShortcut: String? static var pendingShortcut: String?
static var pendingExpiryProfile: String?
static var pendingURL: URL?
static let refreshTaskID = "com.scarriffle.vorrania.refresh"
func application(_ application: UIApplication, func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem { if let item = launchOptions?[.shortcutItem] as? UIApplicationShortcutItem {
AppDelegate.pendingShortcut = item.type AppDelegate.pendingShortcut = item.type
} }
UNUserNotificationCenter.current().delegate = self
// Der Handler muss registriert sein, bevor das Starten abgeschlossen ist.
BGTaskScheduler.shared.register(
forTaskWithIdentifier: AppDelegate.refreshTaskID, using: nil
) { task in
AppDelegate.handleBackgroundRefresh(task as? BGAppRefreshTask)
}
return true return true
} }
@@ -72,4 +157,55 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type) NotificationCenter.default.post(name: AppDelegate.shortcutNotification, object: shortcutItem.type)
completionHandler(true) completionHandler(true)
} }
// Universal Link (/i/<UID>) auch beim Kaltstart, falls die SwiftUI-Hooks
// ihn nicht mitbekommen.
func application(_ application: UIApplication, continue userActivity: NSUserActivity,
restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
guard userActivity.activityType == NSUserActivityTypeBrowsingWeb,
let url = userActivity.webpageURL else { return false }
AppDelegate.pendingURL = url
NotificationCenter.default.post(name: AppDelegate.urlNotification, object: url)
return true
}
// MARK: - Benachrichtigungen
/// Auch im Vordergrund als Banner zeigen - sonst bliebe eine faellige
/// Erinnerung unbemerkt, solange die App offen ist.
func userNotificationCenter(_ center: UNUserNotificationCenter,
willPresent notification: UNNotification) async
-> UNNotificationPresentationOptions {
[.banner, .sound]
}
/// Antippen: auf den betroffenen Server wechseln und Bald ablaufend" oeffnen.
func userNotificationCenter(_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse) async {
guard let profile = response.notification.request.content.userInfo["profileID"] as? String
else { return }
AppDelegate.pendingExpiryProfile = profile
NotificationCenter.default.post(name: AppDelegate.expiryTapNotification, object: profile)
}
// MARK: - Hintergrund-Aktualisierung
static func scheduleBackgroundRefresh() {
let request = BGAppRefreshTaskRequest(identifier: refreshTaskID)
// Fruehestens in ein paar Stunden - iOS entscheidet den genauen Zeitpunkt.
request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 3600)
try? BGTaskScheduler.shared.submit(request)
}
private static func handleBackgroundRefresh(_ task: BGAppRefreshTask?) {
guard let task else { return }
// Gleich den naechsten Lauf einplanen, sonst gibt es nur einen.
scheduleBackgroundRefresh()
let arbeit = Task { @MainActor in
await NotificationScheduler.shared.rescheduleAll()
task.setTaskCompleted(success: true)
}
task.expirationHandler = { arbeit.cancel() }
}
} }

View File

@@ -9,16 +9,26 @@
/* Begin PBXBuildFile section */ /* Begin PBXBuildFile section */
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; }; 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 369B9841E43E727ACA2E2A2A /* ProductViews.swift */; };
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */; }; 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 */; }; 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 */; }; 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 378F1B2DE567B62169E84426 /* APIClient.swift */; };
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; }; 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AF19A735AC17451B57BF5BB /* ScannerView.swift */; };
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */; };
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3731608B8D48960DC98912BC /* NotificationSettings.swift */; };
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; }; 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */; };
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; }; 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */; };
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69B59B9F69BC30451D45A3C7 /* ItemViews.swift */; };
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */; };
6816C33381DF52A96E5BB303 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 314B1BB7220691A7423E8929 /* Models.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 */; }; 75F805F8AE93479933BD811D /* VorraniaApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */; };
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; }; 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6785CD9174067EFBB2B9043 /* ListViews.swift */; };
7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; }; 7E2FA69E0C3BED650E91A7E9 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 099CCD94907BE0179B7D5742 /* AppIcon.icon */; };
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; }; 80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */ = {isa = PBXBuildFile; fileRef = 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */; };
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */ = {isa = PBXBuildFile; fileRef = C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */; };
83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; }; 83F9AD06177F3BC95421F128 /* DashboardCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 090485CC54558EB4433376A9 /* DashboardCards.swift */; };
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; }; 87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA706060734632DE78FA1073 /* ServerListView.swift */; };
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; }; 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */; };
@@ -26,28 +36,44 @@
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; }; 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4741D0E95875919C921945CF /* RootView.swift */; };
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; }; AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F22598772AD8B5EB6394F8 /* LookupScanView.swift */; };
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; }; D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; }; DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; }; E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; }; ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */ = {isa = PBXBuildFile; fileRef = 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */; };
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0DDD06332E567E70CA842F /* CheckOutView.swift */; }; F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC0DDD06332E567E70CA842F /* CheckOutView.swift */; };
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */ = {isa = PBXBuildFile; fileRef = E580BA2815BB8F6341D64B47 /* ChartCards.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXFileReference 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>"; }; 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>"; }; 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>"; }; 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>"; }; 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>"; };
18F22598772AD8B5EB6394F8 /* LookupScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LookupScanView.swift; sourceTree = "<group>"; };
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; }; 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterDataViews.swift; sourceTree = "<group>"; };
314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; }; 314B1BB7220691A7423E8929 /* Models.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Models.swift; sourceTree = "<group>"; };
369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; }; 369B9841E43E727ACA2E2A2A /* ProductViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductViews.swift; sourceTree = "<group>"; };
3731608B8D48960DC98912BC /* NotificationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettings.swift; sourceTree = "<group>"; };
378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; }; 378F1B2DE567B62169E84426 /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; }; 4741D0E95875919C921945CF /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4BF4E4F5524B15728CEEE116 /* Vorrania.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = Vorrania.app; sourceTree = BUILT_PRODUCTS_DIR; };
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductPhotoView.swift; sourceTree = "<group>"; };
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemViews.swift; sourceTree = "<group>"; };
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignScanView.swift; sourceTree = "<group>"; };
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; }; 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VorraniaApp.swift; sourceTree = "<group>"; };
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; }; 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckInFormView.swift; sourceTree = "<group>"; };
A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; }; A6785CD9174067EFBB2B9043 /* ListViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListViews.swift; sourceTree = "<group>"; };
@@ -55,8 +81,11 @@
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; }; AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BestBeforeText.swift; sourceTree = "<group>"; };
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; }; AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplaySettings.swift; sourceTree = "<group>"; };
AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; }; AD2F9406BD0D4F0D30FED345 /* Session.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Session.swift; sourceTree = "<group>"; };
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalOverview.swift; sourceTree = "<group>"; };
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; }; CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryPicker.swift; sourceTree = "<group>"; };
DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; }; DC0DDD06332E567E70CA842F /* CheckOutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckOutView.swift; sourceTree = "<group>"; };
DE4C9D922F5AC05270B1CF9B /* Vorrania.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Vorrania.entitlements; sourceTree = "<group>"; };
E580BA2815BB8F6341D64B47 /* ChartCards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChartCards.swift; sourceTree = "<group>"; };
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; }; E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; }; EA706060734632DE78FA1073 /* ServerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerListView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@@ -66,8 +95,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
378F1B2DE567B62169E84426 /* APIClient.swift */, 378F1B2DE567B62169E84426 /* APIClient.swift */,
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */,
76393ADA26304E5BC5AD7E7F /* AssignScanView.swift */,
AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */, AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */,
CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */, CDD672F65352DA0D122EC9AE /* CategoryPicker.swift */,
E580BA2815BB8F6341D64B47 /* ChartCards.swift */,
99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */, 99E56353CB483EFB1CCD09D8 /* CheckInFormView.swift */,
0365A16FEAE3F2BEC321E68A /* CheckInView.swift */, 0365A16FEAE3F2BEC321E68A /* CheckInView.swift */,
DC0DDD06332E567E70CA842F /* CheckOutView.swift */, DC0DDD06332E567E70CA842F /* CheckOutView.swift */,
@@ -75,18 +107,30 @@
E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */, E6E6139CCD0B6C51B3919EE9 /* DashboardView.swift */,
717C8EB336170526F5F3E695 /* DateScanView.swift */, 717C8EB336170526F5F3E695 /* DateScanView.swift */,
AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */, AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */,
0ED41226394E7F31F37EC3B8 /* GroupViews.swift */,
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */, 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */,
00288DFA409BAD753AA4CA5D /* ItemListView.swift */,
69B59B9F69BC30451D45A3C7 /* ItemViews.swift */,
A6785CD9174067EFBB2B9043 /* ListViews.swift */, A6785CD9174067EFBB2B9043 /* ListViews.swift */,
C35D34107C8CBB8B7C6C6650 /* LocalOverview.swift */,
A75926511854BB8EE316ED3A /* LoginView.swift */, A75926511854BB8EE316ED3A /* LoginView.swift */,
18F22598772AD8B5EB6394F8 /* LookupScanView.swift */,
1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */, 1E86178BB91DBB86ADFB284B /* MasterDataViews.swift */,
314B1BB7220691A7423E8929 /* Models.swift */, 314B1BB7220691A7423E8929 /* Models.swift */,
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */,
3731608B8D48960DC98912BC /* NotificationSettings.swift */,
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
5312AFE88B7284401AA7B0A3 /* ProductPhotoView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
4741D0E95875919C921945CF /* RootView.swift */, 4741D0E95875919C921945CF /* RootView.swift */,
8AF19A735AC17451B57BF5BB /* ScannerView.swift */, 8AF19A735AC17451B57BF5BB /* ScannerView.swift */,
1847E65D41ACEDD01CD108BC /* ServerFetch.swift */,
EA706060734632DE78FA1073 /* ServerListView.swift */, EA706060734632DE78FA1073 /* ServerListView.swift */,
121D44B9990DA931A9CD5847 /* ServerProfiles.swift */, 121D44B9990DA931A9CD5847 /* ServerProfiles.swift */,
AD2F9406BD0D4F0D30FED345 /* Session.swift */, AD2F9406BD0D4F0D30FED345 /* Session.swift */,
DE4C9D922F5AC05270B1CF9B /* Vorrania.entitlements */,
9948219CDDC4188EA4298F22 /* VorraniaApp.swift */, 9948219CDDC4188EA4298F22 /* VorraniaApp.swift */,
); );
path = Sources; path = Sources;
@@ -181,8 +225,11 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */, 39F1F17DCB4E491652D08DEE /* APIClient.swift in Sources */,
F3F718BFC60BF945CFA32BD2 /* AppDashboards.swift in Sources */,
5A0C3254B45CA13B1C3D9265 /* AssignScanView.swift in Sources */,
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */, A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */,
25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */, 25FC3913EFFA37D013328FDE /* CategoryPicker.swift in Sources */,
FFD4DB783F89083C9098F1C4 /* ChartCards.swift in Sources */,
0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */, 0E0AE6CC6FBA82B190BBC36F /* CheckInFormView.swift in Sources */,
4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */, 4F86620DF8303DB02314DB5A /* CheckInView.swift in Sources */,
F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */, F6C7E913CAB0FAF1826E6BD3 /* CheckOutView.swift in Sources */,
@@ -190,15 +237,26 @@
926451E72FD13708C5DD657B /* DashboardView.swift in Sources */, 926451E72FD13708C5DD657B /* DashboardView.swift in Sources */,
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */, ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */,
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */, D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */,
34787DA16A1AB035BF129BB7 /* GroupViews.swift in Sources */,
4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */, 4C486D190D1AB338925D29B5 /* HistoryView.swift in Sources */,
749139BEFF35F9DAFFE87C3B /* ItemListView.swift in Sources */,
56A5DFAC86DF972B1481A691 /* ItemViews.swift in Sources */,
7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */, 7738E209D4D32BFD514B76F5 /* ListViews.swift in Sources */,
82A0C22D4AD228C36868853A /* LocalOverview.swift in Sources */,
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */, E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */,
B531EEDAFAF442B1E86A9931 /* LookupScanView.swift in Sources */,
96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */, 96E5A2455B2EC26050E69232 /* MasterDataViews.swift in Sources */,
6816C33381DF52A96E5BB303 /* Models.swift in Sources */, 6816C33381DF52A96E5BB303 /* Models.swift in Sources */,
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
4AC141151264AF22701079B6 /* ProductPhotoView.swift in Sources */,
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,
44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */, 44F08407AF69F5B7FD15F932 /* ScannerView.swift in Sources */,
728CF5124D2D5BBF826BCD2C /* ServerFetch.swift in Sources */,
87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */, 87A34A269AAD85A19C3F4359 /* ServerListView.swift in Sources */,
80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */, 80B15C315FB4FEBD385A5EA1 /* ServerProfiles.swift in Sources */,
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */, DFA55EF4ACA34537F445E998 /* Session.swift in Sources */,
@@ -269,6 +327,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Sources/Vorrania.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;
@@ -290,6 +349,7 @@
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CODE_SIGN_ENTITLEMENTS = Sources/Vorrania.entitlements;
CODE_SIGN_IDENTITY = "iPhone Developer"; CODE_SIGN_IDENTITY = "iPhone Developer";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = PP34X97WS3; DEVELOPMENT_TEAM = PP34X97WS3;

View File

@@ -39,3 +39,11 @@ targets:
TARGETED_DEVICE_FAMILY: "1,2" TARGETED_DEVICE_FAMILY: "1,2"
SWIFT_VERSION: "5.9" SWIFT_VERSION: "5.9"
GENERATE_INFOPLIST_FILE: NO GENERATE_INFOPLIST_FILE: NO
# Universal Links: …/i/<UID> öffnet direkt die App. Dafür muss im
# Apple-Developer-Portal die Capability "Associated Domains" für die App-ID
# aktiv sein (einmalig), sonst schlägt das Signieren fehl.
entitlements:
path: Sources/Vorrania.entitlements
properties:
com.apple.developer.associated-domains:
- applinks:vorrania-ch.scarriffle.com

View File

@@ -4,9 +4,34 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
# SPA-Routing: unbekannte Pfade auf index.html mappen # Foto-Uploads (Artikelbild, Logo/Favicon) brauchen mehr als die 1 MB, die
# nginx sonst zulaesst - sonst antwortet der Proxy mit 413, bevor das Backend
# den Upload ueberhaupt sieht.
client_max_body_size 12m;
# Universal Links: iOS holt diese Datei, um /i/<UID>-Links direkt in der App
# zu öffnen. Bewusst nur die Einzelstück-Pfade (/i/*); Lagerort-Links (/l/*)
# bleiben im Browser. Team-/Bundle-ID aus ios/project.yml.
location = /.well-known/apple-app-site-association {
default_type application/json;
return 200 '{"applinks":{"details":[{"appIDs":["PP34X97WS3.com.scarriffle.vorrania"],"components":[{"/":"/i/*"}]}]}}';
}
# 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 / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache";
} }
# API an das Backend weiterleiten (Service-Name "backend" aus docker-compose) # API an das Backend weiterleiten (Service-Name "backend" aus docker-compose)

306
web/package-lock.json generated
View File

@@ -8,6 +8,7 @@
"name": "vorrania-web", "name": "vorrania-web",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3", "react-grid-layout": "^1.5.3",
@@ -1270,6 +1271,30 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
} }
}, },
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.11.1", "version": "2.11.1",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
@@ -1317,6 +1342,15 @@
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
} }
}, },
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001806", "version": "1.0.30001806",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
@@ -1338,6 +1372,17 @@
], ],
"license": "CC-BY-4.0" "license": "CC-BY-4.0"
}, },
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/clsx": { "node_modules/clsx": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
@@ -1347,6 +1392,24 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1372,6 +1435,21 @@
} }
} }
}, },
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.395", "version": "1.5.395",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.395.tgz",
@@ -1379,6 +1457,12 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1455,6 +1539,19 @@
} }
} }
}, },
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1480,6 +1577,24 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1512,6 +1627,18 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -1579,6 +1706,51 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1599,6 +1771,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.22", "version": "8.5.22",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
@@ -1639,6 +1820,23 @@
"react-is": "^16.13.1" "react-is": "^16.13.1"
} }
}, },
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/react": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -1758,6 +1956,21 @@
"react-dom": ">=16.8" "react-dom": ">=16.8"
} }
}, },
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/resize-observer-polyfill": { "node_modules/resize-observer-polyfill": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz",
@@ -1828,6 +2041,12 @@
"semver": "bin/semver.js" "semver": "bin/semver.js"
} }
}, },
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1838,6 +2057,32 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1961,12 +2206,73 @@
} }
} }
}, },
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
} }
} }
} }

View File

@@ -9,6 +9,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"qrcode": "^1.5.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-grid-layout": "^1.5.3", "react-grid-layout": "^1.5.3",

View File

@@ -1,17 +1,24 @@
import { useEffect, useState } from "react"; 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 { api } from "./api";
import { useAuth } from "./auth"; import { useAuth } from "./auth";
import Icon from "./components/Icon"; import Icon from "./components/Icon";
import ErrorBoundary from "./components/ErrorBoundary";
import BrandMark from "./components/BrandMark"; import BrandMark from "./components/BrandMark";
import Login from "./pages/Login"; import Login from "./pages/Login";
import Dashboard from "./pages/Dashboard"; import Dashboard from "./pages/Dashboard";
import Products from "./pages/Products"; 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 ProductForm from "./pages/ProductForm";
import CheckIn from "./pages/CheckIn"; import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut"; import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups"; import Groups from "./pages/Groups";
import Categories from "./pages/Categories"; import Categories from "./pages/Categories";
import Shops from "./pages/Shops";
import ItemResolve from "./pages/ItemResolve";
import LocationResolve from "./pages/LocationResolve";
import Locations from "./pages/Locations"; import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes"; import PackageTypes from "./pages/PackageTypes";
import Units from "./pages/Units"; import Units from "./pages/Units";
@@ -71,17 +78,25 @@ function Sidebar() {
))} ))}
<NavItem to="/checkin" icon="checkin" label="Einlagern" /> <NavItem to="/checkin" icon="checkin" label="Einlagern" />
<NavItem to="/checkout" icon="checkout" label="Auslagern" /> <NavItem to="/checkout" icon="checkout" label="Auslagern" />
<NavItem to="/products" icon="package" label="Produkte" /> <NavItem to="/lebensmittel" icon="food" label="Lebensmittel" />
<NavItem to="/groups" icon="tag" label="Gruppen" /> <NavItem to="/gegenstaende" icon="package" label="Gegenstände" />
<NavItem to="/categories" icon="package" label="Kategorien" /> <NavItem to="/items" icon="tag" label="Einzelstücke" />
<NavItem to="/chargen" icon="box" label="Chargen" />
<NavItem to="/shopping" icon="cart" label="Einkaufsliste" /> <NavItem to="/shopping" icon="cart" label="Einkaufsliste" />
<NavItem to="/mindestbestaende" icon="check" label="Mindestbestände" />
<NavItem to="/history" icon="history" label="Verlauf" /> <NavItem to="/history" icon="history" label="Verlauf" />
{isAdmin && ( {isAdmin && (
<> <>
<div className="nav-section">Verwaltung</div> {/* Stammdaten strukturieren den Katalog und werden nur von Admins
gepflegt deshalb alle zusammen hier, wie in der iOS-App. */}
<div className="nav-section">Stammdaten</div>
<NavItem to="/categories" icon="package" label="Kategorien" />
<NavItem to="/groups" icon="tag" label="Gruppen" />
<NavItem to="/locations" icon="location" label="Lagerorte" /> <NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/shops" icon="cart" label="Shops" />
<NavItem to="/units" icon="box" label="Einheiten" /> <NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" /> <NavItem to="/package-types" icon="package" label="Gebinde" />
<div className="nav-section">System</div>
<NavItem to="/users" icon="users" label="Benutzer" /> <NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/transfer" icon="history" label="Import / Export" /> <NavItem to="/transfer" icon="history" label="Import / Export" />
<NavItem to="/settings" icon="settings" label="Einstellungen" /> <NavItem to="/settings" icon="settings" label="Einstellungen" />
@@ -101,16 +116,45 @@ function Sidebar() {
); );
} }
function Protected({ children, adminOnly = false }) { /** Server nicht erreichbar die Anmeldung bleibt bestehen, nur der Abruf nicht. */
const { user, isAdmin, loading } = useAuth(); 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, startFehler, erneutVersuchen } = useAuth();
const { pathname: pfad } = useLocation();
if (loading) return <div className="center muted" style={{ padding: 60 }}>Lädt</div>; 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 (!user) return <Navigate to="/login" replace />;
if (adminOnly && !isAdmin) return <Navigate to="/" replace />; if (adminOnly && !isAdmin) return <Navigate to="/" replace />;
return ( return (
<div className="app-shell"> <div className="app-shell">
<Sidebar /> <Sidebar />
<div className="main"> <div className="main">
<div className="content">{children}</div> {/* `wide` hebt die Lesebreite auf nur fürs Dashboard-Raster sinnvoll. */}
<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>
</div> </div>
); );
@@ -125,20 +169,28 @@ export default function App() {
path="/login" path="/login"
element={loading ? null : user ? <Navigate to="/" replace /> : <Login />} element={loading ? null : user ? <Navigate to="/" replace /> : <Login />}
/> />
<Route path="/" element={<Protected><Dashboard /></Protected>} /> <Route path="/" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/d/:id" element={<Protected><Dashboard /></Protected>} /> <Route path="/d/:id" element={<Protected wide><Dashboard /></Protected>} />
<Route path="/checkin" element={<Protected><CheckIn /></Protected>} /> <Route path="/checkin" element={<Protected><CheckIn /></Protected>} />
<Route path="/checkout" element={<Protected><CheckOut /></Protected>} /> <Route path="/checkout" element={<Protected><CheckOut /></Protected>} />
<Route path="/products" element={<Protected><Products /></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="/chargen" element={<Protected wide><Charges /></Protected>} />
<Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} /> <Route path="/products/new" element={<Protected adminOnly><ProductForm /></Protected>} />
<Route path="/products/:id" element={<Protected><ProductForm /></Protected>} /> <Route path="/products/:id" element={<Protected wide><ProductForm /></Protected>} />
<Route path="/groups" element={<Protected><Groups /></Protected>} /> <Route path="/i/:uid" element={<Protected><ItemResolve /></Protected>} />
<Route path="/categories" element={<Protected><Categories /></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="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/mindestbestaende" element={<Protected wide><MinStock /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} /> <Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/shops" element={<Protected adminOnly><Shops /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} /> <Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} /> <Route path="/units" element={<Protected adminOnly wide><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} /> <Route path="/package-types" element={<Protected adminOnly wide><PackageTypes /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} /> <Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} /> <Route path="/transfer" element={<Protected adminOnly><Transfer /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} /> <Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />

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