Die drei Einheiten-Arten waren bisher strikt getrennt: BASE_OF_KIND bildet count/weight/volume 1:1 auf Stueck/Gramm/Milliliter ab, ohne jeden Faktor dazwischen. Zwei Stellen setzten das durch - to_base lehnte artfremde Einheiten beim Ein-/Auslagern ab, und group_min_context filterte stueckweise gefuehrte Artikel aus einer Kilogramm-Gruppe stillschweigend heraus. Letzteres war der Anlass: eine Gruppe "Wurst" in kg sah Bratwuerste in Stueck gar nicht. Ein Artikel darf jetzt eine Zweiteinheit tragen: "3 Stueck ≙ 250 g". Gespeichert wird das eingegebene PAAR, nicht der Faktor - wer 3 und 250 eintippt, sieht beim naechsten Oeffnen genau das wieder. Das hat auch einen rechnerischen Grund: 250 * 3 / 250 ist exakt 3, der Umweg ueber 250/3 ergibt 3,0000000000000004 und liefe damit gegen die Bestandspruefung beim Auslagern. Der Artikel bleibt in seiner Basiseinheit gefuehrt; die Bruecke ist reine Rechnung. Gruppen zaehlen artfremde Artikel jetzt mit ihrem Faktor mit (GroupMinContext.faktoren), Bestandssummen laufen dafuer je Artikel gewichtet - weiterhin zwei Abfragen, nur mit GROUP BY. Ein-/Auslagern in der Fremdeinheit geht, krumme Mengen werden bewusst gebucht statt gerundet: 100 g sind 1,2 Stueck, und Runden wuerde stumm etwas anderes buchen als angegeben. WICHTIGE KORREKTUR am urspruenglichen Plan: die Teilmengen-Bedingung in _gruppen_bedarfe konnte NICHT bleiben. Sie war bisher zugleich ein Einheiten-Schutz, weil Artikel verschiedener Arten zwangslaeufig disjunkt waren. Mit der Bruecke gilt sie ploetzlich auch zwischen einer Stueck- und einer Gramm-Gruppe - und _netted_topups haette einen Bedarf in Stueck von einem in Gramm abgezogen. Jetzt wird nur noch zwischen Gruppen derselben Basiseinheit verrechnet. Open Food Facts: "3 x 80 g" verlor bisher den Multiplikator, weil der Regex den ersten Zahl-Einheit-Treffer nahm. parse_gebinde liefert jetzt Gesamtmenge UND Stueckzahl und belegt die Zweiteinheit vor; parse_quantity behaelt seinen schmalen Vertrag. 18 neue Tests. Dass test_wrong_kind_rejected und test_einheitenfilter_gilt_auch_fuer_untergruppen unveraendert gruen bleiben, ist selbst der Beleg: ohne Bruecke aendert sich nichts. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
941 lines
29 KiB
Python
941 lines
29 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 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
|
||
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
|