diff --git a/backend/app/main.py b/backend/app/main.py index f13753d..077d17f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -222,6 +222,10 @@ def _ensure_schema() -> None: "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL", "CREATE UNIQUE INDEX IF NOT EXISTS uq_users_email ON users (lower(email)) " "WHERE email IS NOT NULL", + # Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop). + "ALTER TABLE items ADD COLUMN IF NOT EXISTS parent_item_id INTEGER " + "REFERENCES items(id) ON DELETE SET NULL", + "CREATE INDEX IF NOT EXISTS ix_items_parent_item_id ON items (parent_item_id)", ] with engine.begin() as conn: # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), diff --git a/backend/app/models.py b/backend/app/models.py index 7365aae..5fb4d58 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -742,9 +742,30 @@ class Item(Base): # entsteht; Währung getrennt (z.B. CHF, EUR). price_cents: Mapped[int | None] = mapped_column(Integer, nullable=True) currency: Mapped[str | None] = mapped_column(String(3), nullable=True) + # Zubehoer: Dieses Stueck gehoert zu einem anderen (Netzteil -> Laptop). + # Genau ein Geraet je Teil, beliebig viele Teile je Geraet - dafuer genuegt + # eine Spalte, es braucht keine Verknuepfungstabelle wie bei den Gruppen. + # SET NULL statt CASCADE: Verschwindet das Geraet, wird das Netzteil wieder + # eigenstaendig, statt ungefragt mitgeloescht zu werden. + parent_item_id: Mapped[int | None] = mapped_column( + ForeignKey("items.id", ondelete="SET NULL"), nullable=True, index=True + ) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) product: Mapped[Product] = relationship() + # Beide Richtungen sind gefragt: am Geraet "was gehoert dazu", am Teil + # "wozu gehoert das". selectin, weil sonst jede Einzelstueckliste je Zeile + # eine Extraabfrage ausloest (wie bei Group.parents). + gehoert_zu: Mapped["Item | None"] = relationship( + "Item", remote_side="Item.id", back_populates="zubehoer", lazy="joined", + # joined statt selectin: Es ist hoechstens EIN Geraet je Teil, ein + # many-to-one-Join kann die Zeilen also nicht vervielfachen - und ohne + # das holte jede Einzelstueckliste je Zeile eine Extraabfrage. + join_depth=1, + ) + zubehoer: Mapped[list["Item"]] = relationship( + "Item", back_populates="gehoert_zu", lazy="selectin", order_by="Item.uid" + ) location: Mapped[Location | None] = relationship() shop: Mapped[Shop | None] = relationship() documents: Mapped[list["ItemDocument"]] = relationship( diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index f1b2278..96f93c2 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -31,6 +31,7 @@ from ..schemas import ( ItemOut, ItemRemove, ItemUpdate, + ZubehoerZuordnen, ) from ..services.items import generate_uid, item_to_out from ..services.warranty import ( @@ -56,6 +57,18 @@ def _product_or_404(db: Session, product_id: int) -> Product: return product +def _zubehoer_freigeben(item: Item) -> None: + """Zubehoer vom Geraet loesen, bevor das Geraet verschwindet. + + ON DELETE SET NULL steht zwar am Fremdschluessel, aber SQLite erzwingt + Fremdschluessel nicht - in den Tests bliebe sonst eine tote Zuordnung + stehen. Dieselbe Vorsicht wie beim Loeschen einer Gruppe. + """ + for teil in list(item.zubehoer): + teil.parent_item_id = None + item.parent_item_id = None + + def _item_or_404(db: Session, item_id: int) -> Item: item = db.get(Item, item_id) if item is None: @@ -208,6 +221,7 @@ def remove_item( ) -> None: """Einzelstück mit Grund entfernen – als Bewegung protokolliert (Statistik je Modell).""" item = _item_or_404(db, item_id) + _zubehoer_freigeben(item) db.add( Movement( product_id=item.product_id, @@ -232,7 +246,83 @@ def delete_item( _: User = Depends(require_admin), ) -> None: """Einzelstück ohne Grund löschen (Korrektur).""" - db.delete(_item_or_404(db, item_id)) + item = _item_or_404(db, item_id) + _zubehoer_freigeben(item) + db.delete(item) + db.commit() + + + +# -------------------------------------------------------------------------- +# Zubehoer: ein Einzelstueck gehoert zu einem anderen (Netzteil -> Laptop) +# -------------------------------------------------------------------------- +def _ringpruefung(db: Session, geraet: Item, teil: Item) -> None: + """Verhindert, dass ein Stueck ueber Umwege sein eigenes Zubehoer wird. + + Mit nur einem Elternteil je Teil ist die Kette kurz, aber A->B->A waere + ohne Pruefung moeglich - und danach liefe jedes Hochlaufen der Kette + endlos. Deshalb vom Geraet aus nach oben gehen, wie beim Kategoriebaum. + """ + if geraet.id == teil.id: + raise HTTPException( + status.HTTP_409_CONFLICT, "Ein Einzelstück kann nicht sein eigenes Zubehör sein" + ) + gesehen: set[int] = set() + lauf = geraet.gehoert_zu + while lauf is not None and lauf.id not in gesehen: + if lauf.id == teil.id: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"„{teil.uid}“ ist bereits weiter oben in dieser Zubehör-Kette", + ) + gesehen.add(lauf.id) + lauf = lauf.gehoert_zu + + +@router.post("/items/{item_id}/zubehoer", response_model=ItemOut) +def zubehoer_zuordnen( + item_id: int, + payload: ZubehoerZuordnen, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> ItemOut: + """Ein Einzelstück als Zubehör an dieses Gerät hängen. + + Eigener Endpunkt und nicht über ``ItemUpdate``: Das Schema sendet bewusst + immer alle Felder mit, auch leere, damit sich Felder loeschen lassen. Eine + Beziehung darueber zu pflegen hiesse, dass ein Aufrufer sie still kappt, + sobald er das Feld vergisst - genau so gingen schon einmal Kaufpreis und + Waehrung verloren. + """ + geraet = _item_or_404(db, item_id) + teil = _item_or_404(db, payload.child_item_id) + _ringpruefung(db, geraet, teil) + # Ein Teil haengt an genau einem Geraet - ein Wechsel ist deshalb einfach + # ein Umhaengen und braucht kein vorheriges Loesen. + teil.parent_item_id = geraet.id + db.commit() + db.refresh(geraet) + return item_to_out(geraet) + + +@router.delete( + "/items/{item_id}/zubehoer/{child_item_id}", status_code=status.HTTP_204_NO_CONTENT +) +def zubehoer_loesen( + item_id: int, + child_item_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + """Zubehör vom Gerät lösen. Das Stück selbst bleibt bestehen.""" + geraet = _item_or_404(db, item_id) + teil = _item_or_404(db, child_item_id) + if teil.parent_item_id != geraet.id: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + f"„{teil.uid}“ gehört nicht zu „{geraet.uid}“", + ) + teil.parent_item_id = None db.commit() diff --git a/backend/app/routers/loans.py b/backend/app/routers/loans.py index 72f6791..771d903 100644 --- a/backend/app/routers/loans.py +++ b/backend/app/routers/loans.py @@ -51,6 +51,28 @@ def _pruefe_zeitfolge(lent_on: date, due_on: date | None, returned_on: date | No ) +def _zubehoer_pruefen(db: Session, geraet: Item, ids: list[int]) -> list[Item]: + """Die benannten Teile holen und pruefen, dass sie zu diesem Geraet gehoeren. + + Ohne die Pruefung koennte ein Aufrufer ueber diesen Weg ein beliebiges + fremdes Stueck mitverleihen. + """ + teile: list[Item] = [] + for teil_id in dict.fromkeys(ids): + teil = db.get(Item, teil_id) + if teil is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, "Zubehörteil nicht gefunden" + ) + if teil.parent_item_id != geraet.id: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + f"„{teil.uid}“ ist kein Zubehör von „{geraet.uid}“", + ) + teile.append(teil) + return teile + + @router.post( "/items/{item_id}/loan", response_model=LoanOut, status_code=status.HTTP_201_CREATED ) @@ -81,6 +103,27 @@ def lend_item( user_id=user.id, ) db.add(loan) + + # Zubehoer geht nur mit, wenn es ausdruecklich benannt wurde - die + # Oberflaeche fragt danach. Ein Netzteil, das im Schrank bleibt, darf nicht + # als verliehen gefuehrt werden, nur weil es am Geraet haengt. + for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer): + if offener_verleih(db, teil.id) is not None: + raise HTTPException( + status.HTTP_409_CONFLICT, + f"„{teil.uid}“ ist bereits verliehen.", + ) + db.add( + ItemLoan( + item_id=teil.id, + borrower=name, + reason=(payload.reason or "").strip() or None, + lent_on=lent_on, + due_on=payload.due_on, + user_id=user.id, + ) + ) + db.commit() db.refresh(loan) return loan_to_out(loan) @@ -103,6 +146,16 @@ def return_item( returned_on = payload.returned_on or date.today() _pruefe_zeitfolge(loan.lent_on, loan.due_on, returned_on) loan.returned_on = returned_on + + # Spiegelbild zum Verleihen: Ohne das kaeme das Geraet zurueck und sein + # Netzteil bliebe ewig „verliehen“, weil niemand mehr danach sucht. + for teil in _zubehoer_pruefen(db, item, payload.mit_zubehoer): + offen = offener_verleih(db, teil.id) + if offen is None: + continue # war gar nicht mit draussen – kein Fehler, nur nichts zu tun + _pruefe_zeitfolge(offen.lent_on, offen.due_on, returned_on) + offen.returned_on = returned_on + db.commit() db.refresh(loan) return loan_to_out(loan) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 9ccfa36..554e2e2 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -778,6 +778,9 @@ class LoanCreate(BaseModel): lent_on: date | None = None # Erwartete Rückgabe – optional, siehe ItemLoan. due_on: date | None = None + # Zubehörteile, die mitgehen. Leer = nur das Gerät selbst. Die Oberfläche + # fragt danach; der Server nimmt nie ungefragt etwas mit. + mit_zubehoer: list[int] = [] class LoanUpdate(BaseModel): @@ -792,6 +795,9 @@ class LoanUpdate(BaseModel): class LoanReturn(BaseModel): """Rückgabe buchen. Leeres Datum = heute.""" returned_on: date | None = None + # Zubehörteile, die mit zurückkommen. Spiegelbild zu LoanCreate: ohne das + # käme das Gerät zurück und sein Netzteil bliebe ewig „verliehen". + mit_zubehoer: list[int] = [] class LoanOut(BaseModel): @@ -818,6 +824,30 @@ class LoanOut(BaseModel): location_name: str | None = None +class ItemKurz(BaseModel): + """Ein Einzelstueck in Kurzform – fuer Zubehoer-Listen in beide Richtungen. + + Bewusst nicht das volle ItemOut: sonst zoege das Geraet seine Teile und jedes + Teil wieder sein Geraet nach, endlos. + """ + + model_config = ConfigDict(from_attributes=True) + id: int + uid: str + product_id: int + product_name: str | None = None + product_brand: str | None = None + location_name: str | None = None + # Damit die Oberflaeche ein mitverliehenes Teil kennzeichnen kann. + verliehen: bool = False + + +class ZubehoerZuordnen(BaseModel): + """Welches Stueck als Zubehoer an dieses Geraet gehaengt wird.""" + + child_item_id: int + + class ItemOut(BaseModel): model_config = ConfigDict(from_attributes=True) id: int @@ -842,6 +872,10 @@ 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 + # 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] = [] + gehoert_zu: ItemKurz | None = None # ---- Views ---- diff --git a/backend/app/services/items.py b/backend/app/services/items.py index 0fa1b42..b082af0 100644 --- a/backend/app/services/items.py +++ b/backend/app/services/items.py @@ -7,7 +7,7 @@ import secrets from sqlalchemy.orm import Session from ..models import Item, ItemLoan -from ..schemas import ItemOut +from ..schemas import ItemKurz, ItemOut from .loans import loan_to_out # Ohne 0/O/1/I/L, damit die UID auf einem Etikett eindeutig lesbar bleibt. @@ -34,8 +34,21 @@ def _offener(item: Item) -> ItemLoan | None: return None +def item_kurz(item: Item) -> ItemKurz: + """Ein verknuepftes Stueck in Kurzform (Zubehoer, Geraet).""" + return ItemKurz( + id=item.id, + uid=item.uid, + product_id=item.product_id, + product_name=item.product.name if item.product else None, + product_brand=item.product.brand if item.product else None, + location_name=item.location.name if item.location else None, + verliehen=_offener(item) is not None, + ) + + def item_to_out(item: Item) -> ItemOut: - """ItemOut inkl. Lagerort-/Shop-/Produktnamen und laufendem Verleih.""" + """ItemOut inkl. Lagerort-/Shop-/Produktnamen, Verleih und Zubehoer.""" 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 @@ -46,4 +59,9 @@ 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 + # Beide Richtungen ausdruecklich setzen: model_validate wuerde die Beziehung + # zwar finden, aber nur die Spalten fuellen - Artikelname und Lagerort des + # verknuepften Stuecks blieben leer. + out.zubehoer = [item_kurz(t) for t in item.zubehoer] + out.gehoert_zu = item_kurz(item.gehoert_zu) if item.gehoert_zu else None return out diff --git a/backend/tests/test_zubehoer.py b/backend/tests/test_zubehoer.py new file mode 100644 index 0000000..04789ab --- /dev/null +++ b/backend/tests/test_zubehoer.py @@ -0,0 +1,209 @@ +"""Zubehör: ein Einzelstück gehört zu einem anderen (Netzteil → Laptop). + +Ein Teil hängt an genau einem Gerät, ein Gerät trägt beliebig viele Teile. +Geprüft werden die Zuordnung selbst, ihre Grenzen (Selbstbezug, Ring) und das +Mitverleihen, das die Oberfläche erfragt und der Server nie von sich aus tut. +""" + +import pytest +from fastapi import HTTPException + +from app.models import Category, CategoryTracking, Item, Product, Role, User +from app.routers.items import ( + create_items, + delete_item, + get_item, + zubehoer_loesen, + zubehoer_zuordnen, +) +from app.routers.loans import lend_item, return_item +from app.schemas import ItemCreate, LoanCreate, LoanReturn, ZubehoerZuordnen +from app.services.loans import offener_verleih + + +@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 laptop(db): + cat = Category(name="Elektronik", tracking=CategoryTracking.object.value) + db.add(cat) + db.flush() + product = Product(name="Laptop", category_id=cat.id, individual=True, package_size=1) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _stueck(db, admin, product) -> Item: + out = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0] + return db.get(Item, out.id) + + +def _haenge(db, admin, geraet: Item, teil: Item): + return zubehoer_zuordnen( + geraet.id, ZubehoerZuordnen(child_item_id=teil.id), db=db, _=admin + ) + + +# -------------------------------------------------------------------------- +# Zuordnen und lösen +# -------------------------------------------------------------------------- +def test_geraet_traegt_mehrere_teile(db, admin, laptop): + geraet = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + tasche = _stueck(db, admin, laptop) + + _haenge(db, admin, geraet, netzteil) + out = _haenge(db, admin, geraet, tasche) + + assert {t.uid for t in out.zubehoer} == {netzteil.uid, tasche.uid} + # Und die Gegenrichtung steht am Teil. + assert get_item(netzteil.id, db=db, _=admin).gehoert_zu.uid == geraet.uid + + +def test_teil_wechselt_das_geraet_ohne_dublette(db, admin, laptop): + """Ein Teil haengt an genau einem Geraet – Umhaengen ist ein Wechsel.""" + a = _stueck(db, admin, laptop) + b = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + + _haenge(db, admin, a, netzteil) + _haenge(db, admin, b, netzteil) + + assert get_item(a.id, db=db, _=admin).zubehoer == [] + assert [t.uid for t in get_item(b.id, db=db, _=admin).zubehoer] == [netzteil.uid] + + +def test_loesen_laesst_das_stueck_bestehen(db, admin, laptop): + geraet = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + _haenge(db, admin, geraet, netzteil) + + zubehoer_loesen(geraet.id, netzteil.id, db=db, _=admin) + + assert get_item(geraet.id, db=db, _=admin).zubehoer == [] + assert db.get(Item, netzteil.id) is not None + + +def test_loesen_eines_fremden_teils_ist_ein_fehler(db, admin, laptop): + geraet = _stueck(db, admin, laptop) + fremdes = _stueck(db, admin, laptop) + + with pytest.raises(HTTPException) as fehler: + zubehoer_loesen(geraet.id, fremdes.id, db=db, _=admin) + assert fehler.value.status_code == 404 + + +# -------------------------------------------------------------------------- +# Grenzen +# -------------------------------------------------------------------------- +def test_selbstbezug_wird_abgelehnt(db, admin, laptop): + geraet = _stueck(db, admin, laptop) + + with pytest.raises(HTTPException) as fehler: + _haenge(db, admin, geraet, geraet) + assert fehler.value.status_code == 409 + + +def test_ring_wird_abgelehnt(db, admin, laptop): + """A trägt B – dann darf B nicht A tragen, sonst läuft jede Kette endlos.""" + a = _stueck(db, admin, laptop) + b = _stueck(db, admin, laptop) + _haenge(db, admin, a, b) + + with pytest.raises(HTTPException) as fehler: + _haenge(db, admin, b, a) + assert fehler.value.status_code == 409 + + +def test_laengerer_ring_wird_abgelehnt(db, admin, laptop): + a, b, c = (_stueck(db, admin, laptop) for _ in range(3)) + _haenge(db, admin, a, b) + _haenge(db, admin, b, c) + + with pytest.raises(HTTPException) as fehler: + _haenge(db, admin, c, a) + assert fehler.value.status_code == 409 + + +def test_geraet_loeschen_gibt_das_teil_frei(db, admin, laptop): + """Das Netzteil ueberlebt den Laptop – es wird wieder eigenstaendig.""" + geraet = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + _haenge(db, admin, geraet, netzteil) + + delete_item(geraet.id, db=db, _=admin) + + uebrig = db.get(Item, netzteil.id) + assert uebrig is not None + assert uebrig.parent_item_id is None + + +# -------------------------------------------------------------------------- +# Verleihen und Zurücknehmen mit Zubehör +# -------------------------------------------------------------------------- +def test_ohne_angabe_geht_nur_das_geraet(db, admin, laptop): + """Der Server nimmt nie ungefragt etwas mit.""" + geraet = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + _haenge(db, admin, geraet, netzteil) + + lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin) + + assert offener_verleih(db, geraet.id) is not None + assert offener_verleih(db, netzteil.id) is None + + +def test_mit_zubehoer_verleihen_und_zuruecknehmen(db, admin, laptop): + geraet = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + tasche = _stueck(db, admin, laptop) + _haenge(db, admin, geraet, netzteil) + _haenge(db, admin, geraet, tasche) + teile = [netzteil.id, tasche.id] + + lend_item( + geraet.id, + LoanCreate(borrower="Markus", mit_zubehoer=teile), + db=db, user=admin, + ) + assert all(offener_verleih(db, i) is not None for i in [geraet.id, *teile]) + + return_item(geraet.id, LoanReturn(mit_zubehoer=teile), db=db, _=admin) + # Keines darf offen haengenbleiben – sonst sucht es niemand mehr. + assert all(offener_verleih(db, i) is None for i in [geraet.id, *teile]) + + +def test_fremdes_stueck_laesst_sich_nicht_mitverleihen(db, admin, laptop): + geraet = _stueck(db, admin, laptop) + fremdes = _stueck(db, admin, laptop) + + with pytest.raises(HTTPException) as fehler: + lend_item( + geraet.id, + LoanCreate(borrower="Markus", mit_zubehoer=[fremdes.id]), + db=db, user=admin, + ) + assert fehler.value.status_code == 400 + assert offener_verleih(db, fremdes.id) is None + + +def test_zuruecknehmen_uebergeht_ein_teil_das_dablieb(db, admin, laptop): + """Wer das Netzteil behalten hat, verleiht es nicht – die Rückgabe darf + daran trotzdem nicht scheitern.""" + geraet = _stueck(db, admin, laptop) + netzteil = _stueck(db, admin, laptop) + _haenge(db, admin, geraet, netzteil) + + lend_item(geraet.id, LoanCreate(borrower="Markus"), db=db, user=admin) + return_item(geraet.id, LoanReturn(mit_zubehoer=[netzteil.id]), db=db, _=admin) + + assert offener_verleih(db, geraet.id) is None diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 38b2029..654213e 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -264,6 +264,12 @@ actor APIClient { return try await send(request, as: [Item].self) } + /// Ein Einzelstueck frisch holen - nach dem Zuordnen von Zubehoer ist das + /// urspruenglich uebergebene Objekt veraltet, die Ansicht aber noch offen. + func item(id: Int) async throws -> Item { + try await send(try makeRequest("/items/\(id)"), as: Item.self) + } + /// QR-Auflösung: UID → Einzelstück. func itemByUid(uid: String) async throws -> Item { let escaped = uid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? uid @@ -278,6 +284,21 @@ actor APIClient { // MARK: - Verleih von Einzelstuecken + /// Zubehoer zuordnen. Eigener Endpunkt und nicht ueber updateItem: Dessen + /// Anfrage sendet bewusst alle Felder mit, auch leere - eine Beziehung + /// darueber zu pflegen hiesse, sie bei einem vergessenen Feld zu kappen. + func zubehoerZuordnen(itemId: Int, childItemId: Int) async throws -> Item { + var request = try makeRequest("/items/\(itemId)/zubehoer", method: "POST") + try jsonBody(&request, ZubehoerZuordnenRequest(childItemId: childItemId)) + return try await send(request, as: Item.self) + } + + func zubehoerLoesen(itemId: Int, childItemId: Int) async throws { + try await sendNoContent( + try makeRequest("/items/\(itemId)/zubehoer/\(childItemId)", method: "DELETE") + ) + } + /// Herausgeben. Scheitert mit 409, wenn das Stueck schon verliehen ist. func lendItem(id: Int, _ payload: LoanCreateRequest) async throws -> Loan { var request = try makeRequest("/items/\(id)/loan", method: "POST") diff --git a/ios/Sources/ItemListView.swift b/ios/Sources/ItemListView.swift index 9c8a5e4..4de65a4 100644 --- a/ios/Sources/ItemListView.swift +++ b/ios/Sources/ItemListView.swift @@ -28,6 +28,8 @@ struct ItemListView: View { } @State private var busy = true @State private var error: String? + /// Stück, dessen Rückgabe gerade bestätigt werden soll. + @State private var zurueckFuer: Item? private struct TreeEntry: Identifiable { let item: CategoryItem @@ -118,6 +120,23 @@ struct ItemListView: View { NavigationLink { ItemEditView(item: it) } label: { ItemRow(item: it, locations: locations) } + // Zuruecknehmen ohne Umweg: Bisher fuehrte der einzige Weg ueber + // das Bearbeiten-Formular des Stuecks. + .swipeActions(edge: .leading) { + if it.istVerliehen { + Button { zurueckFuer = it } label: { + Label("Zurücknehmen", systemImage: "arrow.uturn.down") + } + .tint(.green) + } + } + .contextMenu { + if it.istVerliehen { + Button { zurueckFuer = it } label: { + Label("Zurücknehmen", systemImage: "arrow.uturn.down") + } + } + } } } .searchable(text: $query, prompt: "Produkt, UID oder Notiz") @@ -128,11 +147,39 @@ struct ItemListView: View { .refreshable { await load() } // Auch beim Zurückkommen aus dem Einzelstück neu laden (z.B. Lagerort geändert). .onAppear { Task { await load() } } + .confirmationDialog( + zurueckFuer.map { "„\($0.uid)“ zurücknehmen?" } ?? "", + isPresented: Binding(get: { zurueckFuer != nil }, + set: { if !$0 { zurueckFuer = nil } }), + titleVisibility: .visible + ) { + Button("Zurücknehmen") { Task { await nimmZurueck() } } + Button("Abbrechen", role: .cancel) { zurueckFuer = nil } + } message: { + if let it = zurueckFuer, let l = it.loan { + let mit = it.teile.filter(\.verliehen) + Text("Von \(l.borrower)." + (mit.isEmpty ? "" : + " Das mitverliehene Zubehör kommt mit zurück: " + + mit.map(\.uid).joined(separator: ", ") + ".")) + } + } .alert("Fehler", isPresented: Binding(get: { error != nil }, set: { if !$0 { error = nil } })) { Button("OK", role: .cancel) {} } message: { Text(error ?? "") } } + private func nimmZurueck() async { + guard let it = zurueckFuer else { return } + zurueckFuer = nil + do { + _ = try await APIClient.shared.returnItem( + id: it.id, + LoanReturnRequest(mitZubehoer: it.teile.filter(\.verliehen).map(\.id)) + ) + await load() + } catch { self.error = error.localizedDescription } + } + private var filterMenu: some View { Menu { Menu("Lagerort") { @@ -163,9 +210,11 @@ struct ItemListView: View { } } } label: { - Image(systemName: hatFilter + Label(hatFilter ? verleihFilter.label : "Filter", + systemImage: hatFilter ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle") + .labelStyle(.titleAndIcon) } } diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index 11d0911..b18a835 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -223,6 +223,13 @@ struct ItemEditView: View { @State private var previewURL: URL? @State private var locScanShown = false @State private var nfcShown = false + @State private var zurueckFrage = false + // Zubehoer wird nachgeladen statt aus `item` genommen: Nach dem Zuordnen + // ist das uebergebene Stueck veraltet, die Ansicht aber noch offen. + @State private var teile: [ItemKurz] = [] + @State private var gehoertZu: ItemKurz? + @State private var zubehoerWaehlen = false + @State private var mitVerleihen: Set = [] // Verleih: der offene Vorgang (nil = im Haus) und die Eingaben dafuer. @State private var loan: Loan? @State private var loanHistory: [Loan] = [] @@ -279,7 +286,7 @@ struct ItemEditView: View { LabeledContent("Zurück bis", value: loan.dueOn == nil ? "offen" : anzeigeDatum(loan.dueOn)) } - Button { Task { await nimmZurueck() } } label: { + Button { zurueckFrage = true } label: { Label(busyLoan ? "Moment…" : "Zurücknehmen", systemImage: "arrow.uturn.down") } .disabled(busyLoan) @@ -296,6 +303,40 @@ struct ItemEditView: View { } } + // Zubehoer: das Netzteil, das zum Laptop gehoert. Ein Teil haengt an + // genau einem Geraet, ein Geraet traegt beliebig viele Teile. + Section("Zubehör") { + if let g = gehoertZu { + LabeledContent("Gehört zu") { + VStack(alignment: .trailing, spacing: 1) { + Text(g.productName ?? "Einzelstück") + Text(g.uid).font(.caption.monospaced()).foregroundStyle(.secondary) + } + } + } + ForEach(teile) { t in + HStack { + VStack(alignment: .leading, spacing: 1) { + Text(t.productName ?? "Einzelstück") + Text(t.uid).font(.caption.monospaced()).foregroundStyle(.secondary) + } + Spacer() + if t.verliehen { + Text("verliehen").font(.caption).foregroundStyle(.orange) + } + } + .swipeActions { + Button("Lösen", role: .destructive) { Task { await loese(t) } } + } + } + if teile.isEmpty && gehoertZu == nil { + Text("Kein Zubehör.").foregroundStyle(.secondary) + } + Button { zubehoerWaehlen = true } label: { + Label("Zubehör hinzufügen", systemImage: "plus") + } + } + Section { Picker("Lagerort", selection: $locationId) { Text("– ohne –").tag(String?.none) @@ -400,6 +441,25 @@ struct ItemEditView: View { .sheet(isPresented: $nfcShown) { NavigationStack { NFCSchreibenView(item: item) } } + .confirmationDialog( + "„\(item.uid)“ zurücknehmen?", + isPresented: $zurueckFrage, titleVisibility: .visible + ) { + Button("Zurücknehmen") { Task { await nimmZurueck() } } + Button("Abbrechen", role: .cancel) { } + } message: { + let wer = loan.map { "Von \($0.borrower)." } ?? "" + let mit = mitZurueck.isEmpty ? "" : + " Das mitverliehene Zubehör kommt mit zurück: " + + mitZurueck.map(\.uid).joined(separator: ", ") + "." + Text(wer + mit) + } + .sheet(isPresented: $zubehoerWaehlen) { + // Dieselbe durchsuchbare Liste wie beim Zuordnen eines Lagerorts. + ItemPickerSheet { gewaehlt in + Task { await haengeAn(gewaehlt) } + } + } .sheet(isPresented: $showRemove) { NavigationStack { removeSheet } } @@ -445,6 +505,29 @@ struct ItemEditView: View { Text("Der Lagerort bleibt stehen – dorthin gehört das Stück zurück. " + "Als verliehen zählt es aber nicht mehr zum Bestand.") } + // Zubehoer geht nur mit, wenn es angehakt ist. Schon anderweitig + // verliehene Teile stehen nicht zur Wahl - die sind gar nicht da. + if !teile.filter({ !$0.verliehen }).isEmpty { + Section("Zubehör mitgeben?") { + ForEach(teile.filter { !$0.verliehen }) { t in + Button { + if mitVerleihen.contains(t.id) { mitVerleihen.remove(t.id) } + else { mitVerleihen.insert(t.id) } + } label: { + HStack { + Image(systemName: mitVerleihen.contains(t.id) + ? "checkmark.circle.fill" : "circle") + .foregroundStyle(Color.marke) + VStack(alignment: .leading, spacing: 1) { + Text(t.productName ?? "Einzelstück").foregroundStyle(.primary) + Text(t.uid).font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + } + } + } + } + } if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } } Section { Button(busyLoan ? "Moment…" : "Verleihen") { Task { await verleihe() } } @@ -481,6 +564,9 @@ struct ItemEditView: View { hasDue = false due = Date() error = nil + // Vorausgewaehlt ist, was im Haus ist: Wer ein Geraet weitergibt, gibt + // das Netzteil meist mit. Abwaehlen geht mit einem Tipp. + mitVerleihen = Set(teile.filter { !$0.verliehen }.map(\.id)) showLend = true } @@ -490,19 +576,28 @@ struct ItemEditView: View { loan = try await APIClient.shared.lendItem(id: item.id, LoanCreateRequest( borrower: borrower.trimmingCharacters(in: .whitespaces), reason: loanReason.isEmpty ? nil : loanReason, - dueOn: hasDue ? dateToString(due) : nil)) + dueOn: hasDue ? dateToString(due) : nil, + mitZubehoer: Array(mitVerleihen))) showLend = false await ladeVerleihe() + await ladeZubehoer() onChanged?() } catch { self.error = error.localizedDescription } } + /// Mitverliehenes Zubehoer, das zusammen mit dem Geraet zurueckkommt. + private var mitZurueck: [ItemKurz] { teile.filter { $0.verliehen } } + private func nimmZurueck() async { busyLoan = true; defer { busyLoan = false }; error = nil do { - _ = try await APIClient.shared.returnItem(id: item.id) + _ = try await APIClient.shared.returnItem( + id: item.id, + LoanReturnRequest(mitZubehoer: mitZurueck.map(\.id)) + ) loan = nil await ladeVerleihe() + await ladeZubehoer() onChanged?() } catch { self.error = error.localizedDescription } } @@ -527,9 +622,38 @@ struct ItemEditView: View { if let s = item.acquiredOn, let d = stringToDate(s) { acquired = d; hasAcquired = true } if let s = item.warrantyUntil, let d = stringToDate(s) { warranty = d; hasWarranty = true } loan = item.loan + teile = item.teile + gehoertZu = item.gehoertZu await ladeVerleihe() } + private func haengeAn(_ gewaehlt: Item) async { + error = nil + do { + _ = try await APIClient.shared.zubehoerZuordnen( + itemId: item.id, childItemId: gewaehlt.id + ) + await ladeZubehoer() + onChanged?() + } catch { self.error = error.localizedDescription } + } + + private func loese(_ teil: ItemKurz) async { + error = nil + do { + try await APIClient.shared.zubehoerLoesen(itemId: item.id, childItemId: teil.id) + await ladeZubehoer() + onChanged?() + } catch { self.error = error.localizedDescription } + } + + private func ladeZubehoer() async { + guard let frisch = try? await APIClient.shared.item(id: item.id) else { return } + teile = frisch.teile + gehoertZu = frisch.gehoertZu + loan = frisch.loan + } + private func save() async { busy = true; defer { busy = false }; error = nil let cents = ItemEditView.parseCents(priceText) diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 0d52a5d..9cc84a3 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -1094,18 +1094,55 @@ struct LoanCreateRequest: Encodable { var reason: String? = nil var lentOn: String? = nil var dueOn: String? = nil + /// Zubehoerteile, die mitgehen. Leer = nur das Geraet selbst; der Server + /// nimmt nie ungefragt etwas mit. + var mitZubehoer: [Int] = [] enum CodingKeys: String, CodingKey { case borrower, reason case lentOn = "lent_on" case dueOn = "due_on" + case mitZubehoer = "mit_zubehoer" } } struct LoanReturnRequest: Encodable { var returnedOn: String? = nil + /// Spiegelbild zu LoanCreateRequest: Ohne das kaeme das Geraet zurueck und + /// sein Netzteil bliebe ewig „verliehen". + var mitZubehoer: [Int] = [] - enum CodingKeys: String, CodingKey { case returnedOn = "returned_on" } + enum CodingKeys: String, CodingKey { + case returnedOn = "returned_on" + case mitZubehoer = "mit_zubehoer" + } +} + +struct ZubehoerZuordnenRequest: Encodable { + let childItemId: Int + enum CodingKeys: String, CodingKey { case childItemId = "child_item_id" } +} + +/// Ein verknuepftes Einzelstueck in Kurzform (Zubehoer bzw. das Geraet dazu). +/// +/// Bewusst nicht `Item`: sonst zoege das Geraet seine Teile und jedes Teil +/// wieder sein Geraet nach, endlos. +struct ItemKurz: Codable, Identifiable, Hashable { + let id: Int + let uid: String + let productId: Int + let productName: String? + let productBrand: String? + let locationName: String? + let verliehen: Bool + + enum CodingKeys: String, CodingKey { + case id, uid, verliehen + case productId = "product_id" + case productName = "product_name" + case productBrand = "product_brand" + case locationName = "location_name" + } } struct Item: Codable, Identifiable, Hashable { @@ -1130,11 +1167,24 @@ struct Item: Codable, Identifiable, Hashable { /// Der offene Verleih, falls das Stueck gerade draussen ist - sonst nil. /// Der Lagerort steht daneben unveraendert: dorthin gehoert es zurueck. let loan: Loan? + /// Zubehoer dieses Geraets und, umgekehrt, das Geraet, zu dem dieses Stueck + /// gehoert. + /// + /// Beide optional, und das ist keine Bequemlichkeit: Die App spricht mit + /// mehreren Serverprofilen, und ein aelterer Server schickt die Felder gar + /// nicht. Ein nicht-optionales Feld mit Vorgabewert reicht dafuer NICHT - + /// Swift nimmt den Vorgabewert beim Dekodieren nicht her, es wuerde werfen. + var zubehoer: [ItemKurz]? + var gehoertZu: ItemKurz? + + /// Zubehoer ohne Optional-Gehampel an der Aufrufstelle. + var teile: [ItemKurz] { zubehoer ?? [] } var istVerliehen: Bool { loan != nil } enum CodingKeys: String, CodingKey { - case id, uid, note, currency, documents, loan + case id, uid, note, currency, documents, loan, zubehoer + case gehoertZu = "gehoert_zu" case productId = "product_id" case locationId = "location_id" case locationName = "location_name" diff --git a/web/src/App.jsx b/web/src/App.jsx index e3674ec..8e904ef 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -27,6 +27,7 @@ import Units from "./pages/Units"; import Users from "./pages/Users"; import ShoppingList from "./pages/ShoppingList"; import History from "./pages/History"; +import Verliehen from "./pages/Verliehen"; import Transfer from "./pages/Transfer"; import Settings from "./pages/Settings"; import Konto from "./pages/Konto"; @@ -87,6 +88,7 @@ function Sidebar() { + {isAdmin && ( <> @@ -190,6 +192,9 @@ export default function App() { } /> } /> } /> + {/* Ohne adminOnly: Verleihen und Zurücknehmen ist eine Buchung wie Ein- + und Auslagern, keine Stammdatenänderung. */} + } /> } /> {/* Bewusst ohne adminOnly: die SSO-Verknuepfung braucht jeder selbst. */} } /> diff --git a/web/src/api.js b/web/src/api.js index bc09f0c..1dbbf58 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -232,6 +232,12 @@ export const api = { lendItem: (itemId, body) => request(`/items/${itemId}/loan`, { method: "POST", body }), returnItem: (itemId, body = {}) => request(`/items/${itemId}/return`, { method: "POST", body }), itemLoans: (itemId) => request(`/items/${itemId}/loans`), + // Zubehör läuft bewusst nicht über updateItem: dessen Schema sendet immer alle + // Felder mit, ein vergessenes Feld würde die Beziehung still kappen. + zubehoerZuordnen: (itemId, childItemId) => + request(`/items/${itemId}/zubehoer`, { method: "POST", body: { child_item_id: childItemId } }), + zubehoerLoesen: (itemId, childItemId) => + request(`/items/${itemId}/zubehoer/${childItemId}`, { method: "DELETE" }), // Offene Verleihe, fälligste zuerst. dueIn grenzt auf die ein, deren Rückgabe // in so vielen Tagen ansteht (überfällige sind immer dabei). loans: ({ openOnly = true, dueIn } = {}) => { diff --git a/web/src/components/Einzelstuecke.jsx b/web/src/components/Einzelstuecke.jsx index 0180749..a069255 100644 --- a/web/src/components/Einzelstuecke.jsx +++ b/web/src/components/Einzelstuecke.jsx @@ -4,6 +4,7 @@ import { api, authorizedObjectUrl } from "../api"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; import Icon from "./Icon"; +import Zubehoer from "./Zubehoer"; import { REASONS } from "./ObjektBestand"; import { locationOptions, locationPathById } from "../locationPath"; import { itemDeepLink } from "../qr"; @@ -362,6 +363,8 @@ export default function Einzelstuecke({ product, locations, shops, isAdmin, onCh +