- Item: price_cents + currency (Kaufpreis rappen-/centgenau). Migration ergaenzt. - Neue Tabelle item_documents (mehrere Belege je Stueck, PDF oder Bild); Blob wird verzoegert geladen, damit Item-Listen leicht bleiben. - Endpunkte: Upload (POST), Ansehen/Download (GET), Loeschen (DELETE) je Stueck. Dateiname beim Download gehaertet (keine Header-Injektion). - services/warranty.py: schaetzt aus PDF-Text lokal ein Garantieende (Zeitraum + Kaufdatum, oder Datum nahe Garantie-Stichwort); pypdf ergaenzt. Der Upload liefert den Vorschlag zurueck. 10 neue Tests, Suite 136 gruen. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
722 lines
21 KiB
Python
722 lines
21 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
|
||
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 GroupOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
name: str
|
||
min_stock: float | None = None
|
||
min_stock_unit_id: int | None = None
|
||
# angereichert:
|
||
product_count: int = 0
|
||
stock: float = 0.0 # Bestand in Basiseinheiten
|
||
min_stock_unit_name: str | None = None
|
||
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] = []
|
||
|
||
|
||
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
|
||
|
||
|
||
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
|
||
|
||
|
||
# ---- 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: int
|
||
name: str
|
||
parent_id: int | None = None
|
||
|
||
|
||
class LocationCreate(BaseModel):
|
||
name: str = Field(min_length=1, max_length=120)
|
||
parent_id: int | None = None
|
||
|
||
|
||
class LocationUpdate(BaseModel):
|
||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||
parent_id: int | 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)
|
||
# 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
|
||
# 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)
|
||
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
|
||
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
|
||
date_precision: DatePrecision = DatePrecision.day
|
||
group_id: int | 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
|
||
created_at: datetime
|
||
# angereichert:
|
||
stock: float = 0.0
|
||
expired_count: int = 0
|
||
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] = {}
|
||
|
||
@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: int | 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: int | 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: int | None = None
|
||
|
||
|
||
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: int | 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: int | None = None
|
||
to_location_id: int | 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: int | 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: int | 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: int | 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: int | 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 ItemDocumentUploadOut(ItemDocumentOut):
|
||
"""Antwort nach dem Upload – mit dem aus dem PDF vorgeschlagenen Garantieende."""
|
||
suggested_warranty_until: date | None = None
|
||
|
||
|
||
class ItemOut(BaseModel):
|
||
model_config = ConfigDict(from_attributes=True)
|
||
id: int
|
||
uid: str
|
||
product_id: int
|
||
location_id: int | 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
|
||
documents: list[ItemDocumentOut] = []
|
||
|
||
|
||
# ---- Views ----
|
||
class ShoppingItem(BaseModel):
|
||
product_id: int
|
||
name: str
|
||
base_unit: BaseUnit
|
||
package_size: float | 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
|
||
|
||
|
||
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
|