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>
148 lines
5.0 KiB
Python
148 lines
5.0 KiB
Python
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)
|