Zwei Luecken, die zusammen den Eindruck machten, ein angelegtes Einzelstueck
sei verschwunden.
Der Verlauf kannte nur das Verschwinden. remove_item schreibt seit jeher eine
Bewegung, create_items nicht - Abgaenge von Einzelstuecken tauchten also auf,
Zugaenge nie, im Verlauf wie im Dashboard. Jetzt schreibt das Anlegen je Stueck
eine in-Bewegung mit UID (oder der eigenen Notiz, falls angegeben), Lagerort und
Benutzer, spiegelbildlich zum Entfernen. Der Bestand bleibt unberuehrt: er
zaehlt bei Einzelstuecken die Zeilen in items und rechnet nicht aus Bewegungen.
Und der Weg dorthin fehlte. ProductFormView liefert den angelegten Artikel, aber
zwei der drei Aufrufer warfen ihn weg ("{ _ in showNew = false }") - nur der
Einlagern-Weg nutzte ihn. "Einzelstuecke anlegen" steckt ausschliesslich in der
Artikelansicht, die man vom Scan-Tab und aus den Listen nach dem Anlegen gar
nicht erreicht. Man musste den frischen Artikel von Hand wiederfinden.
Bei Gegenstaenden mit eigener UID je Stueck oeffnet sich jetzt direkt das
Anlege-Formular. Gesetzt wird das im onDismiss des Artikelformulars: beides im
selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet erscheinen soll,
waehrend das erste noch verschwindet.
Der Parameter von create_items heisst dadurch user statt _; die drei Testaufrufe
sind mitgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
385 lines
14 KiB
Python
385 lines
14 KiB
Python
"""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, user=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
|