Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln

Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -2,9 +2,17 @@ from __future__ import annotations
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models import BaseUnit, DatePrecision, Role, UnitKind
from .models import (
BaseUnit,
CategoryTracking,
DatePrecision,
FieldType,
RemovalReason,
Role,
UnitKind,
)
# ---- Units ----
@@ -131,23 +139,86 @@ class GroupUpdate(BaseModel):
# ---- Categories ----
class CategoryOut(BaseModel):
"""Reine Ordnungshilfe kein Bestand, kein Mindestbestand, keine EAN-Codes."""
"""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 ----
@@ -207,6 +278,11 @@ class ProductBase(BaseModel):
# 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)
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
field_values: dict[int, str | None] | None = None
class ProductCreate(ProductBase):
@@ -228,6 +304,9 @@ class ProductUpdate(BaseModel):
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)
field_values: dict[int, str | None] | None = None
class ProductOut(BaseModel):
@@ -249,6 +328,8 @@ class ProductOut(BaseModel):
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
created_at: datetime
# angereichert:
stock: float = 0.0
@@ -261,6 +342,18 @@ class ProductOut(BaseModel):
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):
@@ -358,6 +451,53 @@ class CheckOutResponse(BaseModel):
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] = []
# ---- Views ----
class ShoppingItem(BaseModel):
product_id: int