Files
Vorrania/backend/app/routers/views.py
Scarriffle 4bb9a10366 iOS-App angefangen; Import-Modi; MHD-Formatierung; Gebinde in Ablauf-Ansichten
iOS (neu, ios/):
- SwiftUI-App: Login (Server + Keychain-Token), Kamera-Scanner (EAN/UPC/Code128),
  Einlagern mit mehreren Chargen und eigenem MHD, Auslagern mit Chargenauswahl
  oder FEFO, Artikelsuche, Anlegen mit Open-Food-Facts-Vorbefuellung.
- Home-Screen-Shortcuts: Schnellaktionen (langer Druck) und URL-Schema
  projectgood://checkin | ://checkout fuer eigene Symbole via Kurzbefehle.
- project.yml (XcodeGen) + README mit Build-Anleitung. NICHT kompiliert - auf
  diesem Rechner ist kein Xcode vorhanden.

Import-Modi (wie besprochen sinnvoll):
- add (Standard, nichts loeschen), replace_listed (Bestaende der in der Datei
  genannten Produkte ersetzen - fuer Inventur), replace_all (alles ersetzen).
  Geleerte Bestaende werden als Korrektur-Bewegung protokolliert; die Oberflaeche
  fragt bei den zerstoerenden Modi nach.

Anzeige:
- "Bald ablaufend"/"Abgelaufen" zeigen jetzt das Gebinde ("1 Glas") mit der
  Basiseinheit klein darunter (ExpiringItem liefert die Einheiten mit).
- MHD als Zeitspanne mit korrektem Numerus: "in 3 Tagen", "in 1 Woche",
  "vor 2 Wochen", dazu heute/morgen/gestern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 14:59:04 +02:00

166 lines
5.8 KiB
Python

from datetime import date, timedelta
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user
from ..models import Group, Lot, Movement, Product, User
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
from ..services.conversion import BASE_OF_KIND, display_unit_info
from ..services.stock import current_stock
from .settings import get_expiry_warning_days
router = APIRouter(tags=["views"])
@router.get("/shopping-list", response_model=list[ShoppingItem])
def shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShoppingItem]:
"""Produkte, deren Bestand unter dem Mindestbestand liegt."""
items: list[ShoppingItem] = []
products = (
db.query(Product)
.filter(Product.min_stock.isnot(None), Product.min_stock > 0)
.all()
)
for product in products:
stock = current_stock(db, product.id)
if stock < product.min_stock:
items.append(
ShoppingItem(
product_id=product.id,
name=product.name,
base_unit=product.base_unit,
package_size=product.package_size,
stock=stock,
min_stock=product.min_stock,
deficit=product.min_stock - stock,
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
@router.get("/shopping-list/groups", response_model=list[GroupShoppingItem])
def group_shopping_list(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[GroupShoppingItem]:
"""Gruppen, deren Gesamtbestand unter dem Gruppen-Mindestbestand liegt.
Gruppen-Bestand = Summe der Produktbestände in der Gruppe (in Basiseinheiten).
Sinnvoll, wenn die Produkte einer Gruppe dieselbe Basiseinheit teilen.
"""
items: list[GroupShoppingItem] = []
groups = (
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
)
for group in groups:
unit = group.min_stock_unit
if unit is not None:
base = BASE_OF_KIND[unit.kind]
products = [p for p in group.products if p.base_unit == base]
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
unit_name = unit.name
else:
products = list(group.products)
stock = float(sum(current_stock(db, p.id) for p in products))
unit_name = ""
if stock < group.min_stock:
items.append(
GroupShoppingItem(
group_id=group.id,
name=group.name,
stock=stock,
min_stock=group.min_stock,
deficit=group.min_stock - stock,
unit_name=unit_name,
product_count=len(products),
)
)
items.sort(key=lambda i: i.deficit, reverse=True)
return items
@router.get("/expiring", response_model=list[ExpiringItem])
def expiring(
days: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[ExpiringItem]:
"""Chargen, die innerhalb der Warnfrist ablaufen (oder schon abgelaufen sind)."""
if days is None:
days = get_expiry_warning_days(db)
today = date.today()
threshold = today + timedelta(days=days)
lots = (
db.query(Lot)
.filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0)
.order_by(Lot.best_before)
.all()
)
result: list[ExpiringItem] = []
for lot in lots:
product = lot.product
unit_name, unit_factor = display_unit_info(product)
result.append(
ExpiringItem(
lot_id=lot.id,
product_id=product.id,
product_name=product.name,
quantity=lot.quantity,
base_unit=product.base_unit,
best_before=lot.best_before,
days_left=(lot.best_before - today).days,
package_size=product.package_size,
package_label=product.package_label,
unit_name=unit_name,
unit_factor=unit_factor,
)
)
return result
@router.get("/movements", response_model=list[MovementOut])
def movements(
limit: int = 100,
product_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[MovementOut]:
"""Bewegungsverlauf (wer hat wann was ein-/ausgelagert), neueste zuerst."""
limit = max(1, min(limit, 500))
query = (
db.query(Movement, Product, User)
.join(Product, Movement.product_id == Product.id)
.outerjoin(User, Movement.user_id == User.id)
)
if product_id is not None:
query = query.filter(Movement.product_id == product_id)
rows = query.order_by(Movement.created_at.desc(), Movement.id.desc()).limit(limit).all()
result: list[MovementOut] = []
for movement, product, user in rows:
unit_name, unit_factor = display_unit_info(product)
result.append(
MovementOut(
id=movement.id,
product_id=product.id,
product_name=product.name,
type=movement.type.value,
quantity=movement.quantity,
base_unit=product.base_unit,
unit_used=movement.unit_used,
username=user.username if user else None,
note=movement.note,
created_at=movement.created_at,
package_size=product.package_size,
package_label=product.package_label,
unit_name=unit_name,
unit_factor=unit_factor,
)
)
return result