Files
Vorrania/backend/app/schemas.py
T
ScarriffleandClaude Opus 5 a3fd9819c4 Rueckgabe sichtbar machen, Zubehoerteile am Einzelstueck
RUECKGABE
Der Knopf war da, nur nicht zu finden: ein 16-px-Symbol in einer Spalte ohne
Ueberschrift, 52 px breit, ganz rechts in einer horizontal scrollenden Tabelle.
Beschriftet war er allein ueber title= - ein Tooltip, den auf einem Touchgeraet
nie jemand sieht.

Jetzt traegt er Text ("Zuruecknehmen"), die Spalte heisst "Verleih" und ist
breit genug, und beide Knoepfe haben ein aria-label. Dazu eine neue Seite
"Verliehen" in der Seitenleiste: alles Draussene an einem Ort, Rueckgabe direkt
aus der Liste. Bisher gab es diese Uebersicht nur als zwei Dashboard-Karten, und
die stehen nicht im Standard-Layout - wer sie nicht selbst hinzufuegte, hatte
gar keine.

In der App fuehrte der einzige Weg ueber das Bearbeiten-Formular des Stuecks;
jetzt geht es per Wischgeste und Kontextmenue direkt aus der Liste. Der
Verleih-Filter dort sass hinter einem nackten Symbol und ist beschriftet.

Zurueckgenommen wird nur noch nach Rueckfrage, in Web und App.

ZUBEHOER
Ein Netzteil gehoert zu einem Laptop: items.parent_item_id, genau ein Geraet je
Teil, beliebig viele Teile je Geraet. Eine Spalte genuegt dafuer, es braucht
keine Verknuepfungstabelle. ON DELETE SET NULL, damit das Netzteil den Laptop
ueberlebt statt mitgeloescht zu werden - und weil SQLite Fremdschluessel nicht
erzwingt, loest der Router die Zuordnung beim Loeschen zusaetzlich selbst.

Ringe (A traegt B, B traegt A) weist die Zuordnung mit 409 ab; die Elternkette
wird dafuer hochgelaufen, wie beim Kategoriebaum.

Die Beziehung laeuft ueber eigene Endpunkte und bewusst NICHT ueber ItemUpdate:
dessen Schema sendet immer alle Felder mit, auch leere, damit sich Felder
loeschen lassen. Genau so gingen schon einmal Kaufpreis und Waehrung verloren,
als ein Aufrufer ein Feld vergass.

Beim Verleihen fragt die Oberflaeche, welche Teile mitgehen (vorausgewaehlt:
alles, was im Haus ist), beim Zuruecknehmen kommen sie zusammen zurueck. Ohne
das Spiegelbild kaeme das Geraet zurueck und sein Netzteil bliebe ewig
"verliehen", weil niemand mehr danach sucht. Ungefragt nimmt der Server nie
etwas mit.

In der App sind zubehoer und gehoert_zu optional dekodiert: Die App spricht mit
mehreren Serverprofilen, und ein aelterer Server schickt die Felder nicht - ein
Vorgabewert reicht dafuer nicht, Swift nimmt ihn beim Dekodieren nicht her.

12 neue Tests (320 gesamt). Der Umlauf ist ausserdem gegen ein lokales Backend
im Browser durchgespielt: verleihen mit Zubehoer, Rueckfrage, beide zusammen
zurueck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 14:08:11 +02:00

1111 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
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
class CategoryUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
parent_id: int | None = None
tracking: CategoryTracking | None = None
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
class ShopOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
website: str | None = None
product_count: int = 0
class ShopCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
class ShopUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
# ---- Selbst definierte Felder je Kategorie ----
class FieldDefinitionBase(BaseModel):
label: str = Field(min_length=1, max_length=120)
field_type: FieldType = FieldType.text
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None # nur für field_type == "select"
required: bool = False
position: int = 0
class FieldDefinitionCreate(FieldDefinitionBase):
category_id: int
class FieldDefinitionUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=120)
field_type: FieldType | None = None
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None
required: bool | None = None
position: int | None = None
class FieldDefinitionOut(BaseModel):
id: int
category_id: int
label: str
key: str
field_type: FieldType
unit: str | None = None
options: list[str] = []
required: bool = False
position: int = 0
is_builtin: bool = False
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
inherited: bool = False
# ---- Locations ----
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)
# 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)
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
# 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
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
class BulkLotLocation(BaseModel):
"""Lagerort für mehrere Chargen auf einmal setzen (oder leeren)."""
lot_ids: list[int]
location_id: str | None = None
class LotSplit(BaseModel):
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
quantity: float = Field(gt=0)
location_id: str | None = None
# ---- Kassenzettel-Abgleich ----
class MatchRequest(BaseModel):
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
lines: list[str]
threshold: int | None = None # 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
# ---- 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
best_before: date
best_before_precision: DatePrecision = DatePrecision.day
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] = []
class ShoppingListAll(BaseModel):
"""Komplette Einkaufsliste in einem Aufruf – dieselben drei Quellen, die auch
die Oberfläche zusammenführt."""
products: list[ShoppingItem] = []
groups: list[GroupShoppingItem] = []
by_location: list[LocationNeeds] = []
class LocationContentEntry(BaseModel):
"""Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten)."""
product_id: int
name: str
brand: str | None = None
stock: float
unit_label: str = ""
individual: bool = False
class LocationContents(BaseModel):
"""Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte."""
location_id: str
location_name: str
products: list[LocationContentEntry] = []
class MovementOut(BaseModel):
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
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
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