Files
Vorrania/backend/app/schemas.py
T
ScarriffleandClaude Opus 5 c741df5963 Etiketten-Vorlagen: gespeicherte Exporte mit frei waehlbaren Spalten
Der Etikettenexport konnte genau eine Sache, und zwar unveraenderlich: sechs
feste Spalten ueber Einzelstuecke. Was fehlte, war genau das, was ein Etikett
brauchbar macht - die eigenen Felder. Eine Kabellaenge oder die Kapazitaet einer
Powerbank steht laengst am Artikel, kam aber in keiner CSV vor. Und jede
Zusammenstellung war beim naechsten Mal wieder weg: Kategorien im React-State,
Trennzeichen im localStorage, Spalten hartkodiert.

Jetzt gibt es benannte Vorlagen mit eigener Spaltenwahl und Reihenfolge, fuer
Einzelstuecke, Artikel und Lagerorte. Eine Tabelle nach dem Vorbild von
DashboardLayout: benannt, Nutzlast als JSON-Text. Ohne user_id - eine Vorlage
gehoert allen, nicht einem Konto.

Spaltenschluessel fuer eigene Felder ist die ID, nicht der key: FieldDefinition.key
ist nur JE KATEGORIE eindeutig, zwei Kategorien duerfen beide "kapazitaet" fuehren.

Die CSV entsteht jetzt im Server statt im Browser. Mit waehlbaren Spalten,
eigenen Feldern und drei Bezugsarten gehoert das an eine Stelle - und zwar
dorthin, wo es sich pruefen laesst. Formel-Injektionsschutz, BOM und CRLF sind
aus der Browser-Fassung mitgenommen. Der Grund, aus dem der QR-Inhalt bisher im
Browser entstand ("nur die Oberflaeche kennt die oeffentliche Adresse"), gilt
allein fuer Lagerorte; dafuer reicht sie ihre Herkunft als Parameter durch.

Die beiden alten Abschnitte weichen den Vorlagen. Beim ersten Start entstehen
zwei mitgelieferte, die genau die bisherigen Spalten fuehren - wer nichts
einstellt, bekommt dasselbe Ergebnis wie vorher.

Dazu laesst sich eine P-touch-Datei hinterlegen. Geprueft wird ueber Endung und
ZIP-Inhalt (label.xml), nicht ueber den Content-Type: Fuer .lbx meldet der
Browser meist application/octet-stream. Eine .lbx merkt sich den Pfad zu ihrer
Datenquelle - auf einem fremden Rechner muss die CSV in P-touch einmal neu
verknuepft werden. Das laesst sich von hier aus nicht loesen; die Datei
dabeizuhaben erspart trotzdem das Suchen.

Sicherung auf Version 4: Vorlagen kommen mit, Kategorien als Pfad und eigene
Felder als Label, weil IDs zwischen zwei Instanzen nicht dieselben sind. Die
.lbx als Base64 - das erste Binaerformat in dieser Sicherung, vertretbar bei
wenigen Kilobyte und einer Vorlage, die ohne ihre Datei nur eine halbe waere.

23 neue Tests (343 gesamt), dazu im Browser durchgespielt: Kabel-Kategorie mit
eigenem Feld, Spalte hinzugefuegt, CSV mit "Länge (m)" und dem Wert, .lbx
hoch- und wieder heruntergeladen (Pruefsumme identisch), PNG und Nicht-ZIP
sauber abgelehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 16:07:49 +02:00

1154 lines
37 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
# ---- 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
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