Einzelstuecke hatten bisher keinen Zustand - der Lagerort sagt, wohin ein
Stueck gehoert, aber nicht, woran es gerade ist. Neue Tabelle item_usages
nach dem Vorbild von item_loans: ein Zeitraum je Zustand, ended_at NULL
heisst "laeuft noch", und ein Teilindex laesst nur einen laufenden zu.
"Verfuegbar" wird bewusst nicht gespeichert - keine offene Zeile IST
verfuegbar, wie bei lots.opened_on und beim Verleih. Damit kostet der
Ruhezustand nichts und die Tabelle fuellt sich nicht mit Rauschen.
Abgeschlossene Zeilen bleiben stehen, sie sind der Verlauf.
Eigener Endpunkt (PUT /items/{id}/verwendung) und ausdruecklich nicht
PATCH /items/{id}: Das ist Admins vorbehalten, und die App schickt dort
immer alle Felder mitsamt null - jedes Speichern wuerde den Status
stillschweigend zuruecksetzen. Umstellen darf deshalb jeder angemeldete
Benutzer, wie Verleihen.
Der Zustand aendert den Bestand nicht und schreibt keine Bewegung: ein
getragenes Hemd ist weiterhin im Haushalt, anders als ein verliehenes.
Sichtbar nur bei Kategorien mit usage_tracking. Ein Haken an einer
Oberkategorie wandert in den Unterbaum, weil die Garderobe als
Kategoriebaum ja schon existiert. Die Markierung fahrt in beiden
Sicherungswegen mit; der Verlauf nicht, weil Einzelstuecke dort
ueberhaupt nicht vorkommen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1519 lines
50 KiB
Python
1519 lines
50 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,
|
||
UsageStatus,
|
||
)
|
||
|
||
|
||
# ---- 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
|
||
# Was gelten wuerde, wenn ``open_days`` leer bleibt – samt Herkunft
|
||
# („Kategorie: Molkerei" / „Einstellungen"). Fuer den Platzhalter in der
|
||
# Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein.
|
||
inherited_open_days: int = 0
|
||
open_days_source: str = ""
|
||
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren.
|
||
usage_tracking: bool = False
|
||
|
||
|
||
class CategoryCreate(BaseModel):
|
||
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)
|
||
# None: erbt vom Elternteil bzw. neue Oberkategorie = aus.
|
||
usage_tracking: bool | None = None
|
||
|
||
|
||
class CategoryUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
parent_id: int | None = None
|
||
tracking: CategoryTracking | None = None
|
||
# 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None
|
||
# heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst.
|
||
open_days: int | None = Field(default=None, ge=1)
|
||
# Wird beim Speichern in den ganzen Unterbaum uebernommen, siehe
|
||
# routers/categories.py::update_category.
|
||
usage_tracking: bool | None = None
|
||
|
||
|
||
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
|
||
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 VerwendungSetzen(BaseModel):
|
||
"""Verwendungsstatus eines Einzelstuecks setzen.
|
||
|
||
``status`` leer heisst verfuegbar – dafuer gibt es keinen eigenen Wert, es
|
||
wird nur der laufende Zustand beendet (siehe models.py::UsageStatus).
|
||
"""
|
||
status: UsageStatus | None = None
|
||
|
||
|
||
class VerwendungOut(BaseModel):
|
||
"""Ein Zeitraum in einem Verwendungszustand – laufend oder abgeschlossen."""
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
item_id: int
|
||
status: UsageStatus
|
||
started_at: datetime
|
||
# None = laeuft noch.
|
||
ended_at: datetime | None = None
|
||
# Wer umgestellt hat (Konto kann spaeter geloescht sein).
|
||
username: str | None = None
|
||
|
||
|
||
class ItemKurz(BaseModel):
|
||
"""Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen.
|
||
|
||
Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes
|
||
Teil wieder sein Geraet nach, endlos.
|
||
"""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
uid: str
|
||
product_id: int
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
location_name: str | None = None
|
||
# Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann.
|
||
verliehen: bool = False
|
||
|
||
|
||
class ZubehoerZuordnen(BaseModel):
|
||
"""Welches Stueck als Zubehoer an dieses Geraet gehaengt wird."""
|
||
|
||
child_item_id: int
|
||
|
||
|
||
class ItemOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
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
|
||
# Der laufende Verwendungszustand – None heisst verfuegbar. Anders als beim
|
||
# Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg.
|
||
verwendung: VerwendungOut | None = None
|
||
# Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt.
|
||
# Steuert, ob die Oberflaeche die Bedienung dafuer zeigt.
|
||
verwendung_moeglich: bool = False
|
||
# Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses
|
||
# Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen.
|
||
zubehoer: list[ItemKurz] = []
|
||
gehoert_zu: ItemKurz | None = None
|
||
|
||
|
||
# ---- Etiketten-Vorlagen ----
|
||
class SpalteOut(BaseModel):
|
||
"""Eine waehlbare Spalte – fest oder ein eigenes Feld."""
|
||
|
||
schluessel: str
|
||
titel: str
|
||
# Nur bei eigenen Feldern gefuellt. Die Oberflaeche braucht beides, um beim
|
||
# Wertefilter die passende Eingabe anzubieten (Checkbox, Auswahl, Zahl).
|
||
typ: str = ""
|
||
optionen: list[str] = []
|
||
|
||
|
||
class FeldFilter(BaseModel):
|
||
"""Eine Bedingung auf einem eigenen Feld.
|
||
|
||
Mehrere Bedingungen muessen alle zutreffen.
|
||
|
||
``op`` ist die Vergleichsart. Vorgabe ist ``=``, damit aeltere Vorlagen
|
||
ohne Angabe unveraendert weiterlaufen:
|
||
|
||
* ``=`` / ``!=`` – genau gleich bzw. ungleich, ohne Ruecksicht auf
|
||
Gross-/Kleinschreibung. Ein leerer ``wert`` trifft bei ``=`` die Zeilen
|
||
OHNE Wert in diesem Feld.
|
||
* ``>=`` / ``<=`` – nur bei Zahlenfeldern sinnvoll; Zeilen ohne Wert oder
|
||
mit unlesbarer Zahl treffen nie.
|
||
"""
|
||
|
||
feld_id: int
|
||
op: str = Field(default="=", pattern=r"^(=|!=|>=|<=)$")
|
||
wert: str = ""
|
||
|
||
|
||
class EtikettenVorlageOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
art: str
|
||
position: int
|
||
# Aus dem JSON-Text der Konfiguration geholt, damit die Oberflaeche nicht
|
||
# selbst parsen muss.
|
||
spalten: list[str] = []
|
||
kategorie_ids: list[int] = []
|
||
trennzeichen: str = ","
|
||
feldfilter: list[FeldFilter] = []
|
||
# Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt.
|
||
datei_name: str | None = None
|
||
updated_at: datetime
|
||
|
||
|
||
class EtikettenVorlageCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=80)
|
||
art: str = Field(default="items", pattern="^(items|products|locations)$")
|
||
spalten: list[str] = []
|
||
kategorie_ids: list[int] = []
|
||
trennzeichen: str = ","
|
||
feldfilter: list[FeldFilter] = []
|
||
|
||
|
||
class EtikettenVorlageUpdate(BaseModel):
|
||
"""Nur mitgeschickte Felder zaehlen."""
|
||
|
||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||
art: str | None = Field(default=None, pattern="^(items|products|locations)$")
|
||
spalten: list[str] | None = None
|
||
kategorie_ids: list[int] | None = None
|
||
trennzeichen: str | None = None
|
||
feldfilter: list[FeldFilter] | None = None
|
||
position: int | None = None
|
||
|
||
|
||
# ---- Views ----
|
||
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
|