Nachschlagen: Lagerort-QR zeigt alle Artikel an diesem Ort

Neuer Endpunkt GET /location/{code} liefert alle Artikel an einem Lagerort inkl. der Unterorte (Lot-Bestaende und Einzelstuecke), sortiert nach Name mit Menge in Artikeleinheiten. In der App loest der /l/-QR beim Nachschlagen jetzt diese Liste auf; ein Tipp auf einen Eintrag oeffnet den Artikel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-27 13:10:29 +02:00
parent 4607e3a6a8
commit 21ab0a825a
6 changed files with 258 additions and 5 deletions

View File

@@ -1,7 +1,7 @@
from collections import defaultdict from collections import defaultdict
from datetime import date, timedelta from datetime import date, timedelta
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
@@ -9,6 +9,7 @@ from ..deps import get_current_user
from ..models import ( from ..models import (
Group, Group,
GroupLocationMinStock, GroupLocationMinStock,
Item,
Location, Location,
Lot, Lot,
Movement, Movement,
@@ -19,6 +20,8 @@ from ..models import (
from ..schemas import ( from ..schemas import (
ExpiringItem, ExpiringItem,
GroupShoppingItem, GroupShoppingItem,
LocationContentEntry,
LocationContents,
LocationNeedGroup, LocationNeedGroup,
LocationNeedProduct, LocationNeedProduct,
LocationNeeds, LocationNeeds,
@@ -26,7 +29,11 @@ from ..schemas import (
ShoppingItem, ShoppingItem,
) )
from ..services.conversion import BASE_OF_KIND, article_unit, display_unit_info 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 from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"]) router = APIRouter(tags=["views"])
@@ -159,6 +166,47 @@ def shopping_list_by_location(
return result 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/<code>`) 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]) @router.get("/expiring", response_model=list[ExpiringItem])
def expiring( def expiring(
days: int | None = None, days: int | None = None,

View File

@@ -658,6 +658,23 @@ class LocationNeeds(BaseModel):
groups: list[LocationNeedGroup] = [] 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): class MovementOut(BaseModel):
id: int id: int
product_id: int product_id: int

View File

@@ -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

View File

@@ -104,6 +104,11 @@ actor APIClient {
try await send(try makeRequest("/products/\(id)"), as: Product.self) 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 { func lookup(barcode: String) async throws -> LookupResult {
let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" let escaped = barcode.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""
return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self) return try await send(try makeRequest("/products/lookup?barcode=\(escaped)"), as: LookupResult.self)

View File

@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
/// Nur ansehen: Barcode Artikel, Einzelstück-QR genau dieses Stück. /// Nur ansehen: Barcode Artikel, Einzelstück-QR genau dieses Stück,
/// Kein Ein-/Auslagern zum schnellen Nachschauen (z.B. wann gekauft, Garantie). /// Lagerort-QR Liste aller Artikel an diesem Ort. Kein Ein-/Auslagern.
struct LookupScanView: View { struct LookupScanView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@EnvironmentObject private var display: DisplaySettings @EnvironmentObject private var display: DisplaySettings
@@ -12,6 +12,7 @@ struct LookupScanView: View {
@State private var error: String? @State private var error: String?
@State private var item: Item? @State private var item: Item?
@State private var product: Product? @State private var product: Product?
@State private var contents: LocationContents?
var body: some View { var body: some View {
ScrollView { ScrollView {
@@ -22,8 +23,9 @@ struct LookupScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 16)) .clipShape(RoundedRectangle(cornerRadius: 16))
.padding(.horizontal) .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) .font(.caption).foregroundStyle(.secondary)
.multilineTextAlignment(.center)
if let error { if let error {
Text(error).font(.callout).foregroundStyle(.red) Text(error).font(.callout).foregroundStyle(.red)
@@ -46,6 +48,10 @@ struct LookupScanView: View {
NavigationStack { ProductDetailView(product: p) } NavigationStack { ProductDetailView(product: p) }
.environmentObject(display) .environmentObject(display)
} }
.sheet(item: $contents, onDismiss: { paused = false }) { c in
NavigationStack { LocationContentsView(contents: c) }
.environmentObject(display)
}
} }
private func resolve(_ code: String) async { private func resolve(_ code: String) async {
@@ -60,6 +66,20 @@ struct LookupScanView: View {
return return
} }
} }
// Lagerort-QR (/l/<Code>)? 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. // Sonst als Barcode auflösen.
do { do {
let result = try await APIClient.shared.lookup(barcode: code) 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 }
}
}
}

View File

@@ -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 { struct ExpiringItem: Codable, Identifiable {
let lotId: Int let lotId: Int
let productId: Int let productId: Int