from __future__ import annotations from datetime import date, datetime from pydantic import BaseModel, ConfigDict, Field, field_validator from .models import ( BaseUnit, CategoryTracking, DatePrecision, FieldType, RemovalReason, Role, UnitKind, ) # ---- Units ---- class UnitOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str kind: UnitKind factor: float is_builtin: bool class UnitCreate(BaseModel): name: str = Field(min_length=1, max_length=64) kind: UnitKind factor: float = Field(gt=0) class UnitUpdate(BaseModel): """Nur der Name. Art und Faktor bleiben fest – sie stecken bereits in umgerechneten Bestaenden, eine Aenderung wuerde die still verfaelschen.""" name: str = Field(min_length=1, max_length=64) # ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ---- class ApiTokenOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str created_at: datetime last_used_at: datetime | None = None class ApiTokenCreate(BaseModel): name: str = Field(min_length=1, max_length=120) class ApiTokenCreated(ApiTokenOut): token: str # nur einmalig beim Anlegen # ---- Barcodes ---- class BarcodeOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int code: str note: str | None = None # Nur bei Gruppen-Codes gefüllt: der Artikel, über den der Code hier steht. # Die Marke unterscheidet innerhalb einer Gruppe besser als der Name – in # der Gruppe "Mehl" heißen alle Artikel irgendwie "…mehl". product_name: str | None = None product_brand: str | None = None class ProductBarcodeOut(BaseModel): """Code, der über einen Artikel dieser Kategorie angehört. Rein informativ: Er steht am Artikel, nicht an der Kategorie. Beim Scannen findet die Suche immer zuerst den Artikel (siehe routers/products.lookup), weshalb ein gleichlautender Kategorie-Eintrag nie greifen könnte. """ code: str product_id: int product_name: str product_brand: str | None = None # ---- Auth / Users ---- class Token(BaseModel): access_token: str token_type: str = "bearer" role: Role username: str class UserOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int username: str email: str | None = None role: Role created_at: datetime class UserCreate(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=4, max_length=255) email: str | None = Field(default=None, max_length=255) role: Role = Role.user class UserUpdate(BaseModel): # Umbenennen ist gefahrlos: Sitzungen, Bewegungen, Dashboards und API-Tokens # haengen an der Benutzer-ID, nicht am Namen. username: str | None = Field(default=None, min_length=1, max_length=64) password: str | None = Field(default=None, min_length=4, max_length=255) # Leerer String heisst „Adresse entfernen" – deshalb hier nicht min_length. email: str | None = Field(default=None, max_length=255) role: Role | None = None # ---- Single Sign-on (OpenID Connect) ---- class OidcProviderOut(BaseModel): """Was ein Client braucht, um den Anmeldeknopf zu zeigen und den Flow zu starten. Bewusst oeffentlich abrufbar: die Web-Oberflaeche kennt keine Build-Variablen, und die iOS-App muss die Angaben je Serverprofil vom jeweiligen Server lernen. Ein Client-Secret steht hier nie drin. """ slug: str display_name: str discovery_url: str # Client-ID fuer die App (oeffentlicher Client, PKCE). Leer, wenn fuer diesen # Anbieter kein nativer Client eingerichtet ist. native_client_id: str | None = None native_redirect_uri: str # Scopes fuer den App-Weg (inkl. offline_access) – der Browser-Weg wird vom # Server selbst gebaut und braucht diese Angabe nicht. scopes: str class OidcIdentityOut(BaseModel): """Eine bestehende Verknuepfung des angemeldeten Kontos.""" model_config = ConfigDict(from_attributes=True) provider: str display_name: str created_at: datetime last_login_at: datetime | None = None class OidcLinkStart(BaseModel): """Adresse, zu der die Oberflaeche fuer die Verknuepfung weiterleiten soll. Der Start laeuft ueber POST und nicht als einfacher Link, weil die Berechtigung aus dem Bearer-Token der laufenden Sitzung kommt – und das laesst sich bei einer normalen Seitennavigation nicht mitschicken. """ authorization_url: str class OidcPendingOut(BaseModel): """Eine gepruefte Kennung, die noch keinem Konto gehoert. Wird der Entscheidungsseite gezeigt: „neues Konto" oder „bestehendes verknuepfen". Der Einmalcode aus der Adresse ist der Nachweis, dass die Anmeldung beim Anbieter tatsaechlich stattgefunden hat. """ provider: str display_name: str email: str | None = None username_vorschlag: str # Ob dieser Server das Anlegen neuer Konten ueberhaupt zulaesst. anlegen_erlaubt: bool class OidcPendingVerknuepfen(BaseModel): """Eigentumsnachweis fuer das bestehende Konto.""" username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=1, max_length=255) class OidcNativeLogin(BaseModel): """ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird.""" id_token: str = Field(min_length=1) # Die nonce, die die App in ihrer Anfrage verwendet hat. AppAuth prueft sie # zwar selbst, aber nur der Server-Vergleich schliesst aus, dass ein anderswo # abgegriffenes ID-Token desselben Clients hier eingereicht wird. nonce: str | None = None # ---- 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): model_config = ConfigDict(from_attributes=True) id: int name: str min_stock: float | None = None # in der aktuellen Erfassungseinheit (s.u.) min_stock_unit_id: int | None = None # Gruppen-Gebinde (Richtwert) + ob der Mindestbestand darin erfasst ist: package_size: float | None = None package_label: str | None = None min_stock_in_packages: bool = False # Obergruppen (n:m – eine Gruppe darf unter mehreren haengen) und die # direkten Untergruppen. Beide als IDs; ``_group_to_out`` setzt sie, weil # ``Group.parents``/``children`` Objekte sind. parent_ids: list[int] = [] child_ids: list[int] = [] # angereichert: # Artikel im gesamten Untergruppen-Graphen – passt zu ``stock``, das # ebenfalls transitiv rechnet. ``direct_product_count`` sind die, die # unmittelbar an dieser Gruppe haengen. product_count: int = 0 direct_product_count: int = 0 stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …) min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit kind: str | None = None # Art der Mindestbestand-Einheit barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind # EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar. product_barcodes: list[ProductBarcodeOut] = [] # Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben). location_min_stocks: list[LocationMinStockOut] = [] class GroupCreate(BaseModel): name: str = Field(min_length=1, max_length=120) min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | None = None package_size: float | None = Field(default=None, ge=0) package_label: str | None = Field(default=None, max_length=32) min_stock_in_packages: bool = False parent_ids: list[int] = [] class GroupUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | None = None package_size: float | None = Field(default=None, ge=0) package_label: str | None = Field(default=None, max_length=32) min_stock_in_packages: bool | None = None # Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen. parent_ids: list[int] | None = None # ---- Categories ---- class CategoryOut(BaseModel): """Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand).""" model_config = ConfigDict(from_attributes=True) id: int name: str parent_id: int | None = None is_builtin: bool = False # "food" = Chargen+MHD, "object" = Menge je Lagerort. tracking: CategoryTracking = CategoryTracking.food product_count: int = 0 # Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der # Oberkategorie, zuletzt vom globalen Wert. open_days: int | None = None class CategoryCreate(BaseModel): name: str = Field(min_length=1, max_length=120) parent_id: int | None = None # None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand. tracking: CategoryTracking | None = None open_days: int | None = Field(default=None, ge=1) class CategoryUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) parent_id: int | None = None tracking: CategoryTracking | None = None # 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None # heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst. open_days: int | None = Field(default=None, ge=1) # ---- 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 ---- class LocationOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: str name: str parent_id: str | None = None class LocationCreate(BaseModel): name: str = Field(min_length=1, max_length=120) parent_id: str | None = None class LocationUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) parent_id: str | None = None # ---- Gebinde (Packung, Glas, …) ---- class PackageTypeCreate(BaseModel): singular: str = Field(min_length=1, max_length=32) plural: str = Field(min_length=1, max_length=32) class PackageTypeUpdate(BaseModel): singular: str | None = Field(default=None, min_length=1, max_length=32) plural: str | None = Field(default=None, min_length=1, max_length=32) class PackageTypeOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int singular: str plural: str is_builtin: bool = False # ---- Products ---- class ProductBase(BaseModel): barcode: str | None = None name: str = Field(min_length=1, max_length=255) brand: str | None = None image_url: str | None = None base_unit: BaseUnit = BaseUnit.piece # Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit. unit_id: int | None = None package_size: float | None = Field(default=None, gt=0) # Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …) package_label: str | None = Field(default=None, max_length=32) # Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g"). # ``secondary_count`` zaehlt in der Basiseinheit des Artikels, # ``secondary_amount`` in ``secondary_base``. Alle drei oder keines. secondary_base: BaseUnit | None = None secondary_count: float | None = Field(default=None, gt=0) secondary_amount: float | None = Field(default=None, gt=0) # Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel. # None = erbt von der Kategorie, zuletzt vom globalen Wert. open_days: int | None = Field(default=None, ge=1) # Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat). date_precision: DatePrecision = DatePrecision.day group_id: int | None = None # Kategorie: nur fuer den Ueberblick, unabhaengig von der Gruppe. category_id: int | None = None min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten # Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde. min_stock_unit_id: int | None = None 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): pass class ProductUpdate(BaseModel): barcode: str | None = None name: str | None = Field(default=None, min_length=1, max_length=255) brand: str | None = None image_url: str | None = None base_unit: BaseUnit | None = None unit_id: int | None = None package_size: float | None = Field(default=None, gt=0) package_label: str | None = Field(default=None, max_length=32) # Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g"). # ``secondary_count`` zaehlt in der Basiseinheit des Artikels, # ``secondary_amount`` in ``secondary_base``. Alle drei oder keines. secondary_base: BaseUnit | None = None secondary_count: float | None = Field(default=None, gt=0) secondary_amount: float | None = Field(default=None, gt=0) # Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel. # None = erbt von der Kategorie, zuletzt vom globalen Wert. open_days: int | None = Field(default=None, ge=1) date_precision: DatePrecision | None = None group_id: int | None = None category_id: int | None = None min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | 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): model_config = ConfigDict(from_attributes=True) id: int barcode: str | None name: str brand: str | None image_url: str | None base_unit: BaseUnit display_unit_id: int | None = None package_size: float | None package_label: str | None = None # Zweiteinheit-Brücke, so wie eingegeben („3 Stück ≙ 250 g"). secondary_base: BaseUnit | None = None secondary_count: float | None = None secondary_amount: float | None = None open_days: int | None = None # Die tatsaechlich geltende Frist und woher sie kommt – damit das # Formular „geerbt: 3 Tage (Kategorie: Molkerei)" zeigen kann, ohne # die Vererbung selbst nachzubauen. effective_open_days: int | None = None open_days_source: str = "" # Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels – # damit die Oberflächen nicht selbst dividieren müssen. secondary_factor: float | None = None date_precision: DatePrecision = DatePrecision.day group_id: int | None # Name der Gruppe – wie category_name, damit die Listen danach filtern # koennen, ohne die Gruppen zusaetzlich zu laden. group_name: str | None = None category_id: int | None = None category_name: str | None = None min_stock: float | None min_stock_unit_id: int | None = None min_stock_in_packages: bool = False source: str shop_id: int | None = None product_url: str | None = None individual: bool = False bulk: bool = False created_at: datetime updated_at: datetime | None = None # angereichert: stock: float = 0.0 expired_count: int = 0 # 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 = "" unit_name: str = "" unit_factor: float = 1.0 # Mindestbestand in der erfassten Einheit (für die Anzeige): min_stock_display: float | None = None min_stock_unit_label: str = "" # Zusätzliche EAN-Codes (neben dem Haupt-Barcode): 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): found: bool existing_product: ProductOut | None = None suggestion: dict | None = None # Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet. group_id: int | None = None group_name: str | None = None # Aus der Open-Food-Facts-Kategorie abgeleiteter Vorschlag (nur Vorschlag). category_id: int | None = None category_name: str | None = None class BarcodeCreate(BaseModel): code: str = Field(min_length=4, max_length=64) note: str | None = Field(default=None, max_length=120) class BarcodeNoteUpdate(BaseModel): """Notiz zu einem Code nachtragen oder ändern.""" note: str | None = Field(default=None, max_length=120) # ---- Stock movements ---- class CheckInRequest(BaseModel): product_id: int | None = None barcode: str | None = None quantity: float = Field(gt=0) unit: str best_before: date | None = None # "month" legt das MHD auf den Monatsletzten (siehe services/dates.py). best_before_precision: DatePrecision = DatePrecision.day location_id: str | None = None note: str | None = None class CheckOutRequest(BaseModel): product_id: int | None = None barcode: str | None = None quantity: float = Field(gt=0) unit: str # Optional: gezielt aus dieser Charge abbuchen (sonst automatisch FEFO). lot_id: int | None = None note: str | None = None class LotOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int product_id: int quantity: float best_before: date | None best_before_precision: DatePrecision = DatePrecision.day location_id: str | None created_at: datetime # Angebrochen: NULL = zu. MHD und Verbrauchsdatum stehen bewusst # nebeneinander – gewarnt wird nach dem fruheren (Lot.faellig_am). opened_on: date | None = None consume_by: date | None = None opened_quantity: float | None = None class LotUpdate(BaseModel): """Korrektur einer Charge (Vertipper beim Einlagern o.ä.).""" quantity: float | None = Field(default=None, gt=0) best_before: date | None = None best_before_precision: DatePrecision | 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 opened_on: date | None = None consume_by: date | None = None opened_quantity: float | None = None class BulkLotLocation(BaseModel): """Lagerort für mehrere Chargen auf einmal setzen (oder leeren).""" lot_ids: list[int] location_id: str | None = None class BulkLotCheckout(BaseModel): """Mehrere Chargen auf einmal ausbuchen. ``quantity`` leer = jede Charge komplett. Mit Menge wird je Charge dieselbe Menge abgebucht – das ergibt nur Sinn, wenn alle zum selben Artikel gehören, sonst wäre die Einheit mehrdeutig (der Server lehnt es dann ab). """ lot_ids: list[int] quantity: float | None = Field(default=None, gt=0) unit: str | None = None note: str | None = Field(default=None, max_length=255) class BulkLotDelete(BaseModel): """Mehrere Chargen als Korrektur entfernen (ohne sie als Verbrauch zu buchen).""" lot_ids: list[int] class LotSplit(BaseModel): """Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt.""" quantity: float = Field(gt=0) location_id: str | None = None class LotOpen(BaseModel): """Eine Packung der Charge anbrechen. ``quantity`` in Basiseinheiten; leer = eine Artikeleinheit (Packung), bzw. die ganze Charge, wenn sie kleiner ist. """ quantity: float | None = Field(default=None, gt=0) class LotConsume(BaseModel): """Teilmenge aus einer Charge verbrauchen. ``unit`` nimmt alles, was auch beim Auslagern geht – Basiseinheit, verwaltete Einheit, „package" und die Hausmaße der Kategorie („EL", „Tasse"). ``auto_open`` bricht eine noch geschlossene Charge dabei an; die Rückfrage dazu stellt die Oberfläche, nicht der Server. """ quantity: float = Field(gt=0) unit: str note: str | None = Field(default=None, max_length=255) auto_open: bool = True class LotConsumeOut(BaseModel): """Was der Verbrauch bewirkt hat.""" affected_lots: list[dict] = [] # Die offene Charge danach – None, wenn sie dabei leer geworden ist. lot: LotOut | None = None # Ob die Charge durch diesen Vorgang angebrochen wurde. opened: bool = False # ---- Mealie ---- class MealieConfigIn(BaseModel): """Zugangsdaten für die einmalige Anmeldung bei Mealie. Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal an, lässt sich ein Dauertoken ausstellen und vergisst es. """ base_url: str = Field(min_length=1, max_length=255) username: str = Field(min_length=1, max_length=120) password: str = Field(min_length=1) class MealieTokenIn(BaseModel): """Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort).""" base_url: str = Field(min_length=1, max_length=255) token: str = Field(min_length=1) class MealieStatus(BaseModel): """Auskunft über die Verbindung – **nie** das Token selbst.""" configured: bool = False base_url: str = "" token_set: bool = False # Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen". reachable: bool | None = None version: str | None = None error: str | None = None class MealieAvailable(BaseModel): """Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer).""" available: bool = False class RecipeSummary(BaseModel): """Ein Rezept in der Liste.""" slug: str name: str description: str = "" image_url: str | None = None servings: float | None = None class RecipePage(BaseModel): items: list[RecipeSummary] = [] page: int = 1 per_page: int = 50 total: int = 0 class ZutatKandidat(BaseModel): art: str # product | group id: int name: str score: int class ZutatOut(BaseModel): """Eine Zutat, auf den Bestand bezogen.""" text: str menge: float | None = None einheit: str = "" # Reiner Lebensmittelname – damit die Oberfläche eine Zuordnung dafür merken kann. food: str = "" # True = so zugeordnet, weil es jemand einmal von Hand entschieden hat. manuell: bool = False art: str | None = None ziel_id: int | None = None ziel_name: str = "" kandidaten: list[ZutatKandidat] = [] benoetigt_base: float | None = None bestand_base: float | None = None fehlt_base: float | None = None basis_einheit: str = "" # vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer status: str = "kein_treffer" hinweis: str = "" fehlendes_mass: str = "" kategorie_id: int | None = None # Fehlmenge als zählbares Gebinde („2 Packungen") – wie in der Einkaufsliste. fehlt_text: str = "" class RecipeDetail(BaseModel): slug: str name: str description: str = "" image_url: str | None = None # Portionen laut Rezept und die, für die gerechnet wurde. base_servings: float | None = None servings: float | None = None skalierung_hinweis: str = "" kochbar: bool = False ingredients: list[ZutatOut] = [] # ---- Gemerkte Zutaten-Zuordnungen ---- class ZuordnungIn(BaseModel): """Eine Zutat von Hand auf einen Artikel oder eine Gruppe legen.""" food: str = Field(min_length=1, max_length=200) art: str = Field(pattern="^(product|group)$") ziel_id: int class ZuordnungOut(BaseModel): """Eine gemerkte Zuordnung, wie sie in der Liste steht.""" food_key: str food_name: str art: str ziel_id: int ziel_name: str = "" class ZielVorschlag(BaseModel): """Ein Artikel oder eine Gruppe als Ziel – die Auswahlliste beim Zuordnen.""" art: str id: int name: str # Zur Unterscheidung gleichnamiger Artikel: Marke bzw. Kategorie. zusatz: str = "" basis_einheit: str = "" # ---- Hausmaße ---- class MeasureIn(BaseModel): """Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel.""" name: str = Field(min_length=1, max_length=40) short: str | None = Field(default=None, max_length=10) position: int = 0 class MeasureOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str short: str | None = None position: int = 0 is_builtin: bool = False class MeasureAmountIn(BaseModel): """Was ein Hausmaß hier wiegt bzw. fasst: ``amount`` in ``base``.""" amount: float = Field(gt=0) base: BaseUnit class MeasureAmountOut(BaseModel): """Ein für einen Artikel (oder eine Kategorie) geltendes Maß samt Herkunft.""" measure_id: int name: str short: str | None = None amount: float base: BaseUnit # „Artikel", „Kategorie: Mehl & Zucker" oder „–" (nicht hinterlegt). herkunft: str = "" # ---- Kassenzettel-Abgleich ---- class MatchRequest(BaseModel): """Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile.""" lines: list[str] threshold: int | None = None # 0–100; ohne Angabe gilt die Einstellung class MatchCandidate(BaseModel): product_id: int name: str brand: str | None = None score: int # 0–100, Ähnlichkeit zur Kassenzeile # Für die Einheit beim Einlagern (ganze Gebinde bzw. Basiseinheit) – so muss # die App den Artikel nicht noch einmal einzeln laden. package_size: float | None = None base_unit: str class MatchLine(BaseModel): text: str candidates: list[MatchCandidate] = [] class CheckInResponse(BaseModel): lot: LotOut product_stock: float class CheckInLine(BaseModel): """Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD).""" quantity: float = Field(gt=0) best_before: date | None = None best_before_precision: DatePrecision = DatePrecision.day location_id: str | None = None class BatchCheckInRequest(BaseModel): product_id: int | None = None barcode: str | None = None unit: str lines: list[CheckInLine] = Field(min_length=1) note: str | None = None class BatchCheckInResponse(BaseModel): lots: list[LotOut] product_stock: float class CheckOutResponse(BaseModel): affected_lots: list[dict] 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 LoanCreate(BaseModel): """Ein Einzelstück verleihen. Nur der Name ist Pflicht.""" borrower: str = Field(min_length=1, max_length=120) reason: str | None = Field(default=None, max_length=255) # Leer = heute; rückdatieren erlaubt (man trägt es oft später nach). lent_on: date | None = None # Erwartete Rückgabe – optional, siehe ItemLoan. due_on: date | None = None # Zubehörteile, die mitgehen. Leer = nur das Gerät selbst. Die Oberfläche # fragt danach; der Server nimmt nie ungefragt etwas mit. mit_zubehoer: list[int] = [] class LoanUpdate(BaseModel): """Laufenden oder abgeschlossenen Verleih korrigieren (Tippfehler, Datum).""" borrower: str | None = Field(default=None, min_length=1, max_length=120) reason: str | None = Field(default=None, max_length=255) lent_on: date | None = None due_on: date | None = None returned_on: date | None = None class LoanReturn(BaseModel): """Rückgabe buchen. Leeres Datum = heute.""" returned_on: date | None = None # Zubehörteile, die mit zurückkommen. Spiegelbild zu LoanCreate: ohne das # käme das Gerät zurück und sein Netzteil bliebe ewig „verliehen". mit_zubehoer: list[int] = [] class LoanOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int item_id: int borrower: str reason: str | None = None lent_on: date due_on: date | None = None returned_on: date | None = None created_at: datetime # Wer herausgegeben hat (Konto kann später gelöscht sein). username: str | None = None # Tage über dem Rückgabedatum; None ohne Datum oder wenn noch nicht fällig. # Vom Server gerechnet, damit App und Web nicht getrennt rechnen. days_overdue: int | None = None # Für die Übersicht „was ist gerade draußen" mitgeliefert: item_uid: str | None = None product_id: int | None = None product_name: str | None = None product_brand: str | None = None location_id: str | None = None location_name: str | None = None class ItemKurz(BaseModel): """Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen. Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes Teil wieder sein Geraet nach, endlos. """ model_config = ConfigDict(from_attributes=True) id: int uid: str product_id: int product_name: str | None = None product_brand: str | None = None location_name: str | None = None # Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann. verliehen: bool = False class ZubehoerZuordnen(BaseModel): """Welches Stueck als Zubehoer an dieses Geraet gehaengt wird.""" child_item_id: int class ItemOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int 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] = [] # Der offene Verleih, falls das Stück gerade draußen ist – sonst None. # Der Lagerort bleibt daneben stehen: dorthin gehört es zurück. loan: LoanOut | None = None # Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses # Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen. zubehoer: list[ItemKurz] = [] gehoert_zu: ItemKurz | None = None # ---- Etiketten-Vorlagen ---- class SpalteOut(BaseModel): """Eine waehlbare Spalte – fest oder ein eigenes Feld.""" schluessel: str titel: str class EtikettenVorlageOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int name: str art: str position: int # Aus dem JSON-Text der Konfiguration geholt, damit die Oberflaeche nicht # selbst parsen muss. spalten: list[str] = [] kategorie_ids: list[int] = [] trennzeichen: str = "," # Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt. datei_name: str | None = None updated_at: datetime class EtikettenVorlageCreate(BaseModel): name: str = Field(min_length=1, max_length=80) art: str = Field(default="items", pattern="^(items|products|locations)$") spalten: list[str] = [] kategorie_ids: list[int] = [] trennzeichen: str = "," class EtikettenVorlageUpdate(BaseModel): """Nur mitgeschickte Felder zaehlen.""" name: str | None = Field(default=None, min_length=1, max_length=80) art: str | None = Field(default=None, pattern="^(items|products|locations)$") spalten: list[str] | None = None kategorie_ids: list[int] | None = None trennzeichen: str | None = None position: int | None = None # ---- Views ---- class ShoppingNeed(BaseModel): """Fehlmenge als Gebinde-Leitangabe – bei zaehlbaren Packungen (Glas/Dose) auf ganze aufgerundet, weil man nur ganze Gebinde kauft – mit der Basiseinheit als kleinem Hinweis. So bekommen auch API-Konsumenten direkt „1 Glas (500 g)", ohne selbst Packungsgroessen umzurechnen.""" text: str # Leitangabe: "1 Glas", "4 Dosen", "500 g" hint: str = "" # Basiseinheit klein: "500 g" / "2 l" – leer wenn identisch count: float # aufgerundete Gebindezahl (zum Weiterrechnen) label: str # zur count passendes, pluralisiertes Label singular: str # Einzahl-Label ("Glas", "Gramm", …) is_package: bool = False # zaehlbares Gebinde (Glas/Dose) vs. Basis-/Anzeigeeinheit base_amount: float | None = None base_unit: BaseUnit | None = None stock: float # Bestand in derselben Leitangabe min_stock: float # Mindestbestand in derselben Leitangabe class ShoppingItem(BaseModel): product_id: int name: str base_unit: BaseUnit package_size: float | None = None need: ShoppingNeed | None = None stock: float min_stock: float deficit: float class ExpiringItem(BaseModel): lot_id: int product_id: int product_name: str quantity: float # in Basiseinheiten base_unit: BaseUnit # Darf fehlen: Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein # Verbrauchsdatum – und genau die soll hier auftauchen. best_before: date | None = None best_before_precision: DatePrecision = DatePrecision.day # Angebrochen: Öffnungstag und das daraus gerechnete Verbrauchsdatum. opened_on: date | None = None consume_by: date | None = None # Das Datum, nach dem gewarnt wird (das frühere der beiden), und welches es # ist – damit die Anzeige „läuft ab" von „angebrochen" unterscheiden kann. warn_date: date warn_source: str = "best_before" # best_before | consume_by days_left: int # Für die Anzeige in Artikeleinheiten: package_size: float | None = None package_label: str | None = None unit_name: str = "" unit_factor: float = 1.0 class GroupShoppingItem(BaseModel): group_id: int name: str stock: float min_stock: float deficit: float unit_name: str = "" product_count: int # Wie viele Untergruppen mitgezaehlt werden (0 = keine). subgroup_count: int = 0 need: ShoppingNeed | None = None class LocationNeedProduct(BaseModel): """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] = [] # ---- Gespeicherte Einkaufsliste ---- class ShoppingEntryIn(BaseModel): """Ein eigener Eintrag. Genau eines von Artikel, Gruppe, Freitext.""" product_id: int | None = None group_id: int | None = None free_text: str | None = Field(default=None, max_length=120) quantity_base: float | None = Field(default=None, gt=0) base: BaseUnit | None = None class ShoppingEntryPatch(BaseModel): """Abhaken oder die Menge korrigieren.""" done: bool | None = None quantity_base: float | None = Field(default=None, gt=0) free_text: str | None = Field(default=None, max_length=120) class ShoppingEntryOut(BaseModel): """Ein gespeicherter Eintrag, fertig für die Liste.""" id: int art: str # product | group | text product_id: int | None = None group_id: int | None = None free_text: str | None = None name: str = "" quantity_base: float | None = None base: BaseUnit | None = None # „2 Packungen" bzw. „150 g" – dieselbe Sprache wie die gerechneten Posten. menge_text: str = "" source: str = "manual" # manual | recipe recipe_slug: str | None = None recipe_name: str | None = None done: bool = False created_at: datetime class ShoppingFromRecipe(BaseModel): """Was die Übernahme eines Rezepts bewirkt hat.""" entries: list[ShoppingEntryOut] = [] # Wie viele davon schon dastanden und nur neu gerechnet wurden. aktualisiert: int = 0 # Zutaten ohne Zuordnung, die als Freitext draufkamen. freitext: int = 0 class ShoppingListAll(BaseModel): """Komplette Einkaufsliste in einem Aufruf – die gerechneten Quellen, die eigenen Einträge und die dauerhaft gesetzten Haken.""" products: list[ShoppingItem] = [] groups: list[GroupShoppingItem] = [] by_location: list[LocationNeeds] = [] # Eigene und aus Rezepten übernommene Einträge. entries: list[ShoppingEntryOut] = [] # Schlüssel der abgehakten GERECHNETEN Posten („p12", „g3", „lKUECHEp12"). checked: list[str] = [] class LocationContentEntry(BaseModel): """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): id: int product_id: int product_name: str type: str quantity: float # in Basiseinheiten base_unit: BaseUnit unit_used: str username: str | None note: str | None created_at: datetime # Für die Anzeige in Artikeleinheiten: package_size: float | None = None package_label: str | None = None unit_name: str = "" unit_factor: float = 1.0 class SettingOut(BaseModel): key: str value: str # ---- Startseite ---- class DashboardLayoutIn(BaseModel): """Anordnung der Karten. Die Felder entsprechen react-grid-layout.""" layout: list[dict] class DashboardCreate(BaseModel): name: str = Field(min_length=1, max_length=80) layout: list[dict] = [] class DashboardUpdate(BaseModel): """Umbenennen und/oder umbauen – nur mitgeschickte Felder zaehlen.""" name: str | None = Field(default=None, min_length=1, max_length=80) layout: list[dict] | None = None position: int | None = None class DashboardOut(BaseModel): id: int name: str position: int layout: list[dict] class DashboardListOut(BaseModel): dashboards: list[DashboardOut] # Woher die Anordnung stammt: eigene, Admin-Vorgabe oder eingebaut. source: str # Ist die Vorgabe verbindlich, darf der Benutzer nicht umbauen. enforced: bool has_default: bool class DashboardLayoutOut(BaseModel): layout: list[dict] source: str enforced: bool has_default: bool class DashboardStats(BaseModel): products_in_stock: int # Artikel mit Bestand > 0 article_units: float # Summe der Artikeleinheiten expiring_soon: int # Chargen innerhalb der Warnfrist (nicht abgelaufen) expired: int # bereits abgelaufene Chargen shopping_items: int # Produkte + Gruppen unter Mindestbestand products_total: int class ExpirySplit(BaseModel): """Artikeleinheiten je Ablaufzustand – Grundlage für Ring und Säulen.""" ok: float soon: float expired: float # Angebrochen und noch nicht in der Warnfrist. Was offen ist UND bald # abläuft, zählt als "soon" – Dringlichkeit schlägt Zustand. opened: float = 0.0 no_date: float # Chargen ohne MHD zählen separat, nicht als "ok" class CategoryShare(BaseModel): category_id: int | None name: str article_units: float ok: float soon: float expired: float opened: float = 0.0 no_date: float class TimelinePoint(BaseModel): # Zeitpunkt statt Datum: Bei kurzen Zeiträumen wird stündlich abgetastet, # damit sichtbar wird, *wann* am Tag ein- und ausgelagert wurde. at: datetime article_units: float class ActivityPoint(BaseModel): at: datetime checked_in: int checked_out: int class FlowPoint(BaseModel): """Ein Abschnitt eines Wasserfalls: Anfangsbestand, Zu- und Abgang. Alle Werte sind Artikeleinheiten und liegen damit auf derselben Skala wie die Bestandslinie – nur so darf beides in ein Diagramm. ``opening + checked_in - checked_out`` ergibt den Endbestand. """ at: datetime opening: float checked_in: float checked_out: float