Verwendungsstatus: Backend fuer "getragen / in Reinigung / in Reparatur"

Einzelstuecke hatten bisher keinen Zustand - der Lagerort sagt, wohin ein
Stueck gehoert, aber nicht, woran es gerade ist. Neue Tabelle item_usages
nach dem Vorbild von item_loans: ein Zeitraum je Zustand, ended_at NULL
heisst "laeuft noch", und ein Teilindex laesst nur einen laufenden zu.

"Verfuegbar" wird bewusst nicht gespeichert - keine offene Zeile IST
verfuegbar, wie bei lots.opened_on und beim Verleih. Damit kostet der
Ruhezustand nichts und die Tabelle fuellt sich nicht mit Rauschen.
Abgeschlossene Zeilen bleiben stehen, sie sind der Verlauf.

Eigener Endpunkt (PUT /items/{id}/verwendung) und ausdruecklich nicht
PATCH /items/{id}: Das ist Admins vorbehalten, und die App schickt dort
immer alle Felder mitsamt null - jedes Speichern wuerde den Status
stillschweigend zuruecksetzen. Umstellen darf deshalb jeder angemeldete
Benutzer, wie Verleihen.

Der Zustand aendert den Bestand nicht und schreibt keine Bewegung: ein
getragenes Hemd ist weiterhin im Haushalt, anders als ein verliehenes.

Sichtbar nur bei Kategorien mit usage_tracking. Ein Haken an einer
Oberkategorie wandert in den Unterbaum, weil die Garderobe als
Kategoriebaum ja schon existiert. Die Markierung fahrt in beiden
Sicherungswegen mit; der Verlauf nicht, weil Einzelstuecke dort
ueberhaupt nicht vorkommen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-20 21:47:02 +02:00
co-authored by Claude Opus 5
parent 8c752e3d1f
commit 98e2d18142
10 changed files with 691 additions and 6 deletions
+8
View File
@@ -32,6 +32,7 @@ from .routers import (
transfer,
units,
users,
verwendung,
views,
)
from .seed import (
@@ -244,6 +245,12 @@ def _ensure_schema() -> None:
# Oberkategorie -> globale Einstellung), siehe services/vererbung.py.
"ALTER TABLE products ADD COLUMN IF NOT EXISTS open_days INTEGER",
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS open_days INTEGER",
# Verwendungsstatus an Einzelstuecken dieser Kategorie (Kleidung,
# Werkzeug). Aus, solange niemand sie setzt - bestehende Kategorien
# aendern sich also nicht. Die Tabelle item_usages selbst kommt aus
# create_all, nur Spalten brauchen diesen Weg hier.
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS usage_tracking "
"BOOLEAN NOT NULL DEFAULT FALSE",
]
with engine.begin() as conn:
# Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent),
@@ -338,3 +345,4 @@ app.include_router(shops.router)
app.include_router(field_definitions.router)
app.include_router(items.router)
app.include_router(loans.router)
app.include_router(verwendung.router)
+86
View File
@@ -98,6 +98,24 @@ class RemovalReason(str, enum.Enum):
other = "other" # sonstiges
class UsageStatus(str, enum.Enum):
"""Woran ein Einzelstueck gerade ist (Hemd getragen, Auto in der Wasche).
Bewusst KEIN Wert fuer "verfuegbar": Fehlt die offene Zeile in
:class:`ItemUsage`, ist das Stueck verfuegbar - wie bei ``lots.opened_on``
und beim Verleih. Ein eigener Wert daneben koennte ihr widersprechen.
Absichtlich allgemein und nicht auf Kleidung zugeschnitten: "in_use" traegt
auch eine Bohrmaschine auf der Baustelle.
Als kurzer String gespeichert (wie CategoryTracking, RemovalReason), damit
sich die Spalte per ADD COLUMN nachziehen laesst.
"""
in_use = "in_use" # wird gerade benutzt/getragen
cleaning = "cleaning" # in der Waesche/Reinigung
repair = "repair" # in Reparatur
class Unit(Base):
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
@@ -353,6 +371,15 @@ class Category(Base):
# NULL = erbt von der Oberkategorie, zuletzt vom globalen Wert
# (services/vererbung.py::oeffnungsfrist).
open_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren
# (Kleidung, Werkzeug - nicht Konserven). Wie tracking eine Kopie beim
# Anlegen und keine Aufloesung beim Lesen: So kommt die Markierung ohne
# zusaetzliche Abfrage am Einzelstueck an (services/items.py::item_to_out).
# Ein Haken an einer Oberkategorie wandert beim Speichern in den Unterbaum,
# siehe routers/categories.py::update_category.
usage_tracking: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=False, server_default=text("false")
)
class Product(Base):
@@ -1069,6 +1096,12 @@ class Item(Base):
back_populates="item", cascade="all, delete-orphan",
order_by="ItemLoan.lent_on.desc(), ItemLoan.id.desc()",
)
# Verwendungs-Historie, neueste zuerst. Der laufende Zustand (ended_at NULL)
# ist hoechstens einer, siehe ItemUsage. Keine offene Zeile = verfuegbar.
usages: Mapped[list["ItemUsage"]] = relationship(
back_populates="item", cascade="all, delete-orphan",
order_by="ItemUsage.started_at.desc(), ItemUsage.id.desc()",
)
class ItemDocument(Base):
@@ -1141,6 +1174,59 @@ class ItemLoan(Base):
user: Mapped[User | None] = relationship()
class ItemUsage(Base):
"""Ein Zeitraum, in dem ein Einzelstueck in einem Verwendungszustand war.
Bewusst eine eigene Tabelle und keine Spalte am Einzelstueck: So bleibt
nachvollziehbar, seit wann ein Hemd in der Waesche liegt und wie oft es
getragen wurde. Abgeschlossene Zeitraeume bleiben stehen - damit ist die
Tabelle gleichzeitig die Historie je Stueck.
``ended_at`` NULL heisst "laeuft noch"; ein Stueck kann nur einen solchen
laufenden Zustand haben (Teilindex unten). Es gibt keine Zeile fuer
"verfuegbar" - keine offene Zeile IST verfuegbar.
Anders als der Verleih aendert der Zustand den Bestand NICHT: ein getragenes
Hemd ist weiterhin im Haushalt. Und wie beim Verleih wird keine Bewegung
(Movement) geschrieben, siehe routers/loans.py.
"""
__tablename__ = "item_usages"
__table_args__ = (
# Zwei Zustaende gleichzeitig kann ein Stueck nicht haben. Als Teilindex,
# weil nur die LAUFENDEN eindeutig sein duerfen: abgeschlossene bleiben
# beliebig oft je Stueck stehen.
Index(
"uq_item_usage_offen",
"item_id",
unique=True,
postgresql_where=text("ended_at IS NULL"),
sqlite_where=text("ended_at 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
)
# UsageStatus-Wert als kurzer String (kein Postgres-Enumtyp).
status: Mapped[str] = mapped_column(String(16), nullable=False)
# Zeitstempel und nicht nur ein Datum: Ein Hemd kann an einem Tag getragen,
# gewaschen und wieder eingeraeumt werden.
started_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
ended_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Wer umgestellt hat. SET NULL, damit ein geloeschtes Konto den Vorgang
# nicht mitnimmt.
user_id: Mapped[int | None] = mapped_column(
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
item: Mapped[Item] = relationship(back_populates="usages")
user: Mapped[User | None] = relationship()
class ProductLocationMinStock(Base):
"""Mindestbestand eines Produkts an EINEM Lagerort (in Basiseinheiten).
+22 -1
View File
@@ -85,9 +85,17 @@ def create_category(
tracking = parent.tracking
else:
tracking = CategoryTracking.object.value
# Verwendungsstatus genauso: gewaehlt > vom Elternteil geerbt > aus.
# Eine Unterkategorie von "Kleidung" soll ihn ohne Zutun fuehren.
if payload.usage_tracking is not None:
usage_tracking = payload.usage_tracking
elif parent is not None:
usage_tracking = parent.usage_tracking
else:
usage_tracking = False
category = Category(
name=payload.name, parent_id=payload.parent_id, tracking=tracking,
open_days=payload.open_days,
open_days=payload.open_days, usage_tracking=usage_tracking,
)
db.add(category)
db.commit()
@@ -125,6 +133,19 @@ def update_category(
data["tracking"] = CategoryTracking(data["tracking"]).value
for field, value in data.items():
setattr(category, field, value)
# Der Verwendungsstatus wandert in den ganzen Unterbaum. Ohne das erreichte
# ein Haken an einer bestehenden Kategorie ("Kleidung") deren vorhandene
# Unterkategorien nicht - und genau die gibt es ja schon. Bewusst sichtbar:
# Die Haken stehen danach auch dort und lassen sich einzeln wieder abwaehlen.
if data.get("usage_tracking") is not None:
kinder = descendant_ids(db, category_id)
if kinder:
(
db.query(Category)
.filter(Category.id.in_(kinder))
.update({"usage_tracking": data["usage_tracking"]},
synchronize_session=False)
)
db.commit()
db.refresh(category)
return _to_out(db, category)
+6 -1
View File
@@ -9,7 +9,7 @@ import re
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from fastapi.responses import Response
from sqlalchemy.orm import Session, joinedload
from sqlalchemy.orm import Session, joinedload, selectinload
from ..database import get_db
from ..deps import get_current_user, require_admin
@@ -17,6 +17,7 @@ from ..models import (
Item,
ItemDocument,
ItemLoan,
ItemUsage,
Location,
Movement,
MovementType,
@@ -166,6 +167,10 @@ def list_all_items(
# Verleihe samt Konto mitladen: Sonst fragt item_to_out je
# verliehenem Stueck einzeln nach (N+1).
joinedload(Item.loans).joinedload(ItemLoan.user),
# selectin und nicht joined: Hier haengen schon drei Sammlungen
# dran, eine vierte vervielfachte die Zeilen ueber Kreuz. So kostet
# es eine zusaetzliche Abfrage statt eines Kreuzprodukts.
selectinload(Item.usages).joinedload(ItemUsage.user),
)
.order_by(Item.id)
.all()
+8 -1
View File
@@ -200,7 +200,11 @@ def export_backup_json(
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
"categories": [
{"path": _category_path(db, c), "tracking": c.tracking}
{
"path": _category_path(db, c),
"tracking": c.tracking,
"usage_tracking": c.usage_tracking,
}
for c in db.query(Category).order_by(Category.id).all()
],
# Bezugsquellen (nur für Gegenstände).
@@ -963,6 +967,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
)
if cat is not None and entry.get("tracking"):
cat.tracking = entry["tracking"]
# Fehlt der Schluessel (aelteres Backup), bleibt die Markierung aus.
if cat is not None:
cat.usage_tracking = bool(entry.get("usage_tracking", False))
for entry in data.get("shops", []):
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
db.flush()
+104
View File
@@ -0,0 +1,104 @@
"""Verwendungsstatus von Einzelstücken: umstellen und Verlauf.
Woran ist das Stück gerade? Getragen, in der Wäsche, in Reparatur – oder
einfach verfügbar. Gedacht für eine Garderobe, aber bewusst allgemein: ein Auto
in der Waschanlage ist genauso „in Reinigung".
Umstellen ist eine **Buchung**, keine Stammdatenänderung – deshalb darf jeder
angemeldete Benutzer es tun (wie Verleihen, Ein- und Auslagern), und es wird mit
dem Konto protokolliert. Ausdrücklich **nicht** über ``PATCH /items/{id}``:
Diese Route ist Admins vorbehalten, und die App schickt dort immer alle Felder
mitsamt ``null`` (siehe ``ItemUpdateRequest.encode`` in ios/Sources/Models.swift)
– jedes beliebige Speichern würde den Status stillschweigend zurücksetzen.
Verleih und Zubehör haben aus demselben Grund eigene Endpunkte.
Bewusst **keine** Bewegung (``Movement``), aus demselben Grund wie beim Verleih
(siehe ``routers/loans.py``). Und bewusst **keine** Auswirkung auf den Bestand:
Ein getragenes Hemd ist weiterhin im Haushalt – anders als ein verliehenes.
"""
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session, joinedload
from ..database import get_db
from ..deps import get_current_user
from ..models import Item, ItemUsage, User
from ..schemas import ItemOut, VerwendungOut, VerwendungSetzen
from ..services.items import item_to_out, verwendung_to_out
router = APIRouter(tags=["verwendung"])
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 _laufend(db: Session, item_id: int) -> ItemUsage | None:
"""Der laufende Zeitraum dieses Stücks, falls einer offen ist."""
return (
db.query(ItemUsage)
.filter(ItemUsage.item_id == item_id, ItemUsage.ended_at.is_(None))
.first()
)
@router.put("/items/{item_id}/verwendung", response_model=ItemOut)
def setze_verwendung(
item_id: int,
payload: VerwendungSetzen,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> ItemOut:
"""Verwendungszustand setzen. Leerer Status = verfügbar.
Eine Route für alle Zustände, auch für den Ruhezustand: „verfügbar" ist
nichts anderes als „kein laufender Zeitraum", also beendet der leere Status
nur den aktuellen. Damit ist der Aufruf idempotent.
Antwortet mit dem ganzen Stück, damit die Oberflächen nach der Buchung nicht
noch einmal laden müssen.
"""
item = _item_or_404(db, item_id)
neu = payload.status.value if payload.status is not None else None
laufend = _laufend(db, item.id)
if laufend is not None and laufend.status == neu:
# Zweimal dasselbe angetippt – sonst entstünde ein Zeitraum der Länge 0.
return item_to_out(item)
if laufend is not None:
laufend.ended_at = datetime.now(UTC)
# Vor dem Einfügen schreiben: Sonst darf SQLAlchemy das INSERT vor das
# UPDATE stellen, und dann liegen kurzzeitig zwei offene Zeiträume an –
# der Teilindex uq_item_usage_offen schlägt zu.
db.flush()
if neu is not None:
db.add(ItemUsage(item_id=item.id, status=neu, user_id=user.id))
db.commit()
db.refresh(item)
return item_to_out(item)
@router.get("/items/{item_id}/verwendungen", response_model=list[VerwendungOut])
def list_verwendungen(
item_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[VerwendungOut]:
"""Der ganze Verlauf dieses Stücks, neueste zuerst."""
_item_or_404(db, item_id)
rows = (
db.query(ItemUsage)
.options(joinedload(ItemUsage.user))
.filter(ItemUsage.item_id == item_id)
.order_by(ItemUsage.started_at.desc(), ItemUsage.id.desc())
.all()
)
return [verwendung_to_out(u) for u in rows]
+36
View File
@@ -12,6 +12,7 @@ from .models import (
RemovalReason,
Role,
UnitKind,
UsageStatus,
)
@@ -283,6 +284,8 @@ class CategoryOut(BaseModel):
# Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein.
inherited_open_days: int = 0
open_days_source: str = ""
# Ob Einzelstuecke dieser Kategorie einen Verwendungsstatus fuehren.
usage_tracking: bool = False
class CategoryCreate(BaseModel):
@@ -291,6 +294,8 @@ class CategoryCreate(BaseModel):
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
tracking: CategoryTracking | None = None
open_days: int | None = Field(default=None, ge=1)
# None: erbt vom Elternteil bzw. neue Oberkategorie = aus.
usage_tracking: bool | None = None
class CategoryUpdate(BaseModel):
@@ -300,6 +305,9 @@ class CategoryUpdate(BaseModel):
# 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None
# heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst.
open_days: int | None = Field(default=None, ge=1)
# Wird beim Speichern in den ganzen Unterbaum uebernommen, siehe
# routers/categories.py::update_category.
usage_tracking: bool | None = None
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
@@ -1064,6 +1072,28 @@ class LoanOut(BaseModel):
location_name: str | None = None
class VerwendungSetzen(BaseModel):
"""Verwendungsstatus eines Einzelstuecks setzen.
``status`` leer heisst verfuegbar – dafuer gibt es keinen eigenen Wert, es
wird nur der laufende Zustand beendet (siehe models.py::UsageStatus).
"""
status: UsageStatus | None = None
class VerwendungOut(BaseModel):
"""Ein Zeitraum in einem Verwendungszustand – laufend oder abgeschlossen."""
model_config = ConfigDict(from_attributes=True)
id: int
item_id: int
status: UsageStatus
started_at: datetime
# None = laeuft noch.
ended_at: datetime | None = None
# Wer umgestellt hat (Konto kann spaeter geloescht sein).
username: str | None = None
class ItemKurz(BaseModel):
"""Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen.
@@ -1112,6 +1142,12 @@ class ItemOut(BaseModel):
# 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
# Der laufende Verwendungszustand – None heisst verfuegbar. Anders als beim
# Verleih bleibt das Stueck dabei im Bestand: getragen ist nicht weg.
verwendung: VerwendungOut | None = None
# Ob die Kategorie dieses Stuecks ueberhaupt einen Verwendungsstatus fuehrt.
# Steuert, ob die Oberflaeche die Bedienung dafuer zeigt.
verwendung_moeglich: bool = False
# Zubehör: die Teile dieses Geräts und, umgekehrt, das Gerät, zu dem dieses
# Stück gehört. Beide Richtungen, weil beide Fragen im Alltag vorkommen.
zubehoer: list[ItemKurz] = []
+27 -2
View File
@@ -6,8 +6,8 @@ import secrets
from sqlalchemy.orm import Session
from ..models import Item, ItemLoan
from ..schemas import ItemKurz, ItemOut
from ..models import Item, ItemLoan, ItemUsage
from ..schemas import ItemKurz, ItemOut, VerwendungOut
from .loans import loan_to_out
# Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt.
@@ -34,6 +34,26 @@ def _offener(item: Item) -> ItemLoan | None:
return None
def _offene_verwendung(item: Item) -> ItemUsage | None:
"""Der laufende Verwendungszustand aus den schon geladenen Zeitraeumen.
Wie ``_offener``: ueber die Beziehung statt per eigener Abfrage, damit die
Einzelstueck-Liste nicht je Zeile nachfragt. Keine offene Zeile heisst
verfuegbar - dafuer gibt es keinen gespeicherten Wert.
"""
for usage in item.usages:
if usage.ended_at is None:
return usage
return None
def verwendung_to_out(usage: ItemUsage) -> VerwendungOut:
"""VerwendungOut samt Konto, das umgestellt hat."""
out = VerwendungOut.model_validate(usage)
out.username = usage.user.username if usage.user else None
return out
def item_kurz(item: Item) -> ItemKurz:
"""Ein verknuepftes Stueck in Kurzform (Zubehoer, Geraet)."""
return ItemKurz(
@@ -59,6 +79,11 @@ def item_to_out(item: Item) -> ItemOut:
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
# Die Kategorie steckt schon oben in ``kategorie`` - die Markierung kostet
# deshalb keine zusaetzliche Abfrage (siehe Category.usage_tracking).
out.verwendung_moeglich = bool(kategorie.usage_tracking) if kategorie else False
verwendung = _offene_verwendung(item)
out.verwendung = verwendung_to_out(verwendung) if verwendung is not None else None
# Beide Richtungen ausdruecklich setzen: model_validate wuerde die Beziehung
# zwar finden, aber nur die Spalten fuellen - Artikelname und Lagerort des
# verknuepften Stuecks blieben leer.
+3 -1
View File
@@ -35,7 +35,8 @@ def export_master_data(db: Session) -> dict:
return {
FORMAT_KEY: FORMAT_VERSION,
"categories": [
{"id": c.id, "name": c.name, "parent_id": c.parent_id, "tracking": c.tracking}
{"id": c.id, "name": c.name, "parent_id": c.parent_id,
"tracking": c.tracking, "usage_tracking": c.usage_tracking}
for c in db.query(Category).order_by(Category.id).all()
],
"field_definitions": [
@@ -152,6 +153,7 @@ def import_master_data(db: Session, data: dict, mode: str = "skip") -> dict:
for row in cats:
upsert(Category, row, "categories", lambda r: {
"name": r["name"], "tracking": r.get("tracking") or "food",
"usage_tracking": bool(r.get("usage_tracking", False)),
}, is_tree=True)
set_parents(Category, cats)