diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index cf04048..712d3e8 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -1,7 +1,7 @@ from collections import defaultdict from datetime import date, timedelta -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session from ..database import get_db @@ -9,6 +9,7 @@ from ..deps import get_current_user from ..models import ( Group, GroupLocationMinStock, + Item, Location, Lot, Movement, @@ -19,6 +20,8 @@ from ..models import ( from ..schemas import ( ExpiringItem, GroupShoppingItem, + LocationContentEntry, + LocationContents, LocationNeedGroup, LocationNeedProduct, LocationNeeds, @@ -26,7 +29,11 @@ from ..schemas import ( ShoppingItem, ) from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info -from ..services.stock import current_stock, location_subtree_stock_base +from ..services.stock import ( + current_stock, + descendant_location_ids, + location_subtree_stock_base, +) from .settings import get_expiry_warning_days router = APIRouter(tags=["views"]) @@ -159,6 +166,47 @@ def shopping_list_by_location( return result +@router.get("/location/{code}", response_model=LocationContents) +def location_contents( + code: str, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> LocationContents: + """Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte. + + Ziel des Lagerort-QR (`/l/`) beim Nachschlagen: einmal scannen und + sehen, was im Regal/Fach (und allem darunter) steht. Zählt sowohl + Lot-Bestände (Lebensmittel/Objekte) als auch Einzelstücke. + """ + loc = db.get(Location, code) + if loc is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") + + ids = {code} | descendant_location_ids(db, code) + product_ids: set[int] = { + pid for (pid,) in db.query(Lot.product_id).filter(Lot.location_id.in_(ids)).distinct() + } + product_ids |= { + pid for (pid,) in db.query(Item.product_id).filter(Item.location_id.in_(ids)).distinct() + } + + entries: list[LocationContentEntry] = [] + for pid in product_ids: + product = db.get(Product, pid) + if product is None: + continue + faktor, label = article_unit(product) + stock = location_subtree_stock_base(db, product, code) / (faktor or 1.0) + if stock <= 0: + continue + entries.append(LocationContentEntry( + product_id=product.id, name=product.name, brand=product.brand, + stock=round(stock, 3), unit_label=label, individual=product.individual, + )) + entries.sort(key=lambda e: e.name.lower()) + return LocationContents(location_id=code, location_name=loc.name, products=entries) + + @router.get("/expiring", response_model=list[ExpiringItem]) def expiring( days: int | None = None, diff --git a/backend/app/schemas.py b/backend/app/schemas.py index c2b14e2..9db520c 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -658,6 +658,23 @@ class LocationNeeds(BaseModel): groups: list[LocationNeedGroup] = [] +class LocationContentEntry(BaseModel): + """Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten).""" + product_id: int + name: str + brand: str | None = None + stock: float + unit_label: str = "" + individual: bool = False + + +class LocationContents(BaseModel): + """Alle Artikel, die an einem Lagerort liegen – inklusive der Unterorte.""" + location_id: str + location_name: str + products: list[LocationContentEntry] = [] + + class MovementOut(BaseModel): id: int product_id: int diff --git a/backend/tests/test_location_contents.py b/backend/tests/test_location_contents.py new file mode 100644 index 0000000..72aba62 --- /dev/null +++ b/backend/tests/test_location_contents.py @@ -0,0 +1,64 @@ +"""Lagerort-Inhalt: was liegt an einem Ort – inklusive der Unterorte?""" + +import pytest +from fastapi import HTTPException + +from app.models import BaseUnit, Item, Location, Lot, Product, Role, User +from app.routers.views import location_contents + + +@pytest.fixture() +def user(db): + person = User(username="tester", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +def test_zeigt_artikel_inkl_unterorten(db, user): + haus = Location(name="Hedingen") + db.add(haus) + db.flush() + keller = Location(name="Keller", parent_id=haus.id) + db.add(keller) + db.flush() + + # package_size=1 -> Artikeleinheit == Basiseinheit, macht die Menge klar. + kaffee = Product(name="Kaffee", base_unit=BaseUnit.gram, package_size=1) + bohrer = Product(name="Bohrer", base_unit=BaseUnit.piece, package_size=1, individual=True) + db.add_all([kaffee, bohrer]) + db.flush() + + # Lot im Unterort (Keller), Einzelstück direkt in Hedingen. + db.add(Lot(product_id=kaffee.id, quantity=3, location_id=keller.id)) + db.add(Item(uid="AAAA111111", product_id=bohrer.id, location_id=haus.id)) + db.commit() + + inhalt = location_contents(code=haus.id, db=db, _=user) + assert inhalt.location_name == "Hedingen" + nach_name = {e.name: e for e in inhalt.products} + assert set(nach_name) == {"Kaffee", "Bohrer"} # Bestand im Unterort zaehlt mit + assert nach_name["Kaffee"].stock == 3 + assert nach_name["Bohrer"].stock == 1 + assert nach_name["Bohrer"].individual is True + + +def test_nur_der_gescannte_teilbaum(db, user): + a = Location(name="Ort A") + b = Location(name="Ort B") + db.add_all([a, b]) + db.flush() + p = Product(name="Reis", base_unit=BaseUnit.gram, package_size=1) + db.add(p) + db.flush() + db.add(Lot(product_id=p.id, quantity=2, location_id=b.id)) + db.commit() + # In A liegt nichts – der Bestand von B darf nicht auftauchen. + assert location_contents(code=a.id, db=db, _=user).products == [] + + +def test_unbekannter_ort_404(db, user): + with pytest.raises(HTTPException) as ex: + location_contents(code="ZZZZZZZZZZ", db=db, _=user) + assert ex.value.status_code == 404 diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index 8aa0121..ccec91b 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -104,6 +104,11 @@ actor APIClient { try await send(try makeRequest("/products/\(id)"), as: Product.self) } + /// Alles, was an einem Lagerort (inkl. Unterorte) liegt – Ziel des /l/-QR beim Nachschlagen. + func locationContents(code: String) async throws -> LocationContents { + try await send(try makeRequest("/location/\(code)"), as: LocationContents.self) + } + func lookup(barcode: String) async throws -> LookupResult { let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self) diff --git a/ios/Sources/LookupScanView.swift b/ios/Sources/LookupScanView.swift index 3216fdd..36199fb 100644 --- a/ios/Sources/LookupScanView.swift +++ b/ios/Sources/LookupScanView.swift @@ -1,7 +1,7 @@ import SwiftUI -/// Nur ansehen: Barcode → Artikel, Einzelstück-QR → genau dieses Stück. -/// Kein Ein-/Auslagern – zum schnellen Nachschauen (z.B. wann gekauft, Garantie). +/// Nur ansehen: Barcode → Artikel, Einzelstück-QR → genau dieses Stück, +/// Lagerort-QR → Liste aller Artikel an diesem Ort. Kein Ein-/Auslagern. struct LookupScanView: View { @Environment(\.dismiss) private var dismiss @EnvironmentObject private var display: DisplaySettings @@ -12,6 +12,7 @@ struct LookupScanView: View { @State private var error: String? @State private var item: Item? @State private var product: Product? + @State private var contents: LocationContents? var body: some View { ScrollView { @@ -22,8 +23,9 @@ struct LookupScanView: View { .clipShape(RoundedRectangle(cornerRadius: 16)) .padding(.horizontal) - Text("Barcode oder Einzelstück-QR scannen – nur ansehen") + Text("Barcode, Einzelstück- oder Lagerort-QR scannen – nur ansehen") .font(.caption).foregroundStyle(.secondary) + .multilineTextAlignment(.center) if let error { Text(error).font(.callout).foregroundStyle(.red) @@ -46,6 +48,10 @@ struct LookupScanView: View { NavigationStack { ProductDetailView(product: p) } .environmentObject(display) } + .sheet(item: $contents, onDismiss: { paused = false }) { c in + NavigationStack { LocationContentsView(contents: c) } + .environmentObject(display) + } } private func resolve(_ code: String) async { @@ -60,6 +66,20 @@ struct LookupScanView: View { return } } + // Lagerort-QR (…/l/)? → alles zeigen, was dort (inkl. Unterorte) liegt. + if let r = code.range(of: "/l/") { + let loc = String(code[r.upperBound...]) + .trimmingCharacters(in: CharacterSet(charactersIn: "/ ")).uppercased() + if !loc.isEmpty { + do { + contents = try await APIClient.shared.locationContents(code: loc) + } catch { + self.error = "Lagerort nicht gefunden: \(loc)" + paused = false + } + return + } + } // Sonst als Barcode auflösen. do { let result = try await APIClient.shared.lookup(barcode: code) @@ -75,3 +95,69 @@ struct LookupScanView: View { } } } + +/// Liste aller Artikel eines gescannten Lagerorts (inkl. Unterorte). +struct LocationContentsView: View { + let contents: LocationContents + @EnvironmentObject private var display: DisplaySettings + + var body: some View { + List { + if contents.products.isEmpty { + Text("Hier ist nichts eingelagert.").foregroundStyle(.secondary) + } else { + Section { + ForEach(contents.products) { e in + NavigationLink { + ProductLoaderView(productId: e.productId).environmentObject(display) + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text(e.name) + if let brand = e.brand, !brand.isEmpty { + Text(brand).font(.caption).foregroundStyle(.secondary) + } + } + Spacer() + Text("\(mengeText(e.stock)) \(e.unitLabel)") + .foregroundStyle(.secondary) + } + } + } + } footer: { + Text("Bestand inkl. der Unterorte. Tippen öffnet den Artikel.") + } + } + } + .navigationTitle(contents.locationName) + .navigationBarTitleDisplayMode(.inline) + } + + private func mengeText(_ v: Double) -> String { + v == v.rounded() ? String(Int(v)) : String(format: "%.2f", v) + } +} + +/// Lädt einen Artikel per ID nach und zeigt dann seine Detailansicht. +struct ProductLoaderView: View { + let productId: Int + @EnvironmentObject private var display: DisplaySettings + @State private var product: Product? + @State private var error: String? + + var body: some View { + Group { + if let product { + ProductDetailView(product: product).environmentObject(display) + } else if let error { + Text(error).foregroundStyle(.red).padding() + } else { + ProgressView() + } + } + .task { + do { product = try await APIClient.shared.product(id: productId) } + catch { self.error = error.localizedDescription } + } + } +} diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 9f33e95..91a6587 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -405,6 +405,39 @@ struct LocationNeeds: Codable, Identifiable { } } +/// Ein Artikel mit seinem Bestand an einem Lagerort (in Artikeleinheiten). +struct LocationContentEntry: Codable, Identifiable, Hashable { + let productId: Int + let name: String + let brand: String? + let stock: Double + let unitLabel: String + let individual: Bool + + var id: Int { productId } + + enum CodingKeys: String, CodingKey { + case name, brand, stock, individual + case productId = "product_id" + case unitLabel = "unit_label" + } +} + +/// Alles, was an einem gescannten Lagerort liegt – inklusive der Unterorte. +struct LocationContents: Codable, Identifiable { + let locationId: String + let locationName: String + let products: [LocationContentEntry] + + var id: String { locationId } + + enum CodingKeys: String, CodingKey { + case products + case locationId = "location_id" + case locationName = "location_name" + } +} + struct ExpiringItem: Codable, Identifiable { let lotId: Int let productId: Int