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
@@ -6,8 +6,9 @@ import secrets
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Item
|
||||
from ..models import Item, ItemLoan
|
||||
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.
|
||||
_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
@@ -21,8 +22,20 @@ def generate_uid(db: Session, length: int = 8) -> str:
|
||||
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:
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen für die Anzeige."""
|
||||
"""ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih."""
|
||||
out = ItemOut.model_validate(item)
|
||||
out.location_name = item.location.name if item.location 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
|
||||
out.category_id = kategorie.id 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
|
||||
|
||||
@@ -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 .dates import clean_precision, normalize_best_before
|
||||
from .loans import nicht_verliehen
|
||||
|
||||
|
||||
class StockError(ValueError):
|
||||
@@ -205,11 +206,14 @@ def current_stock(db: Session, product_id: int) -> float:
|
||||
"""Bestand eines Produkts (in Basiseinheiten).
|
||||
|
||||
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)
|
||||
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 = (
|
||||
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():
|
||||
je_artikel[pid] = je_artikel.get(pid, 0.0) + float(menge or 0.0)
|
||||
if einzel:
|
||||
abfrage = (
|
||||
abfrage = nicht_verliehen(
|
||||
db.query(Item.product_id, func.count(Item.id))
|
||||
.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).
|
||||
|
||||
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:
|
||||
return float(
|
||||
db.query(Item)
|
||||
.filter(Item.product_id == product.id, Item.location_id == location_id)
|
||||
.count()
|
||||
nicht_verliehen(
|
||||
db.query(Item).filter(
|
||||
Item.product_id == product.id, Item.location_id == location_id
|
||||
)
|
||||
).count()
|
||||
)
|
||||
total = (
|
||||
db.query(Lot.quantity)
|
||||
|
||||
Reference in New Issue
Block a user