Einheiten (neu):
- Tabelle units (name, kind=count|weight|volume, factor, is_builtin); eingebaut
Stueck/Gramm/Kilogramm/Milliliter/Liter, Admin kann eigene anlegen (z.B. Pfund=500g).
- Bestaende bleiben intern in kanonischer Basis (Stueck/Gramm/Milliliter);
Product.display_unit_id und Group.min_stock_unit_id als nullable FKs.
- Neuer Umrechnungs-Service (services/conversion.py) ersetzt die feste Einheitenlogik;
Ein-/Auslagern und Gruppen-Mindestbestand rechnen ueber den Faktor.
- Gruppen-Mindestbestand mit Einheit; Gruppenbestand summiert nur Produkte
passender Art. Neue Verwaltungsseite "Einheiten" (Admin).
- Schonende Migration beim Start: ADD COLUMN IF NOT EXISTS (Postgres), damit
bestehende Installationen ihre Daten behalten.
Chargen:
- PATCH /lots/{id} und DELETE /lots/{id}: Menge/MHD korrigieren, Charge loeschen
(wird als Korrektur-Bewegung protokolliert). Bearbeitung im Produktdetail.
Auslagern:
- Optionales lot_id: gezielt aus einer bestimmten Charge/MHD abbuchen statt FEFO;
Auswahl-Dropdown in der Auslagern-Seite.
Sonstiges: Roadmap aktualisiert, Tests fuer die Umrechnung.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
181 lines
6.5 KiB
Python
181 lines
6.5 KiB
Python
from __future__ import annotations
|
|
|
|
import enum
|
|
from datetime import date, datetime, timezone
|
|
|
|
from sqlalchemy import (
|
|
Boolean,
|
|
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 UnitKind(str, enum.Enum):
|
|
"""Art einer Einheit. Bestimmt die kanonische Basiseinheit für die Speicherung."""
|
|
count = "count" # Basis: Stück
|
|
weight = "weight" # Basis: Gramm
|
|
volume = "volume" # Basis: Milliliter
|
|
|
|
|
|
class Unit(Base):
|
|
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
|
|
|
|
factor = wie viele Basiseinheiten 1 dieser Einheit entsprechen
|
|
(z.B. Kilogramm: kind=weight, factor=1000; Liter: kind=volume, factor=1000).
|
|
"""
|
|
__tablename__ = "units"
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
|
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
|
kind: Mapped[UnitKind] = mapped_column(Enum(UnitKind), nullable=False)
|
|
factor: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
|
|
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
|
|
|
|
|
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)
|
|
min_stock: Mapped[float | None] = mapped_column(Float, nullable=True)
|
|
# Einheit des Gruppen-Mindestbestands (z.B. Kilogramm). NULL = zählt in Basiseinheiten.
|
|
min_stock_unit_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
|
|
products: Mapped[list[Product]] = relationship(back_populates="group")
|
|
min_stock_unit: Mapped[Unit | None] = relationship()
|
|
|
|
|
|
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)
|
|
|
|
# Kanonische Basiseinheit (piece/gram/milliliter): bestimmt Speicherung + Art (kind).
|
|
base_unit: Mapped[BaseUnit] = mapped_column(
|
|
Enum(BaseUnit), nullable=False, default=BaseUnit.piece
|
|
)
|
|
# Bevorzugte Anzeige-/Eingabeeinheit (z.B. Kilogramm). NULL = Basiseinheit selbst.
|
|
display_unit_id: Mapped[int | None] = mapped_column(
|
|
ForeignKey("units.id", ondelete="SET NULL"), nullable=True
|
|
)
|
|
# 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")
|
|
display_unit: Mapped[Unit | None] = relationship()
|
|
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)
|