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:
@@ -9,6 +9,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from .models import Lot, Product
|
||||
from .schemas import ProductOut
|
||||
from .services.conversion import KIND_OF_BASE, display_unit_info
|
||||
from .services.stock import current_stock
|
||||
|
||||
|
||||
@@ -25,6 +26,10 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
|
||||
)
|
||||
.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
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from .config import get_settings
|
||||
from .database import Base, SessionLocal, engine
|
||||
@@ -12,20 +13,44 @@ from .routers import (
|
||||
products,
|
||||
settings as settings_router,
|
||||
stock,
|
||||
units,
|
||||
users,
|
||||
views,
|
||||
)
|
||||
from .seed import ensure_first_admin
|
||||
from .seed import ensure_builtin_units, ensure_first_admin
|
||||
|
||||
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
|
||||
async def lifespan(app: FastAPI):
|
||||
# Tabellen anlegen (MVP: create_all statt Alembic-Migrationen).
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_ensure_schema()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
ensure_builtin_units(db)
|
||||
ensure_first_admin(db)
|
||||
finally:
|
||||
db.close()
|
||||
@@ -57,5 +82,6 @@ app.include_router(products.router)
|
||||
app.include_router(stock.router)
|
||||
app.include_router(locations.router)
|
||||
app.include_router(groups.router)
|
||||
app.include_router(units.router)
|
||||
app.include_router(views.router)
|
||||
app.include_router(settings_router.router)
|
||||
|
||||
@@ -4,6 +4,7 @@ import enum
|
||||
from datetime import date, datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Enum,
|
||||
@@ -40,6 +41,28 @@ class MovementType(str, enum.Enum):
|
||||
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"
|
||||
|
||||
@@ -55,10 +78,14 @@ class Group(Base):
|
||||
|
||||
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)
|
||||
# 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):
|
||||
@@ -82,9 +109,14 @@ class Product(Base):
|
||||
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)
|
||||
|
||||
@@ -98,6 +130,7 @@ class Product(Base):
|
||||
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"
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ from ..database import get_db
|
||||
from ..deps import get_current_user, require_admin
|
||||
from ..models import Group, Product, User
|
||||
from ..schemas import GroupCreate, GroupOut, GroupUpdate
|
||||
from ..services.conversion import BASE_OF_KIND
|
||||
from ..services.stock import current_stock
|
||||
|
||||
router = APIRouter(prefix="/groups", tags=["groups"])
|
||||
@@ -14,7 +15,17 @@ def _group_to_out(db: Session, group: Group) -> GroupOut:
|
||||
out = GroupOut.model_validate(group)
|
||||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||||
out.product_count = len(products)
|
||||
out.stock = float(sum(current_stock(db, p.id) for p in 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))
|
||||
return out
|
||||
|
||||
|
||||
@@ -34,7 +45,11 @@ def create_group(
|
||||
) -> GroupOut:
|
||||
if db.query(Group).filter(Group.name == payload.name).first():
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, "Gruppe existiert bereits")
|
||||
group = Group(name=payload.name, min_stock=payload.min_stock)
|
||||
group = Group(
|
||||
name=payload.name,
|
||||
min_stock=payload.min_stock,
|
||||
min_stock_unit_id=payload.min_stock_unit_id,
|
||||
)
|
||||
db.add(group)
|
||||
db.commit()
|
||||
db.refresh(group)
|
||||
|
||||
@@ -7,6 +7,7 @@ from ..deps import get_current_user, require_admin
|
||||
from ..models import BaseUnit, Product, User
|
||||
from ..off import lookup_barcode
|
||||
from ..schemas import LookupResult, ProductCreate, ProductOut, ProductUpdate
|
||||
from ..services.conversion import ConversionError, resolve_product_unit
|
||||
|
||||
router = APIRouter(prefix="/products", tags=["products"])
|
||||
|
||||
@@ -66,12 +67,20 @@ def create_product(
|
||||
raise HTTPException(
|
||||
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(
|
||||
barcode=payload.barcode or None,
|
||||
name=payload.name,
|
||||
brand=payload.brand,
|
||||
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,
|
||||
group_id=payload.group_id,
|
||||
min_stock=payload.min_stock,
|
||||
@@ -105,6 +114,17 @@ def update_product(
|
||||
raise HTTPException(
|
||||
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():
|
||||
setattr(product, field, value)
|
||||
db.commit()
|
||||
|
||||
@@ -4,7 +4,7 @@ from sqlalchemy.orm import Session
|
||||
from ..crud import resolve_product
|
||||
from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Lot, User
|
||||
from ..models import Lot, Movement, MovementType, User
|
||||
from ..schemas import (
|
||||
BatchCheckInRequest,
|
||||
BatchCheckInResponse,
|
||||
@@ -13,9 +13,16 @@ from ..schemas import (
|
||||
CheckOutRequest,
|
||||
CheckOutResponse,
|
||||
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"])
|
||||
|
||||
@@ -38,7 +45,7 @@ def stock_checkin(
|
||||
user=user,
|
||||
note=payload.note,
|
||||
)
|
||||
except UnitError as exc:
|
||||
except ConversionError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
db.commit()
|
||||
db.refresh(lot)
|
||||
@@ -74,7 +81,7 @@ def stock_checkin_batch(
|
||||
note=payload.note,
|
||||
)
|
||||
)
|
||||
except UnitError as exc:
|
||||
except ConversionError as exc:
|
||||
db.rollback()
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
db.commit()
|
||||
@@ -94,15 +101,31 @@ def stock_checkout(
|
||||
) -> CheckOutResponse:
|
||||
product = resolve_product(db, payload.product_id, payload.barcode)
|
||||
try:
|
||||
affected = check_out(
|
||||
db,
|
||||
product=product,
|
||||
quantity=payload.quantity,
|
||||
unit=payload.unit,
|
||||
user=user,
|
||||
note=payload.note,
|
||||
)
|
||||
except UnitError as exc:
|
||||
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(
|
||||
db,
|
||||
product=product,
|
||||
quantity=payload.quantity,
|
||||
unit=payload.unit,
|
||||
user=user,
|
||||
note=payload.note,
|
||||
)
|
||||
except ConversionError as exc:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
|
||||
except StockError as exc:
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
|
||||
@@ -122,3 +145,63 @@ def list_lots(
|
||||
if product_id is not None:
|
||||
query = query.filter(Lot.product_id == product_id)
|
||||
return query.order_by(Lot.best_before.is_(None), Lot.best_before).all()
|
||||
|
||||
|
||||
@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()
|
||||
|
||||
56
backend/app/routers/units.py
Normal file
56
backend/app/routers/units.py
Normal 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()
|
||||
@@ -7,6 +7,7 @@ from ..database import get_db
|
||||
from ..deps import get_current_user
|
||||
from ..models import Group, Lot, Movement, Product, User
|
||||
from ..schemas import ExpiringItem, GroupShoppingItem, MovementOut, ShoppingItem
|
||||
from ..services.conversion import BASE_OF_KIND
|
||||
from ..services.stock import current_stock
|
||||
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()
|
||||
)
|
||||
for group in groups:
|
||||
products = db.query(Product).filter(Product.group_id == group.id).all()
|
||||
stock = float(sum(current_stock(db, p.id) for p in products))
|
||||
unit = group.min_stock_unit
|
||||
if unit is not None:
|
||||
base = BASE_OF_KIND[unit.kind]
|
||||
products = [p for p in group.products if p.base_unit == base]
|
||||
stock = float(sum(current_stock(db, p.id) for p in products)) / unit.factor
|
||||
unit_name = unit.name
|
||||
else:
|
||||
products = list(group.products)
|
||||
stock = float(sum(current_stock(db, p.id) for p in products))
|
||||
unit_name = ""
|
||||
if stock < group.min_stock:
|
||||
items.append(
|
||||
GroupShoppingItem(
|
||||
@@ -66,6 +75,7 @@ def group_shopping_list(
|
||||
stock=stock,
|
||||
min_stock=group.min_stock,
|
||||
deficit=group.min_stock - stock,
|
||||
unit_name=unit_name,
|
||||
product_count=len(products),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -4,7 +4,23 @@ from datetime import date, datetime
|
||||
|
||||
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 ----
|
||||
@@ -40,19 +56,24 @@ class GroupOut(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
min_stock: float | None = None
|
||||
min_stock_unit_id: int | None = None
|
||||
# angereichert:
|
||||
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):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
min_stock: float | None = Field(default=None, ge=0)
|
||||
min_stock_unit_id: int | None = None
|
||||
|
||||
|
||||
class GroupUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
min_stock: float | None = Field(default=None, ge=0)
|
||||
min_stock_unit_id: int | None = None
|
||||
|
||||
|
||||
# ---- Locations ----
|
||||
@@ -75,6 +96,8 @@ class ProductBase(BaseModel):
|
||||
brand: str | None = None
|
||||
image_url: str | None = None
|
||||
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)
|
||||
group_id: int | None = None
|
||||
min_stock: float | None = Field(default=None, ge=0)
|
||||
@@ -90,6 +113,7 @@ class ProductUpdate(BaseModel):
|
||||
brand: str | None = None
|
||||
image_url: str | None = None
|
||||
base_unit: BaseUnit | None = None
|
||||
unit_id: int | None = None
|
||||
package_size: float | None = Field(default=None, gt=0)
|
||||
group_id: int | None = None
|
||||
min_stock: float | None = Field(default=None, ge=0)
|
||||
@@ -103,6 +127,7 @@ class ProductOut(BaseModel):
|
||||
brand: str | None
|
||||
image_url: str | None
|
||||
base_unit: BaseUnit
|
||||
display_unit_id: int | None = None
|
||||
package_size: float | None
|
||||
group_id: int | None
|
||||
min_stock: float | None
|
||||
@@ -111,6 +136,9 @@ class ProductOut(BaseModel):
|
||||
# angereichert:
|
||||
stock: float = 0.0
|
||||
expired_count: int = 0
|
||||
kind: str = ""
|
||||
unit_name: str = ""
|
||||
unit_factor: float = 1.0
|
||||
|
||||
|
||||
class LookupResult(BaseModel):
|
||||
@@ -135,6 +163,8 @@ class CheckOutRequest(BaseModel):
|
||||
barcode: str | None = None
|
||||
quantity: float = Field(gt=0)
|
||||
unit: str
|
||||
# Optional: gezielt aus dieser Charge abbuchen (sonst automatisch FEFO).
|
||||
lot_id: int | None = None
|
||||
note: str | None = None
|
||||
|
||||
|
||||
@@ -148,6 +178,13 @@ class LotOut(BaseModel):
|
||||
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):
|
||||
lot: LotOut
|
||||
product_stock: float
|
||||
@@ -205,6 +242,7 @@ class GroupShoppingItem(BaseModel):
|
||||
stock: float
|
||||
min_stock: float
|
||||
deficit: float
|
||||
unit_name: str = ""
|
||||
product_count: int
|
||||
|
||||
|
||||
|
||||
@@ -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 .config import get_settings
|
||||
from .models import Role, User
|
||||
from .models import Role, Unit, UnitKind, User
|
||||
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:
|
||||
settings = get_settings()
|
||||
|
||||
90
backend/app/services/conversion.py
Normal file
90
backend/app/services/conversion.py
Normal 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
|
||||
@@ -8,7 +8,7 @@ from sqlalchemy import asc
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..models import Lot, Movement, MovementType, Product, User
|
||||
from .units import to_base_quantity
|
||||
from .conversion import to_base
|
||||
|
||||
|
||||
class StockError(ValueError):
|
||||
@@ -34,7 +34,7 @@ def check_in(
|
||||
note: str | None = None,
|
||||
) -> Lot:
|
||||
"""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(
|
||||
product_id=product.id,
|
||||
@@ -59,6 +59,41 @@ def check_in(
|
||||
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]:
|
||||
"""Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter."""
|
||||
lots = (
|
||||
@@ -88,7 +123,7 @@ def check_out(
|
||||
Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück.
|
||||
Wirft StockError, wenn der Gesamtbestand nicht ausreicht.
|
||||
"""
|
||||
needed = to_base_quantity(product, quantity, unit)
|
||||
needed = to_base(db, product, quantity, unit)
|
||||
available = current_stock(db, product.id)
|
||||
if needed > available + 1e-9:
|
||||
raise StockError(
|
||||
|
||||
@@ -5,6 +5,7 @@ from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.database import Base
|
||||
from app.models import BaseUnit, Product
|
||||
from app.seed import ensure_builtin_units
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -17,6 +18,7 @@ def db():
|
||||
Base.metadata.create_all(bind=engine)
|
||||
TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||
session = TestingSession()
|
||||
ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
|
||||
43
backend/tests/test_conversion.py
Normal file
43
backend/tests/test_conversion.py
Normal 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")
|
||||
@@ -2,31 +2,52 @@
|
||||
|
||||
Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten.
|
||||
|
||||
## Schritt 1 – Fundament ✅ (dieser Stand)
|
||||
- Backend (FastAPI + PostgreSQL): Datenmodell, Auth mit Rollen, FEFO-Auslagern,
|
||||
Einheiten-Umrechnung, Open-Food-Facts-Lookup mit lokalem Fallback.
|
||||
## Schritt 1 – Fundament ✅
|
||||
- Backend (FastAPI + PostgreSQL): Datenmodell, Auth mit Rollen (Admin/Benutzer),
|
||||
Chargen mit MHD, FEFO-Auslagern, Open-Food-Facts-Lookup (v0-API) mit lokalem Fallback.
|
||||
- Web-UI (React/Vite): Login, Dashboard, Ein-/Auslagern, Produkte, Lagerorte,
|
||||
Benutzerverwaltung, Einkaufsliste – rollenabhängig.
|
||||
- Deployment: Docker Compose + `install.sh`, erster Admin beim Setup.
|
||||
- Tests: pytest für FEFO-Abbuchung und Einheiten-Umrechnung.
|
||||
Benutzerverwaltung, Einkaufsliste.
|
||||
- Deployment: Docker Compose + `install.sh` (Docker-/curl-Autoinstall, Secrets,
|
||||
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).
|
||||
- Barcode-Scan (VisionKit / AVFoundation).
|
||||
- Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD → speichern.
|
||||
- Auslagern-Flow: scannen → Menge/Einheit (ganz oder Teilmenge g/l) → FEFO.
|
||||
- Einlagern-Flow: scannen → Lookup/Vorbefüllung → Menge + Einheit + MHD (mehrere Chargen).
|
||||
- Auslagern-Flow: scannen → Menge/Einheit, FEFO oder Chargenauswahl.
|
||||
- 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
|
||||
- **Gruppen-Intelligenz:** Produkt beim Einlagern automatisch einer Gruppe zuordnen
|
||||
(Ableitung aus OFF-Kategorie), manuell korrigierbar.
|
||||
- **Mindestbestände auf Gruppen** (z. B. „Nudeln“ gesamt), nicht nur einzelne Produkte.
|
||||
(DB-Spalte `groups.min_stock` ist bereits vorbereitet.)
|
||||
- **Unterlagerorte** (Regal xy / Fach xy). (DB-Spalte `locations.parent_id` vorbereitet.)
|
||||
- **Einheiten-Umwandlung nachträglich** (Packung ↔ g/l) komfortabel in Web + App.
|
||||
- **Ablauf-Push-Benachrichtigungen** via APNs.
|
||||
- **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.
|
||||
## Schritt 3 – Weitere Ideen – offen
|
||||
- **Ablauf-Push-Benachrichtigungen** via APNs (setzt die App voraus).
|
||||
- **Einkaufsliste verfeinern:** Abhaken dauerhaft speichern, manuelle Einträge,
|
||||
Mengenvorschläge in Packungen.
|
||||
- **Statistik/Auswertung** über den Bewegungsverlauf (Verbrauch pro Zeitraum).
|
||||
- **Lagerort beim Auslagern** berücksichtigen bzw. pro Charge wählbar beim Einlagern.
|
||||
- **Produktbilder lokal cachen** (aktuell direkt von Open Food Facts geladen).
|
||||
- **Alembic-Migrationen** statt `create_all` + Startup-ALTER, sobald das Schema stabil ist.
|
||||
|
||||
@@ -9,6 +9,7 @@ import CheckIn from "./pages/CheckIn";
|
||||
import CheckOut from "./pages/CheckOut";
|
||||
import Groups from "./pages/Groups";
|
||||
import Locations from "./pages/Locations";
|
||||
import Units from "./pages/Units";
|
||||
import Users from "./pages/Users";
|
||||
import ShoppingList from "./pages/ShoppingList";
|
||||
import History from "./pages/History";
|
||||
@@ -52,6 +53,7 @@ function Sidebar() {
|
||||
<>
|
||||
<div className="nav-section">Verwaltung</div>
|
||||
<NavItem to="/locations" icon="location" label="Lagerorte" />
|
||||
<NavItem to="/units" icon="box" label="Einheiten" />
|
||||
<NavItem to="/users" icon="users" label="Benutzer" />
|
||||
<NavItem to="/settings" icon="settings" label="Einstellungen" />
|
||||
</>
|
||||
@@ -104,6 +106,7 @@ export default function App() {
|
||||
<Route path="/shopping" element={<Protected><ShoppingList /></Protected>} />
|
||||
<Route path="/history" element={<Protected><History /></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="/settings" element={<Protected adminOnly><Settings /></Protected>} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
|
||||
@@ -88,6 +88,8 @@ export const api = {
|
||||
checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
|
||||
listLots: (productId) =>
|
||||
request(`/lots${productId ? `?product_id=${productId}` : ""}`),
|
||||
updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
|
||||
deleteLot: (id) => request(`/lots/${id}`, { method: "DELETE" }),
|
||||
|
||||
// Views
|
||||
shoppingList: () => request("/shopping-list"),
|
||||
@@ -105,6 +107,10 @@ export const api = {
|
||||
updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }),
|
||||
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
|
||||
listUsers: () => request("/users"),
|
||||
createUser: (body) => request("/users", { method: "POST", body }),
|
||||
|
||||
@@ -4,7 +4,7 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup, suggestionToProduct } from "../offUtils";
|
||||
import { fmt, isExpired, unitOptions, unitShort } from "../units";
|
||||
import { buildUnitOptions, fmt, isExpired } from "../units";
|
||||
|
||||
const emptyLine = () => ({ quantity: "", best_before: "" });
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function CheckIn() {
|
||||
const [search, setSearch] = useState("");
|
||||
const [locations, setLocations] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
|
||||
const [unit, setUnit] = useState("");
|
||||
const [locationId, setLocationId] = useState("");
|
||||
@@ -30,6 +31,7 @@ export default function CheckIn() {
|
||||
useEffect(() => {
|
||||
api.listLocations().then(setLocations).catch(() => {});
|
||||
api.listGroups().then(setGroups).catch(() => {});
|
||||
api.listUnits().then(setUnits).catch(() => {});
|
||||
}, []);
|
||||
|
||||
function resetLookup() {
|
||||
@@ -41,7 +43,8 @@ export default function CheckIn() {
|
||||
setProduct(p);
|
||||
setResults([]);
|
||||
resetLookup();
|
||||
setUnit(unitOptions(p)[0].value);
|
||||
const opts = buildUnitOptions(p, units);
|
||||
setUnit(p.unit_name || (opts[0] && opts[0].value) || "");
|
||||
setLines([emptyLine()]);
|
||||
setLocationId("");
|
||||
}
|
||||
@@ -118,7 +121,7 @@ export default function CheckIn() {
|
||||
const res = await api.checkInBatch({ product_id: product.id, unit, lines: payloadLines });
|
||||
setInfo(
|
||||
`${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()]);
|
||||
setProduct(await api.getProduct(product.id));
|
||||
@@ -197,7 +200,7 @@ export default function CheckIn() {
|
||||
{results.map((p) => (
|
||||
<li key={p.id}>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -214,7 +217,7 @@ export default function CheckIn() {
|
||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||
<div className="info">
|
||||
<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>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
</div>
|
||||
@@ -223,7 +226,7 @@ export default function CheckIn() {
|
||||
<label className="grow">
|
||||
Einheit
|
||||
<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>
|
||||
</label>
|
||||
<label className="grow">
|
||||
@@ -274,7 +277,7 @@ export default function CheckIn() {
|
||||
</button>
|
||||
{totalQty > 0 && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,25 +1,38 @@
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { api } from "../api";
|
||||
import Icon from "../components/Icon";
|
||||
import { fmt, unitOptions, unitShort } from "../units";
|
||||
import { buildUnitOptions, fmt, isExpired, unitShort } from "../units";
|
||||
|
||||
export default function CheckOut() {
|
||||
const [barcode, setBarcode] = useState("");
|
||||
const [search, setSearch] = useState("");
|
||||
const [results, setResults] = useState([]);
|
||||
const [product, setProduct] = useState(null);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [lots, setLots] = useState([]);
|
||||
|
||||
const [quantity, setQuantity] = useState("");
|
||||
const [unit, setUnit] = useState("");
|
||||
const [lotId, setLotId] = useState(""); // "" = automatisch (FEFO)
|
||||
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
function selectProduct(p) {
|
||||
useEffect(() => {
|
||||
api.listUnits().then(setUnits).catch(() => {});
|
||||
}, []);
|
||||
|
||||
async function selectProduct(p) {
|
||||
setProduct(p);
|
||||
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() {
|
||||
@@ -28,7 +41,7 @@ export default function CheckOut() {
|
||||
try {
|
||||
const res = await api.lookup(barcode.trim());
|
||||
if (res.found && res.existing_product) {
|
||||
selectProduct(res.existing_product);
|
||||
await selectProduct(res.existing_product);
|
||||
} else {
|
||||
setError("Kein bekanntes Produkt zu diesem Barcode im Lager.");
|
||||
}
|
||||
@@ -54,10 +67,17 @@ export default function CheckOut() {
|
||||
product_id: product.id,
|
||||
quantity: Number(quantity),
|
||||
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("");
|
||||
setProduct(await api.getProduct(product.id));
|
||||
const fresh = await api.getProduct(product.id);
|
||||
setProduct(fresh);
|
||||
setLots(await api.listLots(fresh.id));
|
||||
setLotId("");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} 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 (
|
||||
<div>
|
||||
<div className="page-head"><h1>Auslagern</h1></div>
|
||||
@@ -96,7 +120,7 @@ export default function CheckOut() {
|
||||
{results.map((p) => (
|
||||
<li key={p.id}>
|
||||
<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>
|
||||
</li>
|
||||
))}
|
||||
@@ -113,11 +137,35 @@ export default function CheckOut() {
|
||||
: <span className="thumb thumb-fallback"><Icon name="box" size={16} /></span>}
|
||||
<div className="info">
|
||||
<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>
|
||||
<button type="button" className="btn ghost" onClick={() => setProduct(null)}>Ändern</button>
|
||||
</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">
|
||||
<label className="grow">
|
||||
Menge
|
||||
@@ -127,14 +175,19 @@ export default function CheckOut() {
|
||||
<label className="grow">
|
||||
Einheit
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
<p className="muted small mt-0">
|
||||
Es wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||
</p>
|
||||
<button className="btn primary" disabled={busy}><Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}</button>
|
||||
|
||||
{!selectedLot && (
|
||||
<p className="muted small mt-0">
|
||||
Ohne Auswahl wird automatisch die zuerst ablaufende Charge zuerst entnommen (FEFO).
|
||||
</p>
|
||||
)}
|
||||
<button className="btn primary" disabled={busy}>
|
||||
<Icon name="checkout" size={16} />{busy ? "…" : "Auslagern"}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -114,8 +114,8 @@ export default function Dashboard() {
|
||||
{groupShopping.map((it) => (
|
||||
<tr key={`g${it.group_id}`}>
|
||||
<td><span className="badge accent">Gruppe</span> {it.name}</td>
|
||||
<td className="num">{fmt(it.stock)}</td>
|
||||
<td className="num strong">{fmt(it.deficit)}</td>
|
||||
<td className="num">{fmt(it.stock)} {it.unit_name}</td>
|
||||
<td className="num strong">{fmt(it.deficit)} {it.unit_name}</td>
|
||||
</tr>
|
||||
))}
|
||||
{shopping.map((it) => (
|
||||
|
||||
@@ -7,13 +7,15 @@ import { fmt } from "../units";
|
||||
export default function Groups() {
|
||||
const { isAdmin } = useAuth();
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [name, setName] = useState("");
|
||||
const [minStock, setMinStock] = useState("");
|
||||
const [units, setUnits] = useState([]);
|
||||
const [form, setForm] = useState({ name: "", min_stock: "", min_stock_unit_id: "" });
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setGroups(await api.listGroups());
|
||||
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
|
||||
setGroups(gs);
|
||||
setUnits(us);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
@@ -25,18 +27,21 @@ export default function Groups() {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
try {
|
||||
await api.createGroup({ name, min_stock: minStock === "" ? null : Number(minStock) });
|
||||
setName("");
|
||||
setMinStock("");
|
||||
await api.createGroup({
|
||||
name: form.name,
|
||||
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();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveMin(group, value) {
|
||||
async function patch(group, body) {
|
||||
try {
|
||||
await api.updateGroup(group.id, { min_stock: value === "" ? null : Number(value) });
|
||||
await api.updateGroup(group.id, body);
|
||||
load();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -58,7 +63,7 @@ export default function Groups() {
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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>
|
||||
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
|
||||
@@ -68,7 +73,14 @@ export default function Groups() {
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<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>
|
||||
<tbody>
|
||||
{groups.map((g) => {
|
||||
@@ -80,17 +92,30 @@ export default function Groups() {
|
||||
{low && <span className="badge warn" style={{ marginLeft: 6 }}>niedrig</span>}
|
||||
</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>
|
||||
{isAdmin ? (
|
||||
<input type="number" step="any" defaultValue={g.min_stock ?? ""}
|
||||
style={{ maxWidth: 110, marginTop: 0 }}
|
||||
style={{ maxWidth: 100, marginTop: 0 }}
|
||||
onBlur={(e) => {
|
||||
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) : "–")}
|
||||
</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">
|
||||
{isAdmin && (
|
||||
<button className="btn-icon danger" onClick={() => remove(g)} title="Löschen">
|
||||
@@ -101,7 +126,7 @@ export default function Groups() {
|
||||
</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>
|
||||
</table>
|
||||
</div>
|
||||
@@ -112,18 +137,27 @@ export default function Groups() {
|
||||
<div className="card-head"><Icon name="tag" /><h2>Neue Gruppe</h2></div>
|
||||
<label>
|
||||
Name
|
||||
<input placeholder="z.B. Nudeln" value={name} onChange={(e) => setName(e.target.value)} required />
|
||||
</label>
|
||||
<label>
|
||||
Mindestbestand (optional)
|
||||
<input type="number" step="any" value={minStock} onChange={(e) => setMinStock(e.target.value)}
|
||||
placeholder="Gesamtmenge über alle Produkte der Gruppe" />
|
||||
<input placeholder="z.B. Nudeln" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Mindestbestand (optional)
|
||||
<input type="number" step="any" value={form.min_stock}
|
||||
onChange={(e) => setForm({ ...form, min_stock: e.target.value })} placeholder="z.B. 2" />
|
||||
</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>
|
||||
<p className="muted small">
|
||||
Produkte ordnest du einer Gruppe im jeweiligen Produkt-Formular zu.
|
||||
Der Gruppen-Bestand ist die Summe der Produktbestände – sinnvoll, wenn
|
||||
die Produkte dieselbe Basiseinheit haben.
|
||||
Der Gruppen-Bestand summiert nur Produkte, die zur gewählten Einheit passen
|
||||
(Gewicht/Volumen/Stück). Produkte ordnest du im Produkt-Formular einer Gruppe zu.
|
||||
</p>
|
||||
</form>
|
||||
)}
|
||||
|
||||
@@ -4,13 +4,16 @@ import { api } from "../api";
|
||||
import { useAuth } from "../auth";
|
||||
import Icon from "../components/Icon";
|
||||
import { guessGroup } from "../offUtils";
|
||||
import { BASE_UNITS, daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
|
||||
import { daysUntil, expiryRowClass, fmt, isExpired, unitShort } from "../units";
|
||||
|
||||
const EMPTY = {
|
||||
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() {
|
||||
const { id } = useParams();
|
||||
const isNew = !id;
|
||||
@@ -22,28 +25,45 @@ export default function ProductForm() {
|
||||
const [product, setProduct] = useState(null);
|
||||
const [lots, setLots] = useState([]);
|
||||
const [groups, setGroups] = useState([]);
|
||||
const [units, setUnits] = useState([]);
|
||||
const [error, setError] = useState(null);
|
||||
const [info, setInfo] = useState(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Einheit, in der der Mindestbestand eingegeben wird: "base" oder "package".
|
||||
const [minUnit, setMinUnit] = useState("base");
|
||||
// Einheit für die Mindestbestand-Eingabe: "unit" (gewählte Einheit) oder "package".
|
||||
const [minUnit, setMinUnit] = useState("unit");
|
||||
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) {
|
||||
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) {
|
||||
const ps = Number(form.package_size);
|
||||
if (form.min_stock !== "" && ps > 0 && newUnit !== minUnit) {
|
||||
const v = Number(form.min_stock);
|
||||
set("min_stock", String(newUnit === "package" ? v / ps : v * ps));
|
||||
if (form.min_stock !== "" && newUnit !== minUnit) {
|
||||
const oldF = minFactor(minUnit, form.package_size, unitFactor);
|
||||
const newF = minFactor(newUnit, form.package_size, unitFactor);
|
||||
const base = Number(form.min_stock) * oldF;
|
||||
set("min_stock", String(base / newF));
|
||||
}
|
||||
setMinUnit(newUnit);
|
||||
}
|
||||
|
||||
function applySuggestion(s, groupsList) {
|
||||
function applySuggestion(s, groupsList, unitList) {
|
||||
const groupGuess = guessGroup(groupsList, s);
|
||||
setForm((f) => ({
|
||||
...f,
|
||||
@@ -51,7 +71,7 @@ export default function ProductForm() {
|
||||
name: s.name || f.name,
|
||||
brand: s.brand || f.brand,
|
||||
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,
|
||||
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;
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
@@ -73,7 +93,7 @@ export default function ProductForm() {
|
||||
setInfo("Dieses Produkt existiert bereits.");
|
||||
navigate(`/products/${res.existing_product.id}`);
|
||||
} else if (res.found && res.suggestion) {
|
||||
applySuggestion(res.suggestion, groupsList);
|
||||
applySuggestion(res.suggestion, groupsList, unitList);
|
||||
} else {
|
||||
setInfo("Barcode unbekannt – bitte Daten selbst eingeben.");
|
||||
}
|
||||
@@ -85,29 +105,30 @@ export default function ProductForm() {
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const gs = await api.listGroups();
|
||||
const [gs, us] = await Promise.all([api.listGroups(), api.listUnits()]);
|
||||
setGroups(gs);
|
||||
setUnits(us);
|
||||
try {
|
||||
const s = await api.listSettings();
|
||||
const row = s.find((x) => x.key === "expiry_warning_days");
|
||||
if (row) setWarnDays(parseInt(row.value, 10) || 7);
|
||||
} catch { /* Einstellungen optional */ }
|
||||
} catch { /* optional */ }
|
||||
|
||||
if (!isNew) {
|
||||
const p = await api.getProduct(id);
|
||||
setProduct(p);
|
||||
// Mindestbestand ist in Basiseinheiten gespeichert; bei Packungsprodukten
|
||||
// zeigen wir ihn zur besseren Verständlichkeit in Packungen an.
|
||||
let minDisplay = p.min_stock ?? "";
|
||||
if (p.min_stock != null && p.package_size && p.package_size > 0) {
|
||||
minDisplay = p.min_stock / p.package_size;
|
||||
setMinUnit("package");
|
||||
} else {
|
||||
setMinUnit("base");
|
||||
}
|
||||
const uid = p.display_unit_id
|
||||
? String(p.display_unit_id)
|
||||
: canonicalUnitId(us, p.base_unit);
|
||||
const uf = us.find((u) => String(u.id) === uid)?.factor || 1;
|
||||
const mode = p.package_size && p.package_size > 0 ? "package" : "unit";
|
||||
const f = mode === "package" ? p.package_size : uf;
|
||||
setMinUnit(mode);
|
||||
setForm({
|
||||
barcode: p.barcode || "", name: p.name, brand: p.brand || "",
|
||||
image_url: p.image_url || "", base_unit: p.base_unit,
|
||||
package_size: p.package_size ?? "", min_stock: minDisplay,
|
||||
image_url: p.image_url || "", unit_id: uid,
|
||||
package_size: p.package_size ?? "",
|
||||
min_stock: p.min_stock != null ? p.min_stock / f : "",
|
||||
group_id: p.group_id ?? "",
|
||||
});
|
||||
setLots(await api.listLots(id));
|
||||
@@ -115,7 +136,7 @@ export default function ProductForm() {
|
||||
const bc = searchParams.get("barcode");
|
||||
if (bc) {
|
||||
set("barcode", bc);
|
||||
await runLookup(bc, gs);
|
||||
await runLookup(bc, gs, us);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -127,18 +148,16 @@ export default function ProductForm() {
|
||||
}, [id]);
|
||||
|
||||
function buildPayload() {
|
||||
const ps = Number(form.package_size);
|
||||
let minBase = null;
|
||||
if (form.min_stock !== "") {
|
||||
const v = Number(form.min_stock);
|
||||
minBase = minUnit === "package" && ps > 0 ? v * ps : v;
|
||||
minBase = Number(form.min_stock) * minFactor(minUnit, form.package_size, unitFactor);
|
||||
}
|
||||
return {
|
||||
barcode: form.barcode || null,
|
||||
name: form.name,
|
||||
brand: form.brand || 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),
|
||||
min_stock: minBase,
|
||||
group_id: form.group_id === "" ? null : Number(form.group_id),
|
||||
@@ -157,6 +176,7 @@ export default function ProductForm() {
|
||||
await api.updateProduct(id, buildPayload());
|
||||
setInfo("Gespeichert.");
|
||||
setProduct(await api.getProduct(id));
|
||||
setLots(await api.listLots(id));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
@@ -176,14 +196,18 @@ export default function ProductForm() {
|
||||
}
|
||||
|
||||
const readOnly = !isAdmin;
|
||||
const unitLabel = unitShort(form.base_unit);
|
||||
const baseShort = product ? unitShort(product.base_unit) : "";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-head">
|
||||
<div>
|
||||
<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>
|
||||
<button className="btn ghost" onClick={() => navigate(-1)}>Zurück</button>
|
||||
</div>
|
||||
@@ -199,7 +223,7 @@ export default function ProductForm() {
|
||||
<input value={form.barcode} onChange={(e) => set("barcode", e.target.value)} disabled={readOnly} />
|
||||
</label>
|
||||
{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
|
||||
</button>
|
||||
)}
|
||||
@@ -214,16 +238,16 @@ export default function ProductForm() {
|
||||
</label>
|
||||
<div className="row">
|
||||
<label className="grow">
|
||||
Basiseinheit
|
||||
<select value={form.base_unit} onChange={(e) => set("base_unit", e.target.value)} disabled={readOnly}>
|
||||
{BASE_UNITS.map((u) => <option key={u.value} value={u.value}>{u.label}</option>)}
|
||||
Einheit
|
||||
<select value={form.unit_id} onChange={(e) => set("unit_id", e.target.value)} disabled={readOnly} required>
|
||||
<option value="">– wählen –</option>
|
||||
{units.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grow">
|
||||
Packungsgröße{unitLabel !== "Stk" ? ` (in ${unitLabel})` : ""}
|
||||
Packungsgröße{baseShort ? ` (in ${baseShort})` : ""}
|
||||
<input type="number" step="any" value={form.package_size}
|
||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 500" />
|
||||
onChange={(e) => set("package_size", e.target.value)} disabled={readOnly} placeholder="z.B. 500" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="row">
|
||||
@@ -231,11 +255,10 @@ export default function ProductForm() {
|
||||
Mindestbestand
|
||||
<div className="field-inline">
|
||||
<input type="number" step="any" value={form.min_stock}
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly}
|
||||
placeholder="z.B. 2" />
|
||||
onChange={(e) => set("min_stock", e.target.value)} disabled={readOnly} placeholder="z.B. 2" />
|
||||
<select value={minUnit} onChange={(e) => changeMinUnit(e.target.value)}
|
||||
disabled={readOnly} style={{ maxWidth: 140, marginTop: 0 }}>
|
||||
<option value="base">{unitShort(form.base_unit)}</option>
|
||||
disabled={readOnly} style={{ maxWidth: 150, marginTop: 0 }}>
|
||||
<option value="unit">{unitName || "Einheit"}</option>
|
||||
{form.package_size && <option value="package">Packung(en)</option>}
|
||||
</select>
|
||||
</div>
|
||||
@@ -263,40 +286,127 @@ export default function ProductForm() {
|
||||
</form>
|
||||
|
||||
{!isNew && (
|
||||
<section className="card">
|
||||
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
|
||||
{lots.some((l) => isExpired(l.best_before)) && (
|
||||
<div className="alert error"><Icon name="alert" size={16} />
|
||||
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
||||
</div>
|
||||
)}
|
||||
{form.image_url && <img className="product-img" src={form.image_url} alt="" />}
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead><tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th></tr></thead>
|
||||
<tbody>
|
||||
{lots.map((l) => {
|
||||
const cls = expiryRowClass(l.best_before, warnDays);
|
||||
const dLeft = daysUntil(l.best_before);
|
||||
return (
|
||||
<tr key={l.id} className={cls}>
|
||||
<td className="num">{fmt(l.quantity)} {unitLabel}</td>
|
||||
<td>
|
||||
{l.best_before || "–"}
|
||||
{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>}
|
||||
</td>
|
||||
<td className="muted">{new Date(l.created_at).toLocaleDateString("de-DE")}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{lots.length === 0 && <tr><td colSpan={3} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
<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">
|
||||
<div className="card-head"><Icon name="box" /><h2>Chargen im Bestand</h2></div>
|
||||
{lots.some((l) => isExpired(l.best_before)) && (
|
||||
<div className="alert error"><Icon name="alert" size={16} />
|
||||
Dieses Produkt hat abgelaufene Chargen im Bestand.
|
||||
</div>
|
||||
)}
|
||||
{imageUrl && <img className="product-img" src={imageUrl} alt="" />}
|
||||
<div className="table-wrap">
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr><th className="num">Menge</th><th>MHD</th><th>Eingelagert</th><th></th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lots.map((l) => {
|
||||
const cls = expiryRowClass(l.best_before, warnDays);
|
||||
const dLeft = daysUntil(l.best_before);
|
||||
const editing = editId === l.id;
|
||||
return (
|
||||
<tr key={l.id} className={cls}>
|
||||
<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>
|
||||
{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 || "–"}
|
||||
{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>}
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
{lots.length === 0 && <tr><td colSpan={4} className="empty">Keine Chargen im Bestand.</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="muted small">Mengen sind in der Basiseinheit ({baseShort}) angegeben.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,17 +84,22 @@ export default function Products() {
|
||||
)}
|
||||
</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">
|
||||
{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>}
|
||||
</td>
|
||||
<td className="num">
|
||||
{p.package_size
|
||||
? `${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 className="num muted">{p.min_stock != null ? fmt(p.min_stock) : "–"}</td>
|
||||
<td className="num"><Link to={`/products/${p.id}`}>Details</Link></td>
|
||||
</tr>
|
||||
);
|
||||
|
||||
@@ -46,7 +46,8 @@ export default function ShoppingList() {
|
||||
<span className="item-name">{it.name}</span>
|
||||
</label>
|
||||
<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>
|
||||
</li>
|
||||
);
|
||||
|
||||
107
web/src/pages/Units.jsx
Normal file
107
web/src/pages/Units.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -69,3 +69,23 @@ export function amountText(qty, packageSize, 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)}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user