Die Einkaufsliste war vollstaendig GERECHNET: Artikel unter ihrem
Mindestbestand, Gruppen unter ihrem, Bedarfe je Lagerort. Das bleibt, es
ist die Staerke der Liste. Was fehlte, war ein Ort fuer "Milch gehoert
drauf, weil ich das sage" - und damit auch fuer "diese Zutaten fehlen mir
fuer das Rezept". Der Knopf konnte nirgends hinschreiben.
Zwei Tabellen dafuer, und die Trennung ist der Kern:
shopping_entries haelt, was jemand bewusst draufgesetzt hat. Genau eines
von Artikel, Gruppe oder Freitext - der Freitext ist unverzichtbar, denn
eine Rezeptzutat ohne Artikel ("Ras el Hanout") kommt nur so auf den
Zettel, und gerade die unbekannte Zutat ist die, die man vergisst.
shopping_checks haelt nur den HAKEN an einem gerechneten Posten, nicht
den Posten. Die gerechneten Vorschlaege duerfen keine Zeile bekommen: Sie
aendern sich mit jedem Einlagern, eine materialisierte Kopie waere sofort
falsch. Abhaken ging deshalb bisher nur im Browser und war nach dem
Neuladen weg - mitten im Supermarkt.
Aus einem Rezept uebernommen wird die FEHLMENGE, nicht die Rezeptmenge:
Wer 250 g braucht und 100 g hat, kauft 150 g. Gerechnet wird frisch mit
der Portionszahl, die auf dem Schirm steht. Je Rezept und Ziel gibt es
hoechstens einen offenen Eintrag - ein zweiter Druck nach dem Hochdrehen
der Portionen rechnet die Menge neu, statt die Liste zu verdoppeln. Ueber
verschiedene Rezepte hinweg bleiben es getrennte Zeilen, denn derselbe
Artikel kann zweimal fehlen. Ein abgehakter Eintrag blockiert nichts -
abgehakt heisst gekauft.
Mengen formatiert dieselbe Funktion wie beim Rezeptabgleich, damit die
Liste eine Sprache spricht: "2 Packungen" statt "1500 ml", aufgerundet,
weil man keine halbe Packung kauft.
In der Oberfläche eine eigene Karte "Selbst draufgesetzt" mit Eingabefeld,
Herkunftsangabe ("fuer Pfannkuchen") und "Erledigte entfernen", das beide
Quellen zusammen aufraeumt - fuer den Nutzer ist es eine Liste. Das
Abhaken wandert sofort und speichert im Hintergrund; schlaegt es fehl,
springt der Haken zurueck. Die Seite holt jetzt alles mit einem Aufruf
statt mit drei.
Von Hand geprueft gegen eine Mealie-Attrappe: Pfannkuchen fuer 48
Portionen ergibt 2 Packungen Mehl, 2 Packungen Milch, 3 Packungen Eier
und "Ras el Hanout" als Notiz; derselbe Knopf bei 96 Portionen
aktualisiert diese vier statt acht daraus zu machen; ein Haken
ueberlebt das Neuladen. 17 neue Tests, Suite bei 512.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1451 lines
47 KiB
Python
1451 lines
47 KiB
Python
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
|