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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user