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
+56
View File
@@ -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).