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 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) role: Role = Role.user class UserUpdate(BaseModel): password: str | None = Field(default=None, min_length=4, max_length=255) role: Role | 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 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] = [] # ---- 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