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) } } }