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
+384
View File
@@ -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