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 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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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)
|
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)
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user