diff --git a/backend/app/main.py b/backend/app/main.py index a8f6be6..f0d0276 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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) diff --git a/backend/app/models.py b/backend/app/models.py index 8d582ef..a470c5e 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -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() diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py new file mode 100644 index 0000000..c6634a0 --- /dev/null +++ b/backend/app/routers/items.py @@ -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() diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 5f0a913..ab9b738 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -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) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 61cd1e3..4b8885b 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -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 diff --git a/backend/app/services/items.py b/backend/app/services/items.py new file mode 100644 index 0000000..45e2f28 --- /dev/null +++ b/backend/app/services/items.py @@ -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 diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 0db6bf9..9ebb632 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -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() ) diff --git a/backend/tests/test_gegenstaende.py b/backend/tests/test_gegenstaende.py index 07cfcb1..75f144b 100644 --- a/backend/tests/test_gegenstaende.py +++ b/backend/tests/test_gegenstaende.py @@ -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