Einzelstuecke verleihen: eigener Vorgang statt Lagerort-Wert

Der Lagerort bleibt beim Verleihen stehen - er sagt weiter, wohin das
Stueck nach der Rueckgabe zurueckgehoert. Ein Lagerort-Wert "verliehen"
haette diese Information vernichtet. Stattdessen eine eigene Tabelle
item_loans mit Entleiher und Grund als Freitext, Verleihdatum und einem
optionalen Rueckgabedatum: "kurz mal mitgenommen" soll nicht an einem
Datumszwang scheitern.

Zurueckgegebene Vorgaenge bleiben stehen, damit die Tabelle gleichzeitig
die Verleih-Historie je Stueck ist. Ein Teilindex auf die offenen
Vorgaenge haelt fest, dass ein Stueck nicht zweimal gleichzeitig
verliehen sein kann - wie bei den "Ueberall"-Mindestbestaenden, weil
Postgres NULLs in UNIQUE als verschieden zaehlt.

Verliehen heisst nicht im Bestand: Ein Stueck, das nicht im Haus ist,
zaehlt nirgends mehr mit - weder am Lagerort noch im Gesamtbestand.
Entschieden wird das an einer Stelle (services/loans.py::nicht_verliehen),
die alle fuenf Zaehlungen benutzen. Sonst behauptete "Was liegt hier?"
weiterhin, die Kamera liege im Regal. Folge: Faellt ein verliehenes
Stueck unter den Mindestbestand, erscheint der Artikel auf der
Einkaufsliste.

Verleihen und Zuruecknehmen darf jeder angemeldete Benutzer - es ist eine
Buchung wie Ein- und Auslagern, keine Stammdatenaenderung - und wird mit
dem Konto protokolliert. Nur das Loeschen eines Vorgangs bleibt Admins
vorbehalten, weil es Historie vernichtet.

Bewusst keine Movement-Bewegung je Verleih: Der Verlauf rechnet den
Bestand rueckwaerts aus vorzeichenbehafteten Bewegungen, ein Abgang ohne
Gegenbuchung wuerde diese Kurve verfaelschen.

GET /loans?due_in= liefert die faelligen Verleihe und ist damit schon die
Datenquelle fuer die spaetere Rueckgabe-Erinnerung der App. Oberflaeche in
Web und App fehlt noch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-17 09:16:47 +02:00
co-authored by Claude Opus 5
parent ba84e4ea01
commit 467ef3c804
12 changed files with 833 additions and 12 deletions
+4 -1
View File
@@ -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()
+2
View File
@@ -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)
+56
View File
@@ -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).
+5 -1
View File
@@ -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)
+4
View File
@@ -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()
+204
View File
@@ -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()
+51
View File
@@ -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 ----
+17 -2
View File
@@ -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
+62
View File
@@ -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
+14 -7
View File
@@ -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)
+384
View File
@@ -0,0 +1,384 @@
"""Verleih von Einzelstücken: Bestand, offener Verleih, Fälligkeit, Historie."""
from datetime import date, timedelta
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from app import crud
from app.models import (
Category,
CategoryTracking,
ItemLoan,
Location,
Product,
Role,
User,
)
from app.routers.items import create_items, get_item, list_all_items
from app.routers.loans import (
delete_loan,
item_loans,
lend_item,
list_loans,
return_item,
update_loan,
)
from app.routers.views import location_contents
from app.schemas import ItemCreate, LoanCreate, LoanReturn, LoanUpdate
from app.services.stock import current_stock, location_stock_base
@pytest.fixture()
def admin(db):
person = User(username="admin", password_hash="x", role=Role.admin)
db.add(person)
db.commit()
db.refresh(person)
return person
@pytest.fixture()
def keller(db):
ort = Location(name="Keller")
db.add(ort)
db.commit()
db.refresh(ort)
return ort
@pytest.fixture()
def kamera(db):
cat = Category(name="Elektronik", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
# package_size=1 -> Artikeleinheit == Basiseinheit, macht die Menge klar.
product = Product(name="Kamera", category_id=cat.id, individual=True, package_size=1)
db.add(product)
db.commit()
db.refresh(product)
return product
def _stueck(db, admin, product, ort=None):
return create_items(
product.id,
ItemCreate(count=1, location_id=(ort.id if ort else None)),
db=db, _=admin,
)[0]
# --------------------------------------------------------------------------
# Bestand: verliehen heisst nicht im Haus
# --------------------------------------------------------------------------
def test_verliehenes_stueck_zaehlt_nirgends_als_bestand(db, admin, kamera, keller):
item = _stueck(db, admin, kamera, keller)
assert current_stock(db, kamera.id) == 1.0
assert location_stock_base(db, kamera, keller.id) == 1.0
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
assert current_stock(db, kamera.id) == 0.0
assert location_stock_base(db, kamera, keller.id) == 0.0
# Auch die Artikel-Liste (gebuendelte Abfrage) darf es nicht mitzaehlen.
assert crud.products_to_out_bulk(db, [kamera])[0].stock == 0.0
def test_lagerort_inhalt_zeigt_verliehenes_nicht(db, admin, kamera, keller):
item = _stueck(db, admin, kamera, keller)
inhalt = location_contents(code=keller.id, db=db, _=admin)
assert [e.name for e in inhalt.products] == ["Kamera"]
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
# „Was liegt hier?" wuerde sonst behaupten, die Kamera liege im Keller.
assert location_contents(code=keller.id, db=db, _=admin).products == []
def test_rueckgabe_bringt_den_bestand_zurueck(db, admin, kamera, keller):
item = _stueck(db, admin, kamera, keller)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
zurueck = return_item(item.id, LoanReturn(), db=db, _=admin)
assert zurueck.returned_on == date.today()
assert current_stock(db, kamera.id) == 1.0
# Der Lagerort stand die ganze Zeit still - dorthin gehoert es zurueck.
assert location_stock_base(db, kamera, keller.id) == 1.0
def test_lagerort_bleibt_waehrend_des_verleihs_stehen(db, admin, kamera, keller):
item = _stueck(db, admin, kamera, keller)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
frisch = get_item(item.id, db=db, _=admin)
assert frisch.location_id == keller.id
assert frisch.location_name == "Keller"
# --------------------------------------------------------------------------
# Verleihen und zuruecknehmen
# --------------------------------------------------------------------------
def test_verleih_merkt_name_grund_und_frist(db, admin, kamera):
item = _stueck(db, admin, kamera)
frist = date.today() + timedelta(days=14)
loan = lend_item(
item.id,
LoanCreate(borrower=" Markus ", reason=" Hochzeit ", due_on=frist),
db=db, user=admin,
)
assert loan.borrower == "Markus" # Freitext getrimmt
assert loan.reason == "Hochzeit"
assert loan.lent_on == date.today()
assert loan.due_on == frist
assert loan.returned_on is None
assert loan.username == "admin" # wer herausgegeben hat
assert loan.item_uid == item.uid
assert loan.product_name == "Kamera"
def test_verleih_ohne_frist_ist_erlaubt(db, admin, kamera):
item = _stueck(db, admin, kamera)
loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
assert loan.due_on is None
assert loan.days_overdue is None
def test_item_out_zeigt_den_offenen_verleih(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus", reason="Ferien"), db=db, user=admin)
frisch = get_item(item.id, db=db, _=admin)
assert frisch.loan is not None
assert frisch.loan.borrower == "Markus"
assert frisch.loan.reason == "Ferien"
return_item(item.id, LoanReturn(), db=db, _=admin)
assert get_item(item.id, db=db, _=admin).loan is None
def test_liste_aller_stuecke_zeigt_den_verleih(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
alle = list_all_items(db=db, _=admin)
assert len(alle) == 1
assert alle[0].loan is not None
assert alle[0].loan.borrower == "Markus"
def test_zweimal_verleihen_ohne_rueckgabe_scheitert(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
with pytest.raises(HTTPException) as fehler:
lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
assert fehler.value.status_code == 409
def test_nach_rueckgabe_wieder_verleihbar(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
return_item(item.id, LoanReturn(), db=db, _=admin)
zweiter = lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
assert zweiter.borrower == "Anna"
assert current_stock(db, kamera.id) == 0.0
def test_rueckgabe_ohne_offenen_verleih_scheitert(db, admin, kamera):
item = _stueck(db, admin, kamera)
with pytest.raises(HTTPException) as fehler:
return_item(item.id, LoanReturn(), db=db, _=admin)
assert fehler.value.status_code == 409
def test_leerer_name_wird_abgelehnt(db, admin, kamera):
item = _stueck(db, admin, kamera)
with pytest.raises(HTTPException) as fehler:
lend_item(item.id, LoanCreate(borrower=" "), db=db, user=admin)
assert fehler.value.status_code == 400
def test_frist_vor_verleihdatum_wird_abgelehnt(db, admin, kamera):
item = _stueck(db, admin, kamera)
with pytest.raises(HTTPException) as fehler:
lend_item(
item.id,
LoanCreate(borrower="Markus", due_on=date.today() - timedelta(days=1)),
db=db, user=admin,
)
assert fehler.value.status_code == 400
def test_rueckgabe_vor_verleihdatum_wird_abgelehnt(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
with pytest.raises(HTTPException) as fehler:
return_item(
item.id, LoanReturn(returned_on=date.today() - timedelta(days=1)),
db=db, _=admin,
)
assert fehler.value.status_code == 400
def test_verleih_eines_unbekannten_stuecks_ist_404(db, admin):
with pytest.raises(HTTPException) as fehler:
lend_item(999, LoanCreate(borrower="Markus"), db=db, user=admin)
assert fehler.value.status_code == 404
# --------------------------------------------------------------------------
# Faelligkeit und Uebersicht
# --------------------------------------------------------------------------
def test_ueberfaellige_tage_werden_gerechnet(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(
item.id,
LoanCreate(
borrower="Markus",
lent_on=date.today() - timedelta(days=10),
due_on=date.today() - timedelta(days=3),
),
db=db, user=admin,
)
assert get_item(item.id, db=db, _=admin).loan.days_overdue == 3
def test_frist_heute_ist_noch_nicht_ueberfaellig(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus", due_on=date.today()),
db=db, user=admin)
assert get_item(item.id, db=db, _=admin).loan.days_overdue is None
def test_uebersicht_zeigt_offene_faelligste_zuerst(db, admin, kamera):
spaet = _stueck(db, admin, kamera)
frueh = _stueck(db, admin, kamera)
ohne = _stueck(db, admin, kamera)
zurueck = _stueck(db, admin, kamera)
lend_item(spaet.id, LoanCreate(borrower="Spaet", due_on=date.today() + timedelta(days=20)),
db=db, user=admin)
lend_item(frueh.id, LoanCreate(borrower="Frueh", due_on=date.today() + timedelta(days=2)),
db=db, user=admin)
lend_item(ohne.id, LoanCreate(borrower="Ohne"), db=db, user=admin)
lend_item(zurueck.id, LoanCreate(borrower="Zurueck"), db=db, user=admin)
return_item(zurueck.id, LoanReturn(), db=db, _=admin)
offen = list_loans(db=db, _=admin)
# Fristen aufsteigend, Verleihe ohne Frist hinten, Zurueckgegebene gar nicht.
assert [v.borrower for v in offen] == ["Frueh", "Spaet", "Ohne"]
alle = list_loans(open_only=False, db=db, _=admin)
assert "Zurueck" in [v.borrower for v in alle]
def test_uebersicht_grenzt_auf_faellige_ein(db, admin, kamera):
bald = _stueck(db, admin, kamera)
spaet = _stueck(db, admin, kamera)
ueberfaellig = _stueck(db, admin, kamera)
ohne = _stueck(db, admin, kamera)
lend_item(bald.id, LoanCreate(borrower="Bald", due_on=date.today() + timedelta(days=3)),
db=db, user=admin)
lend_item(spaet.id, LoanCreate(borrower="Spaet", due_on=date.today() + timedelta(days=30)),
db=db, user=admin)
lend_item(
ueberfaellig.id,
LoanCreate(borrower="Ueberfaellig", lent_on=date.today() - timedelta(days=9),
due_on=date.today() - timedelta(days=2)),
db=db, user=admin,
)
lend_item(ohne.id, LoanCreate(borrower="Ohne"), db=db, user=admin)
namen = [v.borrower for v in list_loans(due_in=7, db=db, _=admin)]
# Ueberfaellige sind immer dabei, „Ohne Frist" ist nie faellig.
assert namen == ["Ueberfaellig", "Bald"]
# --------------------------------------------------------------------------
# Historie und Korrektur
# --------------------------------------------------------------------------
def test_historie_bleibt_nach_der_rueckgabe(db, admin, kamera):
item = _stueck(db, admin, kamera)
lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
return_item(item.id, LoanReturn(), db=db, _=admin)
lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
historie = item_loans(item.id, db=db, _=admin)
assert {v.borrower for v in historie} == {"Markus", "Anna"}
assert len(historie) == 2
def test_korrektur_aendert_name_und_frist(db, admin, kamera):
item = _stueck(db, admin, kamera)
loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
neu = date.today() + timedelta(days=5)
korrigiert = update_loan(
loan.id, LoanUpdate(borrower="Markus Meier", due_on=neu), db=db, _=admin
)
assert korrigiert.borrower == "Markus Meier"
assert korrigiert.due_on == neu
def test_nachtraegliche_rueckgabe_per_korrektur(db, admin, kamera):
item = _stueck(db, admin, kamera)
loan = lend_item(
item.id,
LoanCreate(borrower="Markus", lent_on=date.today() - timedelta(days=5)),
db=db, user=admin,
)
gestern = date.today() - timedelta(days=1)
update_loan(loan.id, LoanUpdate(returned_on=gestern), db=db, _=admin)
assert get_item(item.id, db=db, _=admin).loan is None
assert current_stock(db, kamera.id) == 1.0
def test_wieder_oeffnen_scheitert_bei_offenem_zweitverleih(db, admin, kamera):
item = _stueck(db, admin, kamera)
erster = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
return_item(item.id, LoanReturn(), db=db, _=admin)
lend_item(item.id, LoanCreate(borrower="Anna"), db=db, user=admin)
with pytest.raises(HTTPException) as fehler:
update_loan(erster.id, LoanUpdate(returned_on=None), db=db, _=admin)
assert fehler.value.status_code == 409
def test_loeschen_entfernt_den_vorgang(db, admin, kamera):
item = _stueck(db, admin, kamera)
loan = lend_item(item.id, LoanCreate(borrower="Markus"), db=db, user=admin)
delete_loan(loan.id, db=db, _=admin)
assert item_loans(item.id, db=db, _=admin) == []
assert current_stock(db, kamera.id) == 1.0
def test_datenbank_verhindert_zwei_offene_verleihe(db, admin, kamera):
"""Der Teilindex schuetzt die Regel auch ohne den Router-Check."""
item = _stueck(db, admin, kamera)
db.add(ItemLoan(item_id=item.id, borrower="Markus", lent_on=date.today()))
db.commit()
db.add(ItemLoan(item_id=item.id, borrower="Anna", lent_on=date.today()))
with pytest.raises(IntegrityError):
db.commit()
db.rollback()
def test_zurueckgegebene_verleihe_duerfen_sich_haeufen(db, admin, kamera):
"""Der Index gilt nur fuer offene – Historie darf beliebig lang werden."""
item = _stueck(db, admin, kamera)
for name in ("Markus", "Anna", "Beat"):
db.add(ItemLoan(item_id=item.id, borrower=name, lent_on=date.today(),
returned_on=date.today()))
db.commit()
assert len(item_loans(item.id, db=db, _=admin)) == 3
+29
View File
@@ -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,