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:
@@ -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/<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])
|
||||
def expiring(
|
||||
days: int | None = None,
|
||||
|
||||
@@ -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
|
||||
|
||||
64
backend/tests/test_location_contents.py
Normal file
64
backend/tests/test_location_contents.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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/<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.
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user