Files
Vorrania/backend/app/routers/loans.py
T
ScarriffleandClaude Opus 5 467ef3c804 Einzelstuecke verleihen: eigener Vorgang statt Lagerort-Wert
Der Lagerort bleibt beim Verleihen stehen - er sagt weiter, wohin das
Stueck nach der Rueckgabe zurueckgehoert. Ein Lagerort-Wert "verliehen"
haette diese Information vernichtet. Stattdessen eine eigene Tabelle
item_loans mit Entleiher und Grund als Freitext, Verleihdatum und einem
optionalen Rueckgabedatum: "kurz mal mitgenommen" soll nicht an einem
Datumszwang scheitern.

Zurueckgegebene Vorgaenge bleiben stehen, damit die Tabelle gleichzeitig
die Verleih-Historie je Stueck ist. Ein Teilindex auf die offenen
Vorgaenge haelt fest, dass ein Stueck nicht zweimal gleichzeitig
verliehen sein kann - wie bei den "Ueberall"-Mindestbestaenden, weil
Postgres NULLs in UNIQUE als verschieden zaehlt.

Verliehen heisst nicht im Bestand: Ein Stueck, das nicht im Haus ist,
zaehlt nirgends mehr mit - weder am Lagerort noch im Gesamtbestand.
Entschieden wird das an einer Stelle (services/loans.py::nicht_verliehen),
die alle fuenf Zaehlungen benutzen. Sonst behauptete "Was liegt hier?"
weiterhin, die Kamera liege im Regal. Folge: Faellt ein verliehenes
Stueck unter den Mindestbestand, erscheint der Artikel auf der
Einkaufsliste.

Verleihen und Zuruecknehmen darf jeder angemeldete Benutzer - es ist eine
Buchung wie Ein- und Auslagern, keine Stammdatenaenderung - und wird mit
dem Konto protokolliert. Nur das Loeschen eines Vorgangs bleibt Admins
vorbehalten, weil es Historie vernichtet.

Bewusst keine Movement-Bewegung je Verleih: Der Verlauf rechnet den
Bestand rueckwaerts aus vorzeichenbehafteten Bewegungen, ein Abgang ohne
Gegenbuchung wuerde diese Kurve verfaelschen.

GET /loans?due_in= liefert die faelligen Verleihe und ist damit schon die
Datenquelle fuer die spaetere Rueckgabe-Erinnerung der App. Oberflaeche in
Web und App fehlt noch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:16:47 +02:00

205 lines
7.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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()