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>
This commit is contained in:
Scarriffle
2026-09-17 09:16:47 +02:00
co-authored by Claude Opus 5
parent ba84e4ea01
commit 467ef3c804
12 changed files with 833 additions and 12 deletions
+62
View File
@@ -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