diff --git a/backend/app/crud.py b/backend/app/crud.py index 15abdfe..a4aaf2f 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -26,6 +26,7 @@ from .services.conversion import ( display_unit_info, zweit_faktor, ) +from .services.loans import nicht_verliehen from .services.min_stock import UEBERALL_NAME, lies_ueberall from .services.stock import current_stock, location_subtree_stock_base @@ -164,8 +165,10 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu .filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all() ) item_counts = dict( - db.query(Item.product_id, func.count(Item.id)) - .filter(Item.product_id.in_(ids)).group_by(Item.product_id).all() + nicht_verliehen( + db.query(Item.product_id, func.count(Item.id)) + .filter(Item.product_id.in_(ids)) + ).group_by(Item.product_id).all() ) # Abgelaufene Chargen je Artikel. heute = date.today() diff --git a/backend/app/main.py b/backend/app/main.py index 67731c5..f13753d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -15,6 +15,7 @@ from .routers import ( field_definitions, groups, items, + loans, locations, maintenance, oidc, @@ -305,3 +306,4 @@ app.include_router(dashboard.router) app.include_router(shops.router) app.include_router(field_definitions.router) app.include_router(items.router) +app.include_router(loans.router) diff --git a/backend/app/models.py b/backend/app/models.py index 351795c..7365aae 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -750,6 +750,12 @@ class Item(Base): documents: Mapped[list["ItemDocument"]] = relationship( cascade="all, delete-orphan", order_by="ItemDocument.id" ) + # Verleih-Historie, neueste zuerst. Der offene Verleih (returned_on NULL) + # ist hoechstens einer, siehe ItemLoan. + loans: Mapped[list["ItemLoan"]] = relationship( + back_populates="item", cascade="all, delete-orphan", + order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()", + ) class ItemDocument(Base): @@ -772,6 +778,56 @@ class ItemDocument(Base): uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) +class ItemLoan(Base): + """Ein Verleih-Vorgang eines Einzelstücks: an wen, warum, bis wann. + + Bewusst eine eigene Tabelle statt eines Lagerort-Werts „verliehen": Der + Lagerort bleibt stehen und sagt weiter, wohin das Stück nach der Rückgabe + zurückgehört. Zurückgegebene Verleihe bleiben als Zeile erhalten – damit ist + die Tabelle gleichzeitig die Verleih-Historie je Stück. + + ``returned_on`` NULL heißt „noch draußen"; ein Stück kann nur einen solchen + offenen Verleih haben (Teilindex unten). Verliehene Stücke zählen nirgends + als Bestand, siehe ``services/loans.py``. + """ + + __tablename__ = "item_loans" + __table_args__ = ( + # Zweimal gleichzeitig verliehen kann ein einzelnes Stück nicht sein. + # Als Teilindex, weil nur die OFFENEN Verleihe eindeutig sein dürfen: + # zurückgegebene bleiben ja beliebig oft je Stück stehen. + Index( + "uq_item_loan_offen", + "item_id", + unique=True, + postgresql_where=text("returned_on IS NULL"), + sqlite_where=text("returned_on IS NULL"), + ), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + item_id: Mapped[int] = mapped_column( + ForeignKey("items.id", ondelete="CASCADE"), nullable=False, index=True + ) + # Freitext: Entleiher sind keine Vorrania-Benutzer (Nachbar, Kollege …). + borrower: Mapped[str] = mapped_column(String(120), nullable=False) + reason: Mapped[str | None] = mapped_column(String(255), nullable=True) + lent_on: Mapped[date] = mapped_column(Date, nullable=False, default=date.today) + # Erwartete Rückgabe – bewusst optional: „kurz mal mitgenommen" soll nicht + # an einem Datumszwang scheitern. + due_on: Mapped[date | None] = mapped_column(Date, nullable=True) + returned_on: Mapped[date | None] = mapped_column(Date, nullable=True) + # Wer herausgegeben hat. SET NULL, damit ein gelöschtes Konto den Vorgang + # nicht mitnimmt. + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + + item: Mapped[Item] = relationship(back_populates="loans") + user: Mapped[User | None] = relationship() + + class ProductLocationMinStock(Base): """Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten). diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index c5ca48b..e47ce31 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -45,6 +45,7 @@ from ..schemas import ( TimelinePoint, ) from ..services.conversion import article_unit +from ..services.loans import nicht_verliehen from ..services.stock import current_stock from .settings import get_expiry_warning_days from .views import einkaufsliste_artikel, einkaufsliste_gruppen @@ -323,7 +324,10 @@ def _bestand_beitraege( beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist))) for product in db.query(Product).filter(Product.individual.is_(True)).all(): - anzahl = db.query(Item).filter(Item.product_id == product.id).count() + # Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit. + anzahl = nicht_verliehen( + db.query(Item).filter(Item.product_id == product.id) + ).count() if anzahl <= 0: continue faktor, _label = article_unit(product) diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index 0de9282..b75d250 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -16,6 +16,7 @@ from ..deps import get_current_user, require_admin from ..models import ( Item, ItemDocument, + ItemLoan, Location, Movement, MovementType, @@ -132,6 +133,9 @@ def list_all_items( joinedload(Item.location), joinedload(Item.shop), joinedload(Item.documents), + # Verleihe samt Konto mitladen: Sonst fragt item_to_out je + # verliehenem Stueck einzeln nach (N+1). + joinedload(Item.loans).joinedload(ItemLoan.user), ) .order_by(Item.id) .all() diff --git a/backend/app/routers/loans.py b/backend/app/routers/loans.py new file mode 100644 index 0000000..72f6791 --- /dev/null +++ b/backend/app/routers/loans.py @@ -0,0 +1,204 @@ +"""Verleih von Einzelstücken: herausgeben, zurücknehmen, Übersicht, Historie. + +Verleihen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder +angemeldete Benutzer es tun (wie Ein- und Auslagern), und es wird mit dem Konto +protokolliert. Nur das Löschen eines Vorgangs bleibt Admins vorbehalten, weil es +Historie vernichtet; korrigieren (PATCH) darf jeder. + +Bewusst **keine** Bewegung (``Movement``) je Verleih: Der Verlauf im Dashboard +rechnet den Bestand rückwärts aus vorzeichenbehafteten Bewegungen. Ein +Verleih-Abgang ohne Gegenbuchung würde diese Kurve verfälschen. Die Tabelle +``item_loans`` ist die Historie. +""" + +from datetime import date, timedelta + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session, joinedload + +from ..database import get_db +from ..deps import get_current_user, require_admin +from ..models import Item, ItemLoan, User +from ..schemas import LoanCreate, LoanOut, LoanReturn, LoanUpdate +from ..services.loans import loan_to_out, offener_verleih + +router = APIRouter(tags=["loans"]) + + +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 _loan_or_404(db: Session, loan_id: int) -> ItemLoan: + loan = db.get(ItemLoan, loan_id) + if loan is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Verleih nicht gefunden") + return loan + + +def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | None) -> None: + """Rückgabe und Frist dürfen nicht vor der Herausgabe liegen.""" + if due_on is not None and due_on < lent_on: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "Rückgabedatum liegt vor dem Verleihdatum." + ) + if returned_on is not None and returned_on < lent_on: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, "Rückgabe liegt vor dem Verleihdatum." + ) + + +@router.post( + "/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED +) +def lend_item( + item_id: int, + payload: LoanCreate, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> LoanOut: + """Einzelstück verleihen. Der Lagerort bleibt stehen – dorthin gehört es zurück.""" + item = _item_or_404(db, item_id) + if offener_verleih(db, item.id) is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen." + ) + name = payload.borrower.strip() + if not name: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.") + lent_on = payload.lent_on or date.today() + _pruefe_zeitfolge(lent_on, payload.due_on, None) + + loan = ItemLoan( + item_id=item.id, + borrower=name, + reason=(payload.reason or "").strip() or None, + lent_on=lent_on, + due_on=payload.due_on, + user_id=user.id, + ) + db.add(loan) + db.commit() + db.refresh(loan) + return loan_to_out(loan) + + +@router.post("/items/{item_id}/return", response_model=LoanOut) +def return_item( + item_id: int, + payload: LoanReturn, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> LoanOut: + """Rückgabe buchen. Das Stück zählt danach wieder an seinem Lagerort.""" + item = _item_or_404(db, item_id) + loan = offener_verleih(db, item.id) + if loan is None: + raise HTTPException( + status.HTTP_409_CONFLICT, "Dieses Einzelstück ist nicht verliehen." + ) + returned_on = payload.returned_on or date.today() + _pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on) + loan.returned_on = returned_on + db.commit() + db.refresh(loan) + return loan_to_out(loan) + + +@router.get("/items/{item_id}/loans", response_model=list[LoanOut]) +def item_loans( + item_id: int, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[LoanOut]: + """Verleih-Historie eines Stücks, neueste zuerst.""" + item = _item_or_404(db, item_id) + return [loan_to_out(loan) for loan in item.loans] + + +@router.get("/loans", response_model=list[LoanOut]) +def list_loans( + open_only: bool = True, + due_in: int | None = None, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[LoanOut]: + """Verleihe – standardmäßig nur die offenen, fälligste zuerst. + + ``due_in`` grenzt auf die ein, deren Rückgabe in so vielen Tagen ansteht; + schon überfällige sind dabei immer enthalten (wie bei ``/expiring``). Ohne + Rückgabedatum fällt ein Verleih damit aus der Liste – gewollt: Er ist nie + fällig. Diese Route ist die Datenquelle für die Übersicht und für die + Rückgabe-Erinnerung der App. + """ + query = ( + db.query(ItemLoan) + .options( + joinedload(ItemLoan.user), + joinedload(ItemLoan.item).joinedload(Item.product), + joinedload(ItemLoan.item).joinedload(Item.location), + ) + ) + if open_only: + query = query.filter(ItemLoan.returned_on.is_(None)) + if due_in is not None: + grenze = date.today() + timedelta(days=max(0, due_in)) + query = query.filter(ItemLoan.due_on.isnot(None), ItemLoan.due_on <= grenze) + # Fälligste zuerst; Verleihe ohne Frist hinten nach Verleihdatum. + rows = query.order_by( + ItemLoan.due_on.is_(None), ItemLoan.due_on.asc(), ItemLoan.lent_on.asc() + ).all() + heute = date.today() + return [loan_to_out(loan, heute) for loan in rows] + + +@router.patch("/loans/{loan_id}", response_model=LoanOut) +def update_loan( + loan_id: int, + payload: LoanUpdate, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> LoanOut: + """Verleih korrigieren – Name, Grund, Daten. Auch nachträglich zurückbuchen.""" + loan = _loan_or_404(db, loan_id) + data = payload.model_dump(exclude_unset=True) + if "borrower" in data: + name = (data["borrower"] or "").strip() + if not name: + raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.") + data["borrower"] = name + if "reason" in data: + data["reason"] = (data["reason"] or "").strip() or None + if data.get("lent_on") is None: + data.pop("lent_on", None) # Verleihdatum kann nicht geleert werden + _pruefe_zeitfolge( + data.get("lent_on", loan.lent_on), + data.get("due_on", loan.due_on), + data.get("returned_on", loan.returned_on), + ) + # Wieder-Öffnen nur, solange kein anderer Verleih des Stücks offen ist. + if "returned_on" in data and data["returned_on"] is None: + offen = offener_verleih(db, loan.item_id) + if offen is not None and offen.id != loan.id: + raise HTTPException( + status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen." + ) + for field, value in data.items(): + setattr(loan, field, value) + db.commit() + db.refresh(loan) + return loan_to_out(loan) + + +@router.delete("/loans/{loan_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_loan( + loan_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + """Verleih ganz entfernen (Fehleingabe). Löscht Historie – daher nur Admin.""" + db.delete(_loan_or_404(db, loan_id)) + db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index e8c6de5..9ccfa36 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -770,6 +770,54 @@ class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions): """Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte.""" +class LoanCreate(BaseModel): + """Ein Einzelstück verleihen. Nur der Name ist Pflicht.""" + borrower: str = Field(min_length=1, max_length=120) + reason: str | None = Field(default=None, max_length=255) + # Leer = heute; rückdatieren erlaubt (man trägt es oft später nach). + lent_on: date | None = None + # Erwartete Rückgabe – optional, siehe ItemLoan. + due_on: date | None = None + + +class LoanUpdate(BaseModel): + """Laufenden oder abgeschlossenen Verleih korrigieren (Tippfehler, Datum).""" + borrower: str | None = Field(default=None, min_length=1, max_length=120) + reason: str | None = Field(default=None, max_length=255) + lent_on: date | None = None + due_on: date | None = None + returned_on: date | None = None + + +class LoanReturn(BaseModel): + """Rückgabe buchen. Leeres Datum = heute.""" + returned_on: date | None = None + + +class LoanOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + item_id: int + borrower: str + reason: str | None = None + lent_on: date + due_on: date | None = None + returned_on: date | None = None + created_at: datetime + # Wer herausgegeben hat (Konto kann später gelöscht sein). + username: str | None = None + # Tage über dem Rückgabedatum; None ohne Datum oder wenn noch nicht fällig. + # Vom Server gerechnet, damit App und Web nicht getrennt rechnen. + days_overdue: int | None = None + # Für die Übersicht „was ist gerade draußen" mitgeliefert: + item_uid: str | None = None + product_id: int | None = None + product_name: str | None = None + product_brand: str | None = None + location_id: str | None = None + location_name: str | None = None + + class ItemOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -791,6 +839,9 @@ class ItemOut(BaseModel): category_id: int | None = None category_name: str | None = None documents: list[ItemDocumentOut] = [] + # Der offene Verleih, falls das Stück gerade draußen ist – sonst None. + # Der Lagerort bleibt daneben stehen: dorthin gehört es zurück. + loan: LoanOut | None = None # ---- Views ---- diff --git a/backend/app/services/items.py b/backend/app/services/items.py index f80c6e3..0fa1b42 100644 --- a/backend/app/services/items.py +++ b/backend/app/services/items.py @@ -6,8 +6,9 @@ import secrets from sqlalchemy.orm import Session -from ..models import Item +from ..models import Item, ItemLoan from ..schemas import ItemOut +from .loans import loan_to_out # Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt. _ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" @@ -21,8 +22,20 @@ def generate_uid(db: Session, length: int = 8) -> str: return code +def _offener(item: Item) -> ItemLoan | None: + """Der laufende Verleih aus den schon geladenen Verleihen des Stücks. + + Bewusst über die Beziehung statt per eigener Abfrage: So bleibt die + Einzelstück-Liste bei einer Abfrage, wenn der Aufrufer ``loans`` mitlädt. + """ + for loan in item.loans: + if loan.returned_on is None: + return loan + return None + + def item_to_out(item: Item) -> ItemOut: - """ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige.""" + """ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih.""" 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 @@ -31,4 +44,6 @@ def item_to_out(item: Item) -> ItemOut: kategorie = item.product.category if item.product else None out.category_id = kategorie.id if kategorie else None out.category_name = kategorie.name if kategorie else None + loan = _offener(item) + out.loan = loan_to_out(loan) if loan is not None else None return out diff --git a/backend/app/services/loans.py b/backend/app/services/loans.py new file mode 100644 index 0000000..5eb69fa --- /dev/null +++ b/backend/app/services/loans.py @@ -0,0 +1,62 @@ +"""Verleih von Einzelstücken: Bestandsfilter, offener Verleih, Ausgabe. + +Ein verliehenes Stück ist nicht im Haus. Es zählt deshalb **nirgends** als +Bestand – weder am Lagerort noch im Gesamtbestand; ``nicht_verliehen`` ist die +eine Stelle, an der das steht, und wird von allen Zählungen benutzt +(``services/stock.py``, ``crud.products_to_out_bulk``, Dashboard-Auswertungen). + +Der Lagerort am Stück bleibt dabei erhalten: Er sagt weiter, wohin es nach der +Rückgabe zurückgehört. +""" + +from __future__ import annotations + +from datetime import date + +from sqlalchemy.orm import Query, Session + +from ..models import Item, ItemLoan +from ..schemas import LoanOut + + +def nicht_verliehen(query: Query) -> Query: + """Verliehene Einzelstücke aus einer Item-Abfrage nehmen. + + Arbeitet als korreliertes NOT EXISTS und ist damit unabhängig davon, ob die + Abfrage ganze Items oder nur Spalten (``count``) auswählt. + """ + return query.filter(~Item.loans.any(ItemLoan.returned_on.is_(None))) + + +def offener_verleih(db: Session, item_id: int) -> ItemLoan | None: + """Der laufende Verleih eines Stücks, falls es gerade draußen ist.""" + return ( + db.query(ItemLoan) + .filter(ItemLoan.item_id == item_id, ItemLoan.returned_on.is_(None)) + .first() + ) + + +def tage_ueberfaellig(loan: ItemLoan, heute: date | None = None) -> int | None: + """Tage über dem Rückgabedatum – None ohne Datum, zurück oder noch nicht fällig.""" + if loan.due_on is None or loan.returned_on is not None: + return None + tage = ((heute or date.today()) - loan.due_on).days + return tage if tage > 0 else None + + +def loan_to_out(loan: ItemLoan, heute: date | None = None) -> LoanOut: + """LoanOut inkl. Überfälligkeit und – für die Übersicht – Stück und Artikel.""" + out = LoanOut.model_validate(loan) + out.days_overdue = tage_ueberfaellig(loan, heute) + out.username = loan.user.username if loan.user else None + item = loan.item + if item is not None: + out.item_uid = item.uid + out.product_id = item.product_id + out.location_id = item.location_id + out.location_name = item.location.name if item.location else None + if item.product is not None: + out.product_name = item.product.name + out.product_brand = item.product.brand + return out diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 38c2bde..9563e62 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -20,6 +20,7 @@ from ..models import ( ) from .conversion import to_base from .dates import clean_precision, normalize_best_before +from .loans import nicht_verliehen class StockError(ValueError): @@ -205,11 +206,14 @@ def current_stock(db: Session, product_id: int) -> float: """Bestand eines Produkts (in Basiseinheiten). Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren - die Lot-Mengen. + die Lot-Mengen. Verliehene Stücke sind nicht im Haus und zählen nicht mit + (siehe ``services/loans.py``). """ 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()) + return float( + nicht_verliehen(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() ) @@ -242,7 +246,7 @@ def _summe_je_artikel( for pid, menge in abfrage.group_by(Lot.product_id).all(): je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0) if einzel: - abfrage = ( + abfrage = nicht_verliehen( db.query(Item.product_id, func.count(Item.id)) .filter(Item.product_id.in_(einzel)) ) @@ -304,13 +308,16 @@ def location_stock_base(db: Session, product: Product, location_id: str) -> floa """Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten). Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des - Ortes summiert. + Ortes summiert. Ein verliehenes Stück zählt hier nicht – sein Lagerort bleibt + aber gesetzt, weil es dorthin zurückgehört. """ if product.individual: return float( - db.query(Item) - .filter(Item.product_id == product.id, Item.location_id == location_id) - .count() + nicht_verliehen( + db.query(Item).filter( + Item.product_id == product.id, Item.location_id == location_id + ) + ).count() ) total = ( db.query(Lot.quantity) diff --git a/backend/tests/test_verleih.py b/backend/tests/test_verleih.py new file mode 100644 index 0000000..74c59f8 --- /dev/null +++ b/backend/tests/test_verleih.py @@ -0,0 +1,384 @@ +"""Verleih von Einzelstücken: Bestand, offener Verleih, Fälligkeit, Historie.""" + +from datetime import date, timedelta + +import pytest +from fastapi import HTTPException +from sqlalchemy.exc import IntegrityError + +from app import crud +from app.models import ( + Category, + CategoryTracking, + ItemLoan, + Location, + Product, + Role, + User, +) +from app.routers.items import create_items, get_item, list_all_items +from app.routers.loans import ( + delete_loan, + item_loans, + lend_item, + list_loans, + return_item, + update_loan, +) +from app.routers.views import location_contents +from app.schemas import ItemCreate, LoanCreate, LoanReturn, LoanUpdate +from app.services.stock import current_stock, location_stock_base + + +@pytest.fixture() +def admin(db): + person = User(username="admin", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@pytest.fixture() +def keller(db): + ort = Location(name="Keller") + db.add(ort) + db.commit() + db.refresh(ort) + return ort + + +@pytest.fixture() +def kamera(db): + cat = Category(name="Elektronik", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + # package_size=1 -> Artikeleinheit == Basiseinheit, macht die Menge klar. + product = Product(name="Kamera", category_id=cat.id, individual=True, package_size=1) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _stueck(db, admin, product, ort=None): + return create_items( + product.id, + ItemCreate(count=1, location_id=(ort.id if ort else None)), + db=db, _=admin, + )[0] + + +# -------------------------------------------------------------------------- +# Bestand: verliehen heisst nicht im Haus +# -------------------------------------------------------------------------- +def test_verliehenes_stueck_zaehlt_nirgends_als_bestand(db, admin, kamera, keller): + item = _stueck(db, admin, kamera, keller) + assert current_stock(db, kamera.id) == 1.0 + assert location_stock_base(db, kamera, keller.id) == 1.0 + + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + assert current_stock(db, kamera.id) == 0.0 + assert location_stock_base(db, kamera, keller.id) == 0.0 + # Auch die Artikel-Liste (gebuendelte Abfrage) darf es nicht mitzaehlen. + assert crud.products_to_out_bulk(db, [kamera])[0].stock == 0.0 + + +def test_lagerort_inhalt_zeigt_verliehenes_nicht(db, admin, kamera, keller): + item = _stueck(db, admin, kamera, keller) + inhalt = location_contents(code=keller.id, db=db, _=admin) + assert [e.name for e in inhalt.products] == ["Kamera"] + + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + # „Was liegt hier?" wuerde sonst behaupten, die Kamera liege im Keller. + assert location_contents(code=keller.id, db=db, _=admin).products == [] + + +def test_rueckgabe_bringt_den_bestand_zurueck(db, admin, kamera, keller): + item = _stueck(db, admin, kamera, keller) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + zurueck = return_item(item.id, LoanReturn(), db=db, _=admin) + + assert zurueck.returned_on == date.today() + assert current_stock(db, kamera.id) == 1.0 + # Der Lagerort stand die ganze Zeit still - dorthin gehoert es zurueck. + assert location_stock_base(db, kamera, keller.id) == 1.0 + + +def test_lagerort_bleibt_waehrend_des_verleihs_stehen(db, admin, kamera, keller): + item = _stueck(db, admin, kamera, keller) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + frisch = get_item(item.id, db=db, _=admin) + assert frisch.location_id == keller.id + assert frisch.location_name == "Keller" + + +# -------------------------------------------------------------------------- +# Verleihen und zuruecknehmen +# -------------------------------------------------------------------------- +def test_verleih_merkt_name_grund_und_frist(db, admin, kamera): + item = _stueck(db, admin, kamera) + frist = date.today() + timedelta(days=14) + + loan = lend_item( + item.id, + LoanCreate(borrower=" Markus ", reason=" Hochzeit ", due_on=frist), + db=db, user=admin, + ) + + assert loan.borrower == "Markus" # Freitext getrimmt + assert loan.reason == "Hochzeit" + assert loan.lent_on == date.today() + assert loan.due_on == frist + assert loan.returned_on is None + assert loan.username == "admin" # wer herausgegeben hat + assert loan.item_uid == item.uid + assert loan.product_name == "Kamera" + + +def test_verleih_ohne_frist_ist_erlaubt(db, admin, kamera): + item = _stueck(db, admin, kamera) + loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + assert loan.due_on is None + assert loan.days_overdue is None + + +def test_item_out_zeigt_den_offenen_verleih(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus", reason="Ferien"), db=db, user=admin) + + frisch = get_item(item.id, db=db, _=admin) + assert frisch.loan is not None + assert frisch.loan.borrower == "Markus" + assert frisch.loan.reason == "Ferien" + + return_item(item.id, LoanReturn(), db=db, _=admin) + assert get_item(item.id, db=db, _=admin).loan is None + + +def test_liste_aller_stuecke_zeigt_den_verleih(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + alle = list_all_items(db=db, _=admin) + assert len(alle) == 1 + assert alle[0].loan is not None + assert alle[0].loan.borrower == "Markus" + + +def test_zweimal_verleihen_ohne_rueckgabe_scheitert(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + with pytest.raises(HTTPException) as fehler: + lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin) + assert fehler.value.status_code == 409 + + +def test_nach_rueckgabe_wieder_verleihbar(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + return_item(item.id, LoanReturn(), db=db, _=admin) + + zweiter = lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin) + assert zweiter.borrower == "Anna" + assert current_stock(db, kamera.id) == 0.0 + + +def test_rueckgabe_ohne_offenen_verleih_scheitert(db, admin, kamera): + item = _stueck(db, admin, kamera) + with pytest.raises(HTTPException) as fehler: + return_item(item.id, LoanReturn(), db=db, _=admin) + assert fehler.value.status_code == 409 + + +def test_leerer_name_wird_abgelehnt(db, admin, kamera): + item = _stueck(db, admin, kamera) + with pytest.raises(HTTPException) as fehler: + lend_item(item.id, LoanCreate(borrower=" "), db=db, user=admin) + assert fehler.value.status_code == 400 + + +def test_frist_vor_verleihdatum_wird_abgelehnt(db, admin, kamera): + item = _stueck(db, admin, kamera) + with pytest.raises(HTTPException) as fehler: + lend_item( + item.id, + LoanCreate(borrower="Markus", due_on=date.today() - timedelta(days=1)), + db=db, user=admin, + ) + assert fehler.value.status_code == 400 + + +def test_rueckgabe_vor_verleihdatum_wird_abgelehnt(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + with pytest.raises(HTTPException) as fehler: + return_item( + item.id, LoanReturn(returned_on=date.today() - timedelta(days=1)), + db=db, _=admin, + ) + assert fehler.value.status_code == 400 + + +def test_verleih_eines_unbekannten_stuecks_ist_404(db, admin): + with pytest.raises(HTTPException) as fehler: + lend_item(999, LoanCreate(borrower="Markus"), db=db, user=admin) + assert fehler.value.status_code == 404 + + +# -------------------------------------------------------------------------- +# Faelligkeit und Uebersicht +# -------------------------------------------------------------------------- +def test_ueberfaellige_tage_werden_gerechnet(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item( + item.id, + LoanCreate( + borrower="Markus", + lent_on=date.today() - timedelta(days=10), + due_on=date.today() - timedelta(days=3), + ), + db=db, user=admin, + ) + assert get_item(item.id, db=db, _=admin).loan.days_overdue == 3 + + +def test_frist_heute_ist_noch_nicht_ueberfaellig(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus", due_on=date.today()), + db=db, user=admin) + assert get_item(item.id, db=db, _=admin).loan.days_overdue is None + + +def test_uebersicht_zeigt_offene_faelligste_zuerst(db, admin, kamera): + spaet = _stueck(db, admin, kamera) + frueh = _stueck(db, admin, kamera) + ohne = _stueck(db, admin, kamera) + zurueck = _stueck(db, admin, kamera) + + lend_item(spaet.id, LoanCreate(borrower="Spaet", due_on=date.today() + timedelta(days=20)), + db=db, user=admin) + lend_item(frueh.id, LoanCreate(borrower="Frueh", due_on=date.today() + timedelta(days=2)), + db=db, user=admin) + lend_item(ohne.id, LoanCreate(borrower="Ohne"), db=db, user=admin) + lend_item(zurueck.id, LoanCreate(borrower="Zurueck"), db=db, user=admin) + return_item(zurueck.id, LoanReturn(), db=db, _=admin) + + offen = list_loans(db=db, _=admin) + # Fristen aufsteigend, Verleihe ohne Frist hinten, Zurueckgegebene gar nicht. + assert [v.borrower for v in offen] == ["Frueh", "Spaet", "Ohne"] + + alle = list_loans(open_only=False, db=db, _=admin) + assert "Zurueck" in [v.borrower for v in alle] + + +def test_uebersicht_grenzt_auf_faellige_ein(db, admin, kamera): + bald = _stueck(db, admin, kamera) + spaet = _stueck(db, admin, kamera) + ueberfaellig = _stueck(db, admin, kamera) + ohne = _stueck(db, admin, kamera) + + lend_item(bald.id, LoanCreate(borrower="Bald", due_on=date.today() + timedelta(days=3)), + db=db, user=admin) + lend_item(spaet.id, LoanCreate(borrower="Spaet", due_on=date.today() + timedelta(days=30)), + db=db, user=admin) + lend_item( + ueberfaellig.id, + LoanCreate(borrower="Ueberfaellig", lent_on=date.today() - timedelta(days=9), + due_on=date.today() - timedelta(days=2)), + db=db, user=admin, + ) + lend_item(ohne.id, LoanCreate(borrower="Ohne"), db=db, user=admin) + + namen = [v.borrower for v in list_loans(due_in=7, db=db, _=admin)] + # Ueberfaellige sind immer dabei, „Ohne Frist" ist nie faellig. + assert namen == ["Ueberfaellig", "Bald"] + + +# -------------------------------------------------------------------------- +# Historie und Korrektur +# -------------------------------------------------------------------------- +def test_historie_bleibt_nach_der_rueckgabe(db, admin, kamera): + item = _stueck(db, admin, kamera) + lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + return_item(item.id, LoanReturn(), db=db, _=admin) + lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin) + + historie = item_loans(item.id, db=db, _=admin) + assert {v.borrower for v in historie} == {"Markus", "Anna"} + assert len(historie) == 2 + + +def test_korrektur_aendert_name_und_frist(db, admin, kamera): + item = _stueck(db, admin, kamera) + loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + neu = date.today() + timedelta(days=5) + korrigiert = update_loan( + loan.id, LoanUpdate(borrower="Markus Meier", due_on=neu), db=db, _=admin + ) + assert korrigiert.borrower == "Markus Meier" + assert korrigiert.due_on == neu + + +def test_nachtraegliche_rueckgabe_per_korrektur(db, admin, kamera): + item = _stueck(db, admin, kamera) + loan = lend_item( + item.id, + LoanCreate(borrower="Markus", lent_on=date.today() - timedelta(days=5)), + db=db, user=admin, + ) + + gestern = date.today() - timedelta(days=1) + update_loan(loan.id, LoanUpdate(returned_on=gestern), db=db, _=admin) + + assert get_item(item.id, db=db, _=admin).loan is None + assert current_stock(db, kamera.id) == 1.0 + + +def test_wieder_oeffnen_scheitert_bei_offenem_zweitverleih(db, admin, kamera): + item = _stueck(db, admin, kamera) + erster = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + return_item(item.id, LoanReturn(), db=db, _=admin) + lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin) + + with pytest.raises(HTTPException) as fehler: + update_loan(erster.id, LoanUpdate(returned_on=None), db=db, _=admin) + assert fehler.value.status_code == 409 + + +def test_loeschen_entfernt_den_vorgang(db, admin, kamera): + item = _stueck(db, admin, kamera) + loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + delete_loan(loan.id, db=db, _=admin) + + assert item_loans(item.id, db=db, _=admin) == [] + assert current_stock(db, kamera.id) == 1.0 + + +def test_datenbank_verhindert_zwei_offene_verleihe(db, admin, kamera): + """Der Teilindex schuetzt die Regel auch ohne den Router-Check.""" + item = _stueck(db, admin, kamera) + db.add(ItemLoan(item_id=item.id, borrower="Markus", lent_on=date.today())) + db.commit() + + db.add(ItemLoan(item_id=item.id, borrower="Anna", lent_on=date.today())) + with pytest.raises(IntegrityError): + db.commit() + db.rollback() + + +def test_zurueckgegebene_verleihe_duerfen_sich_haeufen(db, admin, kamera): + """Der Index gilt nur fuer offene – Historie darf beliebig lang werden.""" + item = _stueck(db, admin, kamera) + for name in ("Markus", "Anna", "Beat"): + db.add(ItemLoan(item_id=item.id, borrower=name, lent_on=date.today(), + returned_on=date.today())) + db.commit() + assert len(item_loans(item.id, db=db, _=admin)) == 3 diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 8991ca3..e27670e 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -89,6 +89,35 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. *Bekannte Ungenauigkeit:* Die Umrechnung nutzt die heutige Packungsgröße; wird sie später geändert, verschiebt sich auch die Historie. +## Schritt 2e – Verleih von Einzelstücken (Backend ✅, Oberfläche offen) +- **Eigene Tabelle `item_loans` statt eines Lagerort-Werts „verliehen".** Der + Lagerort am Stück bleibt stehen und sagt weiter, wohin es nach der Rückgabe + zurückgehört; ein Lagerort-Wert hätte diese Information vernichtet. + Je Vorgang: Entleiher (Freitext), Grund (Freitext, optional), Verleihdatum, + erwartetes Rückgabedatum (**optional** – „kurz mal mitgenommen" soll nicht an + einem Datumszwang scheitern) und das Konto, das herausgegeben hat. +- **Zurückgegebene Verleihe bleiben stehen** (`returned_on` gesetzt) – damit ist + die Tabelle gleichzeitig die Verleih-Historie je Stück. Ein Teilindex auf die + offenen Vorgänge sichert ab, dass ein Stück nicht zweimal gleichzeitig + verliehen ist. +- **Verliehen heißt nicht im Bestand.** Ein verliehenes Stück zählt nirgends + mehr mit – weder am Lagerort noch im Gesamtbestand. Entschieden wird das an + einer Stelle (`services/loans.py::nicht_verliehen`), die alle fünf Zählungen + benutzen. „Was liegt hier?" behauptet damit nicht länger, das Stück liege im + Regal. *Folge:* Fällt es unter den Mindestbestand, erscheint der Artikel auf + der Einkaufsliste – gewollt, aber gut zu wissen. +- **Verleihen darf jeder** angemeldete Benutzer (es ist eine Buchung wie Ein- und + Auslagern, keine Stammdatenänderung) und wird mit dem Konto protokolliert. Nur + das *Löschen* eines Vorgangs bleibt Admins vorbehalten, weil es Historie + vernichtet. +- **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den + Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne + Gegenbuchung würde diese Kurve verfälschen. +- Noch offen: Oberfläche in Web und App sowie die **Rückgabe-Erinnerung**. Sie + kann nur den Weg der Ablauf-Meldungen gehen (lokale Benachrichtigung der App + aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die + Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3. + ## Schritt 3 – Weitere Ideen – offen - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus). - **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,