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:
@@ -14,6 +14,7 @@ from .routers import (
|
||||
dashboard,
|
||||
field_definitions,
|
||||
groups,
|
||||
items,
|
||||
locations,
|
||||
maintenance,
|
||||
package_types,
|
||||
@@ -81,6 +82,9 @@ def _ensure_schema() -> None:
|
||||
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
|
||||
"REFERENCES locations(id) ON DELETE SET NULL",
|
||||
"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:
|
||||
for stmt in stmts:
|
||||
@@ -154,3 +158,4 @@ app.include_router(maintenance.router)
|
||||
app.include_router(dashboard.router)
|
||||
app.include_router(shops.router)
|
||||
app.include_router(field_definitions.router)
|
||||
app.include_router(items.router)
|
||||
|
||||
@@ -229,6 +229,10 @@ class Product(Base):
|
||||
ForeignKey("shops.id", ondelete="SET NULL"), 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)
|
||||
|
||||
@@ -498,3 +502,36 @@ class ProductFieldValue(Base):
|
||||
|
||||
product: Mapped[Product] = relationship(back_populates="field_values")
|
||||
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()
|
||||
|
||||
158
backend/app/routers/items.py
Normal file
158
backend/app/routers/items.py
Normal 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()
|
||||
@@ -335,6 +335,7 @@ def create_product(
|
||||
min_stock_in_packages=bool(payload.min_stock_in_packages),
|
||||
shop_id=payload.shop_id,
|
||||
product_url=payload.product_url or None,
|
||||
individual=bool(payload.individual),
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
|
||||
@@ -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
|
||||
|
||||
31
backend/app/services/items.py
Normal file
31
backend/app/services/items.py
Normal 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
|
||||
@@ -7,7 +7,7 @@ from datetime import date
|
||||
from sqlalchemy import asc
|
||||
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 .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:
|
||||
"""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 = (
|
||||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ from app.models import (
|
||||
Category,
|
||||
CategoryTracking,
|
||||
FieldDefinition,
|
||||
Item,
|
||||
Location,
|
||||
Lot,
|
||||
Movement,
|
||||
@@ -21,6 +22,7 @@ from app.models import (
|
||||
ProductFieldValue,
|
||||
RemovalReason,
|
||||
)
|
||||
from app.services.items import generate_uid
|
||||
from app.routers.categories import create_category
|
||||
from app.schemas import CategoryCreate
|
||||
from app.services.fields import (
|
||||
@@ -236,3 +238,40 @@ def test_feldwert_setzen_und_leeren(db):
|
||||
apply_field_values(db, product, {fd.id: ""})
|
||||
db.commit()
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user