From ca4e9a226462ed5415c00c367fd09ebe948bd444 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Fri, 18 Sep 2026 12:12:42 +0200 Subject: [PATCH] Einzelstueck anlegen: im Verlauf sichtbar und vom Artikel aus erreichbar Zwei Luecken, die zusammen den Eindruck machten, ein angelegtes Einzelstueck sei verschwunden. Der Verlauf kannte nur das Verschwinden. remove_item schreibt seit jeher eine Bewegung, create_items nicht - Abgaenge von Einzelstuecken tauchten also auf, Zugaenge nie, im Verlauf wie im Dashboard. Jetzt schreibt das Anlegen je Stueck eine in-Bewegung mit UID (oder der eigenen Notiz, falls angegeben), Lagerort und Benutzer, spiegelbildlich zum Entfernen. Der Bestand bleibt unberuehrt: er zaehlt bei Einzelstuecken die Zeilen in items und rechnet nicht aus Bewegungen. Und der Weg dorthin fehlte. ProductFormView liefert den angelegten Artikel, aber zwei der drei Aufrufer warfen ihn weg ("{ _ in showNew = false }") - nur der Einlagern-Weg nutzte ihn. "Einzelstuecke anlegen" steckt ausschliesslich in der Artikelansicht, die man vom Scan-Tab und aus den Listen nach dem Anlegen gar nicht erreicht. Man musste den frischen Artikel von Hand wiederfinden. Bei Gegenstaenden mit eigener UID je Stueck oeffnet sich jetzt direkt das Anlege-Formular. Gesetzt wird das im onDismiss des Artikelformulars: beides im selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet erscheinen soll, waehrend das erste noch verschwindet. Der Parameter von create_items heisst dadurch user statt _; die drei Testaufrufe sind mitgezogen. Co-Authored-By: Claude Opus 5 --- backend/app/routers/items.py | 19 ++++++++++++- backend/tests/test_items.py | 50 ++++++++++++++++++++++++++++++++--- backend/tests/test_verleih.py | 2 +- ios/Sources/ItemViews.swift | 29 ++++++++++++++++++++ ios/Sources/ListViews.swift | 17 +++++++++--- ios/Sources/RootView.swift | 12 +++++++-- 6 files changed, 118 insertions(+), 11 deletions(-) diff --git a/backend/app/routers/items.py b/backend/app/routers/items.py index b75d250..f1b2278 100644 --- a/backend/app/routers/items.py +++ b/backend/app/routers/items.py @@ -89,7 +89,7 @@ def create_items( product_id: int, payload: ItemCreate, db: Session = Depends(get_db), - _: User = Depends(require_admin), + user: User = Depends(require_admin), ) -> list[ItemOut]: """Ein oder mehrere (count) Einzelstücke mit gemeinsamen Startwerten anlegen.""" product = _product_or_404(db, product_id) @@ -109,6 +109,23 @@ def create_items( ) db.add(item) db.flush() + # Wie beim Entfernen eine Bewegung schreiben (siehe remove_item). Ohne + # sie kannte der Verlauf nur das Verschwinden eines Einzelstuecks, nie + # sein Entstehen - und das Dashboard zeigte Abgaenge ohne Zugaenge. + # Fuer den Bestand aendert das nichts: der zaehlt bei Einzelstuecken die + # Zeilen in items (services/stock.py), er rechnet nicht aus Bewegungen. + db.add( + Movement( + product_id=product.id, + lot_id=None, + user_id=user.id, + type=MovementType.in_, + quantity=1, + unit_used="Stück", + note=(payload.note or f"Einzelstück {item.uid}"), + location_id=payload.location_id, + ) + ) created.append(item) db.commit() for item in created: diff --git a/backend/tests/test_items.py b/backend/tests/test_items.py index 698c420..d7b44dd 100644 --- a/backend/tests/test_items.py +++ b/backend/tests/test_items.py @@ -8,12 +8,15 @@ from app.models import ( Item, ItemDocument, Location, + Movement, + MovementType, Product, Role, User, ) from app.routers.items import _safe_filename, create_items, get_item, list_all_items from app.schemas import ItemCreate +from app.services.stock import current_stock @pytest.fixture() @@ -39,7 +42,7 @@ def _product(db): def test_item_kaufpreis_round_trip(db, admin): product = _product(db) out = create_items( - product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, _=admin + product.id, ItemCreate(count=1, price_cents=49900, currency="CHF"), db=db, user=admin ) assert out[0].price_cents == 49900 assert out[0].currency == "CHF" @@ -47,7 +50,7 @@ def test_item_kaufpreis_round_trip(db, admin): def test_item_out_listet_belege(db, admin): product = _product(db) - item_id = create_items(product.id, ItemCreate(count=1), db=db, _=admin)[0].id + item_id = create_items(product.id, ItemCreate(count=1), db=db, user=admin)[0].id db.add(ItemDocument(item_id=item_id, filename="rechnung.pdf", content_type="application/pdf", data=b"%PDF-1.4 x")) db.commit() @@ -68,7 +71,7 @@ def test_list_all_items_angereichert(db, admin): ort = Location(name="Keller") db.add(ort) db.commit() - create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, _=admin) + create_items(product.id, ItemCreate(count=2, location_id=ort.id), db=db, user=admin) alle = list_all_items(db=db, _=admin) assert len(alle) == 2 @@ -84,3 +87,44 @@ def test_safe_filename_entfernt_header_zeichen(): assert _safe_filename('a"b\r\nc.pdf') == "abc.pdf" assert _safe_filename(" ") == "beleg" assert _safe_filename(None) == "beleg" + + +# -------------------------------------------------------------------------- +# Verlauf: Anlegen und Entfernen muessen beide auftauchen +# -------------------------------------------------------------------------- +def test_anlegen_schreibt_bewegung_je_stueck(db, admin): + """Frueher kannte der Verlauf nur das Verschwinden, nie das Entstehen.""" + product = _product(db) + ort = Location(name="Keller") + db.add(ort) + db.commit() + + create_items(product.id, ItemCreate(count=3, location_id=ort.id), db=db, user=admin) + + bewegungen = db.query(Movement).filter(Movement.product_id == product.id).all() + assert len(bewegungen) == 3 + for b in bewegungen: + assert b.type == MovementType.in_ + assert b.quantity == 1 + assert b.unit_used == "Stück" + assert b.location_id == ort.id + assert b.user_id == admin.id + # Die UID gehoert in den Eintrag, sonst sagt der Verlauf nicht, welches Stueck. + uids = {i.uid for i in db.query(Item).filter(Item.product_id == product.id)} + assert {b.note.removeprefix("Einzelstück ") for b in bewegungen} == uids + + +def test_anlegen_uebernimmt_die_notiz_als_grund(db, admin): + """Eine eigene Notiz ist aussagekraeftiger als die UID - sie gewinnt.""" + product = _product(db) + create_items(product.id, ItemCreate(count=1, note="Geschenk von Oma"), db=db, user=admin) + + bewegung = db.query(Movement).filter(Movement.product_id == product.id).one() + assert bewegung.note == "Geschenk von Oma" + + +def test_anlegen_veraendert_den_bestand_nicht_doppelt(db, admin): + """Der Bestand zaehlt Zeilen in items - die Bewegung darf nichts dazutun.""" + product = _product(db) + create_items(product.id, ItemCreate(count=2), db=db, user=admin) + assert current_stock(db, product.id) == 2.0 diff --git a/backend/tests/test_verleih.py b/backend/tests/test_verleih.py index 74c59f8..f5245eb 100644 --- a/backend/tests/test_verleih.py +++ b/backend/tests/test_verleih.py @@ -65,7 +65,7 @@ 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, + db=db, user=admin, )[0] diff --git a/ios/Sources/ItemViews.swift b/ios/Sources/ItemViews.swift index a7c3be9..11d0911 100644 --- a/ios/Sources/ItemViews.swift +++ b/ios/Sources/ItemViews.swift @@ -641,6 +641,35 @@ struct ItemEditView: View { // MARK: - Anlegen +extension View { + /// Nach dem Anlegen eines Artikels gleich dessen Einzelstücke erfassen. + /// + /// „Artikel anlegen" endete bisher im Nichts: `ProductFormView` liefert den + /// neuen Artikel, aber die Aufrufer warfen ihn weg – und „Einzelstücke + /// anlegen" steckt ausschliesslich in der Artikelansicht, die man von dort + /// aus gar nicht erreicht. + /// + /// Die Bindung wird erst im `onDismiss` des Formulars gesetzt: Beides im + /// selben Zug zu setzen verschluckt SwiftUI, weil das zweite Sheet dann + /// erscheinen soll, waehrend das erste noch verschwindet. + func einzelstueckeNachAnlegen(_ artikel: Binding) -> some View { + sheet(item: artikel) { p in + NavigationStack { + ItemAddSheet(productId: p.id, + onDone: {}, + onFinish: { artikel.wrappedValue = nil }) + } + } + } +} + +/// Hat dieser Artikel ueberhaupt Einzelstuecke? Nur Gegenstaende, die je Stueck +/// eine eigene UID fuehren – Lebensmittel und Mengen-Gegenstaende nicht. +func fuehrtEinzelstuecke(_ p: Product?) -> Bool { + guard let p else { return false } + return p.isObject && p.isIndividual +} + struct ItemAddSheet: View { let productId: Int var onDone: () async -> Void diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index 7ac51e4..535cd63 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -362,6 +362,9 @@ struct ProductListView: View { @State private var verwaltungFilter: String? @State private var busy = false @State private var showNew = false + // Der gerade angelegte Artikel, bis sich das Formular geschlossen hat. + @State private var angelegt: Product? + @State private var einzelstueckeFuer: Product? enum SortMode: String, CaseIterable, Identifiable { case nameAsc = "Name A–Z" @@ -488,12 +491,17 @@ struct ProductListView: View { } } } - .sheet(isPresented: $showNew) { + .sheet(isPresented: $showNew, onDismiss: { + if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt } + angelegt = nil + }) { NavigationStack { // Ohne Barcode: nur Name + Kategorie nötig. Typ aus der Liste - // vorbelegen. Nach dem Anlegen schließt sich das Formular und die - // Liste wird aufgefrischt. - ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { _ in + // vorbelegen. Nach dem Anlegen schließt sich das Formular, die + // Liste wird aufgefrischt – und bei Gegenständen mit eigener UID + // je Stück geht es gleich weiter zum Anlegen der Einzelstücke. + ProductFormView(prefillBarcode: nil, groupId: nil, initialType: fixedType) { created in + angelegt = created showNew = false Task { await load() } } @@ -504,6 +512,7 @@ struct ProductListView: View { } } } + .einzelstueckeNachAnlegen($einzelstueckeFuer) .task { categories = (try? await APIClient.shared.categories()) ?? [] } // Bei jedem Erscheinen neu laden – auch beim Zurückkommen aus der // Detailansicht, damit ein dort geänderter Bestand (z.B. neu angelegte diff --git a/ios/Sources/RootView.swift b/ios/Sources/RootView.swift index c20055a..31c74c3 100644 --- a/ios/Sources/RootView.swift +++ b/ios/Sources/RootView.swift @@ -117,6 +117,9 @@ struct ScanTabView: View { @EnvironmentObject private var router: Router @EnvironmentObject private var session: Session @State private var showNew = false + // Der gerade angelegte Artikel, bis sich das Formular geschlossen hat. + @State private var angelegt: Product? + @State private var einzelstueckeFuer: Product? @State private var showKassenzettel = false var body: some View { @@ -174,13 +177,18 @@ struct ScanTabView: View { .padding() .navigationTitle("Scannen") .fullScreenCover(isPresented: $showKassenzettel) { KassenzettelView() } - .sheet(isPresented: $showNew) { + .sheet(isPresented: $showNew, onDismiss: { + if fuehrtEinzelstuecke(angelegt) { einzelstueckeFuer = angelegt } + angelegt = nil + }) { NavigationStack { - ProductFormView(prefillBarcode: nil, groupId: nil) { _ in + ProductFormView(prefillBarcode: nil, groupId: nil) { created in + angelegt = created showNew = false } } } + .einzelstueckeNachAnlegen($einzelstueckeFuer) } } }