Backend: Einzelstücke (Items) mit UID je Gegenstand

Gegenstands-Produkte koennen als Einzelstuecke gefuehrt werden (Product.individual):
jedes physische Stueck ist ein Item mit eigener kurzer UID (fuer QR), eigenem
Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz. So laesst sich dasselbe
Modell mehrfach getrennt fuehren (Powerbank 2024 + 2025).

Neu: models.Item + Product.individual (+Migration), Schemas, services/items.py
(UID-Erzeugung, Anreicherung), routers/items.py (CRUD, /items/by-uid/{uid} zur
QR-Aufloesung, /items/{id}/remove mit Grund als Bewegung). current_stock zaehlt
bei Einzelstueck-Produkten die Items. Tests + HTTP-Smoke gruen.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-26 00:26:15 +02:00
parent 68e27c1868
commit 5ce4411d1e
8 changed files with 326 additions and 2 deletions

View File

@@ -281,6 +281,8 @@ class ProductBase(BaseModel):
# 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
@@ -306,6 +308,7 @@ class ProductUpdate(BaseModel):
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
@@ -330,6 +333,7 @@ class ProductOut(BaseModel):
source: str
shop_id: int | None = None
product_url: str | None = None
individual: bool = False
created_at: datetime
# angereichert:
stock: float = 0.0
@@ -498,6 +502,48 @@ class RemovalSummary(BaseModel):
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)
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)
class ItemRemove(BaseModel):
reason: RemovalReason
note: str | None = Field(default=None, max_length=255)
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
created_at: datetime
# Für QR-Auflösung/Anzeige mitgeliefert:
product_name: str | None = None
product_brand: str | None = None
# ---- Views ----
class ShoppingItem(BaseModel):
product_id: int