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>
205 lines
7.3 KiB
Python
205 lines
7.3 KiB
Python
"""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()
|