Schritt 1: Fundament der Lebensmittel-Lagerverwaltung (Pantry)
Backend (FastAPI + PostgreSQL): Chargen mit MHD, FEFO-Auslagern, Einheiten-Umrechnung (Stueck/g/ml + Packungen), Open-Food-Facts-Lookup mit lokalem Fallback, JWT-Auth mit Rollen (Admin/Nutzer), erster Admin beim Setup, Einkaufsliste, Ablaufwarnung, Lagerorte, pytest fuer FEFO. Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte, Lagerorte, Benutzerverwaltung, Einkaufsliste - rollenabhaengig. Deploy: docker-compose + install.sh (Docker-Autoinstall, Secrets), README und Roadmap fuer Schritt 2 (iOS) und Schritt 3. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
0
backend/app/__init__.py
Normal file
0
backend/app/__init__.py
Normal file
34
backend/app/config.py
Normal file
34
backend/app/config.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
# Database
|
||||
database_url: str = "postgresql+psycopg2://pantry:pantry@localhost:5432/pantry"
|
||||
|
||||
# Auth / JWT
|
||||
jwt_secret: str = "change-me-in-production"
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_expire_minutes: int = 60 * 24 * 7 # 7 days
|
||||
|
||||
# First admin, created on startup if no users exist
|
||||
admin_username: str = "admin"
|
||||
admin_password: str = "changeme"
|
||||
|
||||
# Open Food Facts
|
||||
off_base_url: str = "https://world.openfoodfacts.org"
|
||||
off_timeout_seconds: float = 8.0
|
||||
|
||||
# Behaviour
|
||||
expiry_warning_days_default: int = 7
|
||||
|
||||
# CORS (comma separated); "*" allows all
|
||||
cors_origins: str = "*"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
34
backend/app/crud.py
Normal file
34
backend/app/crud.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Kleine gemeinsame Helfer für Router."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Product
|
||||
from .schemas import ProductOut
|
||||
from .services.stock import current_stock
|
||||
|
||||
|
||||
def product_to_out(db: Session, product: Product) -> ProductOut:
|
||||
out = ProductOut.model_validate(product)
|
||||
out.stock = current_stock(db, product.id)
|
||||
return out
|
||||
|
||||
|
||||
def resolve_product(
|
||||
db: Session, product_id: int | None, barcode: str | None
|
||||
) -> Product:
|
||||
"""Findet ein Produkt per ID oder Barcode; wirft 404, wenn keins passt."""
|
||||
product: Product | None = None
|
||||
if product_id is not None:
|
||||
product = db.get(Product, product_id)
|
||||
elif barcode:
|
||||
product = db.query(Product).filter(Product.barcode == barcode).first()
|
||||
else:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "product_id oder barcode erforderlich"
|
||||
)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
return product
|
||||
34
backend/app/database.py
Normal file
34
backend/app/database.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
# SQLite (used in tests) needs a special connect arg; Postgres does not.
|
||||
connect_args = {}
|
||||
if settings.database_url.startswith("sqlite"):
|
||||
connect_args = {"check_same_thread": False}
|
||||
|
||||
engine = create_engine(
|
||||
settings.database_url,
|
||||
connect_args=connect_args,
|
||||
pool_pre_ping=True,
|
||||
future=True,
|
||||
)
|
||||
|
||||
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
39
backend/app/deps.py
Normal file
39
backend/app/deps.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import get_db
|
||||
from .models import Role, User
|
||||
from .security import decode_access_token
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login")
|
||||
|
||||
_credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Ungültige oder abgelaufene Anmeldung",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise _credentials_exc
|
||||
username = payload.get("sub")
|
||||
if not username:
|
||||
raise _credentials_exc
|
||||
user = db.query(User).filter(User.username == username).first()
|
||||
if user is None:
|
||||
raise _credentials_exc
|
||||
return user
|
||||
|
||||
|
||||
def require_admin(current_user: User = Depends(get_current_user)) -> User:
|
||||
if current_user.role != Role.admin:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Nur Administratoren dürfen diese Aktion ausführen",
|
||||
)
|
||||
return current_user
|
||||
61
backend/app/main.py
Normal file
61
backend/app/main.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from .config import get_settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
from .routers import (
|
||||
auth,
|
||||
groups,
|
||||
locations,
|
||||
products,
|
||||
settings as settings_router,
|
||||
stock,
|
||||
users,
|
||||
views,
|
||||
)
|
||||
from .seed import ensure_first_admin
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
# Tabellen anlegen (MVP: create_all statt Alembic-Migrationen).
|
||||
Base.metadata.create_all(bind=engine)
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_first_admin(db)
|
||||
finally:
|
||||
db.close()
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(title="Pantry – Lebensmittel-Lagerverwaltung", version="1.0.0", lifespan=lifespan)
|
||||
|
||||
origins = ["*"] if settings.cors_origins.strip() == "*" else [
|
||||
o.strip() for o in settings.cors_origins.split(",") if o.strip()
|
||||
]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health", tags=["meta"])
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(products.router)
|
||||
app.include_router(stock.router)
|
||||
app.include_router(locations.router)
|
||||
app.include_router(groups.router)
|
||||
app.include_router(views.router)
|
||||
app.include_router(settings_router.router)
|
||||
147
backend/app/models.py
Normal file
147
backend/app/models.py
Normal file
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Role(str, enum.Enum):
|
||||
admin = "admin"
|
||||
user = "user"
|
||||
|
||||
|
||||
class BaseUnit(str, enum.Enum):
|
||||
piece = "piece"
|
||||
gram = "gram"
|
||||
milliliter = "milliliter"
|
||||
|
||||
|
||||
class MovementType(str, enum.Enum):
|
||||
in_ = "in"
|
||||
out = "out"
|
||||
adjust = "adjust"
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
role: Mapped[Role] = mapped_column(Enum(Role), nullable=False, default=Role.user)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class Group(Base):
|
||||
__tablename__ = "groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
|
||||
# Group-level minimum stock is a Schritt-3 feature; column kept for forward-compat.
|
||||
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
products: Mapped[list[Product]] = relationship(back_populates="group")
|
||||
|
||||
|
||||
class Location(Base):
|
||||
__tablename__ = "locations"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
# Sub-locations (Regal/Fach) are a Schritt-3 feature; parent_id kept for forward-compat.
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
__table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
barcode: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
image_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||
|
||||
base_unit: Mapped[BaseUnit] = mapped_column(
|
||||
Enum(BaseUnit), nullable=False, default=BaseUnit.piece
|
||||
)
|
||||
# Number of base units contained in one package (e.g. 500 g per package).
|
||||
package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
group_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("groups.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units
|
||||
|
||||
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
|
||||
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
group: Mapped[Group | None] = relationship(back_populates="products")
|
||||
lots: Mapped[list[Lot]] = relationship(
|
||||
back_populates="product", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class Lot(Base):
|
||||
__tablename__ = "lots"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
||||
best_before: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
location_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
product: Mapped[Product] = relationship(back_populates="lots")
|
||||
|
||||
|
||||
class Movement(Base):
|
||||
__tablename__ = "movements"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
lot_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("lots.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
type: Mapped[MovementType] = mapped_column(Enum(MovementType), nullable=False)
|
||||
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
|
||||
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
|
||||
note: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
|
||||
|
||||
|
||||
class Setting(Base):
|
||||
__tablename__ = "settings"
|
||||
|
||||
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
value: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
72
backend/app/off.py
Normal file
72
backend/app/off.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Open Food Facts Lookup-Client."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def _guess_base_unit(quantity: str | None) -> str:
|
||||
"""Rät die Basiseinheit aus dem OFF-Feld 'quantity' (z.B. '500 g', '1 l')."""
|
||||
if not quantity:
|
||||
return "piece"
|
||||
q = quantity.lower()
|
||||
if "ml" in q or "cl" in q or "l" in q or "liter" in q:
|
||||
return "milliliter"
|
||||
if "kg" in q or " g" in q or "gramm" in q or q.strip().endswith("g"):
|
||||
return "gram"
|
||||
return "piece"
|
||||
|
||||
|
||||
def lookup_barcode(barcode: str) -> dict | None:
|
||||
"""Fragt Open Food Facts nach einem Barcode.
|
||||
|
||||
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden.
|
||||
"""
|
||||
url = f"{settings.off_base_url}/api/v2/product/{barcode}.json"
|
||||
try:
|
||||
resp = httpx.get(
|
||||
url,
|
||||
timeout=settings.off_timeout_seconds,
|
||||
headers={"User-Agent": "Pantry-Selfhosted/1.0 (github: pantry)"},
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
if data.get("status") != 1:
|
||||
return None
|
||||
|
||||
product = data.get("product", {})
|
||||
name = (
|
||||
product.get("product_name_de")
|
||||
or product.get("product_name")
|
||||
or product.get("generic_name")
|
||||
or ""
|
||||
).strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
categories = product.get("categories") or ""
|
||||
category_tags = product.get("categories_tags") or []
|
||||
|
||||
return {
|
||||
"barcode": barcode,
|
||||
"name": name,
|
||||
"brand": (product.get("brands") or "").strip() or None,
|
||||
"image_url": product.get("image_front_url") or product.get("image_url") or None,
|
||||
"base_unit": _guess_base_unit(product.get("quantity")),
|
||||
"quantity_text": product.get("quantity"),
|
||||
"category_suggestion": categories.split(",")[0].strip() if categories else None,
|
||||
"category_tags": category_tags,
|
||||
"source": "off",
|
||||
"off_raw": json.dumps(product)[:20000],
|
||||
}
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
30
backend/app/routers/auth.py
Normal file
30
backend/app/routers/auth.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordRequestForm
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import User
|
||||
from ..schemas import Token, UserOut
|
||||
from ..security import create_access_token, verify_password
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
def login(
|
||||
form: OAuth2PasswordRequestForm = Depends(), db: Session = Depends(get_db)
|
||||
) -> Token:
|
||||
user = db.query(User).filter(User.username == form.username).first()
|
||||
if user is None or not verify_password(form.password, user.password_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Benutzername oder Passwort falsch",
|
||||
)
|
||||
token = create_access_token(subject=user.username, role=user.role.value)
|
||||
return Token(access_token=token, role=user.role, username=user.username)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserOut)
|
||||
def me(current_user: User = Depends(get_current_user)) -> User:
|
||||
return current_user
|
||||
44
backend/app/routers/groups.py
Normal file
44
backend/app/routers/groups.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Group, User
|
||||
from ..schemas import GroupCreate, GroupOut
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[GroupOut])
|
||||
def list_groups(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[Group]:
|
||||
return db.query(Group).order_by(Group.name).all()
|
||||
|
||||
|
||||
@router.post("", response_model=GroupOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_group(
|
||||
payload: GroupCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Group:
|
||||
if db.query(Group).filter(Group.name == payload.name).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
||||
group = Group(name=payload.name, min_stock=payload.min_stock)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
return group
|
||||
|
||||
|
||||
@router.delete("/{group_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_group(
|
||||
group_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
group = db.get(Group, group_id)
|
||||
if group is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden")
|
||||
db.delete(group)
|
||||
db.commit()
|
||||
42
backend/app/routers/locations.py
Normal file
42
backend/app/routers/locations.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Location, User
|
||||
from ..schemas import LocationCreate, LocationOut
|
||||
|
||||
router = APIRouter(prefix="/locations", tags=["locations"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[LocationOut])
|
||||
def list_locations(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[Location]:
|
||||
return db.query(Location).order_by(Location.name).all()
|
||||
|
||||
|
||||
@router.post("", response_model=LocationOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_location(
|
||||
payload: LocationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> Location:
|
||||
loc = Location(name=payload.name, parent_id=payload.parent_id)
|
||||
db.add(loc)
|
||||
db.commit()
|
||||
db.refresh(loc)
|
||||
return loc
|
||||
|
||||
|
||||
@router.delete("/{location_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_location(
|
||||
location_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
loc = db.get(Location, location_id)
|
||||
if loc is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden")
|
||||
db.delete(loc)
|
||||
db.commit()
|
||||
125
backend/app/routers/products.py
Normal file
125
backend/app/routers/products.py
Normal file
@@ -0,0 +1,125 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..crud import product_to_out
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import BaseUnit, Product, User
|
||||
from ..off import lookup_barcode
|
||||
from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
|
||||
@router.get("", response_model=list[ProductOut])
|
||||
def list_products(
|
||||
q: str | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[ProductOut]:
|
||||
query = db.query(Product)
|
||||
if q:
|
||||
like = f"%{q}%"
|
||||
query = query.filter(Product.name.ilike(like))
|
||||
products = query.order_by(Product.name).all()
|
||||
return [product_to_out(db, p) for p in products]
|
||||
|
||||
|
||||
@router.get("/lookup", response_model=LookupResult)
|
||||
def lookup(
|
||||
barcode: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> LookupResult:
|
||||
"""Barcode: erst lokal, dann Open Food Facts. Nur Vorschlag, legt nichts an."""
|
||||
existing = db.query(Product).filter(Product.barcode == barcode).first()
|
||||
if existing:
|
||||
return LookupResult(found=True, existing_product=product_to_out(db, existing))
|
||||
|
||||
suggestion = lookup_barcode(barcode)
|
||||
if suggestion:
|
||||
return LookupResult(found=True, suggestion=suggestion)
|
||||
return LookupResult(found=False)
|
||||
|
||||
|
||||
@router.get("/{product_id}", response_model=ProductOut)
|
||||
def get_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> ProductOut:
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_product(
|
||||
payload: ProductCreate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ProductOut:
|
||||
if payload.barcode:
|
||||
exists = db.query(Product).filter(Product.barcode == payload.barcode).first()
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits"
|
||||
)
|
||||
product = Product(
|
||||
barcode=payload.barcode or None,
|
||||
name=payload.name,
|
||||
brand=payload.brand,
|
||||
image_url=payload.image_url,
|
||||
base_unit=payload.base_unit,
|
||||
package_size=payload.package_size,
|
||||
group_id=payload.group_id,
|
||||
min_stock=payload.min_stock,
|
||||
source="manual",
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.patch("/{product_id}", response_model=ProductOut)
|
||||
def update_product(
|
||||
product_id: int,
|
||||
payload: ProductUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> ProductOut:
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if "barcode" in data and data["barcode"]:
|
||||
clash = (
|
||||
db.query(Product)
|
||||
.filter(Product.barcode == data["barcode"], Product.id != product_id)
|
||||
.first()
|
||||
)
|
||||
if clash:
|
||||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
|
||||
)
|
||||
for field, value in data.items():
|
||||
setattr(product, field, value)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return product_to_out(db, product)
|
||||
|
||||
|
||||
@router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> None:
|
||||
product = db.get(Product, product_id)
|
||||
if product is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
49
backend/app/routers/settings.py
Normal file
49
backend/app/routers/settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..config import get_settings
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Setting, User
|
||||
from ..schemas import SettingOut
|
||||
|
||||
router = APIRouter(prefix="/settings", tags=["settings"])
|
||||
|
||||
EXPIRY_WARNING_KEY = "expiry_warning_days"
|
||||
|
||||
|
||||
def get_expiry_warning_days(db: Session) -> int:
|
||||
row = db.get(Setting, EXPIRY_WARNING_KEY)
|
||||
if row is None:
|
||||
return get_settings().expiry_warning_days_default
|
||||
try:
|
||||
return int(row.value)
|
||||
except ValueError:
|
||||
return get_settings().expiry_warning_days_default
|
||||
|
||||
|
||||
@router.get("", response_model=list[SettingOut])
|
||||
def list_settings(
|
||||
db: Session = Depends(get_db), _: User = Depends(get_current_user)
|
||||
) -> list[SettingOut]:
|
||||
rows = db.query(Setting).all()
|
||||
known = {r.key: r.value for r in rows}
|
||||
known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db)))
|
||||
return [SettingOut(key=k, value=v) for k, v in known.items()]
|
||||
|
||||
|
||||
@router.put("/{key}", response_model=SettingOut)
|
||||
def set_setting(
|
||||
key: str,
|
||||
value: str,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(require_admin),
|
||||
) -> SettingOut:
|
||||
row = db.get(Setting, key)
|
||||
if row is None:
|
||||
row = Setting(key=key, value=value)
|
||||
db.add(row)
|
||||
else:
|
||||
row.value = value
|
||||
db.commit()
|
||||
return SettingOut(key=key, value=value)
|
||||
83
backend/app/routers/stock.py
Normal file
83
backend/app/routers/stock.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..crud import resolve_product
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Lot, User
|
||||
from ..schemas import (
|
||||
CheckInRequest,
|
||||
CheckInResponse,
|
||||
CheckOutRequest,
|
||||
CheckOutResponse,
|
||||
LotOut,
|
||||
)
|
||||
from ..services.stock import StockError, check_in, check_out, current_stock
|
||||
from ..services.units import UnitError
|
||||
|
||||
router = APIRouter(tags=["stock"])
|
||||
|
||||
|
||||
@router.post("/stock/checkin", response_model=CheckInResponse)
|
||||
def stock_checkin(
|
||||
payload: CheckInRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> CheckInResponse:
|
||||
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||
try:
|
||||
lot = check_in(
|
||||
db,
|
||||
product=product,
|
||||
quantity=payload.quantity,
|
||||
unit=payload.unit,
|
||||
best_before=payload.best_before,
|
||||
location_id=payload.location_id,
|
||||
user=user,
|
||||
note=payload.note,
|
||||
)
|
||||
except UnitError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
db.commit()
|
||||
db.refresh(lot)
|
||||
return CheckInResponse(
|
||||
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stock/checkout", response_model=CheckOutResponse)
|
||||
def stock_checkout(
|
||||
payload: CheckOutRequest,
|
||||
db: Session = Depends(get_db),
|
||||
user: User = Depends(get_current_user),
|
||||
) -> CheckOutResponse:
|
||||
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||
try:
|
||||
affected = check_out(
|
||||
db,
|
||||
product=product,
|
||||
quantity=payload.quantity,
|
||||
unit=payload.unit,
|
||||
user=user,
|
||||
note=payload.note,
|
||||
)
|
||||
except UnitError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
except StockError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
db.commit()
|
||||
return CheckOutResponse(
|
||||
affected_lots=affected, product_stock=current_stock(db, product.id)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/lots", response_model=list[LotOut])
|
||||
def list_lots(
|
||||
product_id: int | None = None,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[Lot]:
|
||||
query = db.query(Lot)
|
||||
if product_id is not None:
|
||||
query = query.filter(Lot.product_id == product_id)
|
||||
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
|
||||
68
backend/app/routers/users.py
Normal file
68
backend/app/routers/users.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..deps import require_admin
|
||||
from ..models import Role, User
|
||||
from ..schemas import UserCreate, UserOut, UserUpdate
|
||||
from ..security import hash_password
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"], dependencies=[Depends(require_admin)])
|
||||
|
||||
|
||||
@router.get("", response_model=list[UserOut])
|
||||
def list_users(db: Session = Depends(get_db)) -> list[User]:
|
||||
return db.query(User).order_by(User.username).all()
|
||||
|
||||
|
||||
@router.post("", response_model=UserOut, status_code=status.HTTP_201_CREATED)
|
||||
def create_user(payload: UserCreate, db: Session = Depends(get_db)) -> User:
|
||||
if db.query(User).filter(User.username == payload.username).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Benutzername existiert bereits")
|
||||
user = User(
|
||||
username=payload.username,
|
||||
password_hash=hash_password(payload.password),
|
||||
role=payload.role,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.patch("/{user_id}", response_model=UserOut)
|
||||
def update_user(
|
||||
user_id: int, payload: UserUpdate, db: Session = Depends(get_db)
|
||||
) -> User:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
if payload.password is not None:
|
||||
user.password_hash = hash_password(payload.password)
|
||||
if payload.role is not None:
|
||||
user.role = payload.role
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
return user
|
||||
|
||||
|
||||
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_user(
|
||||
user_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(require_admin),
|
||||
) -> None:
|
||||
user = db.get(User, user_id)
|
||||
if user is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Benutzer nicht gefunden")
|
||||
if user.id == current_admin.id:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Du kannst dich nicht selbst löschen")
|
||||
# Verhindere das Löschen des letzten Admins.
|
||||
if user.role == Role.admin:
|
||||
admin_count = db.query(User).filter(User.role == Role.admin).count()
|
||||
if admin_count <= 1:
|
||||
raise HTTPException(
|
||||
status.HTTP_400_BAD_REQUEST, "Der letzte Administrator kann nicht gelöscht werden"
|
||||
)
|
||||
db.delete(user)
|
||||
db.commit()
|
||||
76
backend/app/routers/views.py
Normal file
76
backend/app/routers/views.py
Normal file
@@ -0,0 +1,76 @@
|
||||
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 Lot, Product, User
|
||||
from ..schemas import ExpiringItem, ShoppingItem
|
||||
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,
|
||||
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("/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
|
||||
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,
|
||||
)
|
||||
)
|
||||
return result
|
||||
174
backend/app/schemas.py
Normal file
174
backend/app/schemas.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from .models import BaseUnit, Role
|
||||
|
||||
|
||||
# ---- Auth / Users ----
|
||||
class Token(BaseModel):
|
||||
access_token: str
|
||||
token_type: str = "bearer"
|
||||
role: Role
|
||||
username: str
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
username: str
|
||||
role: Role
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=4, max_length=255)
|
||||
role: Role = Role.user
|
||||
|
||||
|
||||
class UserUpdate(BaseModel):
|
||||
password: str | None = Field(default=None, min_length=4, max_length=255)
|
||||
role: Role | None = None
|
||||
|
||||
|
||||
# ---- Groups ----
|
||||
class GroupOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
min_stock: float | None = None
|
||||
|
||||
|
||||
class GroupCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
min_stock: float | None = None
|
||||
|
||||
|
||||
# ---- Locations ----
|
||||
class LocationOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
name: str
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
class LocationCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
parent_id: int | None = None
|
||||
|
||||
|
||||
# ---- Products ----
|
||||
class ProductBase(BaseModel):
|
||||
barcode: str | None = None
|
||||
name: str = Field(min_length=1, max_length=255)
|
||||
brand: str | None = None
|
||||
image_url: str | None = None
|
||||
base_unit: BaseUnit = BaseUnit.piece
|
||||
package_size: float | None = Field(default=None, gt=0)
|
||||
group_id: int | None = None
|
||||
min_stock: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ProductUpdate(BaseModel):
|
||||
barcode: str | None = None
|
||||
name: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
brand: str | None = None
|
||||
image_url: str | None = None
|
||||
base_unit: BaseUnit | None = None
|
||||
package_size: float | None = Field(default=None, gt=0)
|
||||
group_id: int | None = None
|
||||
min_stock: float | None = Field(default=None, ge=0)
|
||||
|
||||
|
||||
class ProductOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
barcode: str | None
|
||||
name: str
|
||||
brand: str | None
|
||||
image_url: str | None
|
||||
base_unit: BaseUnit
|
||||
package_size: float | None
|
||||
group_id: int | None
|
||||
min_stock: float | None
|
||||
source: str
|
||||
created_at: datetime
|
||||
# angereichert:
|
||||
stock: float = 0.0
|
||||
|
||||
|
||||
class LookupResult(BaseModel):
|
||||
found: bool
|
||||
existing_product: ProductOut | None = None
|
||||
suggestion: dict | None = None
|
||||
|
||||
|
||||
# ---- Stock movements ----
|
||||
class CheckInRequest(BaseModel):
|
||||
product_id: int | None = None
|
||||
barcode: str | None = None
|
||||
quantity: float = Field(gt=0)
|
||||
unit: str
|
||||
best_before: date | None = None
|
||||
location_id: int | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class CheckOutRequest(BaseModel):
|
||||
product_id: int | None = None
|
||||
barcode: str | None = None
|
||||
quantity: float = Field(gt=0)
|
||||
unit: str
|
||||
note: str | None = None
|
||||
|
||||
|
||||
class LotOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: int
|
||||
product_id: int
|
||||
quantity: float
|
||||
best_before: date | None
|
||||
location_id: int | None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class CheckInResponse(BaseModel):
|
||||
lot: LotOut
|
||||
product_stock: float
|
||||
|
||||
|
||||
class CheckOutResponse(BaseModel):
|
||||
affected_lots: list[dict]
|
||||
product_stock: float
|
||||
|
||||
|
||||
# ---- Views ----
|
||||
class ShoppingItem(BaseModel):
|
||||
product_id: int
|
||||
name: str
|
||||
base_unit: BaseUnit
|
||||
stock: float
|
||||
min_stock: float
|
||||
deficit: float
|
||||
|
||||
|
||||
class ExpiringItem(BaseModel):
|
||||
lot_id: int
|
||||
product_id: int
|
||||
product_name: str
|
||||
quantity: float
|
||||
base_unit: BaseUnit
|
||||
best_before: date
|
||||
days_left: int
|
||||
|
||||
|
||||
class SettingOut(BaseModel):
|
||||
key: str
|
||||
value: str
|
||||
31
backend/app/security.py
Normal file
31
backend/app/security.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from .config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
return pwd_context.verify(password, password_hash)
|
||||
|
||||
|
||||
def create_access_token(subject: str, role: str) -> str:
|
||||
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.jwt_expire_minutes)
|
||||
payload = {"sub": subject, "role": role, "exp": expire}
|
||||
return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict | None:
|
||||
try:
|
||||
return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
|
||||
except JWTError:
|
||||
return None
|
||||
21
backend/app/seed.py
Normal file
21
backend/app/seed.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Erstellt beim ersten Start einen Admin-Benutzer, falls noch keiner existiert."""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .config import get_settings
|
||||
from .models import Role, User
|
||||
from .security import hash_password
|
||||
|
||||
|
||||
def ensure_first_admin(db: Session) -> None:
|
||||
settings = get_settings()
|
||||
has_users = db.query(User).first() is not None
|
||||
if has_users:
|
||||
return
|
||||
admin = User(
|
||||
username=settings.admin_username,
|
||||
password_hash=hash_password(settings.admin_password),
|
||||
role=Role.admin,
|
||||
)
|
||||
db.add(admin)
|
||||
db.commit()
|
||||
0
backend/app/services/__init__.py
Normal file
0
backend/app/services/__init__.py
Normal file
125
backend/app/services/stock.py
Normal file
125
backend/app/services/stock.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""Chargen-Logik: Einlagern erzeugt Lots, Auslagern bucht per FEFO ab."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import asc
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Lot, Movement, MovementType, Product, User
|
||||
from .units import to_base_quantity
|
||||
|
||||
|
||||
class StockError(ValueError):
|
||||
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
|
||||
|
||||
|
||||
def current_stock(db: Session, product_id: int) -> float:
|
||||
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
|
||||
total = (
|
||||
db.query(Lot).with_entities(Lot.quantity).filter(Lot.product_id == product_id).all()
|
||||
)
|
||||
return float(sum(q for (q,) in total))
|
||||
|
||||
|
||||
def check_in(
|
||||
db: Session,
|
||||
product: Product,
|
||||
quantity: float,
|
||||
unit: str,
|
||||
best_before: date | None,
|
||||
location_id: int | None,
|
||||
user: User | None,
|
||||
note: str | None = None,
|
||||
) -> Lot:
|
||||
"""Legt eine neue Charge an und protokolliert die Bewegung."""
|
||||
quantity_base = to_base_quantity(product, quantity, unit)
|
||||
|
||||
lot = Lot(
|
||||
product_id=product.id,
|
||||
quantity=quantity_base,
|
||||
best_before=best_before,
|
||||
location_id=location_id,
|
||||
)
|
||||
db.add(lot)
|
||||
db.flush() # lot.id verfügbar machen
|
||||
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=lot.id,
|
||||
user_id=user.id if user else None,
|
||||
type=MovementType.in_,
|
||||
quantity=quantity_base,
|
||||
unit_used=unit,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
return lot
|
||||
|
||||
|
||||
def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
|
||||
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
|
||||
lots = (
|
||||
db.query(Lot)
|
||||
.filter(Lot.product_id == product_id, Lot.quantity > 0)
|
||||
.order_by(asc(Lot.created_at))
|
||||
.all()
|
||||
)
|
||||
# NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit
|
||||
# es über SQLite und Postgres identisch funktioniert.
|
||||
return sorted(
|
||||
lots,
|
||||
key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id),
|
||||
)
|
||||
|
||||
|
||||
def check_out(
|
||||
db: Session,
|
||||
product: Product,
|
||||
quantity: float,
|
||||
unit: str,
|
||||
user: User | None,
|
||||
note: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Bucht ``quantity`` (in ``unit``) per FEFO von den Chargen ab.
|
||||
|
||||
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
|
||||
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
|
||||
"""
|
||||
needed = to_base_quantity(product, quantity, unit)
|
||||
available = current_stock(db, product.id)
|
||||
if needed > available + 1e-9:
|
||||
raise StockError(
|
||||
f"Nicht genug Bestand: benötigt {needed:g}, verfügbar {available:g} "
|
||||
f"{product.base_unit.value}"
|
||||
)
|
||||
|
||||
affected: list[dict] = []
|
||||
remaining = needed
|
||||
for lot in _fefo_lots(db, product.id):
|
||||
if remaining <= 1e-9:
|
||||
break
|
||||
take = min(lot.quantity, remaining)
|
||||
lot.quantity -= take
|
||||
remaining -= take
|
||||
|
||||
db.add(
|
||||
Movement(
|
||||
product_id=product.id,
|
||||
lot_id=lot.id,
|
||||
user_id=user.id if user else None,
|
||||
type=MovementType.out,
|
||||
quantity=take,
|
||||
unit_used=unit,
|
||||
note=note,
|
||||
)
|
||||
)
|
||||
affected.append({"lot_id": lot.id, "quantity": take})
|
||||
|
||||
# Leere Charge entfernen, damit das "MHD-Array" sauber bleibt.
|
||||
if lot.quantity <= 1e-9:
|
||||
db.delete(lot)
|
||||
|
||||
return affected
|
||||
64
backend/app/services/units.py
Normal file
64
backend/app/services/units.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Einheiten-Umrechnung.
|
||||
|
||||
Der Bestand einer Charge (Lot) wird immer in der Basiseinheit des Produkts
|
||||
gespeichert (Stück, Gramm oder Milliliter). Nutzer geben Mengen entweder in der
|
||||
Basiseinheit oder in "Packungen" (package) an. Eine Packung entspricht
|
||||
``product.package_size`` Basiseinheiten.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..models import BaseUnit, Product
|
||||
|
||||
# Aliase, die als "Basiseinheit" akzeptiert werden.
|
||||
_BASE_ALIASES: dict[str, BaseUnit] = {
|
||||
"piece": BaseUnit.piece,
|
||||
"pieces": BaseUnit.piece,
|
||||
"stück": BaseUnit.piece,
|
||||
"stueck": BaseUnit.piece,
|
||||
"st": BaseUnit.piece,
|
||||
"g": BaseUnit.gram,
|
||||
"gram": BaseUnit.gram,
|
||||
"gramm": BaseUnit.gram,
|
||||
"ml": BaseUnit.milliliter,
|
||||
"milliliter": BaseUnit.milliliter,
|
||||
}
|
||||
|
||||
PACKAGE_UNITS = {"package", "packung", "pkg", "pack"}
|
||||
|
||||
|
||||
class UnitError(ValueError):
|
||||
"""Wird geworfen, wenn eine Einheit nicht zum Produkt passt."""
|
||||
|
||||
|
||||
def to_base_quantity(product: Product, quantity: float, unit: str) -> float:
|
||||
"""Rechnet eine vom Nutzer angegebene Menge in Basiseinheiten um."""
|
||||
if quantity <= 0:
|
||||
raise UnitError("Menge muss größer als 0 sein")
|
||||
|
||||
unit_norm = unit.strip().lower()
|
||||
|
||||
if unit_norm in PACKAGE_UNITS:
|
||||
if not product.package_size or product.package_size <= 0:
|
||||
raise UnitError(
|
||||
"Für dieses Produkt ist keine Packungsgröße hinterlegt, "
|
||||
"Packungen können nicht umgerechnet werden"
|
||||
)
|
||||
return quantity * product.package_size
|
||||
|
||||
mapped = _BASE_ALIASES.get(unit_norm)
|
||||
if mapped is None:
|
||||
raise UnitError(f"Unbekannte Einheit: {unit}")
|
||||
if mapped != product.base_unit:
|
||||
raise UnitError(
|
||||
f"Einheit '{unit}' passt nicht zur Basiseinheit "
|
||||
f"'{product.base_unit.value}' des Produkts"
|
||||
)
|
||||
return float(quantity)
|
||||
|
||||
|
||||
def base_to_packages(product: Product, quantity_base: float) -> float | None:
|
||||
"""Rechnet Basiseinheiten in (ggf. gebrochene) Packungen um, falls möglich."""
|
||||
if not product.package_size or product.package_size <= 0:
|
||||
return None
|
||||
return quantity_base / product.package_size
|
||||
Reference in New Issue
Block a user