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
+4
-1
@@ -26,6 +26,7 @@ from .services.conversion import (
|
|||||||
display_unit_info,
|
display_unit_info,
|
||||||
zweit_faktor,
|
zweit_faktor,
|
||||||
)
|
)
|
||||||
|
from .services.loans import nicht_verliehen
|
||||||
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
from .services.min_stock import UEBERALL_NAME, lies_ueberall
|
||||||
from .services.stock import current_stock, location_subtree_stock_base
|
from .services.stock import current_stock, location_subtree_stock_base
|
||||||
|
|
||||||
@@ -164,8 +165,10 @@ def products_to_out_bulk(db: Session, products: list[Product]) -> list[ProductOu
|
|||||||
.filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all()
|
.filter(Lot.product_id.in_(ids)).group_by(Lot.product_id).all()
|
||||||
)
|
)
|
||||||
item_counts = dict(
|
item_counts = dict(
|
||||||
|
nicht_verliehen(
|
||||||
db.query(Item.product_id, func.count(Item.id))
|
db.query(Item.product_id, func.count(Item.id))
|
||||||
.filter(Item.product_id.in_(ids)).group_by(Item.product_id).all()
|
.filter(Item.product_id.in_(ids))
|
||||||
|
).group_by(Item.product_id).all()
|
||||||
)
|
)
|
||||||
# Abgelaufene Chargen je Artikel.
|
# Abgelaufene Chargen je Artikel.
|
||||||
heute = date.today()
|
heute = date.today()
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from .routers import (
|
|||||||
field_definitions,
|
field_definitions,
|
||||||
groups,
|
groups,
|
||||||
items,
|
items,
|
||||||
|
loans,
|
||||||
locations,
|
locations,
|
||||||
maintenance,
|
maintenance,
|
||||||
oidc,
|
oidc,
|
||||||
@@ -305,3 +306,4 @@ app.include_router(dashboard.router)
|
|||||||
app.include_router(shops.router)
|
app.include_router(shops.router)
|
||||||
app.include_router(field_definitions.router)
|
app.include_router(field_definitions.router)
|
||||||
app.include_router(items.router)
|
app.include_router(items.router)
|
||||||
|
app.include_router(loans.router)
|
||||||
|
|||||||
@@ -750,6 +750,12 @@ class Item(Base):
|
|||||||
documents: Mapped[list["ItemDocument"]] = relationship(
|
documents: Mapped[list["ItemDocument"]] = relationship(
|
||||||
cascade="all, delete-orphan", order_by="ItemDocument.id"
|
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):
|
class ItemDocument(Base):
|
||||||
@@ -772,6 +778,56 @@ class ItemDocument(Base):
|
|||||||
uploaded_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
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):
|
class ProductLocationMinStock(Base):
|
||||||
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ from ..schemas import (
|
|||||||
TimelinePoint,
|
TimelinePoint,
|
||||||
)
|
)
|
||||||
from ..services.conversion import article_unit
|
from ..services.conversion import article_unit
|
||||||
|
from ..services.loans import nicht_verliehen
|
||||||
from ..services.stock import current_stock
|
from ..services.stock import current_stock
|
||||||
from .settings import get_expiry_warning_days
|
from .settings import get_expiry_warning_days
|
||||||
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
from .views import einkaufsliste_artikel, einkaufsliste_gruppen
|
||||||
@@ -323,7 +324,10 @@ def _bestand_beitraege(
|
|||||||
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
|
beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist)))
|
||||||
|
|
||||||
for product in db.query(Product).filter(Product.individual.is_(True)).all():
|
for product in db.query(Product).filter(Product.individual.is_(True)).all():
|
||||||
anzahl = db.query(Item).filter(Item.product_id == product.id).count()
|
# Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit.
|
||||||
|
anzahl = nicht_verliehen(
|
||||||
|
db.query(Item).filter(Item.product_id == product.id)
|
||||||
|
).count()
|
||||||
if anzahl <= 0:
|
if anzahl <= 0:
|
||||||
continue
|
continue
|
||||||
faktor, _label = article_unit(product)
|
faktor, _label = article_unit(product)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from ..deps import get_current_user, require_admin
|
|||||||
from ..models import (
|
from ..models import (
|
||||||
Item,
|
Item,
|
||||||
ItemDocument,
|
ItemDocument,
|
||||||
|
ItemLoan,
|
||||||
Location,
|
Location,
|
||||||
Movement,
|
Movement,
|
||||||
MovementType,
|
MovementType,
|
||||||
@@ -132,6 +133,9 @@ def list_all_items(
|
|||||||
joinedload(Item.location),
|
joinedload(Item.location),
|
||||||
joinedload(Item.shop),
|
joinedload(Item.shop),
|
||||||
joinedload(Item.documents),
|
joinedload(Item.documents),
|
||||||
|
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
|
||||||
|
# verliehenem Stueck einzeln nach (N+1).
|
||||||
|
joinedload(Item.loans).joinedload(ItemLoan.user),
|
||||||
)
|
)
|
||||||
.order_by(Item.id)
|
.order_by(Item.id)
|
||||||
.all()
|
.all()
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Verleih von Einzelstücken: herausgeben, zurücknehmen, Übersicht, Historie.
|
||||||
|
|
||||||
|
Verleihen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
|
||||||
|
angemeldete Benutzer es tun (wie Ein- und Auslagern), und es wird mit dem Konto
|
||||||
|
protokolliert. Nur das Löschen eines Vorgangs bleibt Admins vorbehalten, weil es
|
||||||
|
Historie vernichtet; korrigieren (PATCH) darf jeder.
|
||||||
|
|
||||||
|
Bewusst **keine** Bewegung (``Movement``) je Verleih: Der Verlauf im Dashboard
|
||||||
|
rechnet den Bestand rückwärts aus vorzeichenbehafteten Bewegungen. Ein
|
||||||
|
Verleih-Abgang ohne Gegenbuchung würde diese Kurve verfälschen. Die Tabelle
|
||||||
|
``item_loans`` ist die Historie.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session, joinedload
|
||||||
|
|
||||||
|
from ..database import get_db
|
||||||
|
from ..deps import get_current_user, require_admin
|
||||||
|
from ..models import Item, ItemLoan, User
|
||||||
|
from ..schemas import LoanCreate, LoanOut, LoanReturn, LoanUpdate
|
||||||
|
from ..services.loans import loan_to_out, offener_verleih
|
||||||
|
|
||||||
|
router = APIRouter(tags=["loans"])
|
||||||
|
|
||||||
|
|
||||||
|
def _item_or_404(db: Session, item_id: int) -> Item:
|
||||||
|
item = db.get(Item, item_id)
|
||||||
|
if item is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einzelstück nicht gefunden")
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
def _loan_or_404(db: Session, loan_id: int) -> ItemLoan:
|
||||||
|
loan = db.get(ItemLoan, loan_id)
|
||||||
|
if loan is None:
|
||||||
|
raise HTTPException(status.HTTP_404_NOT_FOUND, "Verleih nicht gefunden")
|
||||||
|
return loan
|
||||||
|
|
||||||
|
|
||||||
|
def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | None) -> None:
|
||||||
|
"""Rückgabe und Frist dürfen nicht vor der Herausgabe liegen."""
|
||||||
|
if due_on is not None and due_on < lent_on:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Rückgabedatum liegt vor dem Verleihdatum."
|
||||||
|
)
|
||||||
|
if returned_on is not None and returned_on < lent_on:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_400_BAD_REQUEST, "Rückgabe liegt vor dem Verleihdatum."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED
|
||||||
|
)
|
||||||
|
def lend_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: LoanCreate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
) -> LoanOut:
|
||||||
|
"""Einzelstück verleihen. Der Lagerort bleibt stehen – dorthin gehört es zurück."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
if offener_verleih(db, item.id) is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen."
|
||||||
|
)
|
||||||
|
name = payload.borrower.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.")
|
||||||
|
lent_on = payload.lent_on or date.today()
|
||||||
|
_pruefe_zeitfolge(lent_on, payload.due_on, None)
|
||||||
|
|
||||||
|
loan = ItemLoan(
|
||||||
|
item_id=item.id,
|
||||||
|
borrower=name,
|
||||||
|
reason=(payload.reason or "").strip() or None,
|
||||||
|
lent_on=lent_on,
|
||||||
|
due_on=payload.due_on,
|
||||||
|
user_id=user.id,
|
||||||
|
)
|
||||||
|
db.add(loan)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(loan)
|
||||||
|
return loan_to_out(loan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/items/{item_id}/return", response_model=LoanOut)
|
||||||
|
def return_item(
|
||||||
|
item_id: int,
|
||||||
|
payload: LoanReturn,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> LoanOut:
|
||||||
|
"""Rückgabe buchen. Das Stück zählt danach wieder an seinem Lagerort."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
loan = offener_verleih(db, item.id)
|
||||||
|
if loan is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist nicht verliehen."
|
||||||
|
)
|
||||||
|
returned_on = payload.returned_on or date.today()
|
||||||
|
_pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on)
|
||||||
|
loan.returned_on = returned_on
|
||||||
|
db.commit()
|
||||||
|
db.refresh(loan)
|
||||||
|
return loan_to_out(loan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/items/{item_id}/loans", response_model=list[LoanOut])
|
||||||
|
def item_loans(
|
||||||
|
item_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[LoanOut]:
|
||||||
|
"""Verleih-Historie eines Stücks, neueste zuerst."""
|
||||||
|
item = _item_or_404(db, item_id)
|
||||||
|
return [loan_to_out(loan) for loan in item.loans]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/loans", response_model=list[LoanOut])
|
||||||
|
def list_loans(
|
||||||
|
open_only: bool = True,
|
||||||
|
due_in: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> list[LoanOut]:
|
||||||
|
"""Verleihe – standardmäßig nur die offenen, fälligste zuerst.
|
||||||
|
|
||||||
|
``due_in`` grenzt auf die ein, deren Rückgabe in so vielen Tagen ansteht;
|
||||||
|
schon überfällige sind dabei immer enthalten (wie bei ``/expiring``). Ohne
|
||||||
|
Rückgabedatum fällt ein Verleih damit aus der Liste – gewollt: Er ist nie
|
||||||
|
fällig. Diese Route ist die Datenquelle für die Übersicht und für die
|
||||||
|
Rückgabe-Erinnerung der App.
|
||||||
|
"""
|
||||||
|
query = (
|
||||||
|
db.query(ItemLoan)
|
||||||
|
.options(
|
||||||
|
joinedload(ItemLoan.user),
|
||||||
|
joinedload(ItemLoan.item).joinedload(Item.product),
|
||||||
|
joinedload(ItemLoan.item).joinedload(Item.location),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if open_only:
|
||||||
|
query = query.filter(ItemLoan.returned_on.is_(None))
|
||||||
|
if due_in is not None:
|
||||||
|
grenze = date.today() + timedelta(days=max(0, due_in))
|
||||||
|
query = query.filter(ItemLoan.due_on.isnot(None), ItemLoan.due_on <= grenze)
|
||||||
|
# Fälligste zuerst; Verleihe ohne Frist hinten nach Verleihdatum.
|
||||||
|
rows = query.order_by(
|
||||||
|
ItemLoan.due_on.is_(None), ItemLoan.due_on.asc(), ItemLoan.lent_on.asc()
|
||||||
|
).all()
|
||||||
|
heute = date.today()
|
||||||
|
return [loan_to_out(loan, heute) for loan in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/loans/{loan_id}", response_model=LoanOut)
|
||||||
|
def update_loan(
|
||||||
|
loan_id: int,
|
||||||
|
payload: LoanUpdate,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(get_current_user),
|
||||||
|
) -> LoanOut:
|
||||||
|
"""Verleih korrigieren – Name, Grund, Daten. Auch nachträglich zurückbuchen."""
|
||||||
|
loan = _loan_or_404(db, loan_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "borrower" in data:
|
||||||
|
name = (data["borrower"] or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Bitte einen Namen angeben.")
|
||||||
|
data["borrower"] = name
|
||||||
|
if "reason" in data:
|
||||||
|
data["reason"] = (data["reason"] or "").strip() or None
|
||||||
|
if data.get("lent_on") is None:
|
||||||
|
data.pop("lent_on", None) # Verleihdatum kann nicht geleert werden
|
||||||
|
_pruefe_zeitfolge(
|
||||||
|
data.get("lent_on", loan.lent_on),
|
||||||
|
data.get("due_on", loan.due_on),
|
||||||
|
data.get("returned_on", loan.returned_on),
|
||||||
|
)
|
||||||
|
# Wieder-Öffnen nur, solange kein anderer Verleih des Stücks offen ist.
|
||||||
|
if "returned_on" in data and data["returned_on"] is None:
|
||||||
|
offen = offener_verleih(db, loan.item_id)
|
||||||
|
if offen is not None and offen.id != loan.id:
|
||||||
|
raise HTTPException(
|
||||||
|
status.HTTP_409_CONFLICT, "Dieses Einzelstück ist bereits verliehen."
|
||||||
|
)
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(loan, field, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(loan)
|
||||||
|
return loan_to_out(loan)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/loans/{loan_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_loan(
|
||||||
|
loan_id: int,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
_: User = Depends(require_admin),
|
||||||
|
) -> None:
|
||||||
|
"""Verleih ganz entfernen (Fehleingabe). Löscht Historie – daher nur Admin."""
|
||||||
|
db.delete(_loan_or_404(db, loan_id))
|
||||||
|
db.commit()
|
||||||
@@ -770,6 +770,54 @@ class ItemDocumentUploadOut(ItemDocumentOut, DocumentSuggestions):
|
|||||||
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
"""Antwort nach dem Upload – Beleg-Metadaten plus die geschätzten Werte."""
|
||||||
|
|
||||||
|
|
||||||
|
class LoanCreate(BaseModel):
|
||||||
|
"""Ein Einzelstück verleihen. Nur der Name ist Pflicht."""
|
||||||
|
borrower: str = Field(min_length=1, max_length=120)
|
||||||
|
reason: str | None = Field(default=None, max_length=255)
|
||||||
|
# Leer = heute; rückdatieren erlaubt (man trägt es oft später nach).
|
||||||
|
lent_on: date | None = None
|
||||||
|
# Erwartete Rückgabe – optional, siehe ItemLoan.
|
||||||
|
due_on: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoanUpdate(BaseModel):
|
||||||
|
"""Laufenden oder abgeschlossenen Verleih korrigieren (Tippfehler, Datum)."""
|
||||||
|
borrower: str | None = Field(default=None, min_length=1, max_length=120)
|
||||||
|
reason: str | None = Field(default=None, max_length=255)
|
||||||
|
lent_on: date | None = None
|
||||||
|
due_on: date | None = None
|
||||||
|
returned_on: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoanReturn(BaseModel):
|
||||||
|
"""Rückgabe buchen. Leeres Datum = heute."""
|
||||||
|
returned_on: date | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class LoanOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
item_id: int
|
||||||
|
borrower: str
|
||||||
|
reason: str | None = None
|
||||||
|
lent_on: date
|
||||||
|
due_on: date | None = None
|
||||||
|
returned_on: date | None = None
|
||||||
|
created_at: datetime
|
||||||
|
# Wer herausgegeben hat (Konto kann später gelöscht sein).
|
||||||
|
username: str | None = None
|
||||||
|
# Tage über dem Rückgabedatum; None ohne Datum oder wenn noch nicht fällig.
|
||||||
|
# Vom Server gerechnet, damit App und Web nicht getrennt rechnen.
|
||||||
|
days_overdue: int | None = None
|
||||||
|
# Für die Übersicht „was ist gerade draußen" mitgeliefert:
|
||||||
|
item_uid: str | None = None
|
||||||
|
product_id: int | None = None
|
||||||
|
product_name: str | None = None
|
||||||
|
product_brand: str | None = None
|
||||||
|
location_id: str | None = None
|
||||||
|
location_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class ItemOut(BaseModel):
|
class ItemOut(BaseModel):
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
id: int
|
id: int
|
||||||
@@ -791,6 +839,9 @@ class ItemOut(BaseModel):
|
|||||||
category_id: int | None = None
|
category_id: int | None = None
|
||||||
category_name: str | None = None
|
category_name: str | None = None
|
||||||
documents: list[ItemDocumentOut] = []
|
documents: list[ItemDocumentOut] = []
|
||||||
|
# Der offene Verleih, falls das Stück gerade draußen ist – sonst None.
|
||||||
|
# Der Lagerort bleibt daneben stehen: dorthin gehört es zurück.
|
||||||
|
loan: LoanOut | None = None
|
||||||
|
|
||||||
|
|
||||||
# ---- Views ----
|
# ---- Views ----
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import secrets
|
|||||||
|
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from ..models import Item
|
from ..models import Item, ItemLoan
|
||||||
from ..schemas import ItemOut
|
from ..schemas import ItemOut
|
||||||
|
from .loans import loan_to_out
|
||||||
|
|
||||||
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
|
||||||
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||||
@@ -21,8 +22,20 @@ def generate_uid(db: Session, length: int = 8) -> str:
|
|||||||
return code
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _offener(item: Item) -> ItemLoan | None:
|
||||||
|
"""Der laufende Verleih aus den schon geladenen Verleihen des Stücks.
|
||||||
|
|
||||||
|
Bewusst über die Beziehung statt per eigener Abfrage: So bleibt die
|
||||||
|
Einzelstück-Liste bei einer Abfrage, wenn der Aufrufer ``loans`` mitlädt.
|
||||||
|
"""
|
||||||
|
for loan in item.loans:
|
||||||
|
if loan.returned_on is None:
|
||||||
|
return loan
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def item_to_out(item: Item) -> ItemOut:
|
def item_to_out(item: Item) -> ItemOut:
|
||||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
|
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih."""
|
||||||
out = ItemOut.model_validate(item)
|
out = ItemOut.model_validate(item)
|
||||||
out.location_name = item.location.name if item.location else None
|
out.location_name = item.location.name if item.location else None
|
||||||
out.shop_name = item.shop.name if item.shop else None
|
out.shop_name = item.shop.name if item.shop else None
|
||||||
@@ -31,4 +44,6 @@ def item_to_out(item: Item) -> ItemOut:
|
|||||||
kategorie = item.product.category if item.product else None
|
kategorie = item.product.category if item.product else None
|
||||||
out.category_id = kategorie.id if kategorie else None
|
out.category_id = kategorie.id if kategorie else None
|
||||||
out.category_name = kategorie.name if kategorie else None
|
out.category_name = kategorie.name if kategorie else None
|
||||||
|
loan = _offener(item)
|
||||||
|
out.loan = loan_to_out(loan) if loan is not None else None
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""Verleih von Einzelstücken: Bestandsfilter, offener Verleih, Ausgabe.
|
||||||
|
|
||||||
|
Ein verliehenes Stück ist nicht im Haus. Es zählt deshalb **nirgends** als
|
||||||
|
Bestand – weder am Lagerort noch im Gesamtbestand; ``nicht_verliehen`` ist die
|
||||||
|
eine Stelle, an der das steht, und wird von allen Zählungen benutzt
|
||||||
|
(``services/stock.py``, ``crud.products_to_out_bulk``, Dashboard-Auswertungen).
|
||||||
|
|
||||||
|
Der Lagerort am Stück bleibt dabei erhalten: Er sagt weiter, wohin es nach der
|
||||||
|
Rückgabe zurückgehört.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from sqlalchemy.orm import Query, Session
|
||||||
|
|
||||||
|
from ..models import Item, ItemLoan
|
||||||
|
from ..schemas import LoanOut
|
||||||
|
|
||||||
|
|
||||||
|
def nicht_verliehen(query: Query) -> Query:
|
||||||
|
"""Verliehene Einzelstücke aus einer Item-Abfrage nehmen.
|
||||||
|
|
||||||
|
Arbeitet als korreliertes NOT EXISTS und ist damit unabhängig davon, ob die
|
||||||
|
Abfrage ganze Items oder nur Spalten (``count``) auswählt.
|
||||||
|
"""
|
||||||
|
return query.filter(~Item.loans.any(ItemLoan.returned_on.is_(None)))
|
||||||
|
|
||||||
|
|
||||||
|
def offener_verleih(db: Session, item_id: int) -> ItemLoan | None:
|
||||||
|
"""Der laufende Verleih eines Stücks, falls es gerade draußen ist."""
|
||||||
|
return (
|
||||||
|
db.query(ItemLoan)
|
||||||
|
.filter(ItemLoan.item_id == item_id, ItemLoan.returned_on.is_(None))
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def tage_ueberfaellig(loan: ItemLoan, heute: date | None = None) -> int | None:
|
||||||
|
"""Tage über dem Rückgabedatum – None ohne Datum, zurück oder noch nicht fällig."""
|
||||||
|
if loan.due_on is None or loan.returned_on is not None:
|
||||||
|
return None
|
||||||
|
tage = ((heute or date.today()) - loan.due_on).days
|
||||||
|
return tage if tage > 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def loan_to_out(loan: ItemLoan, heute: date | None = None) -> LoanOut:
|
||||||
|
"""LoanOut inkl. Überfälligkeit und – für die Übersicht – Stück und Artikel."""
|
||||||
|
out = LoanOut.model_validate(loan)
|
||||||
|
out.days_overdue = tage_ueberfaellig(loan, heute)
|
||||||
|
out.username = loan.user.username if loan.user else None
|
||||||
|
item = loan.item
|
||||||
|
if item is not None:
|
||||||
|
out.item_uid = item.uid
|
||||||
|
out.product_id = item.product_id
|
||||||
|
out.location_id = item.location_id
|
||||||
|
out.location_name = item.location.name if item.location else None
|
||||||
|
if item.product is not None:
|
||||||
|
out.product_name = item.product.name
|
||||||
|
out.product_brand = item.product.brand
|
||||||
|
return out
|
||||||
@@ -20,6 +20,7 @@ from ..models import (
|
|||||||
)
|
)
|
||||||
from .conversion import to_base
|
from .conversion import to_base
|
||||||
from .dates import clean_precision, normalize_best_before
|
from .dates import clean_precision, normalize_best_before
|
||||||
|
from .loans import nicht_verliehen
|
||||||
|
|
||||||
|
|
||||||
class StockError(ValueError):
|
class StockError(ValueError):
|
||||||
@@ -205,11 +206,14 @@ def current_stock(db: Session, product_id: int) -> float:
|
|||||||
"""Bestand eines Produkts (in Basiseinheiten).
|
"""Bestand eines Produkts (in Basiseinheiten).
|
||||||
|
|
||||||
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
|
Einzelstück-Produkte zählen die Anzahl ihrer Items, alle anderen summieren
|
||||||
die Lot-Mengen.
|
die Lot-Mengen. Verliehene Stücke sind nicht im Haus und zählen nicht mit
|
||||||
|
(siehe ``services/loans.py``).
|
||||||
"""
|
"""
|
||||||
product = db.get(Product, product_id)
|
product = db.get(Product, product_id)
|
||||||
if product is not None and product.individual:
|
if product is not None and product.individual:
|
||||||
return float(db.query(Item).filter(Item.product_id == product_id).count())
|
return float(
|
||||||
|
nicht_verliehen(db.query(Item).filter(Item.product_id == product_id)).count()
|
||||||
|
)
|
||||||
total = (
|
total = (
|
||||||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||||||
)
|
)
|
||||||
@@ -242,7 +246,7 @@ def _summe_je_artikel(
|
|||||||
for pid, menge in abfrage.group_by(Lot.product_id).all():
|
for pid, menge in abfrage.group_by(Lot.product_id).all():
|
||||||
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
|
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
|
||||||
if einzel:
|
if einzel:
|
||||||
abfrage = (
|
abfrage = nicht_verliehen(
|
||||||
db.query(Item.product_id, func.count(Item.id))
|
db.query(Item.product_id, func.count(Item.id))
|
||||||
.filter(Item.product_id.in_(einzel))
|
.filter(Item.product_id.in_(einzel))
|
||||||
)
|
)
|
||||||
@@ -304,13 +308,16 @@ def location_stock_base(db: Session, product: Product, location_id: str) -> floa
|
|||||||
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
"""Bestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
|
||||||
|
|
||||||
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
Einzelstücke zählen die Items an diesem Ort, sonst werden die Lot-Mengen des
|
||||||
Ortes summiert.
|
Ortes summiert. Ein verliehenes Stück zählt hier nicht – sein Lagerort bleibt
|
||||||
|
aber gesetzt, weil es dorthin zurückgehört.
|
||||||
"""
|
"""
|
||||||
if product.individual:
|
if product.individual:
|
||||||
return float(
|
return float(
|
||||||
db.query(Item)
|
nicht_verliehen(
|
||||||
.filter(Item.product_id == product.id, Item.location_id == location_id)
|
db.query(Item).filter(
|
||||||
.count()
|
Item.product_id == product.id, Item.location_id == location_id
|
||||||
|
)
|
||||||
|
).count()
|
||||||
)
|
)
|
||||||
total = (
|
total = (
|
||||||
db.query(Lot.quantity)
|
db.query(Lot.quantity)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -89,6 +89,35 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
|||||||
*Bekannte Ungenauigkeit:* Die Umrechnung nutzt die heutige Packungsgröße; wird
|
*Bekannte Ungenauigkeit:* Die Umrechnung nutzt die heutige Packungsgröße; wird
|
||||||
sie später geändert, verschiebt sich auch die Historie.
|
sie später geändert, verschiebt sich auch die Historie.
|
||||||
|
|
||||||
|
## Schritt 2e – Verleih von Einzelstücken (Backend ✅, Oberfläche offen)
|
||||||
|
- **Eigene Tabelle `item_loans` statt eines Lagerort-Werts „verliehen".** Der
|
||||||
|
Lagerort am Stück bleibt stehen und sagt weiter, wohin es nach der Rückgabe
|
||||||
|
zurückgehört; ein Lagerort-Wert hätte diese Information vernichtet.
|
||||||
|
Je Vorgang: Entleiher (Freitext), Grund (Freitext, optional), Verleihdatum,
|
||||||
|
erwartetes Rückgabedatum (**optional** – „kurz mal mitgenommen" soll nicht an
|
||||||
|
einem Datumszwang scheitern) und das Konto, das herausgegeben hat.
|
||||||
|
- **Zurückgegebene Verleihe bleiben stehen** (`returned_on` gesetzt) – damit ist
|
||||||
|
die Tabelle gleichzeitig die Verleih-Historie je Stück. Ein Teilindex auf die
|
||||||
|
offenen Vorgänge sichert ab, dass ein Stück nicht zweimal gleichzeitig
|
||||||
|
verliehen ist.
|
||||||
|
- **Verliehen heißt nicht im Bestand.** Ein verliehenes Stück zählt nirgends
|
||||||
|
mehr mit – weder am Lagerort noch im Gesamtbestand. Entschieden wird das an
|
||||||
|
einer Stelle (`services/loans.py::nicht_verliehen`), die alle fünf Zählungen
|
||||||
|
benutzen. „Was liegt hier?" behauptet damit nicht länger, das Stück liege im
|
||||||
|
Regal. *Folge:* Fällt es unter den Mindestbestand, erscheint der Artikel auf
|
||||||
|
der Einkaufsliste – gewollt, aber gut zu wissen.
|
||||||
|
- **Verleihen darf jeder** angemeldete Benutzer (es ist eine Buchung wie Ein- und
|
||||||
|
Auslagern, keine Stammdatenänderung) und wird mit dem Konto protokolliert. Nur
|
||||||
|
das *Löschen* eines Vorgangs bleibt Admins vorbehalten, weil es Historie
|
||||||
|
vernichtet.
|
||||||
|
- **Bewusst keine `Movement`-Bewegung je Verleih:** Der Verlauf rechnet den
|
||||||
|
Bestand rückwärts aus vorzeichenbehafteten Bewegungen – ein Abgang ohne
|
||||||
|
Gegenbuchung würde diese Kurve verfälschen.
|
||||||
|
- Noch offen: Oberfläche in Web und App sowie die **Rückgabe-Erinnerung**. Sie
|
||||||
|
kann nur den Weg der Ablauf-Meldungen gehen (lokale Benachrichtigung der App
|
||||||
|
aus einem Server-Snapshot, `GET /loans?due_in=…` ist dafür schon die
|
||||||
|
Datenquelle) – echtes Push fehlt weiterhin, siehe Schritt 3.
|
||||||
|
|
||||||
## Schritt 3 – Weitere Ideen – offen
|
## Schritt 3 – Weitere Ideen – offen
|
||||||
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
|
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
|
||||||
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
|
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
|
||||||
|
|||||||
Reference in New Issue
Block a user