Files
Vorrania/backend/tests/test_verleih.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

385 lines
14 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: 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