Verwaltbare Einheiten mit Umrechnung + Chargen bearbeiten + gezieltes Auslagern

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>
This commit is contained in:
Scarriffle
2026-07-22 12:33:14 +02:00
parent d4e7ff8e3d
commit 0310cdfbd6
26 changed files with 1015 additions and 177 deletions

View File

@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
from .models import Lot, Product from .models import Lot, Product
from .schemas import ProductOut from .schemas import ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock from .services.stock import current_stock
@@ -25,6 +26,10 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
) )
.count() .count()
) )
out.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product)
out.unit_name = name
out.unit_factor = factor
return out return out

View File

@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import text
from .config import get_settings from .config import get_settings
from .database import Base, SessionLocal, engine from .database import Base, SessionLocal, engine
@@ -12,20 +13,44 @@ from .routers import (
products, products,
settings as settings_router, settings as settings_router,
stock, stock,
units,
users, users,
views, views,
) )
from .seed import ensure_first_admin from .seed import ensure_builtin_units, ensure_first_admin
settings = get_settings() settings = get_settings()
def _ensure_schema() -> None:
"""Schonende Migration: fehlende Spalten auf bestehenden Tabellen nachziehen.
create_all() legt nur fehlende Tabellen an, ändert aber keine bestehenden.
Auf Postgres holen wir neue nullable-Spalten per ADD COLUMN IF NOT EXISTS nach,
damit vorhandene Installationen ihre Daten behalten. Auf SQLite (Tests) sind
die Spalten bereits durch create_all vorhanden.
"""
if engine.dialect.name != "postgresql":
return
stmts = [
"ALTER TABLE products ADD COLUMN IF NOT EXISTS display_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL",
"ALTER TABLE groups ADD COLUMN IF NOT EXISTS min_stock_unit_id INTEGER "
"REFERENCES units(id) ON DELETE SET NULL",
]
with engine.begin() as conn:
for stmt in stmts:
conn.execute(text(stmt))
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Tabellen anlegen (MVP: create_all statt Alembic-Migrationen). # Tabellen anlegen (MVP: create_all statt Alembic-Migrationen).
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
_ensure_schema()
db = SessionLocal() db = SessionLocal()
try: try:
ensure_builtin_units(db)
ensure_first_admin(db) ensure_first_admin(db)
finally: finally:
db.close() db.close()
@@ -57,5 +82,6 @@ app.include_router(products.router)
app.include_router(stock.router) app.include_router(stock.router)
app.include_router(locations.router) app.include_router(locations.router)
app.include_router(groups.router) app.include_router(groups.router)
app.include_router(units.router)
app.include_router(views.router) app.include_router(views.router)
app.include_router(settings_router.router) app.include_router(settings_router.router)

View File

@@ -4,6 +4,7 @@ import enum
from datetime import date, datetime, timezone from datetime import date, datetime, timezone
from sqlalchemy import ( from sqlalchemy import (
Boolean,
Date, Date,
DateTime, DateTime,
Enum, Enum,
@@ -40,6 +41,28 @@ class MovementType(str, enum.Enum):
adjust = "adjust" 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): class User(Base):
__tablename__ = "users" __tablename__ = "users"
@@ -55,10 +78,14 @@ class Group(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True) id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False) 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) 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") products: Mapped[list[Product]] = relationship(back_populates="group")
min_stock_unit: Mapped[Unit | None] = relationship()
class Location(Base): class Location(Base):
@@ -82,9 +109,14 @@ class Product(Base):
brand: Mapped[str | None] = mapped_column(String(255), nullable=True) brand: Mapped[str | None] = mapped_column(String(255), nullable=True)
image_url: Mapped[str | None] = mapped_column(String(1024), 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( base_unit: Mapped[BaseUnit] = mapped_column(
Enum(BaseUnit), nullable=False, default=BaseUnit.piece 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). # Number of base units contained in one package (e.g. 500 g per package).
package_size: Mapped[float | None] = mapped_column(Float, nullable=True) package_size: Mapped[float | None] = mapped_column(Float, nullable=True)
@@ -98,6 +130,7 @@ class Product(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
group: Mapped[Group | None] = relationship(back_populates="products") group: Mapped[Group | None] = relationship(back_populates="products")
display_unit: Mapped[Unit | None] = relationship()
lots: Mapped[list[Lot]] = relationship( lots: Mapped[list[Lot]] = relationship(
back_populates="product", cascade="all, delete-orphan" back_populates="product", cascade="all, delete-orphan"
) )

View File

@@ -5,6 +5,7 @@ from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Group, Product, User from ..models import Group, Product, User
from ..schemas import GroupCreate, GroupOut, GroupUpdate from ..schemas import GroupCreate, GroupOut, GroupUpdate
from ..services.conversion import BASE_OF_KIND
from ..services.stock import current_stock from ..services.stock import current_stock
router = APIRouter(prefix="/groups", tags=["groups"]) router = APIRouter(prefix="/groups", tags=["groups"])
@@ -14,6 +15,16 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
out = GroupOut.model_validate(group) out = GroupOut.model_validate(group)
products = db.query(Product).filter(Product.group_id == group.id).all() products = db.query(Product).filter(Product.group_id == group.id).all()
out.product_count = len(products) out.product_count = len(products)
unit = group.min_stock_unit
if unit is not None:
# Bestand nur über Produkte gleicher Art, in der Gruppen-Einheit ausgedrückt.
base = BASE_OF_KIND[unit.kind]
matching = [p for p in products if p.base_unit == base]
stock_base = float(sum(current_stock(db, p.id) for p in matching))
out.stock = stock_base / unit.factor
out.min_stock_unit_name = unit.name
out.kind = unit.kind.value
else:
out.stock = float(sum(current_stock(db, p.id) for p in products)) out.stock = float(sum(current_stock(db, p.id) for p in products))
return out return out
@@ -34,7 +45,11 @@ def create_group(
) -> GroupOut: ) -> GroupOut:
if db.query(Group).filter(Group.name == payload.name).first(): if db.query(Group).filter(Group.name == payload.name).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits") raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
group = Group(name=payload.name, min_stock=payload.min_stock) group = Group(
name=payload.name,
min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id,
)
db.add(group) db.add(group)
db.commit() db.commit()
db.refresh(group) db.refresh(group)

View File

@@ -7,6 +7,7 @@ from ..deps import get_current_user, require_admin
from ..models import BaseUnit, Product, User from ..models import BaseUnit, Product, User
from ..off import lookup_barcode from ..off import lookup_barcode
from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate
from ..services.conversion import ConversionError, resolve_product_unit
router = APIRouter(prefix="/products", tags=["products"]) router = APIRouter(prefix="/products", tags=["products"])
@@ -66,12 +67,20 @@ def create_product(
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits" status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits"
) )
base_unit = payload.base_unit
display_unit_id = None
if payload.unit_id is not None:
try:
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
product = Product( product = Product(
barcode=payload.barcode or None, barcode=payload.barcode or None,
name=payload.name, name=payload.name,
brand=payload.brand, brand=payload.brand,
image_url=payload.image_url, image_url=payload.image_url,
base_unit=payload.base_unit, base_unit=base_unit,
display_unit_id=display_unit_id,
package_size=payload.package_size, package_size=payload.package_size,
group_id=payload.group_id, group_id=payload.group_id,
min_stock=payload.min_stock, min_stock=payload.min_stock,
@@ -105,6 +114,17 @@ def update_product(
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits" status.HTTP_409_CONFLICT, "Ein anderes Produkt hat diesen Barcode bereits"
) )
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
if "unit_id" in data:
unit_id = data.pop("unit_id")
data.pop("base_unit", None) # unit_id hat Vorrang
if unit_id is None:
product.display_unit_id = None
else:
try:
product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id)
except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
for field, value in data.items(): for field, value in data.items():
setattr(product, field, value) setattr(product, field, value)
db.commit() db.commit()

View File

@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
from ..crud import resolve_product from ..crud import resolve_product
from ..database import get_db from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Lot, User from ..models import Lot, Movement, MovementType, User
from ..schemas import ( from ..schemas import (
BatchCheckInRequest, BatchCheckInRequest,
BatchCheckInResponse, BatchCheckInResponse,
@@ -13,9 +13,16 @@ from ..schemas import (
CheckOutRequest, CheckOutRequest,
CheckOutResponse, CheckOutResponse,
LotOut, LotOut,
LotUpdate,
)
from ..services.conversion import ConversionError
from ..services.stock import (
StockError,
check_in,
check_out,
check_out_lot,
current_stock,
) )
from ..services.stock import StockError, check_in, check_out, current_stock
from ..services.units import UnitError
router = APIRouter(tags=["stock"]) router = APIRouter(tags=["stock"])
@@ -38,7 +45,7 @@ def stock_checkin(
user=user, user=user,
note=payload.note, note=payload.note,
) )
except UnitError as exc: except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit() db.commit()
db.refresh(lot) db.refresh(lot)
@@ -74,7 +81,7 @@ def stock_checkin_batch(
note=payload.note, note=payload.note,
) )
) )
except UnitError as exc: except ConversionError as exc:
db.rollback() db.rollback()
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
db.commit() db.commit()
@@ -94,6 +101,22 @@ def stock_checkout(
) -> CheckOutResponse: ) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode) product = resolve_product(db, payload.product_id, payload.barcode)
try: try:
if payload.lot_id is not None:
lot = db.get(Lot, payload.lot_id)
if lot is None or lot.product_id != product.id:
raise HTTPException(
status.HTTP_404_NOT_FOUND, "Charge gehört nicht zu diesem Produkt"
)
affected = check_out_lot(
db,
product=product,
lot=lot,
quantity=payload.quantity,
unit=payload.unit,
user=user,
note=payload.note,
)
else:
affected = check_out( affected = check_out(
db, db,
product=product, product=product,
@@ -102,7 +125,7 @@ def stock_checkout(
user=user, user=user,
note=payload.note, note=payload.note,
) )
except UnitError as exc: except ConversionError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
except StockError as exc: except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
@@ -122,3 +145,63 @@ def list_lots(
if product_id is not None: if product_id is not None:
query = query.filter(Lot.product_id == product_id) query = query.filter(Lot.product_id == product_id)
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all() return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
@router.patch("/lots/{lot_id}", response_model=LotOut)
def update_lot(
lot_id: int,
payload: LotUpdate,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> Lot:
"""Korrigiert eine Charge (Menge in Basiseinheiten, MHD, Lagerort)."""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
data = payload.model_dump(exclude_unset=True)
old_quantity = lot.quantity
for field, value in data.items():
setattr(lot, field, value)
delta = lot.quantity - old_quantity
if abs(delta) > 1e-9:
db.add(
Movement(
product_id=lot.product_id,
lot_id=lot.id,
user_id=user.id,
type=MovementType.adjust,
quantity=delta,
unit_used="base",
note="Charge korrigiert",
)
)
db.commit()
db.refresh(lot)
return lot
@router.delete("/lots/{lot_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_lot(
lot_id: int,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> None:
"""Entfernt eine Charge komplett aus dem Bestand."""
lot = db.get(Lot, lot_id)
if lot is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden")
db.add(
Movement(
product_id=lot.product_id,
lot_id=None,
user_id=user.id,
type=MovementType.adjust,
quantity=-lot.quantity,
unit_used="base",
note="Charge gelöscht",
)
)
db.delete(lot)
db.commit()

View File

@@ -0,0 +1,56 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Group, Product, Unit, User
from ..schemas import UnitCreate, UnitOut
router = APIRouter(prefix="/units", tags=["units"])
@router.get("", response_model=list[UnitOut])
def list_units(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[Unit]:
return db.query(Unit).order_by(Unit.kind, Unit.factor).all()
@router.post("", response_model=UnitOut, status_code=status.HTTP_201_CREATED)
def create_unit(
payload: UnitCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> Unit:
exists = db.query(Unit).filter(func.lower(Unit.name) == payload.name.lower()).first()
if exists:
raise HTTPException(status.HTTP_409_CONFLICT, "Einheit existiert bereits")
unit = Unit(name=payload.name, kind=payload.kind, factor=payload.factor, is_builtin=False)
db.add(unit)
db.commit()
db.refresh(unit)
return unit
@router.delete("/{unit_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_unit(
unit_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
unit = db.get(Unit, unit_id)
if unit is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Einheit nicht gefunden")
if unit.is_builtin:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "Eingebaute Einheiten können nicht gelöscht werden")
in_use = (
db.query(Product).filter(Product.display_unit_id == unit_id).first()
or db.query(Group).filter(Group.min_stock_unit_id == unit_id).first()
)
if in_use:
raise HTTPException(
status.HTTP_409_CONFLICT, "Einheit wird noch von Produkten/Gruppen verwendet"
)
db.delete(unit)
db.commit()

View File

@@ -7,6 +7,7 @@ from ..database import get_db
from ..deps import get_current_user from ..deps import get_current_user
from ..models import Group, Lot, Movement, Product, User from ..models import Group, Lot, Movement, Product, User
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
from ..services.conversion import BASE_OF_KIND
from ..services.stock import current_stock from ..services.stock import current_stock
from .settings import get_expiry_warning_days from .settings import get_expiry_warning_days
@@ -56,8 +57,16 @@ def group_shopping_list(
db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all() db.query(Group).filter(Group.min_stock.isnot(None), Group.min_stock > 0).all()
) )
for group in groups: for group in groups:
products = db.query(Product).filter(Product.group_id == group.id).all() 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)) stock = float(sum(current_stock(db, p.id) for p in products))
unit_name = ""
if stock < group.min_stock: if stock < group.min_stock:
items.append( items.append(
GroupShoppingItem( GroupShoppingItem(
@@ -66,6 +75,7 @@ def group_shopping_list(
stock=stock, stock=stock,
min_stock=group.min_stock, min_stock=group.min_stock,
deficit=group.min_stock - stock, deficit=group.min_stock - stock,
unit_name=unit_name,
product_count=len(products), product_count=len(products),
) )
) )

View File

@@ -4,7 +4,23 @@ from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from .models import BaseUnit, Role from .models import BaseUnit, Role, UnitKind
# ---- Units ----
class UnitOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
kind: UnitKind
factor: float
is_builtin: bool
class UnitCreate(BaseModel):
name: str = Field(min_length=1, max_length=64)
kind: UnitKind
factor: float = Field(gt=0)
# ---- Auth / Users ---- # ---- Auth / Users ----
@@ -40,19 +56,24 @@ class GroupOut(BaseModel):
id: int id: int
name: str name: str
min_stock: float | None = None min_stock: float | None = None
min_stock_unit_id: int | None = None
# angereichert: # angereichert:
product_count: int = 0 product_count: int = 0
stock: float = 0.0 stock: float = 0.0 # Bestand in Basiseinheiten
min_stock_unit_name: str | None = None
kind: str | None = None # Art der Mindestbestand-Einheit
class GroupCreate(BaseModel): class GroupCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None
class GroupUpdate(BaseModel): class GroupUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120) name: str | None = Field(default=None, min_length=1, max_length=120)
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
min_stock_unit_id: int | None = None
# ---- Locations ---- # ---- Locations ----
@@ -75,6 +96,8 @@ class ProductBase(BaseModel):
brand: str | None = None brand: str | None = None
image_url: str | None = None image_url: str | None = None
base_unit: BaseUnit = BaseUnit.piece base_unit: BaseUnit = BaseUnit.piece
# Optionale verwaltete Einheit; setzt base_unit anhand ihrer Art und die Anzeigeeinheit.
unit_id: int | None = None
package_size: float | None = Field(default=None, gt=0) package_size: float | None = Field(default=None, gt=0)
group_id: int | None = None group_id: int | None = None
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
@@ -90,6 +113,7 @@ class ProductUpdate(BaseModel):
brand: str | None = None brand: str | None = None
image_url: str | None = None image_url: str | None = None
base_unit: BaseUnit | None = None base_unit: BaseUnit | None = None
unit_id: int | None = None
package_size: float | None = Field(default=None, gt=0) package_size: float | None = Field(default=None, gt=0)
group_id: int | None = None group_id: int | None = None
min_stock: float | None = Field(default=None, ge=0) min_stock: float | None = Field(default=None, ge=0)
@@ -103,6 +127,7 @@ class ProductOut(BaseModel):
brand: str | None brand: str | None
image_url: str | None image_url: str | None
base_unit: BaseUnit base_unit: BaseUnit
display_unit_id: int | None = None
package_size: float | None package_size: float | None
group_id: int | None group_id: int | None
min_stock: float | None min_stock: float | None
@@ -111,6 +136,9 @@ class ProductOut(BaseModel):
# angereichert: # angereichert:
stock: float = 0.0 stock: float = 0.0
expired_count: int = 0 expired_count: int = 0
kind: str = ""
unit_name: str = ""
unit_factor: float = 1.0
class LookupResult(BaseModel): class LookupResult(BaseModel):
@@ -135,6 +163,8 @@ class CheckOutRequest(BaseModel):
barcode: str | None = None barcode: str | None = None
quantity: float = Field(gt=0) quantity: float = Field(gt=0)
unit: str unit: str
# Optional: gezielt aus dieser Charge abbuchen (sonst automatisch FEFO).
lot_id: int | None = None
note: str | None = None note: str | None = None
@@ -148,6 +178,13 @@ class LotOut(BaseModel):
created_at: datetime created_at: datetime
class LotUpdate(BaseModel):
"""Korrektur einer Charge (Vertipper beim Einlagern o.ä.)."""
quantity: float | None = Field(default=None, gt=0)
best_before: date | None = None
location_id: int | None = None
class CheckInResponse(BaseModel): class CheckInResponse(BaseModel):
lot: LotOut lot: LotOut
product_stock: float product_stock: float
@@ -205,6 +242,7 @@ class GroupShoppingItem(BaseModel):
stock: float stock: float
min_stock: float min_stock: float
deficit: float deficit: float
unit_name: str = ""
product_count: int product_count: int

View File

@@ -1,11 +1,30 @@
"""Erstellt beim ersten Start einen Admin-Benutzer, falls noch keiner existiert.""" """Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import get_settings from .config import get_settings
from .models import Role, User from .models import Role, Unit, UnitKind, User
from .security import hash_password from .security import hash_password
# (Name, Art, Faktor zur kanonischen Basiseinheit)
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
("Stück", UnitKind.count, 1.0),
("Gramm", UnitKind.weight, 1.0),
("Kilogramm", UnitKind.weight, 1000.0),
("Milliliter", UnitKind.volume, 1.0),
("Liter", UnitKind.volume, 1000.0),
]
def ensure_builtin_units(db: Session) -> None:
changed = False
for name, kind, factor in BUILTIN_UNITS:
if not db.query(Unit).filter(Unit.name == name).first():
db.add(Unit(name=name, kind=kind, factor=factor, is_builtin=True))
changed = True
if changed:
db.commit()
def ensure_first_admin(db: Session) -> None: def ensure_first_admin(db: Session) -> None:
settings = get_settings() settings = get_settings()

View File

@@ -0,0 +1,90 @@
"""Einheiten-Umrechnung auf Basis der verwaltbaren Unit-Tabelle.
Bestände werden immer in der kanonischen Basiseinheit der jeweiligen Art
gespeichert: Stück (count), Gramm (weight), Milliliter (volume). Verwaltete
Einheiten (z.B. Kilogramm, Liter, Pfund) rechnen über ihren Faktor dorthin um.
"""
from __future__ import annotations
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..models import BaseUnit, Product, Unit, UnitKind
BASE_OF_KIND: dict[UnitKind, BaseUnit] = {
UnitKind.count: BaseUnit.piece,
UnitKind.weight: BaseUnit.gram,
UnitKind.volume: BaseUnit.milliliter,
}
KIND_OF_BASE: dict[BaseUnit, UnitKind] = {v: k for k, v in BASE_OF_KIND.items()}
BASE_LABEL: dict[BaseUnit, str] = {
BaseUnit.piece: "Stück",
BaseUnit.gram: "Gramm",
BaseUnit.milliliter: "Milliliter",
}
PACKAGE_TOKENS = {"package", "packung", "pkg", "pack"}
# Kürzel/Aliase, die direkt auf eingebaute Einheitennamen zeigen.
_ALIAS = {
"g": "gramm", "gram": "gramm", "gramm": "gramm",
"kg": "kilogramm", "kilogramm": "kilogramm",
"ml": "milliliter", "milliliter": "milliliter",
"l": "liter", "liter": "liter",
"stück": "stück", "stueck": "stück", "st": "stück", "stk": "stück", "piece": "stück",
}
class ConversionError(ValueError):
"""Fachlicher Fehler bei der Einheiten-Umrechnung."""
def kind_of_product(product: Product) -> UnitKind:
return KIND_OF_BASE[product.base_unit]
def find_unit(db: Session, token: str) -> Unit | None:
t = token.strip().lower()
t = _ALIAS.get(t, t)
return db.query(Unit).filter(func.lower(Unit.name) == t).first()
def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float:
"""Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um."""
if quantity <= 0:
raise ConversionError("Menge muss größer als 0 sein")
t = (unit_token or "").strip().lower()
if t in PACKAGE_TOKENS:
if not product.package_size or product.package_size <= 0:
raise ConversionError(
"Für dieses Produkt ist keine Packungsgröße hinterlegt."
)
return quantity * product.package_size
unit = find_unit(db, t)
if unit is None:
raise ConversionError(f"Unbekannte Einheit: {unit_token}")
if unit.kind != kind_of_product(product):
raise ConversionError(
f"Einheit '{unit.name}' passt nicht zur Art des Produkts "
f"({BASE_LABEL[product.base_unit]})."
)
return quantity * unit.factor
def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]:
"""Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID."""
unit = db.get(Unit, unit_id)
if unit is None:
raise ConversionError("Einheit nicht gefunden")
return BASE_OF_KIND[unit.kind], unit.id
def display_unit_info(product: Product) -> tuple[str, float]:
"""Name und Faktor der Anzeigeeinheit des Produkts (Fallback: Basiseinheit)."""
if product.display_unit is not None:
return product.display_unit.name, product.display_unit.factor
return BASE_LABEL[product.base_unit], 1.0

View File

@@ -8,7 +8,7 @@ from sqlalchemy import asc
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models import Lot, Movement, MovementType, Product, User from ..models import Lot, Movement, MovementType, Product, User
from .units import to_base_quantity from .conversion import to_base
class StockError(ValueError): class StockError(ValueError):
@@ -34,7 +34,7 @@ def check_in(
note: str | None = None, note: str | None = None,
) -> Lot: ) -> Lot:
"""Legt eine neue Charge an und protokolliert die Bewegung.""" """Legt eine neue Charge an und protokolliert die Bewegung."""
quantity_base = to_base_quantity(product, quantity, unit) quantity_base = to_base(db, product, quantity, unit)
lot = Lot( lot = Lot(
product_id=product.id, product_id=product.id,
@@ -59,6 +59,41 @@ def check_in(
return lot return lot
def check_out_lot(
db: Session,
product: Product,
lot: Lot,
quantity: float,
unit: str,
user: User | None,
note: str | None = None,
) -> list[dict]:
"""Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO)."""
needed = to_base(db, product, quantity, unit)
if needed > lot.quantity + 1e-9:
raise StockError(
f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} "
f"(benötigt {needed:g})."
)
lot.quantity -= needed
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=needed,
unit_used=unit,
note=note,
)
)
affected = [{"lot_id": lot.id, "quantity": needed}]
if lot.quantity <= 1e-9:
db.delete(lot)
return affected
def _fefo_lots(db: Session, product_id: int) -> list[Lot]: def _fefo_lots(db: Session, product_id: int) -> list[Lot]:
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter.""" """Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
lots = ( lots = (
@@ -88,7 +123,7 @@ def check_out(
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück. Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
Wirft StockError, wenn der Gesamtbestand nicht ausreicht. Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
""" """
needed = to_base_quantity(product, quantity, unit) needed = to_base(db, product, quantity, unit)
available = current_stock(db, product.id) available = current_stock(db, product.id)
if needed > available + 1e-9: if needed > available + 1e-9:
raise StockError( raise StockError(

View File

@@ -5,6 +5,7 @@ from sqlalchemy.pool import StaticPool
from app.database import Base from app.database import Base
from app.models import BaseUnit, Product from app.models import BaseUnit, Product
from app.seed import ensure_builtin_units
@pytest.fixture() @pytest.fixture()
@@ -17,6 +18,7 @@ def db():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False) TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
session = TestingSession() session = TestingSession()
ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung
try: try:
yield session yield session
finally: finally:

View File

@@ -0,0 +1,43 @@
import pytest
from app.models import BaseUnit, Product
from app.services.conversion import ConversionError, to_base
def test_base_unit(db, rice):
assert to_base(db, rice, 200, "Gramm") == 200
assert to_base(db, rice, 200, "g") == 200 # Kürzel-Alias
def test_kilogram_converts_to_grams(db, rice):
assert to_base(db, rice, 1, "Kilogramm") == 1000
assert to_base(db, rice, 2.5, "kg") == 2500
def test_package_uses_package_size(db, rice):
# rice: package_size = 500 g
assert to_base(db, rice, 3, "package") == 1500
def test_wrong_kind_rejected(db, rice):
# rice ist ein Gewichts-Produkt -> Volumeneinheit passt nicht
with pytest.raises(ConversionError):
to_base(db, rice, 1, "Liter")
def test_unknown_unit_rejected(db, rice):
with pytest.raises(ConversionError):
to_base(db, rice, 1, "Eimer")
def test_volume_product(db):
milk = Product(name="Milch", base_unit=BaseUnit.milliliter, package_size=1000)
db.add(milk)
db.commit()
assert to_base(db, milk, 1, "Liter") == 1000
assert to_base(db, milk, 500, "ml") == 500
def test_non_positive_quantity_rejected(db, rice):
with pytest.raises(ConversionError):
to_base(db, rice, 0, "Gramm")

View File

@@ -2,31 +2,52 @@
Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
## Schritt 1 Fundament ✅ (dieser Stand) ## Schritt 1 Fundament ✅
- Backend (FastAPI + PostgreSQL): Datenmodell, Auth mit Rollen, FEFO-Auslagern, - Backend (FastAPI + PostgreSQL): Datenmodell, Auth mit Rollen (Admin/Benutzer),
Einheiten-Umrechnung, Open-Food-Facts-Lookup mit lokalem Fallback. Chargen mit MHD, FEFO-Auslagern, Open-Food-Facts-Lookup (v0-API) mit lokalem Fallback.
- Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte, Lagerorte, - Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte, Lagerorte,
Benutzerverwaltung, Einkaufsliste rollenabhängig. Benutzerverwaltung, Einkaufsliste.
- Deployment: Docker Compose + `install.sh`, erster Admin beim Setup. - Deployment: Docker Compose + `install.sh` (Docker-/curl-Autoinstall, Secrets,
- Tests: pytest für FEFO-Abbuchung und Einheiten-Umrechnung. erster Admin, Health-Check), README.
- Tests: pytest für FEFO, Einheiten-Umrechnung und OFF-Mengenerkennung.
## Schritt 2 Native iOS-App (SwiftUI) ## Schritt 1b Ausbau Web-UI ✅
- **Design:** professionelles Layout mit Seitenleiste, eigenes SVG-Icon-Set
(bewusst keine Emojis), Light-/Dark-Mode, konsistente Farbcodierung
(gelb = Warnfrist, rot = abgelaufen).
- **Gruppen:** Verwaltung, Zuordnung im Produkt, Gruppen-Mindestbestand **mit Einheit**,
Auto-Zuordnungsvorschlag aus der OFF-Kategorie.
- **Einheiten (verwaltbar):** eigene Tabelle mit Art (Anzahl/Gewicht/Volumen) und
Umrechnungsfaktor. Eingebaut: Stück, Gramm, Kilogramm, Milliliter, Liter.
Admins können weitere anlegen (z.B. Pfund = 500 g). Gilt für Produkte **und**
Gruppen-Mindestbestände; Bestände werden intern in der kanonischen Basiseinheit
(Stück/Gramm/Milliliter) gespeichert.
- **Einlagern:** Drei-Wege-Barcode-Erkennung (bekannt / bei OFF gefunden → inline
anlegen / unbekannt), mehrere Chargen je Vorgang mit eigenem MHD,
Warnung bei bereits abgelaufenem MHD.
- **Auslagern:** automatisch per FEFO **oder** gezielte Auswahl einer Charge/MHD,
auch Teilmengen.
- **Chargen bearbeiten:** Menge/MHD korrigieren und Chargen löschen (mit
Protokollierung als Korrektur-Bewegung).
- **Lagerorte:** beliebig tief verschachtelbar (Schrank → Fach → Kiste → …).
- **Mindestbestände:** je Produkt wahlweise in der Produkteinheit oder in Packungen.
- **Verlauf:** Bewegungsprotokoll (wer/was/wann), **Einstellungen:** Ablauf-Warnfrist.
- **Migration:** schonendes Nachziehen neuer Spalten beim Start
(`ADD COLUMN IF NOT EXISTS`), damit bestehende Installationen ihre Daten behalten.
## Schritt 2 Native iOS-App (SwiftUI) offen
- Login (Server-URL + Passwort, Token im Keychain). - Login (Server-URL + Passwort, Token im Keychain).
- Barcode-Scan (VisionKit / AVFoundation). - Barcode-Scan (VisionKit / AVFoundation).
- Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD → speichern. - Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD (mehrere Chargen).
- Auslagern-Flow: scannen → Menge/Einheit (ganz oder Teilmenge g/l) → FEFO. - Auslagern-Flow: scannen → Menge/Einheit, FEFO oder Chargenauswahl.
- Einkaufsliste- und „bald ablaufend“-Ansicht. - Einkaufsliste- und „bald ablaufend“-Ansicht.
- Optional: unbekannte Produkte direkt in der App anlegen. - Produkte bei Bedarf direkt in der App anlegen.
## Schritt 3 Fortgeschrittene Features ## Schritt 3 Weitere Ideen offen
- **Gruppen-Intelligenz:** Produkt beim Einlagern automatisch einer Gruppe zuordnen - **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
(Ableitung aus OFF-Kategorie), manuell korrigierbar. - **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
- **Mindestbestände auf Gruppen** (z. B. „Nudeln“ gesamt), nicht nur einzelne Produkte. Mengenvorschläge in Packungen.
(DB-Spalte `groups.min_stock` ist bereits vorbereitet.) - **Statistik/Auswertung** über den Bewegungsverlauf (Verbrauch pro Zeitraum).
- **Unterlagerorte** (Regal xy / Fach xy). (DB-Spalte `locations.parent_id` vorbereitet.) - **Lagerort beim Auslagern** berücksichtigen bzw. pro Charge wählbar beim Einlagern.
- **Einheiten-Umwandlung nachträglich** (Packung ↔ g/l) komfortabel in Web + App. - **Produktbilder lokal cachen** (aktuell direkt von Open Food Facts geladen).
- **Ablauf-Push-Benachrichtigungen** via APNs. - **Alembic-Migrationen** statt `create_all` + Startup-ALTER, sobald das Schema stabil ist.
- **Produktbilder** aus OFF anzeigen (Web + App) + lokaler Bild-Cache.
- **Einkaufsliste verfeinern:** Mengenvorschläge, manuelle Ergänzungen, Abhaken/Erledigen.
- **Bewegungsverlauf/Statistik** (wer/was wann ein-/ausgelagert) Tabelle `movements`
wird bereits befüllt, es fehlt nur die Ansicht.

View File

@@ -9,6 +9,7 @@ import CheckIn from "./pages/CheckIn";
import CheckOut from "./pages/CheckOut"; import CheckOut from "./pages/CheckOut";
import Groups from "./pages/Groups"; import Groups from "./pages/Groups";
import Locations from "./pages/Locations"; import Locations from "./pages/Locations";
import Units from "./pages/Units";
import Users from "./pages/Users"; import Users from "./pages/Users";
import ShoppingList from "./pages/ShoppingList"; import ShoppingList from "./pages/ShoppingList";
import History from "./pages/History"; import History from "./pages/History";
@@ -52,6 +53,7 @@ function Sidebar() {
<> <>
<div className="nav-section">Verwaltung</div> <div className="nav-section">Verwaltung</div>
<NavItem to="/locations" icon="location" label="Lagerorte" /> <NavItem to="/locations" icon="location" label="Lagerorte" />
<NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/users" icon="users" label="Benutzer" /> <NavItem to="/users" icon="users" label="Benutzer" />
<NavItem to="/settings" icon="settings" label="Einstellungen" /> <NavItem to="/settings" icon="settings" label="Einstellungen" />
</> </>
@@ -104,6 +106,7 @@ export default function App() {
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} /> <Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
<Route path="/history" element={<Protected><History /></Protected>} /> <Route path="/history" element={<Protected><History /></Protected>} />
<Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} /> <Route path="/locations" element={<Protected adminOnly><Locations /></Protected>} />
<Route path="/units" element={<Protected adminOnly><Units /></Protected>} />
<Route path="/users" element={<Protected adminOnly><Users /></Protected>} /> <Route path="/users" element={<Protected adminOnly><Users /></Protected>} />
<Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} /> <Route path="/settings" element={<Protected adminOnly><Settings /></Protected>} />
<Route path="*" element={<Navigate to="/" replace />} /> <Route path="*" element={<Navigate to="/" replace />} />

View File

@@ -88,6 +88,8 @@ export const api = {
checkOut: (body) => request("/stock/checkout", { method: "POST", body }), checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
listLots: (productId) => listLots: (productId) =>
request(`/lots${productId ? `?product_id=${productId}` : ""}`), request(`/lots${productId ? `?product_id=${productId}` : ""}`),
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
// Views // Views
shoppingList: () => request("/shopping-list"), shoppingList: () => request("/shopping-list"),
@@ -105,6 +107,10 @@ export const api = {
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }), deleteGroup: (id) => request(`/groups/${id}`, { method: "DELETE" }),
listUnits: () => request("/units"),
createUnit: (body) => request("/units", { method: "POST", body }),
deleteUnit: (id) => request(`/units/${id}`, { method: "DELETE" }),
// Benutzer // Benutzer
listUsers: () => request("/users"), listUsers: () => request("/users"),
createUser: (body) => request("/users", { method: "POST", body }), createUser: (body) => request("/users", { method: "POST", body }),

View File

@@ -4,7 +4,7 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup, suggestionToProduct } from "../offUtils"; import { guessGroup, suggestionToProduct } from "../offUtils";
import { fmt, isExpired, unitOptions, unitShort } from "../units"; import { buildUnitOptions, fmt, isExpired } from "../units";
const emptyLine = () => ({ quantity: "", best_before: "" }); const emptyLine = () => ({ quantity: "", best_before: "" });
@@ -18,6 +18,7 @@ export default function CheckIn() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [locations, setLocations] = useState([]); const [locations, setLocations] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [unit, setUnit] = useState(""); const [unit, setUnit] = useState("");
const [locationId, setLocationId] = useState(""); const [locationId, setLocationId] = useState("");
@@ -30,6 +31,7 @@ export default function CheckIn() {
useEffect(() => { useEffect(() => {
api.listLocations().then(setLocations).catch(() => {}); api.listLocations().then(setLocations).catch(() => {});
api.listGroups().then(setGroups).catch(() => {}); api.listGroups().then(setGroups).catch(() => {});
api.listUnits().then(setUnits).catch(() => {});
}, []); }, []);
function resetLookup() { function resetLookup() {
@@ -41,7 +43,8 @@ export default function CheckIn() {
setProduct(p); setProduct(p);
setResults([]); setResults([]);
resetLookup(); resetLookup();
setUnit(unitOptions(p)[0].value); const opts = buildUnitOptions(p, units);
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
setLines([emptyLine()]); setLines([emptyLine()]);
setLocationId(""); setLocationId("");
} }
@@ -118,7 +121,7 @@ export default function CheckIn() {
const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines }); const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines });
setInfo( setInfo(
`${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` + `${payloadLines.length} Charge(n) eingelagert. Neuer Bestand: ` +
`${fmt(res.product_stock)} ${unitShort(product.base_unit)}` `${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
); );
setLines([emptyLine()]); setLines([emptyLine()]);
setProduct(await api.getProduct(product.id)); setProduct(await api.getProduct(product.id));
@@ -197,7 +200,7 @@ export default function CheckIn() {
{results.map((p) => ( {results.map((p) => (
<li key={p.id}> <li key={p.id}>
<button className="link-btn" onClick={() => selectProduct(p)}> <button className="link-btn" onClick={() => selectProduct(p)}>
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span> {p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
</button> </button>
</li> </li>
))} ))}
@@ -214,7 +217,7 @@ export default function CheckIn() {
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>} : <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info"> <div className="info">
<div className="title">{product.name}</div> <div className="title">{product.name}</div>
<div className="muted small">Bestand: {fmt(product.stock)} {unitShort(product.base_unit)}</div> <div className="muted small">Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}</div>
</div> </div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button> <button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div> </div>
@@ -223,7 +226,7 @@ export default function CheckIn() {
<label className="grow"> <label className="grow">
Einheit Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}> <select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)} {buildUnitOptions(product, units).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
</label> </label>
<label className="grow"> <label className="grow">
@@ -274,7 +277,7 @@ export default function CheckIn() {
</button> </button>
{totalQty > 0 && ( {totalQty > 0 && (
<span className="muted small">Summe: {fmt(totalQty)} { <span className="muted small">Summe: {fmt(totalQty)} {
unitOptions(product).find((o) => o.value === unit)?.label || "" buildUnitOptions(product, units).find((o) => o.value === unit)?.label || ""
}</span> }</span>
)} )}
</div> </div>

View File

@@ -1,25 +1,38 @@
import { useState } from "react"; import { useEffect, useState } from "react";
import { api } from "../api"; import { api } from "../api";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { fmt, unitOptions, unitShort } from "../units"; import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
export default function CheckOut() { export default function CheckOut() {
const [barcode, setBarcode] = useState(""); const [barcode, setBarcode] = useState("");
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [units, setUnits] = useState([]);
const [lots, setLots] = useState([]);
const [quantity, setQuantity] = useState(""); const [quantity, setQuantity] = useState("");
const [unit, setUnit] = useState(""); const [unit, setUnit] = useState("");
const [lotId, setLotId] = useState(""); // "" = automatisch (FEFO)
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
function selectProduct(p) { useEffect(() => {
api.listUnits().then(setUnits).catch(() => {});
}, []);
async function selectProduct(p) {
setProduct(p); setProduct(p);
setResults([]); setResults([]);
setUnit(unitOptions(p)[0].value); setLotId("");
setQuantity("");
const opts = buildUnitOptions(p, units);
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
try {
setLots(await api.listLots(p.id));
} catch { setLots([]); }
} }
async function doLookup() { async function doLookup() {
@@ -28,7 +41,7 @@ export default function CheckOut() {
try { try {
const res = await api.lookup(barcode.trim()); const res = await api.lookup(barcode.trim());
if (res.found && res.existing_product) { if (res.found && res.existing_product) {
selectProduct(res.existing_product); await selectProduct(res.existing_product);
} else { } else {
setError("Kein bekanntes Produkt zu diesem Barcode im Lager."); setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
} }
@@ -54,10 +67,17 @@ export default function CheckOut() {
product_id: product.id, product_id: product.id,
quantity: Number(quantity), quantity: Number(quantity),
unit, unit,
lot_id: lotId === "" ? null : Number(lotId),
}); });
setInfo(`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ${fmt(res.product_stock)} ${unitShort(product.base_unit)}`); setInfo(
`Ausgelagert (${res.affected_lots.length} Charge(n)). Neuer Bestand: ` +
`${fmt(res.product_stock / (product.unit_factor || 1))} ${product.unit_name}`
);
setQuantity(""); setQuantity("");
setProduct(await api.getProduct(product.id)); const fresh = await api.getProduct(product.id);
setProduct(fresh);
setLots(await api.listLots(fresh.id));
setLotId("");
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -65,6 +85,10 @@ export default function CheckOut() {
} }
} }
const unitOpts = product ? buildUnitOptions(product, units) : [];
const selectedLot = lots.find((l) => String(l.id) === String(lotId)) || null;
const baseShort = product ? unitShort(product.base_unit) : "";
return ( return (
<div> <div>
<div className="page-head"><h1>Auslagern</h1></div> <div className="page-head"><h1>Auslagern</h1></div>
@@ -96,7 +120,7 @@ export default function CheckOut() {
{results.map((p) => ( {results.map((p) => (
<li key={p.id}> <li key={p.id}>
<button className="link-btn" onClick={() => selectProduct(p)}> <button className="link-btn" onClick={() => selectProduct(p)}>
{p.name} <span className="muted">({fmt(p.stock)} {unitShort(p.base_unit)})</span> {p.name} <span className="muted">({fmt(p.stock / (p.unit_factor || 1))} {p.unit_name})</span>
</button> </button>
</li> </li>
))} ))}
@@ -113,11 +137,35 @@ export default function CheckOut() {
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>} : <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
<div className="info"> <div className="info">
<div className="title">{product.name}</div> <div className="title">{product.name}</div>
<div className="muted small">Verfügbar: {fmt(product.stock)} {unitShort(product.base_unit)}</div> <div className="muted small">
Verfügbar: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
</div>
</div> </div>
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button> <button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
</div> </div>
<label>
Charge
<select value={lotId} onChange={(e) => setLotId(e.target.value)}>
<option value="">Automatisch zuerst ablaufende zuerst (FEFO)</option>
{lots.map((l) => (
<option key={l.id} value={l.id}>
{l.best_before ? `MHD ${l.best_before}` : "ohne MHD"} · {fmt(l.quantity)} {baseShort}
{isExpired(l.best_before) ? " · abgelaufen" : ""}
</option>
))}
</select>
</label>
{selectedLot && (
<div className={`alert ${isExpired(selectedLot.best_before) ? "error" : "info"}`}>
<Icon name="alert" size={16} />
Gewählte Charge: {fmt(selectedLot.quantity)} {baseShort}
{selectedLot.best_before ? ` · MHD ${selectedLot.best_before}` : " · ohne MHD"}
{isExpired(selectedLot.best_before) ? " (abgelaufen)" : ""}
</div>
)}
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
Menge Menge
@@ -127,14 +175,19 @@ export default function CheckOut() {
<label className="grow"> <label className="grow">
Einheit Einheit
<select value={unit} onChange={(e) => setUnit(e.target.value)}> <select value={unit} onChange={(e) => setUnit(e.target.value)}>
{unitOptions(product).map((o) => <option key={o.value} value={o.value}>{o.label}</option>)} {unitOpts.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select> </select>
</label> </label>
</div> </div>
{!selectedLot && (
<p className="muted small mt-0"> <p className="muted small mt-0">
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO). Ohne Auswahl wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
</p> </p>
<button className="btn primary" disabled={busy}><Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}</button> )}
<button className="btn primary" disabled={busy}>
<Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}
</button>
</form> </form>
)} )}
</div> </div>

View File

@@ -114,8 +114,8 @@ export default function Dashboard() {
{groupShopping.map((it) => ( {groupShopping.map((it) => (
<tr key={`g${it.group_id}`}> <tr key={`g${it.group_id}`}>
<td><span className="badge accent">Gruppe</span> {it.name}</td> <td><span className="badge accent">Gruppe</span> {it.name}</td>
<td className="num">{fmt(it.stock)}</td> <td className="num">{fmt(it.stock)} {it.unit_name}</td>
<td className="num strong">{fmt(it.deficit)}</td> <td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
</tr> </tr>
))} ))}
{shopping.map((it) => ( {shopping.map((it) => (

View File

@@ -7,13 +7,15 @@ import { fmt } from "../units";
export default function Groups() { export default function Groups() {
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [name, setName] = useState(""); const [units, setUnits] = useState([]);
const [minStock, setMinStock] = useState(""); const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
const [error, setError] = useState(null); const [error, setError] = useState(null);
async function load() { async function load() {
try { try {
setGroups(await api.listGroups()); const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
setGroups(gs);
setUnits(us);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
@@ -25,18 +27,21 @@ export default function Groups() {
e.preventDefault(); e.preventDefault();
setError(null); setError(null);
try { try {
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) }); await api.createGroup({
setName(""); name: form.name,
setMinStock(""); min_stock: form.min_stock === "" ? null : Number(form.min_stock),
min_stock_unit_id: form.min_stock_unit_id === "" ? null : Number(form.min_stock_unit_id),
});
setForm({ name: "", min_stock: "", min_stock_unit_id: form.min_stock_unit_id });
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} }
} }
async function saveMin(group, value) { async function patch(group, body) {
try { try {
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) }); await api.updateGroup(group.id, body);
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -58,7 +63,7 @@ export default function Groups() {
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>Gruppen</h1> <h1>Gruppen</h1>
<div className="sub">Fasse Produkte zusammen (z.B. Nudeln, Mehl) und setze einen Gruppen-Mindestbestand</div> <div className="sub">Produkte zusammenfassen (z.B. Nudeln) und einen Gruppen-Mindestbestand mit Einheit setzen</div>
</div> </div>
</div> </div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>} {error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
@@ -68,7 +73,14 @@ export default function Groups() {
<div className="table-wrap"> <div className="table-wrap">
<table className="table"> <table className="table">
<thead> <thead>
<tr><th>Gruppe</th><th className="num">Produkte</th><th className="num">Bestand</th><th>Mindestbestand</th><th></th></tr> <tr>
<th>Gruppe</th>
<th className="num">Produkte</th>
<th className="num">Bestand</th>
<th>Mindestbestand</th>
<th>Einheit</th>
<th></th>
</tr>
</thead> </thead>
<tbody> <tbody>
{groups.map((g) => { {groups.map((g) => {
@@ -80,17 +92,30 @@ export default function Groups() {
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>} {low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td> </td>
<td className="num muted">{g.product_count}</td> <td className="num muted">{g.product_count}</td>
<td className="num">{fmt(g.stock)}</td> <td className="num">{fmt(g.stock)} {g.min_stock_unit_name || ""}</td>
<td> <td>
{isAdmin ? ( {isAdmin ? (
<input type="number" step="any" defaultValue={g.min_stock ?? ""} <input type="number" step="any" defaultValue={g.min_stock ?? ""}
style={{ maxWidth: 110, marginTop: 0 }} style={{ maxWidth: 100, marginTop: 0 }}
onBlur={(e) => { onBlur={(e) => {
const v = e.target.value; const v = e.target.value;
if (v !== String(g.min_stock ?? "")) saveMin(g, v); if (v !== String(g.min_stock ?? "")) {
patch(g, { min_stock: v === "" ? null : Number(v) });
}
}} /> }} />
) : (g.min_stock != null ? fmt(g.min_stock) : "")} ) : (g.min_stock != null ? fmt(g.min_stock) : "")}
</td> </td>
<td>
{isAdmin ? (
<select value={g.min_stock_unit_id ?? ""} style={{ maxWidth: 140, marginTop: 0 }}
onChange={(e) => patch(g, {
min_stock_unit_id: e.target.value === "" ? null : Number(e.target.value),
})}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
) : (g.min_stock_unit_name || "")}
</td>
<td className="num"> <td className="num">
{isAdmin && ( {isAdmin && (
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen"> <button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
@@ -101,7 +126,7 @@ export default function Groups() {
</tr> </tr>
); );
})} })}
{groups.length === 0 && <tr><td colSpan={5} className="empty">Noch keine Gruppen.</td></tr>} {groups.length === 0 && <tr><td colSpan={6} className="empty">Noch keine Gruppen.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -112,18 +137,27 @@ export default function Groups() {
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div> <div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
<label> <label>
Name Name
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required /> <input placeholder="z.B. Nudeln" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label> </label>
<label> <div className="row">
<label className="grow">
Mindestbestand (optional) Mindestbestand (optional)
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)} <input type="number" step="any" value={form.min_stock}
placeholder="Gesamtmenge über alle Produkte der Gruppe" /> onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
</label> </label>
<label className="grow">
Einheit
<select value={form.min_stock_unit_id}
onChange={(e) => setForm({ ...form, min_stock_unit_id: e.target.value })}>
<option value=""> Basiseinheit </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select>
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button> <button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
<p className="muted small"> <p className="muted small">
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu. Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen
Der Gruppen-Bestand ist die Summe der Produktbestände sinnvoll, wenn (Gewicht/Volumen/Stück). Produkte ordnest du im Produkt-Formular einer Gruppe zu.
die Produkte dieselbe Basiseinheit haben.
</p> </p>
</form> </form>
)} )}

View File

@@ -4,13 +4,16 @@ import { api } from "../api";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { guessGroup } from "../offUtils"; import { guessGroup } from "../offUtils";
import { BASE_UNITS, daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units"; import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
const EMPTY = { const EMPTY = {
barcode: "", name: "", brand: "", image_url: "", barcode: "", name: "", brand: "", image_url: "",
base_unit: "piece", package_size: "", min_stock: "", group_id: "", unit_id: "", package_size: "", min_stock: "", group_id: "",
}; };
// Kanonische Einheit je Basiseinheit (für OFF-Vorschläge und Altbestand).
const CANONICAL_NAME = { piece: "Stück", gram: "Gramm", milliliter: "Milliliter" };
export default function ProductForm() { export default function ProductForm() {
const { id } = useParams(); const { id } = useParams();
const isNew = !id; const isNew = !id;
@@ -22,28 +25,45 @@ export default function ProductForm() {
const [product, setProduct] = useState(null); const [product, setProduct] = useState(null);
const [lots, setLots] = useState([]); const [lots, setLots] = useState([]);
const [groups, setGroups] = useState([]); const [groups, setGroups] = useState([]);
const [units, setUnits] = useState([]);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [info, setInfo] = useState(null); const [info, setInfo] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package". // Einheitr die Mindestbestand-Eingabe: "unit" (gewählte Einheit) oder "package".
const [minUnit, setMinUnit] = useState("base"); const [minUnit, setMinUnit] = useState("unit");
const [warnDays, setWarnDays] = useState(7); const [warnDays, setWarnDays] = useState(7);
const selectedUnit = units.find((u) => String(u.id) === String(form.unit_id)) || null;
const unitFactor = selectedUnit ? selectedUnit.factor : 1;
const unitName = selectedUnit ? selectedUnit.name : "";
function set(k, v) { function set(k, v) {
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
} }
// Wechselt die Mindestbestand-Einheit und rechnet den angezeigten Wert um. function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name);
return hit ? String(hit.id) : "";
}
// Faktor, mit dem der angezeigte Mindestbestand in Basiseinheiten umgerechnet wird.
function minFactor(mode, pkgSize, uFactor) {
if (mode === "package") return Number(pkgSize) > 0 ? Number(pkgSize) : 1;
return uFactor || 1;
}
function changeMinUnit(newUnit) { function changeMinUnit(newUnit) {
const ps = Number(form.package_size); if (form.min_stock !== "" && newUnit !== minUnit) {
if (form.min_stock !== "" && ps > 0 && newUnit !== minUnit) { const oldF = minFactor(minUnit, form.package_size, unitFactor);
const v = Number(form.min_stock); const newF = minFactor(newUnit, form.package_size, unitFactor);
set("min_stock", String(newUnit === "package" ? v / ps : v * ps)); const base = Number(form.min_stock) * oldF;
set("min_stock", String(base / newF));
} }
setMinUnit(newUnit); setMinUnit(newUnit);
} }
function applySuggestion(s, groupsList) { function applySuggestion(s, groupsList, unitList) {
const groupGuess = guessGroup(groupsList, s); const groupGuess = guessGroup(groupsList, s);
setForm((f) => ({ setForm((f) => ({
...f, ...f,
@@ -51,7 +71,7 @@ export default function ProductForm() {
name: s.name || f.name, name: s.name || f.name,
brand: s.brand || f.brand, brand: s.brand || f.brand,
image_url: s.image_url || f.image_url, image_url: s.image_url || f.image_url,
base_unit: s.base_unit || f.base_unit, unit_id: f.unit_id || canonicalUnitId(unitList, s.base_unit || "piece"),
package_size: s.package_size != null ? String(s.package_size) : f.package_size, package_size: s.package_size != null ? String(s.package_size) : f.package_size,
group_id: f.group_id || groupGuess, group_id: f.group_id || groupGuess,
})); }));
@@ -63,7 +83,7 @@ export default function ProductForm() {
); );
} }
async function runLookup(code, groupsList) { async function runLookup(code, groupsList, unitList) {
if (!code) return; if (!code) return;
setError(null); setError(null);
setInfo(null); setInfo(null);
@@ -73,7 +93,7 @@ export default function ProductForm() {
setInfo("Dieses Produkt existiert bereits."); setInfo("Dieses Produkt existiert bereits.");
navigate(`/products/${res.existing_product.id}`); navigate(`/products/${res.existing_product.id}`);
} else if (res.found && res.suggestion) { } else if (res.found && res.suggestion) {
applySuggestion(res.suggestion, groupsList); applySuggestion(res.suggestion, groupsList, unitList);
} else { } else {
setInfo("Barcode unbekannt bitte Daten selbst eingeben."); setInfo("Barcode unbekannt bitte Daten selbst eingeben.");
} }
@@ -85,29 +105,30 @@ export default function ProductForm() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const gs = await api.listGroups(); const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
setGroups(gs); setGroups(gs);
setUnits(us);
try { try {
const s = await api.listSettings(); const s = await api.listSettings();
const row = s.find((x) => x.key === "expiry_warning_days"); const row = s.find((x) => x.key === "expiry_warning_days");
if (row) setWarnDays(parseInt(row.value, 10) || 7); if (row) setWarnDays(parseInt(row.value, 10) || 7);
} catch { /* Einstellungen optional */ } } catch { /* optional */ }
if (!isNew) { if (!isNew) {
const p = await api.getProduct(id); const p = await api.getProduct(id);
setProduct(p); setProduct(p);
// Mindestbestand ist in Basiseinheiten gespeichert; bei Packungsprodukten const uid = p.display_unit_id
// zeigen wir ihn zur besseren Verständlichkeit in Packungen an. ? String(p.display_unit_id)
let minDisplay = p.min_stock ?? ""; : canonicalUnitId(us, p.base_unit);
if (p.min_stock != null && p.package_size && p.package_size > 0) { const uf = us.find((u) => String(u.id) === uid)?.factor || 1;
minDisplay = p.min_stock / p.package_size; const mode = p.package_size && p.package_size > 0 ? "package" : "unit";
setMinUnit("package"); const f = mode === "package" ? p.package_size : uf;
} else { setMinUnit(mode);
setMinUnit("base");
}
setForm({ setForm({
barcode: p.barcode || "", name: p.name, brand: p.brand || "", barcode: p.barcode || "", name: p.name, brand: p.brand || "",
image_url: p.image_url || "", base_unit: p.base_unit, image_url: p.image_url || "", unit_id: uid,
package_size: p.package_size ?? "", min_stock: minDisplay, package_size: p.package_size ?? "",
min_stock: p.min_stock != null ? p.min_stock / f : "",
group_id: p.group_id ?? "", group_id: p.group_id ?? "",
}); });
setLots(await api.listLots(id)); setLots(await api.listLots(id));
@@ -115,7 +136,7 @@ export default function ProductForm() {
const bc = searchParams.get("barcode"); const bc = searchParams.get("barcode");
if (bc) { if (bc) {
set("barcode", bc); set("barcode", bc);
await runLookup(bc, gs); await runLookup(bc, gs, us);
} }
} }
} catch (err) { } catch (err) {
@@ -127,18 +148,16 @@ export default function ProductForm() {
}, [id]); }, [id]);
function buildPayload() { function buildPayload() {
const ps = Number(form.package_size);
let minBase = null; let minBase = null;
if (form.min_stock !== "") { if (form.min_stock !== "") {
const v = Number(form.min_stock); minBase = Number(form.min_stock) * minFactor(minUnit, form.package_size, unitFactor);
minBase = minUnit === "package" && ps > 0 ? v * ps : v;
} }
return { return {
barcode: form.barcode || null, barcode: form.barcode || null,
name: form.name, name: form.name,
brand: form.brand || null, brand: form.brand || null,
image_url: form.image_url || null, image_url: form.image_url || null,
base_unit: form.base_unit, unit_id: form.unit_id === "" ? null : Number(form.unit_id),
package_size: form.package_size === "" ? null : Number(form.package_size), package_size: form.package_size === "" ? null : Number(form.package_size),
min_stock: minBase, min_stock: minBase,
group_id: form.group_id === "" ? null : Number(form.group_id), group_id: form.group_id === "" ? null : Number(form.group_id),
@@ -157,6 +176,7 @@ export default function ProductForm() {
await api.updateProduct(id, buildPayload()); await api.updateProduct(id, buildPayload());
setInfo("Gespeichert."); setInfo("Gespeichert.");
setProduct(await api.getProduct(id)); setProduct(await api.getProduct(id));
setLots(await api.listLots(id));
} }
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -176,14 +196,18 @@ export default function ProductForm() {
} }
const readOnly = !isAdmin; const readOnly = !isAdmin;
const unitLabel = unitShort(form.base_unit); const baseShort = product ? unitShort(product.base_unit) : "";
return ( return (
<div> <div>
<div className="page-head"> <div className="page-head">
<div> <div>
<h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1> <h1>{isNew ? "Neues Produkt" : form.name || "Produkt"}</h1>
{!isNew && <div className="sub">Bestand: {fmt(product?.stock)} {unitLabel}</div>} {!isNew && product && (
<div className="sub">
Bestand: {fmt(product.stock / (product.unit_factor || 1))} {product.unit_name}
</div>
)}
</div> </div>
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button> <button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
</div> </div>
@@ -199,7 +223,7 @@ export default function ProductForm() {
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} /> <input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
</label> </label>
{isAdmin && ( {isAdmin && (
<button type="button" className="btn" onClick={() => runLookup(form.barcode, groups)}> <button type="button" className="btn" onClick={() => runLookup(form.barcode, groups, units)}>
<Icon name="search" size={16} />Nachschlagen <Icon name="search" size={16} />Nachschlagen
</button> </button>
)} )}
@@ -214,16 +238,16 @@ export default function ProductForm() {
</label> </label>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
Basiseinheit Einheit
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}> <select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)} <option value=""> wählen </option>
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</select> </select>
</label> </label>
<label className="grow"> <label className="grow">
Packungsgröße{unitLabel !== "Stk" ? ` (in ${unitLabel})` : ""} Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
<input type="number" step="any" value={form.package_size} <input type="number" step="any" value={form.package_size}
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
placeholder="z.B. 500" />
</label> </label>
</div> </div>
<div className="row"> <div className="row">
@@ -231,11 +255,10 @@ export default function ProductForm() {
Mindestbestand Mindestbestand
<div className="field-inline"> <div className="field-inline">
<input type="number" step="any" value={form.min_stock} <input type="number" step="any" value={form.min_stock}
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
placeholder="z.B. 2" />
<select value={minUnit} onChange={(e) => changeMinUnit(e.target.value)} <select value={minUnit} onChange={(e) => changeMinUnit(e.target.value)}
disabled={readOnly} style={{ maxWidth: 140, marginTop: 0 }}> disabled={readOnly} style={{ maxWidth: 150, marginTop: 0 }}>
<option value="base">{unitShort(form.base_unit)}</option> <option value="unit">{unitName || "Einheit"}</option>
{form.package_size && <option value="package">Packung(en)</option>} {form.package_size && <option value="package">Packung(en)</option>}
</select> </select>
</div> </div>
@@ -263,6 +286,59 @@ export default function ProductForm() {
</form> </form>
{!isNew && ( {!isNew && (
<LotsCard
productId={id}
lots={lots}
baseShort={baseShort}
warnDays={warnDays}
isAdmin={isAdmin}
imageUrl={form.image_url}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
)}
</div>
</div>
);
}
/** Chargen-Karte mit Bearbeiten/Löschen. */
function LotsCard({ productId, lots, baseShort, warnDays, isAdmin, imageUrl, onChanged, onError }) {
const [editId, setEditId] = useState(null);
const [draft, setDraft] = useState({ quantity: "", best_before: "" });
function startEdit(l) {
setEditId(l.id);
setDraft({ quantity: l.quantity, best_before: l.best_before || "" });
}
async function saveEdit(l) {
try {
await api.updateLot(l.id, {
quantity: Number(draft.quantity),
best_before: draft.best_before || null,
});
setEditId(null);
await onChanged();
} catch (err) {
onError(err.message);
}
}
async function removeLot(l) {
if (!confirm("Charge wirklich löschen?")) return;
try {
await api.deleteLot(l.id);
await onChanged();
} catch (err) {
onError(err.message);
}
}
return (
<section className="card"> <section className="card">
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div> <div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
{lots.some((l) => isExpired(l.best_before)) && ( {lots.some((l) => isExpired(l.best_before)) && (
@@ -270,33 +346,67 @@ export default function ProductForm() {
Dieses Produkt hat abgelaufene Chargen im Bestand. Dieses Produkt hat abgelaufene Chargen im Bestand.
</div> </div>
)} )}
{form.image_url && <img className="product-img" src={form.image_url} alt="" />} {imageUrl && <img className="product-img" src={imageUrl} alt="" />}
<div className="table-wrap"> <div className="table-wrap">
<table className="table"> <table className="table">
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead> <thead>
<tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th><th></th></tr>
</thead>
<tbody> <tbody>
{lots.map((l) => { {lots.map((l) => {
const cls = expiryRowClass(l.best_before, warnDays); const cls = expiryRowClass(l.best_before, warnDays);
const dLeft = daysUntil(l.best_before); const dLeft = daysUntil(l.best_before);
const editing = editId === l.id;
return ( return (
<tr key={l.id} className={cls}> <tr key={l.id} className={cls}>
<td className="num">{fmt(l.quantity)} {unitLabel}</td> <td className="num">
{editing ? (
<input type="number" step="any" min="0" value={draft.quantity}
onChange={(e) => setDraft({ ...draft, quantity: e.target.value })}
style={{ maxWidth: 100, marginTop: 0 }} />
) : (
<>{fmt(l.quantity)} {baseShort}</>
)}
</td>
<td> <td>
{editing ? (
<input type="date" value={draft.best_before}
onChange={(e) => setDraft({ ...draft, best_before: e.target.value })}
style={{ maxWidth: 150, marginTop: 0 }} />
) : (
<>
{l.best_before || ""} {l.best_before || ""}
{cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>} {cls === "row-danger" && <span className="badge danger" style={{ marginLeft: 6 }}>abgelaufen</span>}
{cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>} {cls === "row-warn" && <span className="badge warn" style={{ marginLeft: 6 }}>{dLeft}d</span>}
</>
)}
</td> </td>
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td> <td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
<td className="num">
{isAdmin && (editing ? (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn sm primary" onClick={() => saveEdit(l)}>Speichern</button>
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
</div>
) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" onClick={() => startEdit(l)} title="Charge bearbeiten">
<Icon name="edit" size={16} />
</button>
<button className="btn-icon danger" onClick={() => removeLot(l)} title="Charge löschen">
<Icon name="trash" size={16} />
</button>
</div>
))}
</td>
</tr> </tr>
); );
})} })}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>} {lots.length === 0 && <tr><td colSpan={4} className="empty">Keine Chargen im Bestand.</td></tr>}
</tbody> </tbody>
</table> </table>
</div> </div>
<p className="muted small">Mengen sind in der Basiseinheit ({baseShort}) angegeben.</p>
</section> </section>
)}
</div>
</div>
); );
} }

View File

@@ -84,17 +84,22 @@ export default function Products() {
)} )}
</td> </td>
<td className="muted">{p.brand || ""}</td> <td className="muted">{p.brand || ""}</td>
<td>{unitShort(p.base_unit)}{p.package_size ? ` · Pkg ${fmt(p.package_size)}` : ""}</td> <td>
{p.unit_name || unitShort(p.base_unit)}
{p.package_size ? ` · Pkg ${fmt(p.package_size)} ${unitShort(p.base_unit)}` : ""}
</td>
<td className="num"> <td className="num">
{fmt(p.stock)} {unitShort(p.base_unit)} {fmt(p.stock / (p.unit_factor || 1))} {p.unit_name || unitShort(p.base_unit)}
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>} {low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
</td> </td>
<td className="num"> <td className="num">
{p.package_size {p.package_size
? `${fmt(p.stock / p.package_size)} Pkg` ? `${fmt(p.stock / p.package_size)} Pkg`
: `${fmt(p.stock)} ${unitShort(p.base_unit)}`} : `${fmt(p.stock / (p.unit_factor || 1))} ${p.unit_name || unitShort(p.base_unit)}`}
</td>
<td className="num muted">
{p.min_stock != null ? `${fmt(p.min_stock / (p.unit_factor || 1))}` : ""}
</td> </td>
<td className="num muted">{p.min_stock != null ? fmt(p.min_stock) : ""}</td>
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td> <td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
</tr> </tr>
); );

View File

@@ -46,7 +46,8 @@ export default function ShoppingList() {
<span className="item-name">{it.name}</span> <span className="item-name">{it.name}</span>
</label> </label>
<span className="muted small"> <span className="muted small">
fehlt <strong>{fmt(it.deficit)}</strong> (Bestand {fmt(it.stock)} / min {fmt(it.min_stock)}) fehlt <strong>{fmt(it.deficit)} {it.unit_name}</strong>{" "}
(Bestand {fmt(it.stock)} / min {fmt(it.min_stock)} {it.unit_name})
</span> </span>
</li> </li>
); );

107
web/src/pages/Units.jsx Normal file
View File

@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import Icon from "../components/Icon";
import { fmt } from "../units";
const KIND_LABEL = { count: "Anzahl (Stück)", weight: "Gewicht (Basis: Gramm)", volume: "Volumen (Basis: Milliliter)" };
export default function Units() {
const [units, setUnits] = useState([]);
const [form, setForm] = useState({ name: "", kind: "weight", factor: "" });
const [error, setError] = useState(null);
async function load() {
try {
setUnits(await api.listUnits());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createUnit({ name: form.name, kind: form.kind, factor: Number(form.factor) });
setForm({ name: "", kind: form.kind, factor: "" });
load();
} catch (err) {
setError(err.message);
}
}
async function remove(u) {
if (!confirm(`Einheit "${u.name}" löschen?`)) return;
try {
await api.deleteUnit(u.id);
load();
} catch (err) {
setError(err.message);
}
}
const baseUnitOfKind = { count: "Stück", weight: "Gramm", volume: "Milliliter" };
return (
<div>
<div className="page-head">
<div>
<h1>Einheiten</h1>
<div className="sub">Faktor = wie viele Basiseinheiten 1 dieser Einheit entsprechen</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="grid-2">
<div className="card" style={{ padding: 0 }}>
<div className="table-wrap">
<table className="table">
<thead><tr><th>Name</th><th>Art</th><th className="num">Faktor</th><th></th></tr></thead>
<tbody>
{units.map((u) => (
<tr key={u.id}>
<td className="strong">{u.name}{u.is_builtin && <span className="badge" style={{ marginLeft: 6 }}>eingebaut</span>}</td>
<td className="muted">{KIND_LABEL[u.kind] || u.kind}</td>
<td className="num">{fmt(u.factor)} {baseUnitOfKind[u.kind]}</td>
<td className="num">
{!u.is_builtin && (
<button className="btn-icon danger" onClick={() => remove(u)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<form className="card" onSubmit={add}>
<div className="card-head"><Icon name="settings" /><h2>Neue Einheit</h2></div>
<label>
Name
<input placeholder="z.B. Pfund" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<label>
Art
<select value={form.kind} onChange={(e) => setForm({ ...form, kind: e.target.value })}>
<option value="count">Anzahl (Stück)</option>
<option value="weight">Gewicht (Basis: Gramm)</option>
<option value="volume">Volumen (Basis: Milliliter)</option>
</select>
</label>
<label>
Faktor zur Basiseinheit
<input type="number" step="any" min="0" value={form.factor}
onChange={(e) => setForm({ ...form, factor: e.target.value })} required
placeholder={`z.B. 500 (= 500 ${baseUnitOfKind[form.kind]})`} />
</label>
<button className="btn primary"><Icon name="plus" size={16} />Anlegen</button>
</form>
</div>
</div>
);
}

View File

@@ -69,3 +69,23 @@ export function amountText(qty, packageSize, baseUnit) {
} }
return `${fmt(qty)} ${unitShort(baseUnit)}`; return `${fmt(qty)} ${unitShort(baseUnit)}`;
} }
// Einheiten-Optionen fürs Ein-/Auslagern: verwaltete Einheiten der Produkt-Art + Packung.
export function buildUnitOptions(product, units) {
const opts = (units || [])
.filter((u) => u.kind === product.kind)
.map((u) => ({ value: u.name, label: u.name }));
if (product.package_size) {
opts.push({
value: "package",
label: `Packung (${fmt(product.package_size)} ${unitShort(product.base_unit)})`,
});
}
return opts;
}
// Bestand eines Produkts in seiner Anzeigeeinheit, z.B. "1,5 Kilogramm".
export function stockLabel(product) {
const factor = product.unit_factor || 1;
return `${fmt((product.stock || 0) / factor)} ${product.unit_name || unitShort(product.base_unit)}`;
}