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
+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).