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
|
||||
Reference in New Issue
Block a user