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

@@ -14,6 +14,7 @@ from .routers import (
dashboard, dashboard,
field_definitions, field_definitions,
groups, groups,
items,
locations, locations,
maintenance, maintenance,
package_types, package_types,
@@ -81,6 +82,9 @@ def _ensure_schema() -> None:
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER " "ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
"REFERENCES locations(id) ON DELETE SET NULL", "REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)", "ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
# Einzelstück-Verwaltung (Items mit UID/QR) je Produkt.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS individual BOOLEAN "
"NOT NULL DEFAULT FALSE",
] ]
with engine.begin() as conn: with engine.begin() as conn:
for stmt in stmts: for stmt in stmts:
@@ -154,3 +158,4 @@ app.include_router(maintenance.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(shops.router) app.include_router(shops.router)
app.include_router(field_definitions.router) app.include_router(field_definitions.router)
app.include_router(items.router)

View File

@@ -229,6 +229,10 @@ class Product(Base):
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
) )
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True) product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
# Gegenstände: als Einzelstücke (Items mit eigener UID/QR) statt als Menge je
# Lagerort verwalten. So bekommt jedes physische Stück ein eigenes Kaufdatum,
# eine eigene Garantie und Bezugsquelle (z.B. dieselbe Powerbank 2024 + 2025).
individual: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -498,3 +502,36 @@ class ProductFieldValue(Base):
product: Mapped[Product] = relationship(back_populates="field_values") product: Mapped[Product] = relationship(back_populates="field_values")
field_definition: Mapped[FieldDefinition] = relationship() field_definition: Mapped[FieldDefinition] = relationship()
class Item(Base):
"""Ein physisches Einzelstück eines Gegenstands (Instanz) mit eigener UID/QR.
Nur für Gegenstands-Produkte mit Einzelstück-Verwaltung (``Product.individual``).
Je Stück ein eigener Lagerort, Kaufdatum, Garantie, Bezugsquelle und Notiz
so lässt sich dasselbe Modell mehrfach getrennt führen (Powerbank 2024 + 2025).
Der QR-Code trägt die UID; ein Scan öffnet genau dieses Stück.
"""
__tablename__ = "items"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
# Kurzer, gut lesbarer Code für den QR (ohne verwechselbare Zeichen).
uid: Mapped[str] = mapped_column(String(16), unique=True, index=True, nullable=False)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
)
acquired_on: Mapped[date | None] = mapped_column(Date, nullable=True) # gekauft am
warranty_until: Mapped[date | None] = mapped_column(Date, nullable=True) # Garantie bis
note: Mapped[str | None] = mapped_column(String(255), nullable=True) # Notiz/Zustand
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
product: Mapped[Product] = relationship()
location: Mapped[Location | None] = relationship()
shop: Mapped[Shop | None] = relationship()

View File

@@ -0,0 +1,158 @@
"""Einzelstücke (Items) eines Gegenstands: anlegen, ändern, entfernen, per UID finden.
Ein Item ist ein physisches Exemplar mit eigener UID/QR und eigenen Angaben
(Lagerort, Kaufdatum, Garantie, Bezugsquelle, Notiz). Nur sinnvoll für
Gegenstands-Produkte mit ``Product.individual``.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Item, Location, Movement, MovementType, Product, Shop, User
from ..schemas import ItemCreate, ItemOut, ItemRemove, ItemUpdate
from ..services.items import generate_uid, item_to_out
router = APIRouter(tags=["items"])
def _product_or_404(db: Session, product_id: int) -> Product:
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
return product
def _item_or_404(db: Session, item_id: int) -> Item:
item = db.get(Item, item_id)
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
return item
def _check_refs(db: Session, shop_id: int | None, location_id: int | None) -> None:
if shop_id is not None and db.get(Shop, shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
if location_id is not None and db.get(Location, location_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
@router.get("/products/{product_id}/items", response_model=list[ItemOut])
def list_items(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ItemOut]:
_product_or_404(db, product_id)
rows = db.query(Item).filter(Item.product_id == product_id).order_by(Item.id).all()
return [item_to_out(i) for i in rows]
@router.post(
"/products/{product_id}/items", response_model=list[ItemOut],
status_code=status.HTTP_201_CREATED,
)
def create_items(
product_id: int,
payload: ItemCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> list[ItemOut]:
"""Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen."""
product = _product_or_404(db, product_id)
_check_refs(db, payload.shop_id, payload.location_id)
created: list[Item] = []
for _ in range(payload.count):
item = Item(
uid=generate_uid(db),
product_id=product.id,
location_id=payload.location_id,
shop_id=payload.shop_id,
acquired_on=payload.acquired_on,
warranty_until=payload.warranty_until,
note=payload.note,
)
db.add(item)
db.flush()
created.append(item)
db.commit()
for item in created:
db.refresh(item)
return [item_to_out(i) for i in created]
@router.get("/items/by-uid/{uid}", response_model=ItemOut)
def item_by_uid(
uid: str,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ItemOut:
"""QR-Auflösung: UID → Einzelstück (mit Produktangaben)."""
item = db.query(Item).filter(Item.uid == uid.strip().upper()).first()
if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Einzelstück mit dieser UID")
return item_to_out(item)
@router.get("/items/{item_id}", response_model=ItemOut)
def get_item(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> ItemOut:
return item_to_out(_item_or_404(db, item_id))
@router.patch("/items/{item_id}", response_model=ItemOut)
def update_item(
item_id: int,
payload: ItemUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ItemOut:
item = _item_or_404(db, item_id)
data = payload.model_dump(exclude_unset=True)
_check_refs(db, data.get("shop_id"), data.get("location_id"))
for field, value in data.items():
setattr(item, field, value)
db.commit()
db.refresh(item)
return item_to_out(item)
@router.post("/items/{item_id}/remove", status_code=status.HTTP_204_NO_CONTENT)
def remove_item(
item_id: int,
payload: ItemRemove,
db: Session = Depends(get_db),
user: User = Depends(require_admin),
) -> None:
"""Einzelstück mit Grund entfernen als Bewegung protokolliert (Statistik je Modell)."""
item = _item_or_404(db, item_id)
db.add(
Movement(
product_id=item.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.out,
quantity=1,
unit_used="Stück",
note=(payload.note or f"Einzelstück {item.uid}"),
location_id=item.location_id,
reason=payload.reason.value,
)
)
db.delete(item)
db.commit()
@router.delete("/items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_item(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Einzelstück ohne Grund löschen (Korrektur)."""
db.delete(_item_or_404(db, item_id))
db.commit()

View File

@@ -335,6 +335,7 @@ def create_product(
min_stock_in_packages=bool(payload.min_stock_in_packages), min_stock_in_packages=bool(payload.min_stock_in_packages),
shop_id=payload.shop_id, shop_id=payload.shop_id,
product_url=payload.product_url or None, product_url=payload.product_url or None,
individual=bool(payload.individual),
source="manual", source="manual",
) )
db.add(product) db.add(product)

View File

@@ -281,6 +281,8 @@ class ProductBase(BaseModel):
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link. # Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
shop_id: int | None = None shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024) 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}. # Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
field_values: dict[int, str | None] | None = None field_values: dict[int, str | None] | None = None
@@ -306,6 +308,7 @@ class ProductUpdate(BaseModel):
min_stock_in_packages: bool | None = None min_stock_in_packages: bool | None = None
shop_id: int | None = None shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024) product_url: str | None = Field(default=None, max_length=1024)
individual: bool | None = None
field_values: dict[int, str | None] | None = None field_values: dict[int, str | None] | None = None
@@ -330,6 +333,7 @@ class ProductOut(BaseModel):
source: str source: str
shop_id: int | None = None shop_id: int | None = None
product_url: str | None = None product_url: str | None = None
individual: bool = False
created_at: datetime created_at: datetime
# angereichert: # angereichert:
stock: float = 0.0 stock: float = 0.0
@@ -498,6 +502,48 @@ class RemovalSummary(BaseModel):
history: list[RemovalHistoryItem] = [] 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 ---- # ---- Views ----
class ShoppingItem(BaseModel): class ShoppingItem(BaseModel):
product_id: int product_id: int

View File

@@ -0,0 +1,31 @@
"""Einzelstücke (Items): UID-Erzeugung und Anreicherung für die Ausgabe."""
from __future__ import annotations
import secrets
from sqlalchemy.orm import Session
from ..models import Item
from ..schemas import ItemOut
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
def generate_uid(db: Session, length: int = 8) -> str:
"""Kurze, eindeutige UID erzeugen (bei Kollision neu würfeln)."""
while True:
code = "".join(secrets.choice(_ALPHABET) for _ in range(length))
if db.query(Item).filter(Item.uid == code).first() is None:
return code
def item_to_out(item: Item) -> ItemOut:
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
out = ItemOut.model_validate(item)
out.location_name = item.location.name if item.location else None
out.shop_name = item.shop.name if item.shop else None
out.product_name = item.product.name if item.product else None
out.product_brand = item.product.brand if item.product else None
return out

View File

@@ -7,7 +7,7 @@ from datetime import date
from sqlalchemy import asc from sqlalchemy import asc
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import DatePrecision, Lot, Movement, MovementType, Product, User from ..models import DatePrecision, Item, Lot, Movement, MovementType, Product, User
from .conversion import to_base from .conversion import to_base
from .dates import clean_precision, normalize_best_before from .dates import clean_precision, normalize_best_before
@@ -192,7 +192,14 @@ def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
def current_stock(db: Session, product_id: int) -> float: def current_stock(db: Session, product_id: int) -> float:
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten).""" """Bestand eines Produkts (in Basiseinheiten).
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
die Lot-Mengen.
"""
product = db.get(Product, product_id)
if product is not None and product.individual:
return float(db.query(Item).filter(Item.product_id == product_id).count())
total = ( total = (
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all() db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
) )

View File

@@ -14,6 +14,7 @@ from app.models import (
Category, Category,
CategoryTracking, CategoryTracking,
FieldDefinition, FieldDefinition,
Item,
Location, Location,
Lot, Lot,
Movement, Movement,
@@ -21,6 +22,7 @@ from app.models import (
ProductFieldValue, ProductFieldValue,
RemovalReason, RemovalReason,
) )
from app.services.items import generate_uid
from app.routers.categories import create_category from app.routers.categories import create_category
from app.schemas import CategoryCreate from app.schemas import CategoryCreate
from app.services.fields import ( from app.services.fields import (
@@ -236,3 +238,40 @@ def test_feldwert_setzen_und_leeren(db):
apply_field_values(db, product, {fd.id: ""}) apply_field_values(db, product, {fd.id: ""})
db.commit() db.commit()
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0 assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0
# ---- Einzelstücke (Items mit UID/QR) ----
def _individual_product(db, name="Powerbank"):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name=name, category_id=cat.id, individual=True)
db.add(product)
db.commit()
db.refresh(product)
return product
def test_uid_ist_eindeutig(db):
codes = {generate_uid(db) for _ in range(50)}
assert len(codes) == 50
# Keine verwechselbaren Zeichen (0/O/1/I/L) in der UID.
assert all(ch not in "01OIL" for code in codes for ch in code)
def test_einzelstueck_bestand_zaehlt_items(db):
product = _individual_product(db)
for _ in range(3):
db.add(Item(uid=generate_uid(db), product_id=product.id))
db.commit()
assert current_stock(db, product.id) == 3
def test_mengen_produkt_zaehlt_weiter_lots(db):
# Ein Nicht-Einzelstück-Produkt zählt weiterhin über Lots, nicht über Items.
product, _ = _object_product(db, name="Unterhose")
a = _loc(db, "Schrank A")
object_add(db, product, 5, a.id, None)
db.commit()
assert current_stock(db, product.id) == 5