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

View File

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

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