Der unscharfe Abgleich muss raten und wird das immer tun: "Lime Curd" hat
mit "Zitronencreme" kein Wort gemeinsam, und manchmal liegt er daneben.
Bisher war das eine Sackgasse - die Zutat blieb unzugeordnet, bei jedem
Aufruf neu.
Jetzt traegt jede Zutatenzeile einen Knopf "Zuordnen". Entschieden wird
gegen Artikel UND Gruppen, gemerkt unter dem normalisierten Namen des
Lebensmittels: "Schlagsahne", "schlagsahne" und "SCHLAGSAHNE " treffen
dieselbe Zeile. Ab dann gilt die Entscheidung fuer jedes Rezept, in dem
diese Zutat vorkommt - eine Zutat wird hoechstens einmal zugeordnet.
Schluessel ist der Name, nicht die Mealie-ID: IDs sind UUIDs, die beim
Neuanlegen eines Lebensmittels wechseln, und nur ueber den Namen greift
die Zuordnung auch fuer Zutaten ohne verknuepftes Lebensmittel ("2 Eier").
Im Auswahldialog stehen oben die Vorschlaege, die der Abgleich selbst
gefunden hat - meist ist der Richtige darunter, nur nicht an erster
Stelle -, darunter die freie Suche ueber alles. Die Basiseinheit steht
dabei, damit nicht wieder Milliliter auf ein Gramm-Produkt wandern.
Beim Raten bleiben Gegenstaende aussen vor; von Hand darf alles gewaehlt
werden ausser Einzelstuecken. Backpapier ist kein Lebensmittel und steht
trotzdem in Rezepten, waehrend ein Einzelstueck nie eine Zutat ist.
Zeigt das Ziel ins Leere, weil der Artikel geloescht wurde, faellt die
Zeile beim Laden still heraus: Auf SQLite greift kein Fremdschluessel,
und ein geloeschter Artikel darf kein Rezept mehr sprengen.
Die Zuordnungs-Routen stehen bewusst vor /recipes/{slug}, sonst liest
FastAPI "zuordnungen" als Rezept-Slug und fragt Mealie danach. Ein Test
wacht ueber die Reihenfolge, weil ein Umsortieren der Datei den Fehler
sonst still einbauen wuerde.
16 neue Tests, Suite bei 487.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1402 lines
45 KiB
Python
1402 lines
45 KiB
Python
from __future__ import annotations
|
||
|
||
from datetime import date, datetime
|
||
|
||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||
|
||
from .models import (
|
||
BaseUnit,
|
||
CategoryTracking,
|
||
DatePrecision,
|
||
FieldType,
|
||
RemovalReason,
|
||
Role,
|
||
UnitKind,
|
||
)
|
||
|
||
|
||
# ---- Units ----
|
||
class UnitOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
kind: UnitKind
|
||
factor: float
|
||
is_builtin: bool
|
||
|
||
|
||
class UnitCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=64)
|
||
kind: UnitKind
|
||
factor: float = Field(gt=0)
|
||
|
||
|
||
class UnitUpdate(BaseModel):
|
||
"""Nur der Name. Art und Faktor bleiben fest – sie stecken bereits in
|
||
umgerechneten Bestaenden, eine Aenderung wuerde die still verfaelschen."""
|
||
|
||
name: str = Field(min_length=1, max_length=64)
|
||
|
||
|
||
# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ----
|
||
class ApiTokenOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
created_at: datetime
|
||
last_used_at: datetime | None = None
|
||
|
||
|
||
class ApiTokenCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
|
||
|
||
class ApiTokenCreated(ApiTokenOut):
|
||
token: str # nur einmalig beim Anlegen
|
||
|
||
|
||
# ---- Barcodes ----
|
||
class BarcodeOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
code: str
|
||
note: str | None = None
|
||
# Nur bei Gruppen-Codes gefüllt: der Artikel, über den der Code hier steht.
|
||
# Die Marke unterscheidet innerhalb einer Gruppe besser als der Name – in
|
||
# der Gruppe "Mehl" heißen alle Artikel irgendwie "…mehl".
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
|
||
|
||
class ProductBarcodeOut(BaseModel):
|
||
"""Code, der über einen Artikel dieser Kategorie angehört.
|
||
|
||
Rein informativ: Er steht am Artikel, nicht an der Kategorie. Beim Scannen
|
||
findet die Suche immer zuerst den Artikel (siehe routers/products.lookup),
|
||
weshalb ein gleichlautender Kategorie-Eintrag nie greifen könnte.
|
||
"""
|
||
code: str
|
||
product_id: int
|
||
product_name: str
|
||
product_brand: str | None = None
|
||
|
||
|
||
# ---- Auth / Users ----
|
||
class Token(BaseModel):
|
||
access_token: str
|
||
token_type: str = "bearer"
|
||
role: Role
|
||
username: str
|
||
|
||
|
||
class UserOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
username: str
|
||
email: str | None = None
|
||
role: Role
|
||
created_at: datetime
|
||
|
||
|
||
class UserCreate(BaseModel):
|
||
username: str = Field(min_length=1, max_length=64)
|
||
password: str = Field(min_length=4, max_length=255)
|
||
email: str | None = Field(default=None, max_length=255)
|
||
role: Role = Role.user
|
||
|
||
|
||
class UserUpdate(BaseModel):
|
||
# Umbenennen ist gefahrlos: Sitzungen, Bewegungen, Dashboards und API-Tokens
|
||
# haengen an der Benutzer-ID, nicht am Namen.
|
||
username: str | None = Field(default=None, min_length=1, max_length=64)
|
||
password: str | None = Field(default=None, min_length=4, max_length=255)
|
||
# Leerer String heisst „Adresse entfernen" – deshalb hier nicht min_length.
|
||
email: str | None = Field(default=None, max_length=255)
|
||
role: Role | None = None
|
||
|
||
|
||
# ---- Single Sign-on (OpenID Connect) ----
|
||
class OidcProviderOut(BaseModel):
|
||
"""Was ein Client braucht, um den Anmeldeknopf zu zeigen und den Flow zu starten.
|
||
|
||
Bewusst oeffentlich abrufbar: die Web-Oberflaeche kennt keine Build-Variablen,
|
||
und die iOS-App muss die Angaben je Serverprofil vom jeweiligen Server lernen.
|
||
Ein Client-Secret steht hier nie drin.
|
||
"""
|
||
|
||
slug: str
|
||
display_name: str
|
||
discovery_url: str
|
||
# Client-ID fuer die App (oeffentlicher Client, PKCE). Leer, wenn fuer diesen
|
||
# Anbieter kein nativer Client eingerichtet ist.
|
||
native_client_id: str | None = None
|
||
native_redirect_uri: str
|
||
# Scopes fuer den App-Weg (inkl. offline_access) – der Browser-Weg wird vom
|
||
# Server selbst gebaut und braucht diese Angabe nicht.
|
||
scopes: str
|
||
|
||
|
||
class OidcIdentityOut(BaseModel):
|
||
"""Eine bestehende Verknuepfung des angemeldeten Kontos."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
provider: str
|
||
display_name: str
|
||
created_at: datetime
|
||
last_login_at: datetime | None = None
|
||
|
||
|
||
class OidcLinkStart(BaseModel):
|
||
"""Adresse, zu der die Oberflaeche fuer die Verknuepfung weiterleiten soll.
|
||
|
||
Der Start laeuft ueber POST und nicht als einfacher Link, weil die
|
||
Berechtigung aus dem Bearer-Token der laufenden Sitzung kommt – und das
|
||
laesst sich bei einer normalen Seitennavigation nicht mitschicken.
|
||
"""
|
||
|
||
authorization_url: str
|
||
|
||
|
||
class OidcPendingOut(BaseModel):
|
||
"""Eine gepruefte Kennung, die noch keinem Konto gehoert.
|
||
|
||
Wird der Entscheidungsseite gezeigt: „neues Konto" oder „bestehendes
|
||
verknuepfen". Der Einmalcode aus der Adresse ist der Nachweis, dass die
|
||
Anmeldung beim Anbieter tatsaechlich stattgefunden hat.
|
||
"""
|
||
|
||
provider: str
|
||
display_name: str
|
||
email: str | None = None
|
||
username_vorschlag: str
|
||
# Ob dieser Server das Anlegen neuer Konten ueberhaupt zulaesst.
|
||
anlegen_erlaubt: bool
|
||
|
||
|
||
class OidcPendingVerknuepfen(BaseModel):
|
||
"""Eigentumsnachweis fuer das bestehende Konto."""
|
||
|
||
username: str = Field(min_length=1, max_length=64)
|
||
password: str = Field(min_length=1, max_length=255)
|
||
|
||
|
||
class OidcNativeLogin(BaseModel):
|
||
"""ID-Token aus dem App-Flow, das gegen ein Vorrania-Token getauscht wird."""
|
||
|
||
id_token: str = Field(min_length=1)
|
||
# Die nonce, die die App in ihrer Anfrage verwendet hat. AppAuth prueft sie
|
||
# zwar selbst, aber nur der Server-Vergleich schliesst aus, dass ein anderswo
|
||
# abgegriffenes ID-Token desselben Clients hier eingereicht wird.
|
||
nonce: str | None = None
|
||
|
||
|
||
# ---- Groups ----
|
||
class LocationMinStockIn(BaseModel):
|
||
"""Ein Mindestbestand-Eintrag je Lagerort (Menge in Basiseinheiten).
|
||
|
||
``location_id = null`` heisst „Überall" – egal wo, Hauptsache die Menge ist
|
||
im Haus. Das ersetzt den frueheren separaten Gesamt-Mindestbestand.
|
||
"""
|
||
location_id: str | None = None
|
||
min_stock: float = Field(ge=0)
|
||
|
||
|
||
class LocationMinStockOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
location_id: str | None = None
|
||
location_name: str | None = None # bei location_id = null: „Überall"
|
||
min_stock: float
|
||
# Bestand AN DIESEM Ort (inkl. Unterorte), ebenfalls in Basiseinheiten –
|
||
# bei Artikeln wie bei Gruppen. Bei „Überall" der Gesamtbestand.
|
||
stock: float | None = None
|
||
|
||
|
||
class GroupOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
min_stock: float | None = None # in der aktuellen Erfassungseinheit (s.u.)
|
||
min_stock_unit_id: int | None = None
|
||
# Gruppen-Gebinde (Richtwert) + ob der Mindestbestand darin erfasst ist:
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
min_stock_in_packages: bool = False
|
||
# Obergruppen (n:m – eine Gruppe darf unter mehreren haengen) und die
|
||
# direkten Untergruppen. Beide als IDs; ``_group_to_out`` setzt sie, weil
|
||
# ``Group.parents``/``children`` Objekte sind.
|
||
parent_ids: list[int] = []
|
||
child_ids: list[int] = []
|
||
# angereichert:
|
||
# Artikel im gesamten Untergruppen-Graphen – passt zu ``stock``, das
|
||
# ebenfalls transitiv rechnet. ``direct_product_count`` sind die, die
|
||
# unmittelbar an dieser Gruppe haengen.
|
||
product_count: int = 0
|
||
direct_product_count: int = 0
|
||
stock: float = 0.0 # Bestand in der aktuellen Erfassungseinheit
|
||
min_stock_unit_name: str | None = None # Name der verwalteten Einheit (g/kg …)
|
||
min_stock_unit_factor: float | None = None # Basiseinheiten je verwalteter Einheit
|
||
kind: str | None = None # Art der Mindestbestand-Einheit
|
||
barcodes: list[BarcodeOut] = [] # EANs, die dieser Gruppe zugeordnet sind
|
||
# EANs der Artikel in dieser Gruppe - nur zur Anzeige, nicht bearbeitbar.
|
||
product_barcodes: list[ProductBarcodeOut] = []
|
||
# Mindestbestand je Lagerort (zusaetzlich zum Gesamt-Mindestbestand oben).
|
||
location_min_stocks: list[LocationMinStockOut] = []
|
||
|
||
|
||
class GroupCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
min_stock: float | None = Field(default=None, ge=0)
|
||
min_stock_unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, ge=0)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
min_stock_in_packages: bool = False
|
||
parent_ids: list[int] = []
|
||
|
||
|
||
class GroupUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
min_stock: float | None = Field(default=None, ge=0)
|
||
min_stock_unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, ge=0)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
min_stock_in_packages: bool | None = None
|
||
# Nicht mitgeschickt = unveraendert, [] = alle Obergruppen entfernen.
|
||
parent_ids: list[int] | None = None
|
||
|
||
|
||
# ---- Categories ----
|
||
class CategoryOut(BaseModel):
|
||
"""Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand)."""
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
parent_id: int | None = None
|
||
is_builtin: bool = False
|
||
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
|
||
tracking: CategoryTracking = CategoryTracking.food
|
||
product_count: int = 0
|
||
# Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der
|
||
# Oberkategorie, zuletzt vom globalen Wert.
|
||
open_days: int | None = None
|
||
|
||
|
||
class CategoryCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
parent_id: int | None = None
|
||
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
|
||
tracking: CategoryTracking | None = None
|
||
open_days: int | None = Field(default=None, ge=1)
|
||
|
||
|
||
class CategoryUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
parent_id: int | None = None
|
||
tracking: CategoryTracking | None = None
|
||
# 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None
|
||
# heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst.
|
||
open_days: int | None = Field(default=None, ge=1)
|
||
|
||
|
||
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
|
||
class ShopOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
website: str | None = None
|
||
product_count: int = 0
|
||
|
||
|
||
class ShopCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
website: str | None = Field(default=None, max_length=1024)
|
||
|
||
|
||
class ShopUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
website: str | None = Field(default=None, max_length=1024)
|
||
|
||
|
||
# ---- Selbst definierte Felder je Kategorie ----
|
||
class FieldDefinitionBase(BaseModel):
|
||
label: str = Field(min_length=1, max_length=120)
|
||
field_type: FieldType = FieldType.text
|
||
unit: str | None = Field(default=None, max_length=32)
|
||
options: list[str] | None = None # nur für field_type == "select"
|
||
required: bool = False
|
||
position: int = 0
|
||
|
||
|
||
class FieldDefinitionCreate(FieldDefinitionBase):
|
||
category_id: int
|
||
|
||
|
||
class FieldDefinitionUpdate(BaseModel):
|
||
label: str | None = Field(default=None, min_length=1, max_length=120)
|
||
field_type: FieldType | None = None
|
||
unit: str | None = Field(default=None, max_length=32)
|
||
options: list[str] | None = None
|
||
required: bool | None = None
|
||
position: int | None = None
|
||
|
||
|
||
class FieldDefinitionOut(BaseModel):
|
||
id: int
|
||
category_id: int
|
||
label: str
|
||
key: str
|
||
field_type: FieldType
|
||
unit: str | None = None
|
||
options: list[str] = []
|
||
required: bool = False
|
||
position: int = 0
|
||
is_builtin: bool = False
|
||
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
|
||
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
|
||
inherited: bool = False
|
||
|
||
|
||
# ---- Locations ----
|
||
class LocationOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: str
|
||
name: str
|
||
parent_id: str | None = None
|
||
|
||
|
||
class LocationCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
parent_id: str | None = None
|
||
|
||
|
||
class LocationUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
parent_id: str | None = None
|
||
|
||
|
||
# ---- Gebinde (Packung, Glas, …) ----
|
||
class PackageTypeCreate(BaseModel):
|
||
singular: str = Field(min_length=1, max_length=32)
|
||
plural: str = Field(min_length=1, max_length=32)
|
||
|
||
|
||
class PackageTypeUpdate(BaseModel):
|
||
singular: str | None = Field(default=None, min_length=1, max_length=32)
|
||
plural: str | None = Field(default=None, min_length=1, max_length=32)
|
||
|
||
|
||
class PackageTypeOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
singular: str
|
||
plural: str
|
||
is_builtin: bool = False
|
||
|
||
|
||
# ---- Products ----
|
||
class ProductBase(BaseModel):
|
||
barcode: str | None = None
|
||
name: str = Field(min_length=1, max_length=255)
|
||
brand: str | None = None
|
||
image_url: str | None = None
|
||
base_unit: BaseUnit = BaseUnit.piece
|
||
# Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit.
|
||
unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, gt=0)
|
||
# Bezeichnung eines Gebindes ("Packung", "Glas", "Tüte", …)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
|
||
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
|
||
secondary_base: BaseUnit | None = None
|
||
secondary_count: float | None = Field(default=None, gt=0)
|
||
secondary_amount: float | None = Field(default=None, gt=0)
|
||
# Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel.
|
||
# None = erbt von der Kategorie, zuletzt vom globalen Wert.
|
||
open_days: int | None = Field(default=None, ge=1)
|
||
# Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat).
|
||
date_precision: DatePrecision = DatePrecision.day
|
||
group_id: int | None = None
|
||
# Kategorie: nur fuer den Ueberblick, unabhaengig von der Gruppe.
|
||
category_id: int | None = None
|
||
min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten
|
||
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
|
||
min_stock_unit_id: int | None = None
|
||
min_stock_in_packages: bool = False
|
||
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
|
||
shop_id: int | None = None
|
||
product_url: str | None = Field(default=None, max_length=1024)
|
||
# Gegenstände als Einzelstücke (Items mit UID/QR) statt als Menge führen.
|
||
individual: bool = False
|
||
# Gegenstand als Verbrauchsgegenstand wie ein Lebensmittel führen (Chargen).
|
||
bulk: bool = False
|
||
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
|
||
field_values: dict[int, str | None] | None = None
|
||
|
||
|
||
class ProductCreate(ProductBase):
|
||
pass
|
||
|
||
|
||
class ProductUpdate(BaseModel):
|
||
barcode: str | None = None
|
||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||
brand: str | None = None
|
||
image_url: str | None = None
|
||
base_unit: BaseUnit | None = None
|
||
unit_id: int | None = None
|
||
package_size: float | None = Field(default=None, gt=0)
|
||
package_label: str | None = Field(default=None, max_length=32)
|
||
# Zweiteinheit: Bruecke zwischen den Einheiten-Arten („3 Stück ≙ 250 g").
|
||
# ``secondary_count`` zaehlt in der Basiseinheit des Artikels,
|
||
# ``secondary_amount`` in ``secondary_base``. Alle drei oder keines.
|
||
secondary_base: BaseUnit | None = None
|
||
secondary_count: float | None = Field(default=None, gt=0)
|
||
secondary_amount: float | None = Field(default=None, gt=0)
|
||
# Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel.
|
||
# None = erbt von der Kategorie, zuletzt vom globalen Wert.
|
||
open_days: int | None = Field(default=None, ge=1)
|
||
date_precision: DatePrecision | None = None
|
||
group_id: int | None = None
|
||
category_id: int | None = None
|
||
min_stock: float | None = Field(default=None, ge=0)
|
||
min_stock_unit_id: int | None = None
|
||
min_stock_in_packages: bool | None = None
|
||
shop_id: int | None = None
|
||
product_url: str | None = Field(default=None, max_length=1024)
|
||
individual: bool | None = None
|
||
bulk: bool | None = None
|
||
field_values: dict[int, str | None] | None = None
|
||
|
||
|
||
class ProductOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
barcode: str | None
|
||
name: str
|
||
brand: str | None
|
||
image_url: str | None
|
||
base_unit: BaseUnit
|
||
display_unit_id: int | None = None
|
||
package_size: float | None
|
||
package_label: str | None = None
|
||
# Zweiteinheit-Brücke, so wie eingegeben („3 Stück ≙ 250 g").
|
||
secondary_base: BaseUnit | None = None
|
||
secondary_count: float | None = None
|
||
secondary_amount: float | None = None
|
||
open_days: int | None = None
|
||
# Die tatsaechlich geltende Frist und woher sie kommt – damit das
|
||
# Formular „geerbt: 3 Tage (Kategorie: Molkerei)" zeigen kann, ohne
|
||
# die Vererbung selbst nachzubauen.
|
||
effective_open_days: int | None = None
|
||
open_days_source: str = ""
|
||
# Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels –
|
||
# damit die Oberflächen nicht selbst dividieren müssen.
|
||
secondary_factor: float | None = None
|
||
date_precision: DatePrecision = DatePrecision.day
|
||
group_id: int | None
|
||
# Name der Gruppe – wie category_name, damit die Listen danach filtern
|
||
# koennen, ohne die Gruppen zusaetzlich zu laden.
|
||
group_name: str | None = None
|
||
category_id: int | None = None
|
||
category_name: str | None = None
|
||
min_stock: float | None
|
||
min_stock_unit_id: int | None = None
|
||
min_stock_in_packages: bool = False
|
||
source: str
|
||
shop_id: int | None = None
|
||
product_url: str | None = None
|
||
individual: bool = False
|
||
bulk: bool = False
|
||
created_at: datetime
|
||
updated_at: datetime | None = None
|
||
# angereichert:
|
||
stock: float = 0.0
|
||
expired_count: int = 0
|
||
# Epoch (Sekunden) der letzten Bildänderung, sonst None (= kein Bild). Der
|
||
# Client cacht Bilder danach und lädt nur bei geänderter Version neu.
|
||
image_version: int | None = None
|
||
kind: str = ""
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
# Mindestbestand in der erfassten Einheit (für die Anzeige):
|
||
min_stock_display: float | None = None
|
||
min_stock_unit_label: str = ""
|
||
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
|
||
barcodes: list[BarcodeOut] = []
|
||
# Verwaltungsart aus der Kategorie (food/object), Bezugsquelle und Feldwerte:
|
||
tracking: CategoryTracking = CategoryTracking.food
|
||
shop_name: str | None = None
|
||
field_values: dict[int, str | None] = {}
|
||
# Mindestbestand je Lagerort (in Artikeleinheiten), zusaetzlich zum globalen.
|
||
location_min_stocks: list[LocationMinStockOut] = []
|
||
|
||
@field_validator("field_values", mode="before")
|
||
@classmethod
|
||
def _field_values_from_orm(cls, v):
|
||
"""Beim Lesen aus der DB kommt eine Liste ProductFieldValue – zu Map machen."""
|
||
if isinstance(v, list):
|
||
return {pfv.field_definition_id: pfv.value for pfv in v}
|
||
return v
|
||
|
||
|
||
class LookupResult(BaseModel):
|
||
found: bool
|
||
existing_product: ProductOut | None = None
|
||
suggestion: dict | None = None
|
||
# Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet.
|
||
group_id: int | None = None
|
||
group_name: str | None = None
|
||
# Aus der Open-Food-Facts-Kategorie abgeleiteter Vorschlag (nur Vorschlag).
|
||
category_id: int | None = None
|
||
category_name: str | None = None
|
||
|
||
|
||
class BarcodeCreate(BaseModel):
|
||
code: str = Field(min_length=4, max_length=64)
|
||
note: str | None = Field(default=None, max_length=120)
|
||
|
||
|
||
class BarcodeNoteUpdate(BaseModel):
|
||
"""Notiz zu einem Code nachtragen oder ändern."""
|
||
note: str | None = Field(default=None, max_length=120)
|
||
|
||
|
||
# ---- Stock movements ----
|
||
class CheckInRequest(BaseModel):
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
unit: str
|
||
best_before: date | None = None
|
||
# "month" legt das MHD auf den Monatsletzten (siehe services/dates.py).
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None = None
|
||
note: str | None = None
|
||
|
||
|
||
class CheckOutRequest(BaseModel):
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
unit: str
|
||
# Optional: gezielt aus dieser Charge abbuchen (sonst automatisch FEFO).
|
||
lot_id: int | None = None
|
||
note: str | None = None
|
||
|
||
|
||
class LotOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
product_id: int
|
||
quantity: float
|
||
best_before: date | None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None
|
||
created_at: datetime
|
||
# Angebrochen: NULL = zu. MHD und Verbrauchsdatum stehen bewusst
|
||
# nebeneinander – gewarnt wird nach dem fruheren (Lot.faellig_am).
|
||
opened_on: date | None = None
|
||
consume_by: date | None = None
|
||
opened_quantity: float | None = None
|
||
|
||
|
||
class LotUpdate(BaseModel):
|
||
"""Korrektur einer Charge (Vertipper beim Einlagern o.ä.)."""
|
||
quantity: float | None = Field(default=None, gt=0)
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision | None = None
|
||
location_id: str | None = None
|
||
|
||
|
||
class LotRow(BaseModel):
|
||
"""Eine Charge mit Artikel-Infos – für die übergreifende Chargenliste."""
|
||
id: int
|
||
product_id: int
|
||
product_name: str
|
||
product_brand: str | None = None
|
||
tracking: CategoryTracking = CategoryTracking.food # food / object
|
||
quantity: float # in Basiseinheiten
|
||
base_unit: BaseUnit
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None = None
|
||
created_at: datetime
|
||
opened_on: date | None = None
|
||
consume_by: date | None = None
|
||
opened_quantity: float | None = None
|
||
|
||
|
||
class BulkLotLocation(BaseModel):
|
||
"""Lagerort für mehrere Chargen auf einmal setzen (oder leeren)."""
|
||
lot_ids: list[int]
|
||
location_id: str | None = None
|
||
|
||
|
||
class BulkLotCheckout(BaseModel):
|
||
"""Mehrere Chargen auf einmal ausbuchen.
|
||
|
||
``quantity`` leer = jede Charge komplett. Mit Menge wird je Charge
|
||
dieselbe Menge abgebucht – das ergibt nur Sinn, wenn alle zum selben
|
||
Artikel gehören, sonst wäre die Einheit mehrdeutig (der Server lehnt es
|
||
dann ab).
|
||
"""
|
||
lot_ids: list[int]
|
||
quantity: float | None = Field(default=None, gt=0)
|
||
unit: str | None = None
|
||
note: str | None = Field(default=None, max_length=255)
|
||
|
||
|
||
class BulkLotDelete(BaseModel):
|
||
"""Mehrere Chargen als Korrektur entfernen (ohne sie als Verbrauch zu buchen)."""
|
||
lot_ids: list[int]
|
||
|
||
|
||
class LotSplit(BaseModel):
|
||
"""Eine Charge aufteilen: `quantity` (in Basiseinheiten) wird abgezweigt und
|
||
als neue Charge mit gleichem MHD an `location_id` gelegt; der Rest bleibt."""
|
||
quantity: float = Field(gt=0)
|
||
location_id: str | None = None
|
||
|
||
|
||
class LotOpen(BaseModel):
|
||
"""Eine Packung der Charge anbrechen.
|
||
|
||
``quantity`` in Basiseinheiten; leer = eine Artikeleinheit (Packung), bzw.
|
||
die ganze Charge, wenn sie kleiner ist.
|
||
"""
|
||
quantity: float | None = Field(default=None, gt=0)
|
||
|
||
|
||
class LotConsume(BaseModel):
|
||
"""Teilmenge aus einer Charge verbrauchen.
|
||
|
||
``unit`` nimmt alles, was auch beim Auslagern geht – Basiseinheit, verwaltete
|
||
Einheit, „package" und die Hausmaße der Kategorie („EL", „Tasse").
|
||
``auto_open`` bricht eine noch geschlossene Charge dabei an; die Rückfrage
|
||
dazu stellt die Oberfläche, nicht der Server.
|
||
"""
|
||
quantity: float = Field(gt=0)
|
||
unit: str
|
||
note: str | None = Field(default=None, max_length=255)
|
||
auto_open: bool = True
|
||
|
||
|
||
class LotConsumeOut(BaseModel):
|
||
"""Was der Verbrauch bewirkt hat."""
|
||
affected_lots: list[dict] = []
|
||
# Die offene Charge danach – None, wenn sie dabei leer geworden ist.
|
||
lot: LotOut | None = None
|
||
# Ob die Charge durch diesen Vorgang angebrochen wurde.
|
||
opened: bool = False
|
||
|
||
|
||
# ---- Mealie ----
|
||
class MealieConfigIn(BaseModel):
|
||
"""Zugangsdaten für die einmalige Anmeldung bei Mealie.
|
||
|
||
Das Passwort wird **nicht** gespeichert: Vorrania meldet sich damit einmal
|
||
an, lässt sich ein Dauertoken ausstellen und vergisst es.
|
||
"""
|
||
base_url: str = Field(min_length=1, max_length=255)
|
||
username: str = Field(min_length=1, max_length=120)
|
||
password: str = Field(min_length=1)
|
||
|
||
|
||
class MealieTokenIn(BaseModel):
|
||
"""Ein in Mealie selbst erzeugtes Dauertoken einsetzen (Alternative zum Passwort)."""
|
||
base_url: str = Field(min_length=1, max_length=255)
|
||
token: str = Field(min_length=1)
|
||
|
||
|
||
class MealieStatus(BaseModel):
|
||
"""Auskunft über die Verbindung – **nie** das Token selbst."""
|
||
configured: bool = False
|
||
base_url: str = ""
|
||
token_set: bool = False
|
||
# Erst bei einer echten Abfrage gefüllt; None heisst „nicht nachgesehen".
|
||
reachable: bool | None = None
|
||
version: str | None = None
|
||
error: str | None = None
|
||
|
||
|
||
class MealieAvailable(BaseModel):
|
||
"""Ob die Rezept-Seite überhaupt etwas anzeigen kann (für alle Benutzer)."""
|
||
available: bool = False
|
||
|
||
|
||
class RecipeSummary(BaseModel):
|
||
"""Ein Rezept in der Liste."""
|
||
slug: str
|
||
name: str
|
||
description: str = ""
|
||
image_url: str | None = None
|
||
servings: float | None = None
|
||
|
||
|
||
class RecipePage(BaseModel):
|
||
items: list[RecipeSummary] = []
|
||
page: int = 1
|
||
per_page: int = 50
|
||
total: int = 0
|
||
|
||
|
||
class ZutatKandidat(BaseModel):
|
||
art: str # product | group
|
||
id: int
|
||
name: str
|
||
score: int
|
||
|
||
|
||
class ZutatOut(BaseModel):
|
||
"""Eine Zutat, auf den Bestand bezogen."""
|
||
text: str
|
||
menge: float | None = None
|
||
einheit: str = ""
|
||
# Reiner Lebensmittelname – damit die Oberfläche eine Zuordnung dafür merken kann.
|
||
food: str = ""
|
||
# True = so zugeordnet, weil es jemand einmal von Hand entschieden hat.
|
||
manuell: bool = False
|
||
art: str | None = None
|
||
ziel_id: int | None = None
|
||
ziel_name: str = ""
|
||
kandidaten: list[ZutatKandidat] = []
|
||
benoetigt_base: float | None = None
|
||
bestand_base: float | None = None
|
||
fehlt_base: float | None = None
|
||
basis_einheit: str = ""
|
||
# vorhanden | zu_wenig | fehlt | menge_unbekannt | kein_treffer
|
||
status: str = "kein_treffer"
|
||
hinweis: str = ""
|
||
fehlendes_mass: str = ""
|
||
kategorie_id: int | None = None
|
||
# Fehlmenge als zählbares Gebinde („2 Packungen") – wie in der Einkaufsliste.
|
||
fehlt_text: str = ""
|
||
|
||
|
||
class RecipeDetail(BaseModel):
|
||
slug: str
|
||
name: str
|
||
description: str = ""
|
||
image_url: str | None = None
|
||
# Portionen laut Rezept und die, für die gerechnet wurde.
|
||
base_servings: float | None = None
|
||
servings: float | None = None
|
||
skalierung_hinweis: str = ""
|
||
kochbar: bool = False
|
||
ingredients: list[ZutatOut] = []
|
||
|
||
|
||
# ---- Gemerkte Zutaten-Zuordnungen ----
|
||
class ZuordnungIn(BaseModel):
|
||
"""Eine Zutat von Hand auf einen Artikel oder eine Gruppe legen."""
|
||
food: str = Field(min_length=1, max_length=200)
|
||
art: str = Field(pattern="^(product|group)$")
|
||
ziel_id: int
|
||
|
||
|
||
class ZuordnungOut(BaseModel):
|
||
"""Eine gemerkte Zuordnung, wie sie in der Liste steht."""
|
||
food_key: str
|
||
food_name: str
|
||
art: str
|
||
ziel_id: int
|
||
ziel_name: str = ""
|
||
|
||
|
||
class ZielVorschlag(BaseModel):
|
||
"""Ein Artikel oder eine Gruppe als Ziel – die Auswahlliste beim Zuordnen."""
|
||
art: str
|
||
id: int
|
||
name: str
|
||
# Zur Unterscheidung gleichnamiger Artikel: Marke bzw. Kategorie.
|
||
zusatz: str = ""
|
||
basis_einheit: str = ""
|
||
|
||
|
||
# ---- Hausmaße ----
|
||
class MeasureIn(BaseModel):
|
||
"""Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel."""
|
||
name: str = Field(min_length=1, max_length=40)
|
||
short: str | None = Field(default=None, max_length=10)
|
||
position: int = 0
|
||
|
||
|
||
class MeasureOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
short: str | None = None
|
||
position: int = 0
|
||
is_builtin: bool = False
|
||
|
||
|
||
class MeasureAmountIn(BaseModel):
|
||
"""Was ein Hausmaß hier wiegt bzw. fasst: ``amount`` in ``base``."""
|
||
amount: float = Field(gt=0)
|
||
base: BaseUnit
|
||
|
||
|
||
class MeasureAmountOut(BaseModel):
|
||
"""Ein für einen Artikel (oder eine Kategorie) geltendes Maß samt Herkunft."""
|
||
measure_id: int
|
||
name: str
|
||
short: str | None = None
|
||
amount: float
|
||
base: BaseUnit
|
||
# „Artikel", „Kategorie: Mehl & Zucker" oder „–" (nicht hinterlegt).
|
||
herkunft: str = ""
|
||
|
||
|
||
# ---- Kassenzettel-Abgleich ----
|
||
class MatchRequest(BaseModel):
|
||
"""Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile."""
|
||
lines: list[str]
|
||
threshold: int | None = None # 0–100; ohne Angabe gilt die Einstellung
|
||
|
||
|
||
class MatchCandidate(BaseModel):
|
||
product_id: int
|
||
name: str
|
||
brand: str | None = None
|
||
score: int # 0–100, Ähnlichkeit zur Kassenzeile
|
||
# Für die Einheit beim Einlagern (ganze Gebinde bzw. Basiseinheit) – so muss
|
||
# die App den Artikel nicht noch einmal einzeln laden.
|
||
package_size: float | None = None
|
||
base_unit: str
|
||
|
||
|
||
class MatchLine(BaseModel):
|
||
text: str
|
||
candidates: list[MatchCandidate] = []
|
||
|
||
|
||
class CheckInResponse(BaseModel):
|
||
lot: LotOut
|
||
product_stock: float
|
||
|
||
|
||
class CheckInLine(BaseModel):
|
||
"""Eine Charge innerhalb eines Sammel-Einlagerns (Menge + eigenes MHD)."""
|
||
quantity: float = Field(gt=0)
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
location_id: str | None = None
|
||
|
||
|
||
class BatchCheckInRequest(BaseModel):
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
unit: str
|
||
lines: list[CheckInLine] = Field(min_length=1)
|
||
note: str | None = None
|
||
|
||
|
||
class BatchCheckInResponse(BaseModel):
|
||
lots: list[LotOut]
|
||
product_stock: float
|
||
|
||
|
||
class CheckOutResponse(BaseModel):
|
||
affected_lots: list[dict]
|
||
product_stock: float
|
||
|
||
|
||
# ---- Gegenstände: Umlagern und Entfernen mit Grund ----
|
||
class RelocateRequest(BaseModel):
|
||
"""Menge eines Gegenstands von einem Lagerort zu einem anderen umbuchen (ohne Grund)."""
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
from_location_id: str | None = None
|
||
to_location_id: str | None = None
|
||
note: str | None = None
|
||
|
||
|
||
class RemoveRequest(BaseModel):
|
||
"""Menge eines Gegenstands aus dem Bestand entfernen – Grund ist Pflicht."""
|
||
product_id: int | None = None
|
||
barcode: str | None = None
|
||
quantity: float = Field(gt=0)
|
||
location_id: str | None = None
|
||
reason: RemovalReason
|
||
note: str | None = None
|
||
|
||
|
||
class StockActionResponse(BaseModel):
|
||
product_stock: float
|
||
|
||
|
||
class RemovalStat(BaseModel):
|
||
"""Summe der Entnahmen je Grund (für die kleine Statistik am Artikel)."""
|
||
reason: RemovalReason
|
||
quantity: float
|
||
count: int
|
||
|
||
|
||
class RemovalHistoryItem(BaseModel):
|
||
reason: RemovalReason
|
||
quantity: float
|
||
location_id: str | None = None
|
||
location_name: str | None = None
|
||
note: str | None = None
|
||
username: str | None = None
|
||
created_at: datetime
|
||
|
||
|
||
class RemovalSummary(BaseModel):
|
||
stats: list[RemovalStat] = []
|
||
history: list[RemovalHistoryItem] = []
|
||
|
||
|
||
# ---- Einzelstücke (Items mit UID/QR) ----
|
||
class ItemCreate(BaseModel):
|
||
"""Ein oder mehrere Einzelstücke mit gemeinsamen Startwerten anlegen."""
|
||
count: int = Field(default=1, ge=1, le=200)
|
||
location_id: str | None = None
|
||
shop_id: int | None = None
|
||
acquired_on: date | None = None
|
||
warranty_until: date | None = None
|
||
note: str | None = Field(default=None, max_length=255)
|
||
price_cents: int | None = Field(default=None, ge=0)
|
||
currency: str | None = Field(default=None, max_length=3)
|
||
|
||
|
||
class ItemUpdate(BaseModel):
|
||
location_id: str | None = None
|
||
shop_id: int | None = None
|
||
acquired_on: date | None = None
|
||
warranty_until: date | None = None
|
||
note: str | None = Field(default=None, max_length=255)
|
||
price_cents: int | None = Field(default=None, ge=0)
|
||
currency: str | None = Field(default=None, max_length=3)
|
||
|
||
|
||
class ItemRemove(BaseModel):
|
||
reason: RemovalReason
|
||
note: str | None = Field(default=None, max_length=255)
|
||
|
||
|
||
class ItemDocumentOut(BaseModel):
|
||
"""Beleg-Metadaten (ohne Binärdaten)."""
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
filename: str
|
||
content_type: str
|
||
uploaded_at: datetime
|
||
|
||
|
||
class DocumentSuggestions(BaseModel):
|
||
"""Aus einem Beleg-PDF geschätzte Werte (ohne den Beleg zu speichern)."""
|
||
suggested_warranty_until: date | None = None
|
||
suggested_price_cents: int | None = None
|
||
# Alle plausiblen Preise (bester zuerst) – zur Auswahl, falls es mehrere gibt.
|
||
suggested_price_candidates: list[int] = []
|
||
suggested_acquired_on: date | None = None
|
||
# Erkannter Shop: id = bereits hinterlegt; sonst name = Vorschlag zum Anlegen.
|
||
suggested_shop_id: int | None = None
|
||
suggested_shop_name: str | None = None
|
||
|
||
|
||
class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
||
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||
|
||
|
||
class LoanCreate(BaseModel):
|
||
"""Ein Einzelstück verleihen. Nur der Name ist Pflicht."""
|
||
borrower: str = Field(min_length=1, max_length=120)
|
||
reason: str | None = Field(default=None, max_length=255)
|
||
# Leer = heute; rückdatieren erlaubt (man trägt es oft später nach).
|
||
lent_on: date | None = None
|
||
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
||
due_on: date | None = None
|
||
# Zubehörteile, die mitgehen. Leer = nur das Gerät selbst. Die Oberfläche
|
||
# fragt danach; der Server nimmt nie ungefragt etwas mit.
|
||
mit_zubehoer: list[int] = []
|
||
|
||
|
||
class LoanUpdate(BaseModel):
|
||
"""Laufenden oder abgeschlossenen Verleih korrigieren (Tippfehler, Datum)."""
|
||
borrower: str | None = Field(default=None, min_length=1, max_length=120)
|
||
reason: str | None = Field(default=None, max_length=255)
|
||
lent_on: date | None = None
|
||
due_on: date | None = None
|
||
returned_on: date | None = None
|
||
|
||
|
||
class LoanReturn(BaseModel):
|
||
"""Rückgabe buchen. Leeres Datum = heute."""
|
||
returned_on: date | None = None
|
||
# Zubehörteile, die mit zurückkommen. Spiegelbild zu LoanCreate: ohne das
|
||
# käme das Gerät zurück und sein Netzteil bliebe ewig „verliehen".
|
||
mit_zubehoer: list[int] = []
|
||
|
||
|
||
class LoanOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
item_id: int
|
||
borrower: str
|
||
reason: str | None = None
|
||
lent_on: date
|
||
due_on: date | None = None
|
||
returned_on: date | None = None
|
||
created_at: datetime
|
||
# Wer herausgegeben hat (Konto kann später gelöscht sein).
|
||
username: str | None = None
|
||
# Tage über dem Rückgabedatum; None ohne Datum oder wenn noch nicht fällig.
|
||
# Vom Server gerechnet, damit App und Web nicht getrennt rechnen.
|
||
days_overdue: int | None = None
|
||
# Für die Übersicht „was ist gerade draußen" mitgeliefert:
|
||
item_uid: str | None = None
|
||
product_id: int | None = None
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
location_id: str | None = None
|
||
location_name: str | None = None
|
||
|
||
|
||
class ItemKurz(BaseModel):
|
||
"""Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen.
|
||
|
||
Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes
|
||
Teil wieder sein Geraet nach, endlos.
|
||
"""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
uid: str
|
||
product_id: int
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
location_name: str | None = None
|
||
# Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann.
|
||
verliehen: bool = False
|
||
|
||
|
||
class ZubehoerZuordnen(BaseModel):
|
||
"""Welches Stueck als Zubehoer an dieses Geraet gehaengt wird."""
|
||
|
||
child_item_id: int
|
||
|
||
|
||
class ItemOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
uid: str
|
||
product_id: int
|
||
location_id: str | None = None
|
||
location_name: str | None = None
|
||
shop_id: int | None = None
|
||
shop_name: str | None = None
|
||
acquired_on: date | None = None
|
||
warranty_until: date | None = None
|
||
note: str | None = None
|
||
price_cents: int | None = None
|
||
currency: str | None = None
|
||
created_at: datetime
|
||
# Für QR-Auflösung/Anzeige mitgeliefert:
|
||
product_name: str | None = None
|
||
product_brand: str | None = None
|
||
category_id: int | None = None
|
||
category_name: str | None = None
|
||
documents: list[ItemDocumentOut] = []
|
||
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
|
||
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
|
||
loan: LoanOut | None = None
|
||
# Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses
|
||
# Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen.
|
||
zubehoer: list[ItemKurz] = []
|
||
gehoert_zu: ItemKurz | None = None
|
||
|
||
|
||
# ---- Etiketten-Vorlagen ----
|
||
class SpalteOut(BaseModel):
|
||
"""Eine waehlbare Spalte – fest oder ein eigenes Feld."""
|
||
|
||
schluessel: str
|
||
titel: str
|
||
|
||
|
||
class EtikettenVorlageOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
art: str
|
||
position: int
|
||
# Aus dem JSON-Text der Konfiguration geholt, damit die Oberflaeche nicht
|
||
# selbst parsen muss.
|
||
spalten: list[str] = []
|
||
kategorie_ids: list[int] = []
|
||
trennzeichen: str = ","
|
||
# Nur der Name; die Binaerdaten gehen ueber einen eigenen Endpunkt.
|
||
datei_name: str | None = None
|
||
updated_at: datetime
|
||
|
||
|
||
class EtikettenVorlageCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=80)
|
||
art: str = Field(default="items", pattern="^(items|products|locations)$")
|
||
spalten: list[str] = []
|
||
kategorie_ids: list[int] = []
|
||
trennzeichen: str = ","
|
||
|
||
|
||
class EtikettenVorlageUpdate(BaseModel):
|
||
"""Nur mitgeschickte Felder zaehlen."""
|
||
|
||
name: str | None = Field(default=None, min_length=1, max_length=80)
|
||
art: str | None = Field(default=None, pattern="^(items|products|locations)$")
|
||
spalten: list[str] | None = None
|
||
kategorie_ids: list[int] | None = None
|
||
trennzeichen: str | None = None
|
||
position: int | None = None
|
||
|
||
|
||
# ---- Views ----
|
||
class ShoppingNeed(BaseModel):
|
||
"""Fehlmenge als Gebinde-Leitangabe – bei zaehlbaren Packungen (Glas/Dose)
|
||
auf ganze aufgerundet, weil man nur ganze Gebinde kauft – mit der
|
||
Basiseinheit als kleinem Hinweis. So bekommen auch API-Konsumenten direkt
|
||
„1 Glas (500 g)", ohne selbst Packungsgroessen umzurechnen."""
|
||
text: str # Leitangabe: "1 Glas", "4 Dosen", "500 g"
|
||
hint: str = "" # Basiseinheit klein: "500 g" / "2 l" – leer wenn identisch
|
||
count: float # aufgerundete Gebindezahl (zum Weiterrechnen)
|
||
label: str # zur count passendes, pluralisiertes Label
|
||
singular: str # Einzahl-Label ("Glas", "Gramm", …)
|
||
is_package: bool = False # zaehlbares Gebinde (Glas/Dose) vs. Basis-/Anzeigeeinheit
|
||
base_amount: float | None = None
|
||
base_unit: BaseUnit | None = None
|
||
stock: float # Bestand in derselben Leitangabe
|
||
min_stock: float # Mindestbestand in derselben Leitangabe
|
||
|
||
|
||
class ShoppingItem(BaseModel):
|
||
product_id: int
|
||
name: str
|
||
base_unit: BaseUnit
|
||
package_size: float | None = None
|
||
need: ShoppingNeed | None = None
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
|
||
|
||
class ExpiringItem(BaseModel):
|
||
lot_id: int
|
||
product_id: int
|
||
product_name: str
|
||
quantity: float # in Basiseinheiten
|
||
base_unit: BaseUnit
|
||
# Darf fehlen: Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein
|
||
# Verbrauchsdatum – und genau die soll hier auftauchen.
|
||
best_before: date | None = None
|
||
best_before_precision: DatePrecision = DatePrecision.day
|
||
# Angebrochen: Öffnungstag und das daraus gerechnete Verbrauchsdatum.
|
||
opened_on: date | None = None
|
||
consume_by: date | None = None
|
||
# Das Datum, nach dem gewarnt wird (das frühere der beiden), und welches es
|
||
# ist – damit die Anzeige „läuft ab" von „angebrochen" unterscheiden kann.
|
||
warn_date: date
|
||
warn_source: str = "best_before" # best_before | consume_by
|
||
days_left: int
|
||
# Für die Anzeige in Artikeleinheiten:
|
||
package_size: float | None = None
|
||
package_label: str | None = None
|
||
unit_name: str = ""
|
||
unit_factor: float = 1.0
|
||
|
||
|
||
class GroupShoppingItem(BaseModel):
|
||
group_id: int
|
||
name: str
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
unit_name: str = ""
|
||
product_count: int
|
||
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||
subgroup_count: int = 0
|
||
need: ShoppingNeed | None = None
|
||
|
||
|
||
class LocationNeedProduct(BaseModel):
|
||
"""Ein Produkt-Bedarf an einem Lagerort (Mengen in Artikeleinheiten)."""
|
||
product_id: int
|
||
name: str
|
||
unit_label: str = ""
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
need: ShoppingNeed | None = None
|
||
|
||
|
||
class LocationNeedGroup(BaseModel):
|
||
group_id: int
|
||
name: str
|
||
unit_name: str = ""
|
||
stock: float
|
||
min_stock: float
|
||
deficit: float
|
||
# Wie viele Untergruppen mitgezaehlt werden (0 = keine).
|
||
subgroup_count: int = 0
|
||
need: ShoppingNeed | None = None
|
||
|
||
|
||
class LocationNeeds(BaseModel):
|
||
"""Alle Bedarfe (Produkte + Gruppen) eines Lagerorts."""
|
||
location_id: str
|
||
location_name: str
|
||
products: list[LocationNeedProduct] = []
|
||
groups: list[LocationNeedGroup] = []
|
||
|
||
|
||
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
|
||
# 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
|