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:
co-authored by
Claude Opus 5
parent
ba84e4ea01
commit
467ef3c804
@@ -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
|
||||
Reference in New Issue
Block a user