Gegenstands-Verwaltung (Non-Food) neben Lebensmitteln

Die Kategorie bestimmt die Verwaltungsart (food/object). Gegenstaende:
Menge je Lagerort statt Chargen/MHD, Umlagern (ohne Grund) und Entfernen
mit Pflicht-Grund samt Entnahme-Statistik. Beliebig viele eigene Felder
je Kategorie (vererbt an Unterkategorien), "gekauft bei" ueber eine
verwaltbare Shop-Liste und ein Produktlink. Barcode-Lookup zusaetzlich
ueber Open Products Facts.

Der Lebensmittel-Teil (Chargen/MHD/FEFO) bleibt unveraendert. Umgesetzt in
Backend (FastAPI, +Tests gruen), Web (React) und iOS (SwiftUI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-07-25 15:33:56 +02:00
parent 580afcc133
commit 5b952524d7
29 changed files with 3116 additions and 115 deletions

View File

@@ -18,8 +18,10 @@ class Settings(BaseSettings):
admin_username: str = "admin" admin_username: str = "admin"
admin_password: str = "changeme" admin_password: str = "changeme"
# Open Food Facts # Open Food Facts (Lebensmittel)
off_base_url: str = "https://world.openfoodfacts.org" off_base_url: str = "https://world.openfoodfacts.org"
# Open Products Facts (allgemeine Produkte / Gegenstände) gleiche API-Struktur.
opf_base_url: str = "https://world.openproductsfacts.org"
off_timeout_seconds: float = 8.0 off_timeout_seconds: float = 8.0
# Behaviour # Behaviour

View File

@@ -7,12 +7,25 @@ from datetime import date
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .models import Barcode, Lot, Product from .models import Barcode, Category, CategoryTracking, Lot, Product
from .schemas import BarcodeOut, ProductOut from .schemas import BarcodeOut, ProductOut
from .services.conversion import KIND_OF_BASE, display_unit_info from .services.conversion import KIND_OF_BASE, display_unit_info
from .services.stock import current_stock from .services.stock import current_stock
def product_tracking(db: Session, product: Product) -> str:
"""Verwaltungsart eines Artikels: aus seiner Kategorie abgeleitet.
Ohne Kategorie gilt "food" so bleibt das Verhalten bestehender
(reiner Lebensmittel-)Installationen unverändert. Ein Artikel in einer
Gegenstands-Kategorie wird als "object" geführt.
"""
if product.category_id is None:
return CategoryTracking.food.value
cat = product.category or db.get(Category, product.category_id)
return cat.tracking if cat and cat.tracking else CategoryTracking.food.value
def product_to_out(db: Session, product: Product) -> ProductOut: def product_to_out(db: Session, product: Product) -> ProductOut:
out = ProductOut.model_validate(product) out = ProductOut.model_validate(product)
out.stock = current_stock(db, product.id) out.stock = current_stock(db, product.id)
@@ -31,6 +44,8 @@ def product_to_out(db: Session, product: Product) -> ProductOut:
for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all() for b in db.query(Barcode).filter(Barcode.product_id == product.id).order_by(Barcode.id).all()
] ]
out.category_name = product.category.name if product.category else None out.category_name = product.category.name if product.category else None
out.tracking = CategoryTracking(product_tracking(db, product))
out.shop_name = product.shop.name if product.shop else None
out.kind = KIND_OF_BASE[product.base_unit].value out.kind = KIND_OF_BASE[product.base_unit].value
name, factor = display_unit_info(product) name, factor = display_unit_info(product)
out.unit_name = name out.unit_name = name

View File

@@ -12,12 +12,14 @@ from .routers import (
branding, branding,
categories, categories,
dashboard, dashboard,
field_definitions,
groups, groups,
locations, locations,
maintenance, maintenance,
package_types, package_types,
products, products,
settings as settings_router, settings as settings_router,
shops,
stock, stock,
transfer, transfer,
units, units,
@@ -28,6 +30,7 @@ from .seed import (
ensure_builtin_categories, ensure_builtin_categories,
ensure_builtin_package_types, ensure_builtin_package_types,
ensure_builtin_units, ensure_builtin_units,
ensure_example_object_categories,
ensure_first_admin, ensure_first_admin,
) )
from .services.group_codes import backfill as backfill_group_codes from .services.group_codes import backfill as backfill_group_codes
@@ -66,6 +69,18 @@ def _ensure_schema() -> None:
"NOT NULL DEFAULT 'Übersicht'", "NOT NULL DEFAULT 'Übersicht'",
"ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER " "ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER "
"NOT NULL DEFAULT 0", "NOT NULL DEFAULT 0",
# Gegenstands-Verwaltung: Verwaltungsart je Kategorie. Bestehende
# (reine Lebensmittel-)Kategorien werden dabei auf "food" gesetzt.
"ALTER TABLE categories ADD COLUMN IF NOT EXISTS tracking VARCHAR(16) "
"NOT NULL DEFAULT 'food'",
# Bezugsquelle und Onlineshop-Link am Artikel (nur für Gegenstände genutzt).
"ALTER TABLE products ADD COLUMN IF NOT EXISTS shop_id INTEGER "
"REFERENCES shops(id) ON DELETE SET NULL",
"ALTER TABLE products ADD COLUMN IF NOT EXISTS product_url VARCHAR(1024)",
# Bewegungen: Lagerort (Gegenstands-Buchungen) und Entnahmegrund.
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS location_id INTEGER "
"REFERENCES locations(id) ON DELETE SET NULL",
"ALTER TABLE movements ADD COLUMN IF NOT EXISTS reason VARCHAR(16)",
] ]
with engine.begin() as conn: with engine.begin() as conn:
for stmt in stmts: for stmt in stmts:
@@ -93,6 +108,7 @@ async def lifespan(app: FastAPI):
ensure_builtin_units(db) ensure_builtin_units(db)
ensure_builtin_package_types(db) ensure_builtin_package_types(db)
ensure_builtin_categories(db) ensure_builtin_categories(db)
ensure_example_object_categories(db)
ensure_first_admin(db) ensure_first_admin(db)
# Codes bestehender Gruppen-Zuordnungen nachziehen. # Codes bestehender Gruppen-Zuordnungen nachziehen.
backfill_group_codes(db) backfill_group_codes(db)
@@ -136,3 +152,5 @@ app.include_router(branding.router)
app.include_router(categories.router) app.include_router(categories.router)
app.include_router(maintenance.router) app.include_router(maintenance.router)
app.include_router(dashboard.router) app.include_router(dashboard.router)
app.include_router(shops.router)
app.include_router(field_definitions.router)

View File

@@ -60,6 +60,39 @@ class UnitKind(str, enum.Enum):
volume = "volume" # Basis: Milliliter volume = "volume" # Basis: Milliliter
class CategoryTracking(str, enum.Enum):
"""Wie Artikel einer Kategorie verwaltet werden.
food = Lebensmittel: Chargen mit MHD und FEFO (bestehende Logik, unverändert).
object = Gegenstand: nur Menge pro Lagerort, kein MHD, keine Chargen.
Bewusst als kurzer String gespeichert (wie DatePrecision), damit sich die
Spalte per ADD COLUMN nachziehen lässt, ohne einen Postgres-Enumtyp anzulegen.
"""
food = "food"
object = "object"
class FieldType(str, enum.Enum):
"""Art eines selbst definierten Feldes an einer Kategorie."""
text = "text" # einzeilig
textarea = "textarea" # mehrzeilig
number = "number" # Zahl, optional mit Einheit
date = "date" # Datum (z.B. Kaufdatum)
select = "select" # Auswahlliste (options = JSON-Liste)
boolean = "boolean" # Ja/Nein
class RemovalReason(str, enum.Enum):
"""Grund einer Gegenstands-Entnahme aus dem Bestand."""
lost = "lost" # verloren
broken = "broken" # kaputt
given_away = "given_away" # verschenkt
sold = "sold" # verkauft
used_up = "used_up" # aufgebraucht
other = "other" # sonstiges
class Unit(Base): class Unit(Base):
"""Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis. """Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis.
@@ -130,6 +163,15 @@ class Category(Base):
ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ForeignKey("categories.id", ondelete="SET NULL"), nullable=True
) )
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
# Verwaltungsart der Artikel dieser Kategorie: "food" (Chargen+MHD) oder
# "object" (Menge je Lagerort). Neue Kategorien sind Gegenstände; bestehende
# (reine Lebensmittel-Installationen) werden bei der Migration auf "food"
# gesetzt. Als kurzer String wie date_precision, damit per ADD COLUMN nachziehbar.
tracking: Mapped[str] = mapped_column(
String(16), nullable=False,
default=CategoryTracking.object.value,
server_default=CategoryTracking.food.value,
)
class Product(Base): class Product(Base):
@@ -181,15 +223,26 @@ class Product(Base):
source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual") source: Mapped[str] = mapped_column(String(16), nullable=False, default="manual")
off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF off_raw: Mapped[str | None] = mapped_column(Text, nullable=True) # JSON blob from OFF
# Nur für Gegenstände: Bezugsquelle ("gekauft bei") und Link zum Onlineshop.
shop_id: Mapped[int | None] = mapped_column(
ForeignKey("shops.id", ondelete="SET NULL"), nullable=True
)
product_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
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")
category: Mapped[Category | None] = relationship() category: Mapped[Category | None] = relationship()
shop: Mapped[Shop | None] = relationship()
display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id]) display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id])
min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id]) min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id])
lots: Mapped[list[Lot]] = relationship( lots: Mapped[list[Lot]] = relationship(
back_populates="product", cascade="all, delete-orphan" back_populates="product", cascade="all, delete-orphan"
) )
field_values: Mapped[list[ProductFieldValue]] = relationship(
back_populates="product", cascade="all, delete-orphan"
)
class ApiToken(Base): class ApiToken(Base):
@@ -274,6 +327,14 @@ class Movement(Base):
quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units
unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered
note: Mapped[str | None] = mapped_column(String(255), nullable=True) note: Mapped[str | None] = mapped_column(String(255), nullable=True)
# Bei Gegenstands-Buchungen der betroffene Lagerort (Lebensmittel führen den
# Ort an der Charge/Lot). Nullable, damit bestehende Bewegungen gültig bleiben.
location_id: Mapped[int | None] = mapped_column(
ForeignKey("locations.id", ondelete="SET NULL"), nullable=True
)
# Grund einer Entnahme (lost/broken/…), nur bei type=out aus dem Entfernen-Dialog.
# Als kurzer String, damit per ADD COLUMN nachziehbar (kein Postgres-Enumtyp).
reason: Mapped[str | None] = mapped_column(String(16), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
@@ -369,3 +430,71 @@ class Setting(Base):
key: Mapped[str] = mapped_column(String(64), primary_key=True) key: Mapped[str] = mapped_column(String(64), primary_key=True)
value: Mapped[str] = mapped_column(String(255), nullable=False) value: Mapped[str] = mapped_column(String(255), nullable=False)
class Shop(Base):
"""Bezugsquelle / Geschäft, aus dem ein Gegenstand stammt ("gekauft bei").
Vom Admin verwaltbare Liste (wie Lagerorte). Am Artikel optional "unbekannt"
oder "mehrere Herkünfte" bleibt einfach leer.
"""
__tablename__ = "shops"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(120), unique=True, nullable=False)
website: Mapped[str | None] = mapped_column(String(1024), nullable=True)
class FieldDefinition(Base):
"""Selbst definiertes Feld an einer Kategorie (z.B. „Kapazität“ in mAh).
Gilt für Artikel dieser Kategorie und über die Vererbung im Kategorie-Baum
auch für deren Unterkategorien. Der konkrete Wert je Artikel steht in
:class:`ProductFieldValue`.
"""
__tablename__ = "field_definitions"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
category_id: Mapped[int] = mapped_column(
ForeignKey("categories.id", ondelete="CASCADE"), nullable=False
)
label: Mapped[str] = mapped_column(String(120), nullable=False)
# Maschinenlesbarer Schlüssel (aus dem Label abgeleitet) für Export/Anzeige.
key: Mapped[str] = mapped_column(String(120), nullable=False)
field_type: Mapped[str] = mapped_column(
String(16), nullable=False, default=FieldType.text.value
)
# Einheit für Zahlenfelder (z.B. "mAh", "g", "W").
unit: Mapped[str | None] = mapped_column(String(32), nullable=True)
# JSON-Liste der Auswahlmöglichkeiten für field_type == "select".
options: Mapped[str | None] = mapped_column(Text, nullable=True)
required: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
position: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now)
class ProductFieldValue(Base):
"""Wert eines selbst definierten Feldes für einen konkreten Artikel."""
__tablename__ = "product_field_values"
__table_args__ = (
UniqueConstraint(
"product_id", "field_definition_id", name="uq_pfv_product_field"
),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
product_id: Mapped[int] = mapped_column(
ForeignKey("products.id", ondelete="CASCADE"), nullable=False
)
field_definition_id: Mapped[int] = mapped_column(
ForeignKey("field_definitions.id", ondelete="CASCADE"), nullable=False
)
# Immer als Text gespeichert; typgerecht interpretiert wird beim Lesen/Schreiben.
value: Mapped[str | None] = mapped_column(Text, nullable=True)
product: Mapped[Product] = relationship(back_populates="field_values")
field_definition: Mapped[FieldDefinition] = relationship()

View File

@@ -42,15 +42,16 @@ def parse_quantity(quantity: str | None) -> tuple[str, float | None]:
return base_unit, (package_size if package_size > 0 else None) return base_unit, (package_size if package_size > 0 else None)
def lookup_barcode(barcode: str) -> dict | None: def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None:
"""Fragt Open Food Facts nach einem Barcode. """Fragt eine Open-Facts-Instanz (OFF oder Open Products Facts) nach einem Barcode.
Gibt ein vorbefülltes Produkt-Dict zurück oder None, wenn nicht gefunden. Beide Dienste teilen sich dieselbe API-Struktur; es unterscheidet sich nur die
Basis-URL. Gibt ein vorbefülltes Produkt-Dict zurück oder None.
""" """
# v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0 # v0-API: liefert bei Treffer status=1 + product, bei Nicht-Treffer status=0
# (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404, # (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404,
# weshalb wir bewusst v0 nutzen. # weshalb wir bewusst v0 nutzen.
url = f"{settings.off_base_url}/api/v0/product/{barcode}.json" url = f"{base_url}/api/v0/product/{barcode}.json"
try: try:
resp = httpx.get( resp = httpx.get(
url, url,
@@ -96,6 +97,26 @@ def lookup_barcode(barcode: str) -> dict | None:
"quantity_text": product.get("quantity"), "quantity_text": product.get("quantity"),
"category_suggestion": categories.split(",")[0].strip() if categories else None, "category_suggestion": categories.split(",")[0].strip() if categories else None,
"category_tags": category_tags, "category_tags": category_tags,
"source": "off", "source": source,
"off_raw": json.dumps(product)[:20000], "off_raw": json.dumps(product)[:20000],
} }
def lookup_barcode(barcode: str, prefer: str | None = None) -> dict | None:
"""Sucht einen Barcode in den offenen Datenbanken.
Beim Scannen steht die Kategorie noch nicht fest, deshalb werden beide Quellen
der Reihe nach befragt: Lebensmittel (Open Food Facts) und allgemeine Produkte
(Open Products Facts). ``prefer="object"`` stellt die allgemeine Produkt-DB nach
vorn (z.B. beim erneuten Abgleich eines Gegenstands), sonst gewinnt Essen.
"""
off = (settings.off_base_url, "off")
opf = (settings.opf_base_url, "opf")
reihenfolge = [opf, off] if prefer == "object" else [off, opf]
for base_url, source in reihenfolge:
if not base_url:
continue
treffer = _lookup_at(barcode, base_url, source)
if treffer is not None:
return treffer
return None

View File

@@ -10,8 +10,17 @@ from sqlalchemy.orm import Session
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Category, Product, User from ..models import (
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate Category,
CategoryTracking,
FieldDefinition,
Product,
ProductFieldValue,
User,
)
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
from ..services.fields import effective_field_definitions
from .field_definitions import to_out as field_to_out
router = APIRouter(prefix="/categories", tags=["categories"]) router = APIRouter(prefix="/categories", tags=["categories"])
@@ -61,9 +70,19 @@ def create_category(
db: Session = Depends(get_db), db: Session = Depends(get_db),
_: User = Depends(require_admin), _: User = Depends(require_admin),
) -> CategoryOut: ) -> CategoryOut:
if payload.parent_id is not None and db.get(Category, payload.parent_id) is None: parent = None
if payload.parent_id is not None:
parent = db.get(Category, payload.parent_id)
if parent is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden")
category = Category(name=payload.name, parent_id=payload.parent_id) # Modus: ausdrücklich gewählt > vom Elternteil geerbt > neue Oberkategorie = Gegenstand.
if payload.tracking is not None:
tracking = payload.tracking.value
elif parent is not None:
tracking = parent.tracking
else:
tracking = CategoryTracking.object.value
category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking)
db.add(category) db.add(category)
db.commit() db.commit()
db.refresh(category) db.refresh(category)
@@ -95,6 +114,9 @@ def update_category(
"Unterkategorien untergeordnet werden.", "Unterkategorien untergeordnet werden.",
) )
# Modus als kurzen String ablegen (Spalte ist String, nicht Enum-Typ).
if "tracking" in data and data["tracking"] is not None:
data["tracking"] = CategoryTracking(data["tracking"]).value
for field, value in data.items(): for field, value in data.items():
setattr(category, field, value) setattr(category, field, value)
db.commit() db.commit()
@@ -120,6 +142,35 @@ def delete_category(
db.query(Category).filter(Category.parent_id == category_id).update( db.query(Category).filter(Category.parent_id == category_id).update(
{Category.parent_id: None} {Category.parent_id: None}
) )
# Eigene Felder dieser Kategorie samt aller erfassten Werte entfernen.
feld_ids = [
fid
for (fid,) in db.query(FieldDefinition.id)
.filter(FieldDefinition.category_id == category_id)
.all()
]
if feld_ids:
db.query(ProductFieldValue).filter(
ProductFieldValue.field_definition_id.in_(feld_ids)
).delete(synchronize_session=False)
db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id
).delete(synchronize_session=False)
db.delete(category) db.delete(category)
db.commit() db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT) return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/{category_id}/fields", response_model=list[FieldDefinitionOut])
def category_fields(
category_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[FieldDefinitionOut]:
"""Alle für eine Kategorie geltenden Felder inkl. der von Oberkategorien geerbten."""
if db.get(Category, category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
return [
field_to_out(fd, inherited=inherited)
for fd, inherited in effective_field_definitions(db, category_id)
]

View File

@@ -0,0 +1,143 @@
"""Verwaltung der selbst definierten Felder je Kategorie."""
import json
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from ..database import get_db
from ..deps import get_current_user, require_admin
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue, User
from ..schemas import FieldDefinitionCreate, FieldDefinitionOut, FieldDefinitionUpdate
from ..services.fields import options_list, slugify
router = APIRouter(prefix="/field-definitions", tags=["field-definitions"])
def to_out(fd: FieldDefinition, inherited: bool = False) -> FieldDefinitionOut:
return FieldDefinitionOut(
id=fd.id,
category_id=fd.category_id,
label=fd.label,
key=fd.key,
field_type=FieldType(fd.field_type),
unit=fd.unit,
options=options_list(fd),
required=fd.required,
position=fd.position,
is_builtin=fd.is_builtin,
inherited=inherited,
)
def _unique_key(db: Session, category_id: int, label: str, exclude_id: int | None = None) -> str:
"""Eindeutigen Schlüssel je Kategorie erzeugen (kapazitaet, kapazitaet_2, …)."""
basis = slugify(label)
kandidat = basis
n = 1
while True:
query = db.query(FieldDefinition).filter(
FieldDefinition.category_id == category_id, FieldDefinition.key == kandidat
)
if exclude_id is not None:
query = query.filter(FieldDefinition.id != exclude_id)
if query.first() is None:
return kandidat
n += 1
kandidat = f"{basis}_{n}"
def _options_json(field_type: FieldType, options: list[str] | None) -> str | None:
if field_type != FieldType.select:
return None
return json.dumps([o.strip() for o in (options or []) if o.strip()])
@router.get("", response_model=list[FieldDefinitionOut])
def list_field_definitions(
category_id: int | None = None,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> list[FieldDefinitionOut]:
"""Felder auflisten ohne category_id alle, sonst nur die dieser Kategorie."""
query = db.query(FieldDefinition)
if category_id is not None:
query = query.filter(FieldDefinition.category_id == category_id)
rows = query.order_by(
FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id
).all()
return [to_out(fd) for fd in rows]
@router.post("", response_model=FieldDefinitionOut, status_code=status.HTTP_201_CREATED)
def create_field_definition(
payload: FieldDefinitionCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> FieldDefinitionOut:
if db.get(Category, payload.category_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
fd = FieldDefinition(
category_id=payload.category_id,
label=payload.label.strip(),
key=_unique_key(db, payload.category_id, payload.label),
field_type=payload.field_type.value,
unit=(payload.unit or None),
options=_options_json(payload.field_type, payload.options),
required=bool(payload.required),
position=payload.position,
)
db.add(fd)
db.commit()
db.refresh(fd)
return to_out(fd)
@router.patch("/{field_id}", response_model=FieldDefinitionOut)
def update_field_definition(
field_id: int,
payload: FieldDefinitionUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> FieldDefinitionOut:
fd = db.get(FieldDefinition, field_id)
if fd is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "label" in data and data["label"]:
fd.label = data["label"].strip()
fd.key = _unique_key(db, fd.category_id, fd.label, exclude_id=fd.id)
if "field_type" in data and data["field_type"] is not None:
fd.field_type = data["field_type"].value
if "unit" in data:
fd.unit = data["unit"] or None
if "required" in data and data["required"] is not None:
fd.required = bool(data["required"])
if "position" in data and data["position"] is not None:
fd.position = data["position"]
# Optionen immer passend zum (ggf. neuen) Typ ablegen.
if "options" in data or "field_type" in data:
neuer_typ = FieldType(fd.field_type)
optionen = data["options"] if "options" in data else options_list(fd)
fd.options = _options_json(neuer_typ, optionen)
db.commit()
db.refresh(fd)
return to_out(fd)
@router.delete("/{field_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_field_definition(
field_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Löscht das Feld samt aller dazu erfassten Werte (ON DELETE CASCADE)."""
fd = db.get(FieldDefinition, field_id)
if fd is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Feld nicht gefunden")
db.query(ProductFieldValue).filter(
ProductFieldValue.field_definition_id == field_id
).delete()
db.delete(fd)
db.commit()

View File

@@ -2,17 +2,41 @@ from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.responses import Response from fastapi.responses import Response
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..crud import product_to_out from ..crud import product_to_out, product_tracking
from ..database import get_db from ..database import get_db
from ..deps import get_current_user, require_admin from ..deps import get_current_user, require_admin
from ..models import Barcode, BaseUnit, Category, Group, Product, ProductImage, User from ..models import (
Barcode,
BaseUnit,
Category,
Group,
Location,
Movement,
MovementType,
Product,
ProductImage,
RemovalReason,
Shop,
User,
)
from ..off import lookup_barcode from ..off import lookup_barcode
from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate from ..schemas import (
BarcodeCreate,
LookupResult,
ProductCreate,
ProductOut,
ProductUpdate,
RemovalHistoryItem,
RemovalStat,
RemovalSummary,
)
from ..services import images from ..services import images
from ..services.categories import suggest_category from ..services.categories import suggest_category
from .categories import descendant_ids from .categories import descendant_ids
from ..services.conversion import ConversionError, resolve_product_unit from ..services.conversion import ConversionError, resolve_product_unit
from ..services.fields import FieldError, apply_field_values
from ..services.group_codes import detach as detach_group_code, sync as sync_group_code from ..services.group_codes import detach as detach_group_code, sync as sync_group_code
from ..services.stock import removal_stats
router = APIRouter(prefix="/products", tags=["products"]) router = APIRouter(prefix="/products", tags=["products"])
@@ -92,6 +116,56 @@ def get_product(
return product_to_out(db, product) return product_to_out(db, product)
@router.get("/{product_id}/removals", response_model=RemovalSummary)
def product_removals(
product_id: int,
db: Session = Depends(get_db),
_: User = Depends(get_current_user),
) -> RemovalSummary:
"""Entnahmen mit Grund: Summe je Grund plus die jüngsten Einträge."""
product = db.get(Product, product_id)
if product is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
stats = [
RemovalStat(reason=RemovalReason(grund), quantity=v["quantity"], count=v["count"])
for grund, v in removal_stats(db, product_id).items()
]
stats.sort(key=lambda s: s.quantity, reverse=True)
rows = (
db.query(Movement)
.filter(
Movement.product_id == product_id,
Movement.type == MovementType.out,
Movement.reason.isnot(None),
)
.order_by(Movement.created_at.desc())
.limit(50)
.all()
)
loc_names = {loc.id: loc.name for loc in db.query(Location).all()}
user_ids = {m.user_id for m in rows if m.user_id}
users = (
{u.id: u.username for u in db.query(User).filter(User.id.in_(user_ids)).all()}
if user_ids
else {}
)
history = [
RemovalHistoryItem(
reason=RemovalReason(m.reason),
quantity=m.quantity,
location_id=m.location_id,
location_name=loc_names.get(m.location_id),
note=m.note,
username=users.get(m.user_id),
created_at=m.created_at,
)
for m in rows
]
return RemovalSummary(stats=stats, history=history)
@router.get("/{product_id}/off", response_model=LookupResult) @router.get("/{product_id}/off", response_model=LookupResult)
def off_vergleich( def off_vergleich(
product_id: int, product_id: int,
@@ -123,8 +197,10 @@ def off_vergleich(
"Dieser Artikel hat keinen Barcode ohne den kann Open Food Facts nichts finden.", "Dieser Artikel hat keinen Barcode ohne den kann Open Food Facts nichts finden.",
) )
# Gegenstände zuerst in der allgemeinen Produkt-DB nachschlagen, Lebensmittel in OFF.
prefer = "object" if product_tracking(db, product) == "object" else None
for code in codes: for code in codes:
suggestion = lookup_barcode(code) suggestion = lookup_barcode(code, prefer=prefer)
if suggestion: if suggestion:
category = suggest_category(db, suggestion) category = suggest_category(db, suggestion)
return LookupResult( return LookupResult(
@@ -183,6 +259,8 @@ def create_product(
base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id) base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id)
except ConversionError 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
if payload.shop_id is not None and db.get(Shop, payload.shop_id) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
product = Product( product = Product(
barcode=payload.barcode or None, barcode=payload.barcode or None,
name=payload.name, name=payload.name,
@@ -198,11 +276,17 @@ def create_product(
min_stock=payload.min_stock, min_stock=payload.min_stock,
min_stock_unit_id=payload.min_stock_unit_id, min_stock_unit_id=payload.min_stock_unit_id,
min_stock_in_packages=bool(payload.min_stock_in_packages), min_stock_in_packages=bool(payload.min_stock_in_packages),
shop_id=payload.shop_id,
product_url=payload.product_url or None,
source="manual", source="manual",
) )
db.add(product) db.add(product)
db.flush() # product.id fuer den Gruppen-Code db.flush() # product.id fuer den Gruppen-Code
sync_group_code(db, product) sync_group_code(db, product)
try:
apply_field_values(db, product, payload.field_values)
except FieldError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
if product.image_url: if product.image_url:
# Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler: # Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler:
# Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern. # Ein fehlendes Bild darf das Anlegen eines Artikels nicht verhindern.
@@ -224,6 +308,8 @@ def update_product(
raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden")
data = payload.model_dump(exclude_unset=True) data = payload.model_dump(exclude_unset=True)
# Feldwerte sind keine Spalte, sondern eigene Zeilen getrennt behandeln.
field_values = data.pop("field_values", None)
if "barcode" in data and data["barcode"]: if "barcode" in data and data["barcode"]:
clash = ( clash = (
db.query(Product) db.query(Product)
@@ -234,6 +320,8 @@ 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"
) )
if data.get("shop_id") is not None and db.get(Shop, data["shop_id"]) is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
# Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit. # Einheit: unit_id (falls gesetzt) bestimmt base_unit + Anzeigeeinheit.
if "unit_id" in data: if "unit_id" in data:
unit_id = data.pop("unit_id") unit_id = data.pop("unit_id")
@@ -253,6 +341,11 @@ def update_product(
data["date_precision"] = data["date_precision"].value data["date_precision"] = data["date_precision"].value
for field, value in data.items(): for field, value in data.items():
setattr(product, field, value) setattr(product, field, value)
if field_values is not None:
try:
apply_field_values(db, product, field_values)
except FieldError as exc:
raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc
# Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen. # Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen.
sync_group_code(db, product) sync_group_code(db, product)
if "image_url" in data: if "image_url" in data:

View File

@@ -0,0 +1,88 @@
"""Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ (nur Gegenstände)."""
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 Product, Shop, User
from ..schemas import ShopCreate, ShopOut, ShopUpdate
router = APIRouter(prefix="/shops", tags=["shops"])
def _to_out(db: Session, shop: Shop) -> ShopOut:
out = ShopOut.model_validate(shop)
out.product_count = db.query(Product).filter(Product.shop_id == shop.id).count()
return out
@router.get("", response_model=list[ShopOut])
def list_shops(
db: Session = Depends(get_db), _: User = Depends(get_current_user)
) -> list[ShopOut]:
return [_to_out(db, s) for s in db.query(Shop).order_by(Shop.name).all()]
@router.post("", response_model=ShopOut, status_code=status.HTTP_201_CREATED)
def create_shop(
payload: ShopCreate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ShopOut:
name = payload.name.strip()
if db.query(Shop).filter(func.lower(Shop.name) == name.lower()).first():
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
shop = Shop(name=name, website=(payload.website or None))
db.add(shop)
db.commit()
db.refresh(shop)
return _to_out(db, shop)
@router.patch("/{shop_id}", response_model=ShopOut)
def update_shop(
shop_id: int,
payload: ShopUpdate,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> ShopOut:
shop = db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
data = payload.model_dump(exclude_unset=True)
if "name" in data and data["name"]:
name = data["name"].strip()
doppelt = (
db.query(Shop)
.filter(func.lower(Shop.name) == name.lower(), Shop.id != shop_id)
.first()
)
if doppelt is not None:
raise HTTPException(status.HTTP_409_CONFLICT, "Diesen Shop gibt es bereits")
shop.name = name
if "website" in data:
shop.website = data["website"] or None
db.commit()
db.refresh(shop)
return _to_out(db, shop)
@router.delete("/{shop_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shop(
shop_id: int,
db: Session = Depends(get_db),
_: User = Depends(require_admin),
) -> None:
"""Artikel bleiben bestehen, sie verlieren nur die Bezugsquelle (SET NULL)."""
shop = db.get(Shop, shop_id)
if shop is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "Shop nicht gefunden")
# SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich.
db.query(Product).filter(Product.shop_id == shop_id).update(
{Product.shop_id: None}
)
db.delete(shop)
db.commit()

View File

@@ -1,10 +1,10 @@
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..crud import resolve_product from ..crud import product_tracking, 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, Movement, MovementType, User from ..models import CategoryTracking, Lot, Movement, MovementType, User
from ..schemas import ( from ..schemas import (
BatchCheckInRequest, BatchCheckInRequest,
BatchCheckInResponse, BatchCheckInResponse,
@@ -14,6 +14,9 @@ from ..schemas import (
CheckOutResponse, CheckOutResponse,
LotOut, LotOut,
LotUpdate, LotUpdate,
RelocateRequest,
RemoveRequest,
StockActionResponse,
) )
from ..services.conversion import ConversionError from ..services.conversion import ConversionError
from ..services.dates import clean_precision, normalize_best_before from ..services.dates import clean_precision, normalize_best_before
@@ -23,10 +26,15 @@ from ..services.stock import (
check_out, check_out,
check_out_lot, check_out_lot,
current_stock, current_stock,
object_add,
object_relocate,
object_remove,
) )
router = APIRouter(tags=["stock"]) router = APIRouter(tags=["stock"])
OBJECT = CategoryTracking.object.value
@router.post("/stock/checkin", response_model=CheckInResponse) @router.post("/stock/checkin", response_model=CheckInResponse)
def stock_checkin( def stock_checkin(
@@ -35,6 +43,21 @@ def stock_checkin(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> CheckInResponse: ) -> CheckInResponse:
product = resolve_product(db, payload.product_id, payload.barcode) product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
# Gegenstände: Menge am Lagerort erhöhen, kein MHD, keine Charge-Auswahl.
lot = object_add(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
user=user,
note=payload.note,
)
db.commit()
db.refresh(lot)
return CheckInResponse(
lot=LotOut.model_validate(lot), product_stock=current_stock(db, product.id)
)
try: try:
lot = check_in( lot = check_in(
db, db,
@@ -103,6 +126,12 @@ def stock_checkout(
user: User = Depends(get_current_user), user: User = Depends(get_current_user),
) -> CheckOutResponse: ) -> CheckOutResponse:
product = resolve_product(db, payload.product_id, payload.barcode) product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) == OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Gegenstände werden über „Entfernen“ (mit Grund) oder „Umlagern“ gebucht, "
"nicht ausgecheckt.",
)
try: try:
if payload.lot_id is not None: if payload.lot_id is not None:
lot = db.get(Lot, payload.lot_id) lot = db.get(Lot, payload.lot_id)
@@ -138,6 +167,63 @@ def stock_checkout(
) )
@router.post("/stock/relocate", response_model=StockActionResponse)
def stock_relocate(
payload: RelocateRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge von einem Lagerort zum anderen umbuchen (ohne Grund)."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, "Umlagern gibt es nur für Gegenstände."
)
try:
object_relocate(
db,
product=product,
quantity=payload.quantity,
from_location_id=payload.from_location_id,
to_location_id=payload.to_location_id,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.post("/stock/remove", response_model=StockActionResponse)
def stock_remove(
payload: RemoveRequest,
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> StockActionResponse:
"""Gegenstands-Menge mit Pflicht-Grund aus dem Bestand entfernen."""
product = resolve_product(db, payload.product_id, payload.barcode)
if product_tracking(db, product) != OBJECT:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
"Entfernen mit Grund gibt es nur für Gegenstände.",
)
try:
object_remove(
db,
product=product,
quantity=payload.quantity,
location_id=payload.location_id,
reason=payload.reason.value,
user=user,
note=payload.note,
)
except StockError as exc:
raise HTTPException(status.HTTP_409_CONFLICT, str(exc)) from exc
db.commit()
return StockActionResponse(product_stock=current_stock(db, product.id))
@router.get("/lots", response_model=list[LotOut]) @router.get("/lots", response_model=list[LotOut])
def list_lots( def list_lots(
product_id: int | None = None, product_id: int | None = None,

View File

@@ -22,18 +22,28 @@ from ..services.group_codes import sync as sync_group_code
from ..models import ( from ..models import (
BaseUnit, BaseUnit,
Category, Category,
CategoryTracking,
DatePrecision, DatePrecision,
FieldDefinition,
Group, Group,
Location, Location,
Lot, Lot,
Movement, Movement,
MovementType, MovementType,
Product, Product,
Shop,
Unit, Unit,
UnitKind, UnitKind,
User, User,
) )
from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit from ..services.conversion import BASE_OF_KIND, display_unit_info, find_unit
from ..services.fields import (
FieldError,
apply_field_values,
effective_field_definitions,
options_list,
slugify,
)
router = APIRouter(tags=["transfer"]) router = APIRouter(tags=["transfer"])
@@ -128,7 +138,7 @@ def export_backup_json(
loc_name = {loc.id: loc.name for loc in locations} loc_name = {loc.id: loc.name for loc in locations}
data = { data = {
"version": 1, "version": 2,
"exported_at": datetime.now(timezone.utc).isoformat(), "exported_at": datetime.now(timezone.utc).isoformat(),
"exported_at_local": datetime.now().isoformat(timespec="seconds"), "exported_at_local": datetime.now().isoformat(timespec="seconds"),
"units": [ "units": [
@@ -147,6 +157,32 @@ def export_backup_json(
{"name": loc.name, "parent": loc_name.get(loc.parent_id)} {"name": loc.name, "parent": loc_name.get(loc.parent_id)}
for loc in locations for loc in locations
], ],
# Kategorien mit Verwaltungsart, damit Lebensmittel/Gegenstände beim
# Wiederherstellen erhalten bleiben (auch leere Kategorien).
"categories": [
{"path": _category_path(db, c), "tracking": c.tracking}
for c in db.query(Category).order_by(Category.id).all()
],
# Bezugsquellen (nur für Gegenstände).
"shops": [
{"name": s.name, "website": s.website}
for s in db.query(Shop).order_by(Shop.id).all()
],
# Selbst definierte Felder je Kategorie.
"field_definitions": [
{
"category": _category_path(db, db.get(Category, fd.category_id)),
"label": fd.label,
"field_type": fd.field_type,
"unit": fd.unit,
"options": options_list(fd),
"required": fd.required,
"position": fd.position,
}
for fd in db.query(FieldDefinition)
.order_by(FieldDefinition.category_id, FieldDefinition.position, FieldDefinition.id)
.all()
],
"products": [], "products": [],
} }
@@ -168,6 +204,12 @@ def export_backup_json(
"min_stock": p.min_stock, "min_stock": p.min_stock,
"min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None, "min_stock_unit": p.min_stock_unit.name if p.min_stock_unit else None,
"min_stock_in_packages": bool(p.min_stock_in_packages), "min_stock_in_packages": bool(p.min_stock_in_packages),
# Gegenstands-Felder:
"shop": p.shop.name if p.shop else None,
"product_url": p.product_url,
"field_values": {
pfv.field_definition.label: pfv.value for pfv in p.field_values
},
"lots": [ "lots": [
{ {
"quantity": lot.quantity, "quantity": lot.quantity,
@@ -307,8 +349,16 @@ def _category_path(db: Session, category: Category | None) -> str:
return " > ".join(reversed(teile)) return " > ".join(reversed(teile))
def _get_or_create_category(db: Session, path: str | None) -> Category | None: def _get_or_create_category(
"""Legt den ganzen Pfad an, falls Teile davon fehlen.""" db: Session, path: str | None, tracking: str = CategoryTracking.food.value
) -> Category | None:
"""Legt den ganzen Pfad an, falls Teile davon fehlen.
Importierte Kategorien sind standardmäßig „food": Backups stammen aus der
Lebensmittel-Ausgabe, und so verhalten sich wiederhergestellte Artikel wie
zuvor. Der Modus aus einem neueren Backup (Liste ``categories``) überschreibt
das anschließend.
"""
path = (path or "").strip() path = (path or "").strip()
if not path: if not path:
return None return None
@@ -320,13 +370,29 @@ def _get_or_create_category(db: Session, path: str | None) -> Category | None:
) )
node = query.first() node = query.first()
if node is None: if node is None:
node = Category(name=name, parent_id=parent.id if parent else None) node = Category(
name=name,
parent_id=parent.id if parent else None,
tracking=tracking,
)
db.add(node) db.add(node)
db.flush() db.flush()
parent = node parent = node
return parent return parent
def _get_or_create_shop(db: Session, name: str | None, website: str | None = None) -> Shop | None:
name = (name or "").strip()
if not name:
return None
shop = db.query(Shop).filter(Shop.name == name).first()
if shop is None:
shop = Shop(name=name, website=(website or None))
db.add(shop)
db.flush()
return shop
def _get_or_create_group(db: Session, name: str | None) -> Group | None: def _get_or_create_group(db: Session, name: str | None) -> Group | None:
name = (name or "").strip() name = (name or "").strip()
if not name: if not name:
@@ -524,6 +590,42 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
if child and parent and child.parent_id is None and child.id != parent.id: if child and parent and child.parent_id is None and child.id != parent.id:
child.parent_id = parent.id child.parent_id = parent.id
# Kategorien samt Verwaltungsart (Backup v2). Ältere Backups ohne diese Liste
# legen ihre Kategorien weiter über die Produktpfade an (Standard: food).
for entry in data.get("categories", []):
cat = _get_or_create_category(
db,
entry.get("path"),
tracking=entry.get("tracking") or CategoryTracking.food.value,
)
if cat is not None and entry.get("tracking"):
cat.tracking = entry["tracking"]
for entry in data.get("shops", []):
_get_or_create_shop(db, entry.get("name"), entry.get("website"))
db.flush()
for entry in data.get("field_definitions", []):
cat = _get_or_create_category(db, entry.get("category"))
label = (entry.get("label") or "").strip()
if cat is None or not label:
continue
if db.query(FieldDefinition).filter_by(category_id=cat.id, label=label).first():
continue
ftype = entry.get("field_type") or "text"
optionen = entry.get("options") or []
db.add(
FieldDefinition(
category_id=cat.id,
label=label,
key=slugify(label),
field_type=ftype,
unit=(entry.get("unit") or None),
options=json.dumps(optionen) if ftype == "select" and optionen else None,
required=bool(entry.get("required")),
position=int(entry.get("position") or 0),
)
)
db.flush()
for entry in data.get("products", []): for entry in data.get("products", []):
try: try:
row = { row = {
@@ -543,6 +645,27 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict:
"mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "", "mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "",
} }
product = _get_or_create_product(db, row, created_products) product = _get_or_create_product(db, row, created_products)
# Gegenstands-Zusatzfelder nur ergänzend, nichts überschreiben.
shop_name = (entry.get("shop") or "").strip()
if shop_name and product.shop_id is None:
shop = _get_or_create_shop(db, shop_name)
product.shop_id = shop.id if shop else None
if entry.get("product_url") and not product.product_url:
product.product_url = entry["product_url"]
feldwerte = entry.get("field_values") or {}
if feldwerte and product.category_id:
nach_label = {
fd.label: fd
for fd, _ in effective_field_definitions(db, product.category_id)
}
for label, value in feldwerte.items():
fd = nach_label.get(label)
if fd is None:
continue
try:
apply_field_values(db, product, {fd.id: value})
except FieldError:
pass
if mode == "replace_listed" and product.id not in cleared: if mode == "replace_listed" and product.id not in cleared:
_clear_lots(db, product, user) _clear_lots(db, product, user)
cleared.add(product.id) cleared.add(product.id)

View File

@@ -2,9 +2,17 @@ from __future__ import annotations
from datetime import date, datetime from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field, field_validator
from .models import BaseUnit, DatePrecision, Role, UnitKind from .models import (
BaseUnit,
CategoryTracking,
DatePrecision,
FieldType,
RemovalReason,
Role,
UnitKind,
)
# ---- Units ---- # ---- Units ----
@@ -131,23 +139,86 @@ class GroupUpdate(BaseModel):
# ---- Categories ---- # ---- Categories ----
class CategoryOut(BaseModel): class CategoryOut(BaseModel):
"""Reine Ordnungshilfe kein Bestand, kein Mindestbestand, keine EAN-Codes.""" """Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand)."""
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
name: str name: str
parent_id: int | None = None parent_id: int | None = None
is_builtin: bool = False is_builtin: bool = False
# "food" = Chargen+MHD, "object" = Menge je Lagerort.
tracking: CategoryTracking = CategoryTracking.food
product_count: int = 0 product_count: int = 0
class CategoryCreate(BaseModel): class CategoryCreate(BaseModel):
name: str = Field(min_length=1, max_length=120) name: str = Field(min_length=1, max_length=120)
parent_id: int | None = None parent_id: int | None = None
# None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand.
tracking: CategoryTracking | None = None
class CategoryUpdate(BaseModel): class CategoryUpdate(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)
parent_id: int | None = None parent_id: int | None = None
tracking: CategoryTracking | None = None
# ---- Shops (Bezugsquellen, nur für Gegenstände) ----
class ShopOut(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
website: str | None = None
product_count: int = 0
class ShopCreate(BaseModel):
name: str = Field(min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
class ShopUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=120)
website: str | None = Field(default=None, max_length=1024)
# ---- Selbst definierte Felder je Kategorie ----
class FieldDefinitionBase(BaseModel):
label: str = Field(min_length=1, max_length=120)
field_type: FieldType = FieldType.text
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None # nur für field_type == "select"
required: bool = False
position: int = 0
class FieldDefinitionCreate(FieldDefinitionBase):
category_id: int
class FieldDefinitionUpdate(BaseModel):
label: str | None = Field(default=None, min_length=1, max_length=120)
field_type: FieldType | None = None
unit: str | None = Field(default=None, max_length=32)
options: list[str] | None = None
required: bool | None = None
position: int | None = None
class FieldDefinitionOut(BaseModel):
id: int
category_id: int
label: str
key: str
field_type: FieldType
unit: str | None = None
options: list[str] = []
required: bool = False
position: int = 0
is_builtin: bool = False
# Bei der vererbten Liste (GET /categories/{id}/fields): stammt das Feld von
# einer Oberkategorie? Dann in der Verwaltung dort bearbeiten.
inherited: bool = False
# ---- Locations ---- # ---- Locations ----
@@ -207,6 +278,11 @@ class ProductBase(BaseModel):
# Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde. # Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde.
min_stock_unit_id: int | None = None min_stock_unit_id: int | None = None
min_stock_in_packages: bool = False min_stock_in_packages: bool = False
# Nur für Gegenstände: Bezugsquelle und Onlineshop-Link.
shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024)
# Selbst definierte Feldwerte: {field_definition_id: Wert-als-Text}.
field_values: dict[int, str | None] | None = None
class ProductCreate(ProductBase): class ProductCreate(ProductBase):
@@ -228,6 +304,9 @@ class ProductUpdate(BaseModel):
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 min_stock_unit_id: int | None = None
min_stock_in_packages: bool | None = None min_stock_in_packages: bool | None = None
shop_id: int | None = None
product_url: str | None = Field(default=None, max_length=1024)
field_values: dict[int, str | None] | None = None
class ProductOut(BaseModel): class ProductOut(BaseModel):
@@ -249,6 +328,8 @@ class ProductOut(BaseModel):
min_stock_unit_id: int | None = None min_stock_unit_id: int | None = None
min_stock_in_packages: bool = False min_stock_in_packages: bool = False
source: str source: str
shop_id: int | None = None
product_url: str | None = None
created_at: datetime created_at: datetime
# angereichert: # angereichert:
stock: float = 0.0 stock: float = 0.0
@@ -261,6 +342,18 @@ class ProductOut(BaseModel):
min_stock_unit_label: str = "" min_stock_unit_label: str = ""
# Zusätzliche EAN-Codes (neben dem Haupt-Barcode): # Zusätzliche EAN-Codes (neben dem Haupt-Barcode):
barcodes: list[BarcodeOut] = [] barcodes: list[BarcodeOut] = []
# Verwaltungsart aus der Kategorie (food/object), Bezugsquelle und Feldwerte:
tracking: CategoryTracking = CategoryTracking.food
shop_name: str | None = None
field_values: dict[int, str | None] = {}
@field_validator("field_values", mode="before")
@classmethod
def _field_values_from_orm(cls, v):
"""Beim Lesen aus der DB kommt eine Liste ProductFieldValue zu Map machen."""
if isinstance(v, list):
return {pfv.field_definition_id: pfv.value for pfv in v}
return v
class LookupResult(BaseModel): class LookupResult(BaseModel):
@@ -358,6 +451,53 @@ class CheckOutResponse(BaseModel):
product_stock: float product_stock: float
# ---- Gegenstände: Umlagern und Entfernen mit Grund ----
class RelocateRequest(BaseModel):
"""Menge eines Gegenstands von einem Lagerort zu einem anderen umbuchen (ohne Grund)."""
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
from_location_id: int | None = None
to_location_id: int | None = None
note: str | None = None
class RemoveRequest(BaseModel):
"""Menge eines Gegenstands aus dem Bestand entfernen Grund ist Pflicht."""
product_id: int | None = None
barcode: str | None = None
quantity: float = Field(gt=0)
location_id: int | None = None
reason: RemovalReason
note: str | None = None
class StockActionResponse(BaseModel):
product_stock: float
class RemovalStat(BaseModel):
"""Summe der Entnahmen je Grund (für die kleine Statistik am Artikel)."""
reason: RemovalReason
quantity: float
count: int
class RemovalHistoryItem(BaseModel):
reason: RemovalReason
quantity: float
location_id: int | None = None
location_name: str | None = None
note: str | None = None
username: str | None = None
created_at: datetime
class RemovalSummary(BaseModel):
stats: list[RemovalStat] = []
history: list[RemovalHistoryItem] = []
# ---- Views ---- # ---- Views ----
class ShoppingItem(BaseModel): class ShoppingItem(BaseModel):
product_id: int product_id: int

View File

@@ -1,10 +1,13 @@
"""Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten.""" """Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten."""
import json
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import get_settings from .config import get_settings
from .models import Category, PackageType, Role, Unit, UnitKind, User from .models import Category, CategoryTracking, FieldDefinition, PackageType, Role, Unit, UnitKind, User
from .security import hash_password from .security import hash_password
from .services.fields import slugify
# (Name, Art, Faktor zur kanonischen Basiseinheit) # (Name, Art, Faktor zur kanonischen Basiseinheit)
BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [ BUILTIN_UNITS: list[tuple[str, UnitKind, float]] = [
@@ -87,12 +90,99 @@ def ensure_builtin_categories(db: Session) -> None:
""" """
if db.query(Category).first() is not None: if db.query(Category).first() is not None:
return return
food = CategoryTracking.food.value
for name, children in BUILTIN_CATEGORIES: for name, children in BUILTIN_CATEGORIES:
parent = Category(name=name, is_builtin=True) parent = Category(name=name, is_builtin=True, tracking=food)
db.add(parent) db.add(parent)
db.flush() db.flush()
for child in children: for child in children:
db.add(Category(name=child, parent_id=parent.id, is_builtin=True)) db.add(Category(name=child, parent_id=parent.id, is_builtin=True, tracking=food))
db.commit()
# Beispiel-Kategorien für Gegenstände (Non-Food) mit ein paar sinnvollen Feldern.
# Frei änderbar; wer sie löscht, bekommt sie nicht zurück (siehe Guard unten).
OBJECT_EXAMPLES: list[dict] = [
{
"name": "Kleidung",
"fields": [
{"label": "Größe", "type": "select",
"options": ["XS", "S", "M", "L", "XL", "XXL"]},
{"label": "Farbe", "type": "text"},
],
"children": [
{"name": "Schuhe",
"fields": [{"label": "Schuhgröße", "type": "number", "unit": "EU"}]},
],
},
{
"name": "Elektronik",
"fields": [
{"label": "Kaufdatum", "type": "date"},
{"label": "Garantie bis", "type": "date"},
],
"children": [
{"name": "Powerbank",
"fields": [{"label": "Kapazität", "type": "number", "unit": "mAh"}]},
{"name": "Kabel", "fields": [
{"label": "Länge", "type": "number", "unit": "cm"},
{"label": "Anschluss", "type": "text"},
]},
],
},
{"name": "Werkzeug", "fields": []},
{"name": "Bücher & Medien", "fields": []},
]
def _add_fields(db: Session, category_id: int, felder: list[dict]) -> None:
for pos, f in enumerate(felder):
optionen = f.get("options")
db.add(
FieldDefinition(
category_id=category_id,
label=f["label"],
key=slugify(f["label"]),
field_type=f.get("type", "text"),
unit=f.get("unit"),
options=json.dumps(optionen) if optionen else None,
required=bool(f.get("required")),
position=pos,
is_builtin=True,
)
)
def ensure_example_object_categories(db: Session) -> None:
"""Legt Beispiel-Gegenstandskategorien mit Feldern an nur, wenn es noch keine gibt.
So sieht man das Gegenstands-Feature gleich in Aktion; wer die Beispiele
bewusst entfernt, bekommt sie beim nächsten Start nicht zurück.
"""
if (
db.query(Category)
.filter(Category.tracking == CategoryTracking.object.value)
.first()
is not None
):
return
for top in OBJECT_EXAMPLES:
parent = Category(
name=top["name"], tracking=CategoryTracking.object.value, is_builtin=True
)
db.add(parent)
db.flush()
_add_fields(db, parent.id, top.get("fields", []))
for child in top.get("children", []):
node = Category(
name=child["name"],
parent_id=parent.id,
tracking=CategoryTracking.object.value,
is_builtin=True,
)
db.add(node)
db.flush()
_add_fields(db, node.id, child.get("fields", []))
db.commit() db.commit()

View File

@@ -0,0 +1,139 @@
"""Selbst definierte Felder je Kategorie: Vererbung, Validierung, Werte.
Felder hängen an einer Kategorie und gelten über den Kategorie-Baum auch für
deren Unterkategorien. Der Wert je Artikel steht in ProductFieldValue (immer als
Text; typgerecht geprüft wird hier).
"""
from __future__ import annotations
import json
import re
import unicodedata
from datetime import date
from sqlalchemy.orm import Session
from ..models import Category, FieldDefinition, FieldType, ProductFieldValue
class FieldError(ValueError):
"""Ungültige Feld-Definition oder ein Wert, der nicht zum Feldtyp passt."""
def slugify(label: str) -> str:
"""Maschinenlesbaren Schlüssel aus einem Label ableiten (z.B. „Kapazität“ → „kapazitaet“)."""
text = unicodedata.normalize("NFKD", label)
text = text.encode("ascii", "ignore").decode("ascii").lower()
text = re.sub(r"[^a-z0-9]+", "_", text).strip("_")
return text or "feld"
def options_list(fd: FieldDefinition) -> list[str]:
"""Auswahlmöglichkeiten eines select-Felds als Liste (leer, wenn keine)."""
if not fd.options:
return []
try:
data = json.loads(fd.options)
except (ValueError, TypeError):
return []
return [str(o) for o in data] if isinstance(data, list) else []
def _ancestry(db: Session, category_id: int) -> list[Category]:
"""Kategorien von der Wurzel bis zur angegebenen (einschließlich)."""
chain: list[Category] = []
seen: set[int] = set()
cid: int | None = category_id
while cid is not None and cid not in seen:
seen.add(cid)
cat = db.get(Category, cid)
if cat is None:
break
chain.append(cat)
cid = cat.parent_id
chain.reverse() # Wurzel zuerst, damit deren Felder oben stehen
return chain
def effective_field_definitions(
db: Session, category_id: int | None
) -> list[tuple[FieldDefinition, bool]]:
"""Alle für eine Kategorie geltenden Felder inkl. der geerbten.
Rückgabe: Liste aus (Felddefinition, geerbt?) geerbt=True, wenn das Feld
von einer Oberkategorie stammt. Reihenfolge: Oberkategorien zuerst.
"""
if category_id is None:
return []
result: list[tuple[FieldDefinition, bool]] = []
for cat in _ancestry(db, category_id):
defs = (
db.query(FieldDefinition)
.filter(FieldDefinition.category_id == cat.id)
.order_by(FieldDefinition.position, FieldDefinition.id)
.all()
)
for d in defs:
result.append((d, cat.id != category_id))
return result
def coerce_value(fd: FieldDefinition, raw) -> str | None:
"""Einen Rohwert typgerecht prüfen und als Text zurückgeben (oder None = leer)."""
if raw is None:
return None
text = str(raw).strip()
if text == "":
return None
if fd.field_type == FieldType.number.value:
try:
float(text.replace(",", "."))
except ValueError as exc:
raise FieldError(f"{fd.label}“ erwartet eine Zahl.") from exc
return text
if fd.field_type == FieldType.date.value:
try:
date.fromisoformat(text)
except ValueError as exc:
raise FieldError(f"{fd.label}“ erwartet ein Datum (JJJJ-MM-TT).") from exc
return text
if fd.field_type == FieldType.boolean.value:
return "true" if text.lower() in ("1", "true", "ja", "yes", "on") else "false"
if fd.field_type == FieldType.select.value:
opts = options_list(fd)
if opts and text not in opts:
raise FieldError(f"{text}“ ist keine gültige Auswahl für „{fd.label}“.")
return text
return text # text / textarea
def apply_field_values(
db: Session, product, values: dict[int, str | None] | None
) -> None:
"""Feldwerte eines Artikels setzen/ändern/löschen (nur die übergebenen Felder)."""
if not values:
return
existing = {
pfv.field_definition_id: pfv
for pfv in db.query(ProductFieldValue)
.filter(ProductFieldValue.product_id == product.id)
.all()
}
for fid, raw in values.items():
fd = db.get(FieldDefinition, fid)
if fd is None:
raise FieldError(f"Feld {fid} existiert nicht.")
val = coerce_value(fd, raw)
if fid in existing:
if val is None:
db.delete(existing[fid])
else:
existing[fid].value = val
elif val is not None:
db.add(
ProductFieldValue(
product_id=product.id, field_definition_id=fid, value=val
)
)

View File

@@ -16,6 +16,181 @@ class StockError(ValueError):
"""Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand).""" """Fachlicher Fehler beim Ein-/Auslagern (z.B. zu wenig Bestand)."""
# ---------------------------------------------------------------------------
# Gegenstände (Non-Food): Menge je Lagerort statt Chargen mit MHD.
#
# Technisch wird dieselbe Lot-Tabelle genutzt je (Produkt, Lagerort) genau
# eine Zeile mit ``best_before = NULL``. So laufen Bestands-Summe, Bewegungslog
# und Export unverändert weiter; nur MHD/FEFO entfällt. Die Lebensmittel-Logik
# oben (check_in/check_out) bleibt davon unberührt.
# ---------------------------------------------------------------------------
def _object_lot(db: Session, product_id: int, location_id: int | None) -> Lot | None:
"""Die (einzige) Bestandszeile eines Gegenstands an einem Lagerort."""
query = db.query(Lot).filter(
Lot.product_id == product_id, Lot.best_before.is_(None)
)
if location_id is None:
query = query.filter(Lot.location_id.is_(None))
else:
query = query.filter(Lot.location_id == location_id)
return query.first()
def object_add(
db: Session,
product: Product,
quantity: float,
location_id: int | None,
user: User | None,
note: str | None = None,
) -> Lot:
"""Erhöht die Menge eines Gegenstands an einem Lagerort."""
lot = _object_lot(db, product.id, location_id)
if lot is None:
lot = Lot(
product_id=product.id,
quantity=0.0,
best_before=None,
best_before_precision=DatePrecision.day.value,
location_id=location_id,
)
db.add(lot)
db.flush()
lot.quantity += quantity
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.in_,
quantity=quantity,
unit_used=product.base_unit.value,
note=note,
location_id=location_id,
)
)
return lot
def object_remove(
db: Session,
product: Product,
quantity: float,
location_id: int | None,
reason: str,
user: User | None,
note: str | None = None,
) -> None:
"""Entfernt eine Menge mit Grund (verloren/kaputt/…) aus dem Bestand."""
lot = _object_lot(db, product.id, location_id)
have = lot.quantity if lot else 0.0
if quantity > have + 1e-9:
raise StockError(
f"Am Lagerort sind nur {have:g} {product.base_unit.value} vorhanden "
f"(benötigt {quantity:g})."
)
lot.quantity -= quantity
db.add(
Movement(
product_id=product.id,
lot_id=lot.id,
user_id=user.id if user else None,
type=MovementType.out,
quantity=quantity,
unit_used=product.base_unit.value,
note=note,
location_id=location_id,
reason=reason,
)
)
if lot.quantity <= 1e-9:
db.delete(lot)
def object_relocate(
db: Session,
product: Product,
quantity: float,
from_location_id: int | None,
to_location_id: int | None,
user: User | None,
note: str | None = None,
) -> None:
"""Bucht eine Menge von einem Lagerort zum anderen um (ohne Grund)."""
if from_location_id == to_location_id:
raise StockError("Quell- und Ziel-Lagerort sind identisch.")
src = _object_lot(db, product.id, from_location_id)
have = src.quantity if src else 0.0
if quantity > have + 1e-9:
raise StockError(
f"Am Quell-Lagerort sind nur {have:g} {product.base_unit.value} "
f"vorhanden (benötigt {quantity:g})."
)
beleg = note or "Umlagerung"
src.quantity -= quantity
# Als neutrale Korrektur (adjust) protokollieren, damit Umlagerungen die
# Ein-/Auslager-Statistiken nicht verfälschen.
db.add(
Movement(
product_id=product.id,
lot_id=src.id,
user_id=user.id if user else None,
type=MovementType.adjust,
quantity=-quantity,
unit_used=product.base_unit.value,
note=beleg,
location_id=from_location_id,
)
)
if src.quantity <= 1e-9:
db.delete(src)
dest = _object_lot(db, product.id, to_location_id)
if dest is None:
dest = Lot(
product_id=product.id,
quantity=0.0,
best_before=None,
best_before_precision=DatePrecision.day.value,
location_id=to_location_id,
)
db.add(dest)
db.flush()
dest.quantity += quantity
db.add(
Movement(
product_id=product.id,
lot_id=dest.id,
user_id=user.id if user else None,
type=MovementType.adjust,
quantity=quantity,
unit_used=product.base_unit.value,
note=beleg,
location_id=to_location_id,
)
)
def removal_stats(db: Session, product_id: int) -> dict[str, dict]:
"""Entnahmen je Grund summieren: {reason: {quantity, count}}."""
rows = (
db.query(Movement)
.filter(
Movement.product_id == product_id,
Movement.type == MovementType.out,
Movement.reason.isnot(None),
)
.all()
)
stats: dict[str, dict] = {}
for m in rows:
eintrag = stats.setdefault(m.reason, {"quantity": 0.0, "count": 0})
eintrag["quantity"] += m.quantity
eintrag["count"] += 1
return stats
def current_stock(db: Session, product_id: int) -> float: def current_stock(db: Session, product_id: int) -> float:
"""Summe der Lot-Mengen eines Produkts (in Basiseinheiten).""" """Summe der Lot-Mengen eines Produkts (in Basiseinheiten)."""
total = ( total = (

View File

@@ -0,0 +1,238 @@
"""Gegenstände (Non-Food): Menge je Lagerort, Umlagern, Entfernen mit Grund,
Kategorie-Modus und selbst definierte Felder.
Die Lebensmittel-Logik (Chargen/MHD/FEFO) bleibt davon unberührt dafür sorgt
test_fefo.py weiterhin.
"""
import json
import pytest
from app.crud import product_tracking
from app.models import (
Category,
CategoryTracking,
FieldDefinition,
Location,
Lot,
Movement,
Product,
ProductFieldValue,
RemovalReason,
)
from app.routers.categories import create_category
from app.schemas import CategoryCreate
from app.services.fields import (
FieldError,
apply_field_values,
coerce_value,
effective_field_definitions,
)
from app.services.stock import (
StockError,
current_stock,
object_add,
object_relocate,
object_remove,
removal_stats,
)
def _object_product(db, name="Unterhose"):
cat = Category(name="Kleidung", tracking=CategoryTracking.object.value)
db.add(cat)
db.flush()
product = Product(name=name, category_id=cat.id)
db.add(product)
db.commit()
db.refresh(product)
return product, cat
def _loc(db, name):
loc = Location(name=name)
db.add(loc)
db.commit()
db.refresh(loc)
return loc
# ---- Modus je Kategorie ----
def test_ohne_kategorie_bleibt_food(db):
product = Product(name="Altbestand")
db.add(product)
db.commit()
assert product_tracking(db, product) == "food"
def test_gegenstandskategorie_macht_object(db):
product, _ = _object_product(db)
assert product_tracking(db, product) == "object"
def test_neue_oberkategorie_ist_gegenstand(db):
out = create_category(CategoryCreate(name="Werkzeug"), db, None)
assert out.tracking == CategoryTracking.object
def test_unterkategorie_erbt_modus_vom_elternteil(db):
ober = create_category(
CategoryCreate(name="Lebensmittel", tracking=CategoryTracking.food), db, None
)
unter = create_category(CategoryCreate(name="Käse", parent_id=ober.id), db, None)
assert unter.tracking == CategoryTracking.food
# ---- Menge je Lagerort ----
def test_add_summiert_je_ort(db):
product, _ = _object_product(db)
a, b = _loc(db, "Schrank A"), _loc(db, "Schrank B")
object_add(db, product, 5, a.id, None)
object_add(db, product, 3, b.id, None)
db.commit()
assert current_stock(db, product.id) == 8
verteilung = {
lot.location_id: lot.quantity
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
}
assert verteilung == {a.id: 5, b.id: 3}
def test_add_am_gleichen_ort_erhoeht_dieselbe_zeile(db):
product, _ = _object_product(db)
a = _loc(db, "Schrank A")
object_add(db, product, 2, a.id, None)
object_add(db, product, 3, a.id, None)
db.commit()
lots = db.query(Lot).filter(Lot.product_id == product.id).all()
assert len(lots) == 1 and lots[0].quantity == 5
# ---- Umlagern (ohne Grund) ----
def test_umlagern_verschiebt_menge(db):
product, _ = _object_product(db)
a, b = _loc(db, "A"), _loc(db, "B")
object_add(db, product, 5, a.id, None)
db.commit()
object_relocate(db, product, 2, a.id, b.id, None)
db.commit()
verteilung = {
lot.location_id: lot.quantity
for lot in db.query(Lot).filter(Lot.product_id == product.id).all()
}
assert verteilung == {a.id: 3, b.id: 2}
assert current_stock(db, product.id) == 5
# Umlagern erzeugt keine Entnahme mit Grund.
assert db.query(Movement).filter(Movement.reason.isnot(None)).count() == 0
def test_umlagern_zu_wenig_bestand(db):
product, _ = _object_product(db)
a, b = _loc(db, "A"), _loc(db, "B")
object_add(db, product, 1, a.id, None)
db.commit()
with pytest.raises(StockError):
object_relocate(db, product, 5, a.id, b.id, None)
def test_umlagern_gleicher_ort_fehler(db):
product, _ = _object_product(db)
a = _loc(db, "A")
object_add(db, product, 3, a.id, None)
db.commit()
with pytest.raises(StockError):
object_relocate(db, product, 1, a.id, a.id, None)
# ---- Entfernen mit Grund + Statistik ----
def test_entfernen_mit_grund_und_statistik(db):
product, _ = _object_product(db)
a = _loc(db, "A")
object_add(db, product, 5, a.id, None)
db.commit()
object_remove(db, product, 2, a.id, RemovalReason.broken.value, None)
object_remove(db, product, 1, a.id, RemovalReason.broken.value, None)
object_remove(db, product, 1, a.id, RemovalReason.lost.value, None)
db.commit()
assert current_stock(db, product.id) == 1
stats = removal_stats(db, product.id)
assert stats["broken"] == {"quantity": 3, "count": 2}
assert stats["lost"] == {"quantity": 1, "count": 1}
def test_entfernen_zu_viel_bestand(db):
product, _ = _object_product(db)
a = _loc(db, "A")
object_add(db, product, 1, a.id, None)
db.commit()
with pytest.raises(StockError):
object_remove(db, product, 5, a.id, RemovalReason.lost.value, None)
# ---- Selbst definierte Felder ----
def test_felder_werden_vererbt_oberkategorie_zuerst(db):
ober = Category(name="Elektronik", tracking="object")
db.add(ober)
db.flush()
unter = Category(name="Powerbank", parent_id=ober.id, tracking="object")
db.add(unter)
db.commit()
db.add(FieldDefinition(category_id=ober.id, label="Kaufdatum", key="kaufdatum", field_type="date"))
db.add(
FieldDefinition(
category_id=unter.id, label="Kapazität", key="kapazitaet",
field_type="number", unit="mAh",
)
)
db.commit()
eff = effective_field_definitions(db, unter.id)
assert [fd.label for fd, _ in eff] == ["Kaufdatum", "Kapazität"]
geerbt = {fd.label: inh for fd, inh in eff}
assert geerbt["Kaufdatum"] is True and geerbt["Kapazität"] is False
def test_zahlfeld_prueft_typ(db):
fd = FieldDefinition(category_id=1, label="Kapazität", key="kap", field_type="number")
assert coerce_value(fd, "20000") == "20000"
assert coerce_value(fd, "3,5") == "3,5"
with pytest.raises(FieldError):
coerce_value(fd, "abc")
def test_auswahlfeld_prueft_optionen(db):
fd = FieldDefinition(
category_id=1, label="Größe", key="groesse", field_type="select",
options=json.dumps(["S", "M", "L"]),
)
assert coerce_value(fd, "M") == "M"
with pytest.raises(FieldError):
coerce_value(fd, "XXL")
def test_feldwert_setzen_und_leeren(db):
cat = Category(name="Elektronik", tracking="object")
db.add(cat)
db.flush()
fd = FieldDefinition(category_id=cat.id, label="Notiz", key="notiz", field_type="text")
db.add(fd)
db.commit()
product = Product(name="Gerät", category_id=cat.id)
db.add(product)
db.commit()
apply_field_values(db, product, {fd.id: "wichtig"})
db.commit()
assert (
db.query(ProductFieldValue).filter_by(product_id=product.id).one().value
== "wichtig"
)
# Leerer Wert löscht den Eintrag wieder.
apply_field_values(db, product, {fd.id: ""})
db.commit()
assert db.query(ProductFieldValue).filter_by(product_id=product.id).count() == 0

View File

@@ -188,6 +188,43 @@ actor APIClient {
try check(response, data: data) try check(response, data: data)
} }
// MARK: - Gegenstaende (Non-Food)
func shops() async throws -> [ShopItem] {
try await send(try makeRequest("/shops"), as: [ShopItem].self)
}
/// Effektive (vererbte) Felder einer Kategorie fuer das Artikelformular.
func categoryFields(categoryId: Int) async throws -> [FieldDefinition] {
try await send(try makeRequest("/categories/\(categoryId)/fields"), as: [FieldDefinition].self)
}
/// Menge eines Gegenstands an einem Lagerort erhoehen.
func objectCheckIn(_ payload: ObjectCheckInRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/checkin", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge von einem Lagerort zum anderen umbuchen (ohne Grund).
func relocate(_ payload: RelocateRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/relocate", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
func removeStock(_ payload: RemoveRequest) async throws -> StockResponse {
var request = try makeRequest("/stock/remove", method: "POST")
try jsonBody(&request, payload)
return try await send(request, as: StockResponse.self)
}
/// Entnahme-Statistik (Summe je Grund) und die juengsten Entnahmen.
func productRemovals(id: Int) async throws -> RemovalSummary {
try await send(try makeRequest("/products/\(id)/removals"), as: RemovalSummary.self)
}
// MARK: - Uebersicht und Verlauf // MARK: - Uebersicht und Verlauf
func dashboardStats() async throws -> DashboardStats { func dashboardStats() async throws -> DashboardStats {

View File

@@ -66,9 +66,17 @@ struct Product: Codable, Identifiable, Hashable {
/// Mindestbestand in der erfassten Einheit, fuer die Anzeige. /// Mindestbestand in der erfassten Einheit, fuer die Anzeige.
let minStockDisplay: Double? let minStockDisplay: Double?
let minStockUnitLabel: String? let minStockUnitLabel: String?
/// Verwaltungsart aus der Kategorie: "food" (Chargen+MHD) oder "object" (Menge je Ort).
let tracking: String?
/// Nur fuer Gegenstaende: Bezugsquelle und Onlineshop-Link.
let shopId: Int?
let shopName: String?
let productUrl: String?
/// Selbst definierte Feldwerte: {feld_id (als Text): Wert}.
let fieldValues: [String: String?]?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, barcode, name, brand, stock, kind, barcodes case id, barcode, name, brand, stock, kind, barcodes, tracking
case datePrecision = "date_precision" case datePrecision = "date_precision"
case categoryId = "category_id" case categoryId = "category_id"
case categoryName = "category_name" case categoryName = "category_name"
@@ -83,8 +91,15 @@ struct Product: Codable, Identifiable, Hashable {
case expiredCount = "expired_count" case expiredCount = "expired_count"
case unitName = "unit_name" case unitName = "unit_name"
case unitFactor = "unit_factor" case unitFactor = "unit_factor"
case shopId = "shop_id"
case shopName = "shop_name"
case productUrl = "product_url"
case fieldValues = "field_values"
} }
/// Gegenstand (Menge je Lagerort) statt Lebensmittel (Chargen/MHD)?
var isObject: Bool { tracking == "object" }
/// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit). /// Bezeichnung der Artikeleinheit (Gebinde, sonst Produkteinheit).
var articleUnitLabel: String { var articleUnitLabel: String {
if let size = packageSize, size > 0 { return packageLabel ?? "Packung" } if let size = packageSize, size > 0 { return packageLabel ?? "Packung" }
@@ -353,6 +368,10 @@ struct ProductUpdateRequest: Encodable {
var datePrecision: String? var datePrecision: String?
var groupId: Int? var groupId: Int?
var categoryId: Int? var categoryId: Int?
// Nur fuer Gegenstaende (Standard nil = nicht mitschicken bei fieldValues).
var shopId: Int? = nil
var productUrl: String? = nil
var fieldValues: [String: String?]? = nil
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case name, brand case name, brand
@@ -361,6 +380,9 @@ struct ProductUpdateRequest: Encodable {
case datePrecision = "date_precision" case datePrecision = "date_precision"
case groupId = "group_id" case groupId = "group_id"
case categoryId = "category_id" case categoryId = "category_id"
case shopId = "shop_id"
case productUrl = "product_url"
case fieldValues = "field_values"
} }
func encode(to encoder: Encoder) throws { func encode(to encoder: Encoder) throws {
@@ -372,6 +394,10 @@ struct ProductUpdateRequest: Encodable {
try container.encode(datePrecision, forKey: .datePrecision) try container.encode(datePrecision, forKey: .datePrecision)
try container.encode(groupId, forKey: .groupId) try container.encode(groupId, forKey: .groupId)
try container.encode(categoryId, forKey: .categoryId) try container.encode(categoryId, forKey: .categoryId)
try container.encode(shopId, forKey: .shopId)
try container.encode(productUrl, forKey: .productUrl)
// Feldwerte nur senden, wenn gesetzt sonst nichts an den Feldern ändern.
if let fieldValues { try container.encode(fieldValues, forKey: .fieldValues) }
} }
} }
@@ -399,16 +425,143 @@ struct GroupItem: Codable, Identifiable, Hashable {
let name: String let name: String
} }
/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade). /// Kategorie: ordnet die Artikelliste und bestimmt die Verwaltungsart
/// ("food"/"object"), verschachtelbar (Suesswaren -> Schokolade).
struct CategoryItem: Codable, Identifiable, Hashable { struct CategoryItem: Codable, Identifiable, Hashable {
let id: Int let id: Int
let name: String let name: String
let parentId: Int? let parentId: Int?
let tracking: String?
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case id, name case id, name, tracking
case parentId = "parent_id" case parentId = "parent_id"
} }
var isObject: Bool { tracking == "object" }
}
/// Shop / Bezugsquelle fuer Gegenstaende ("gekauft bei").
struct ShopItem: Codable, Identifiable, Hashable {
let id: Int
let name: String
let website: String?
let productCount: Int?
enum CodingKeys: String, CodingKey {
case id, name, website
case productCount = "product_count"
}
}
/// Selbst definiertes Feld einer Kategorie (inkl. der geerbten).
struct FieldDefinition: Codable, Identifiable, Hashable {
let id: Int
let categoryId: Int
let label: String
let key: String
let fieldType: String
let unit: String?
let options: [String]
let required: Bool
let position: Int
let isBuiltin: Bool
let inherited: Bool
enum CodingKeys: String, CodingKey {
case id, label, key, unit, options, required, position, inherited
case categoryId = "category_id"
case fieldType = "field_type"
case isBuiltin = "is_builtin"
}
}
// MARK: - Gegenstaende: Bestandsbuchungen
struct ObjectCheckInRequest: Codable {
let productId: Int
let quantity: Double
let unit: String
let locationId: Int?
enum CodingKeys: String, CodingKey {
case quantity, unit
case productId = "product_id"
case locationId = "location_id"
}
}
struct RelocateRequest: Codable {
let productId: Int
let quantity: Double
let fromLocationId: Int?
let toLocationId: Int?
enum CodingKeys: String, CodingKey {
case quantity
case productId = "product_id"
case fromLocationId = "from_location_id"
case toLocationId = "to_location_id"
}
}
struct RemoveRequest: Codable {
let productId: Int
let quantity: Double
let locationId: Int?
let reason: String
let note: String?
enum CodingKeys: String, CodingKey {
case quantity, reason, note
case productId = "product_id"
case locationId = "location_id"
}
}
struct RemovalStat: Codable, Identifiable, Hashable {
let reason: String
let quantity: Double
let count: Int
var id: String { reason }
}
struct RemovalHistoryItem: Codable, Identifiable, Hashable {
let reason: String
let quantity: Double
let locationId: Int?
let locationName: String?
let note: String?
let username: String?
let createdAt: String
var id: String { "\(reason)-\(createdAt)-\(quantity)" }
enum CodingKeys: String, CodingKey {
case reason, quantity, note, username
case locationId = "location_id"
case locationName = "location_name"
case createdAt = "created_at"
}
}
struct RemovalSummary: Codable {
let stats: [RemovalStat]
let history: [RemovalHistoryItem]
}
/// Anzeige-Bezeichnungen der Entnahmegruende.
enum RemovalReasons {
static let all: [(value: String, label: String)] = [
("broken", "kaputt"),
("lost", "verloren"),
("given_away", "verschenkt"),
("sold", "verkauft"),
("used_up", "aufgebraucht"),
("other", "sonstiges"),
]
static func label(_ value: String) -> String {
all.first { $0.value == value }?.label ?? value
}
} }
// MARK: - Uebersicht und Verlauf // MARK: - Uebersicht und Verlauf

View File

@@ -0,0 +1,327 @@
import SwiftUI
/// Bestand eines Gegenstands je Lagerort das Gegenstück zur Chargen-Liste der
/// Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
/// Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik. Wird als Abschnitt
/// in die Produkt-Detailansicht eingebettet.
struct ObjectStockSection: View {
let product: Product
let locations: [StorageLocation]
let lots: [Lot]
var onChanged: () async -> Void
@State private var removals: RemovalSummary?
@State private var sheet: StockSheet?
enum StockSheet: Identifiable {
case add
case relocate(from: Int?)
case remove(loc: Int?)
var id: String {
switch self {
case .add: return "add"
case .relocate(let f): return "relocate-\(f.map(String.init) ?? "none")"
case .remove(let l): return "remove-\(l.map(String.init) ?? "none")"
}
}
}
private var einheit: String { product.unitName.isEmpty ? "Stück" : product.unitName }
private func ortName(_ id: Int?) -> String {
guard let id else { return "Ohne Lagerort" }
return locations.first { $0.id == id }?.name ?? "Ort \(id)"
}
var body: some View {
Group {
Section("Bestand je Lagerort") {
ForEach(lots) { lot in
HStack {
Text(ortName(lot.locationId))
Spacer()
Text("\(formatAmount(lot.quantity)) \(einheit)")
.foregroundStyle(.secondary)
}
}
if lots.isEmpty {
Text("Noch kein Bestand.").foregroundStyle(.secondary)
}
HStack {
Button { sheet = .add } label: { Label("Hinzufügen", systemImage: "plus") }
Spacer()
Button { sheet = .relocate(from: lots.first?.locationId) } label: {
Label("Umlagern", systemImage: "arrow.left.arrow.right")
}
.disabled(lots.isEmpty)
Spacer()
Button(role: .destructive) { sheet = .remove(loc: lots.first?.locationId) } label: {
Label("Entfernen", systemImage: "trash")
}
.disabled(lots.isEmpty)
}
.buttonStyle(.borderless)
.font(.callout)
}
if let removals, !removals.stats.isEmpty {
Section("Bereits entnommen") {
ForEach(removals.stats) { s in
LabeledContent(RemovalReasons.label(s.reason),
value: "\(formatAmount(s.quantity)) (\(s.count)×)")
}
}
}
}
.sheet(item: $sheet) { welche in
NavigationStack {
switch welche {
case .add:
ObjectAddSheet(product: product, locations: locations, einheit: einheit,
perform: { await afterAction() })
case .relocate(let from):
ObjectRelocateSheet(product: product, locations: locations, initialFrom: from,
perform: { await afterAction() })
case .remove(let loc):
ObjectRemoveSheet(product: product, locations: locations, einheit: einheit,
initialLoc: loc, perform: { await afterAction() })
}
}
}
.task(id: product.id) { await loadRemovals() }
.onChange(of: lots) { _ in Task { await loadRemovals() } }
}
private func loadRemovals() async {
removals = try? await APIClient.shared.productRemovals(id: product.id)
}
private func afterAction() async {
await onChanged()
await loadRemovals()
}
}
/// Eine Eingabe für ein selbst definiertes Feld, passend zum Feldtyp.
struct ObjectFieldRow: View {
let def: FieldDefinition
@Binding var value: String
private var titel: String { def.label + (def.required ? " *" : "") }
private var dateBinding: Binding<Date> {
Binding(
get: {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
return f.date(from: value) ?? Date()
},
set: {
let f = DateFormatter(); f.dateFormat = "yyyy-MM-dd"
value = f.string(from: $0)
}
)
}
private var boolBinding: Binding<Bool> {
Binding(get: { value == "true" }, set: { value = $0 ? "true" : "false" })
}
var body: some View {
switch def.fieldType {
case "textarea":
VStack(alignment: .leading, spacing: 4) {
Text(titel).font(.caption).foregroundStyle(.secondary)
TextEditor(text: $value).frame(minHeight: 60)
}
case "number":
HStack {
Text(titel)
Spacer()
TextField("", text: $value)
.keyboardType(.decimalPad)
.multilineTextAlignment(.trailing)
.frame(maxWidth: 120)
if let u = def.unit, !u.isEmpty { Text(u).foregroundStyle(.secondary) }
}
case "date":
HStack {
DatePicker(titel, selection: dateBinding, displayedComponents: .date)
if !value.isEmpty {
Button { value = "" } label: { Image(systemName: "xmark.circle.fill") }
.buttonStyle(.borderless).foregroundStyle(.secondary)
}
}
case "select":
Picker(titel, selection: $value) {
Text(" keine ").tag("")
ForEach(def.options, id: \.self) { Text($0).tag($0) }
}
case "boolean":
Toggle(titel, isOn: boolBinding)
default:
HStack {
Text(titel)
Spacer()
TextField("", text: $value).multilineTextAlignment(.trailing)
}
}
}
}
/// Auswahl eines Lagerorts (oder ohne").
private struct LocationPicker: View {
let title: String
let locations: [StorageLocation]
@Binding var selection: Int?
var body: some View {
Picker(title, selection: $selection) {
Text(" ohne Lagerort ").tag(Int?.none)
ForEach(locations) { loc in Text(loc.name).tag(Int?.some(loc.id)) }
}
}
}
/// Menge an einem Lagerort hinzufügen.
struct ObjectAddSheet: View {
let product: Product
let locations: [StorageLocation]
let einheit: String
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var locationId: Int?
@State private var quantity = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Speichern…" : "Hinzufügen") { Task { await save() } }
.disabled(busy)
}
}
.navigationTitle("Menge hinzufügen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
}
private func save() async {
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."; return
}
busy = true; defer { busy = false }
do {
_ = try await APIClient.shared.objectCheckIn(
ObjectCheckInRequest(productId: product.id, quantity: menge,
unit: einheit, locationId: locationId))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
/// Menge von einem Lagerort zum anderen umbuchen.
struct ObjectRelocateSheet: View {
let product: Product
let locations: [StorageLocation]
let initialFrom: Int?
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var fromId: Int?
@State private var toId: Int?
@State private var quantity = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
LocationPicker(title: "Von", locations: locations, selection: $fromId)
LocationPicker(title: "Nach", locations: locations, selection: $toId)
QuantityField(label: "Menge", text: $quantity, suffix: product.unitName)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Umlagern…" : "Umlagern") { Task { await save() } }.disabled(busy)
}
}
.navigationTitle("Umlagern")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onAppear { fromId = initialFrom }
}
private func save() async {
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."; return
}
busy = true; defer { busy = false }
do {
_ = try await APIClient.shared.relocate(
RelocateRequest(productId: product.id, quantity: menge,
fromLocationId: fromId, toLocationId: toId))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}
/// Menge mit Pflicht-Grund aus dem Bestand entfernen.
struct ObjectRemoveSheet: View {
let product: Product
let locations: [StorageLocation]
let einheit: String
let initialLoc: Int?
var perform: () async -> Void
@Environment(\.dismiss) private var dismiss
@State private var locationId: Int?
@State private var quantity = ""
@State private var reason = "broken"
@State private var note = ""
@State private var busy = false
@State private var error: String?
var body: some View {
Form {
Section {
LocationPicker(title: "Lagerort", locations: locations, selection: $locationId)
QuantityField(label: "Menge", text: $quantity, suffix: einheit)
Picker("Grund", selection: $reason) {
ForEach(RemovalReasons.all, id: \.value) { Text($0.label).tag($0.value) }
}
LabeledField(label: "Notiz", text: $note)
}
if let error { Section { Text(error).foregroundStyle(.red).font(.callout) } }
Section {
Button(busy ? "Entfernen…" : "Entfernen") { Task { await save() } }
.disabled(busy)
.foregroundStyle(.red)
}
}
.navigationTitle("Aus Bestand entfernen")
.navigationBarTitleDisplayMode(.inline)
.toolbar { ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } }
.onAppear { locationId = initialLoc }
}
private func save() async {
guard let menge = Double(quantity.replacingOccurrences(of: ",", with: ".")), menge > 0 else {
error = "Bitte eine Menge größer 0 angeben."; return
}
busy = true; defer { busy = false }
do {
_ = try await APIClient.shared.removeStock(
RemoveRequest(productId: product.id, quantity: menge, locationId: locationId,
reason: reason, note: note.isEmpty ? nil : note))
await perform()
dismiss()
} catch { self.error = error.localizedDescription }
}
}

View File

@@ -23,6 +23,14 @@ struct ProductDetailView: View {
@State private var groups: [GroupItem] = [] @State private var groups: [GroupItem] = []
@State private var categories: [CategoryItem] = [] @State private var categories: [CategoryItem] = []
// Gegenstände (Non-Food): Bezugsquelle, Link, Lagerorte und eigene Felder.
@State private var shopId: Int?
@State private var productUrl = ""
@State private var shops: [ShopItem] = []
@State private var locations: [StorageLocation] = []
@State private var fieldDefs: [FieldDefinition] = []
@State private var fieldValues: [String: String] = [:]
@State private var editLot: Lot? @State private var editLot: Lot?
private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"]
@@ -41,6 +49,10 @@ struct ProductDetailView: View {
Section { Text(error).foregroundStyle(.red).font(.callout) } Section { Text(error).foregroundStyle(.red).font(.callout) }
} }
if current.isObject {
ObjectStockSection(product: current, locations: locations, lots: lots,
onChanged: { await reload() })
} else {
Section("Bestand") { Section("Bestand") {
LabeledContent("Vorrat", LabeledContent("Vorrat",
value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)")
@@ -49,12 +61,14 @@ struct ProductDetailView: View {
.foregroundStyle(.red) .foregroundStyle(.red)
} }
} }
}
// Beschriftungen links, Werte rechts: Ohne sie las man nur noch // Beschriftungen links, Werte rechts: Ohne sie las man nur noch
// "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang. // "Bratbutter" / "M-Classic" / "450" ohne jeden Zusammenhang.
Section("Artikel") { Section("Artikel") {
LabeledField(label: "Name", text: $name) LabeledField(label: "Name", text: $name)
LabeledField(label: "Marke", text: $brand) LabeledField(label: "Marke", text: $brand)
if !current.isObject {
QuantityField(label: "Packungsgröße", text: $packageSize, QuantityField(label: "Packungsgröße", text: $packageSize,
suffix: display.baseUnitLabel(current.baseUnit)) suffix: display.baseUnitLabel(current.baseUnit))
Picker("Bezeichnung", selection: $packageLabel) { Picker("Bezeichnung", selection: $packageLabel) {
@@ -65,13 +79,35 @@ struct ProductDetailView: View {
Text("nur Monat/Jahr").tag("month") Text("nur Monat/Jahr").tag("month")
} }
} }
}
Section { Section {
CategoryPicker(categories: categories, selection: $categoryId) CategoryPicker(categories: categories, selection: $categoryId)
} footer: { } footer: {
Text("Kategorie: nur für den Überblick in der Artikelliste.") Text(current.isObject
? "Kategorie bestimmt die Verwaltungsart (Gegenstand) und die eigenen Felder."
: "Kategorie: nur für den Überblick in der Artikelliste.")
} }
if current.isObject {
Section("Gekauft bei") {
Picker("Shop", selection: $shopId) {
Text(" unbekannt / mehrere ").tag(Int?.none)
ForEach(shops) { shop in Text(shop.name).tag(Int?.some(shop.id)) }
}
LabeledField(label: "Produktlink", text: $productUrl)
if !productUrl.isEmpty, let url = URL(string: productUrl) {
Link("Im Onlineshop öffnen", destination: url)
}
}
if !fieldDefs.isEmpty {
Section("Eigene Felder") {
ForEach(fieldDefs) { def in
ObjectFieldRow(def: def, value: fieldBinding(def))
}
}
}
} else {
Section { Section {
Picker("Gruppe", selection: $groupId) { Picker("Gruppe", selection: $groupId) {
Text(" keine ").tag(Int?.none) Text(" keine ").tag(Int?.none)
@@ -83,6 +119,7 @@ struct ProductDetailView: View {
Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code "
+ "wandert bei einem Wechsel mit.") + "wandert bei einem Wechsel mit.")
} }
}
Section("Erkennung") { Section("Erkennung") {
LabeledContent("Barcode", value: current.barcode ?? "") LabeledContent("Barcode", value: current.barcode ?? "")
@@ -96,6 +133,7 @@ struct ProductDetailView: View {
.disabled(busy || name.isEmpty) .disabled(busy || name.isEmpty)
} }
if !current.isObject {
Section("Chargen") { Section("Chargen") {
ForEach(lots) { lot in ForEach(lots) { lot in
Button { Button {
@@ -120,6 +158,7 @@ struct ProductDetailView: View {
Text("Keine Chargen im Bestand.").foregroundStyle(.secondary) Text("Keine Chargen im Bestand.").foregroundStyle(.secondary)
} }
} }
}
Section { Section {
// Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel // Derselbe Verlauf wie im Listen-Tab, nur auf diesen Artikel
@@ -142,6 +181,16 @@ struct ProductDetailView: View {
fillForm() fillForm()
await reload() await reload()
} }
.onChange(of: categoryId) { newId in
// Bei Wechsel auf eine Gegenstands-Kategorie deren Felder nachladen.
Task {
if let cid = newId, categories.first(where: { $0.id == cid })?.isObject == true {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
}
}
} }
private func fillForm() { private func fillForm() {
@@ -152,15 +201,37 @@ struct ProductDetailView: View {
datePrecision = current.datePrecision == "month" ? "month" : "day" datePrecision = current.datePrecision == "month" ? "month" : "day"
groupId = current.groupId groupId = current.groupId
categoryId = current.categoryId categoryId = current.categoryId
shopId = current.shopId
productUrl = current.productUrl ?? ""
fieldValues = Self.stringValues(current.fieldValues)
}
private static func stringValues(_ dict: [String: String?]?) -> [String: String] {
guard let dict else { return [:] }
var out: [String: String] = [:]
for (schluessel, wert) in dict { if let wert { out[schluessel] = wert } }
return out
}
private func fieldBinding(_ def: FieldDefinition) -> Binding<String> {
let key = String(def.id)
return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 })
} }
private func reload() async { private func reload() async {
groups = (try? await APIClient.shared.groups()) ?? [] groups = (try? await APIClient.shared.groups()) ?? []
categories = (try? await APIClient.shared.categories()) ?? [] categories = (try? await APIClient.shared.categories()) ?? []
lots = (try? await APIClient.shared.lots(productId: current.id)) ?? [] lots = (try? await APIClient.shared.lots(productId: current.id)) ?? []
shops = (try? await APIClient.shared.shops()) ?? []
locations = (try? await APIClient.shared.locations()) ?? []
if let frisch = try? await APIClient.shared.product(id: current.id) { if let frisch = try? await APIClient.shared.product(id: current.id) {
current = frisch current = frisch
} }
if current.isObject, let cid = current.categoryId {
fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? []
} else {
fieldDefs = []
}
} }
private func save() async { private func save() async {
@@ -169,6 +240,24 @@ struct ProductDetailView: View {
busy = true busy = true
defer { busy = false } defer { busy = false }
do { do {
if current.isObject {
// Gegenstände: keine Lebensmittel-Felder, dafür Shop, Link und eigene Felder.
var fv: [String: String?] = [:]
for def in fieldDefs { fv[String(def.id)] = fieldValues[String(def.id)] ?? "" }
current = try await APIClient.shared.updateProduct(
id: current.id,
ProductUpdateRequest(
name: name,
brand: brand.isEmpty ? nil : brand,
packageSize: nil, packageLabel: nil, datePrecision: nil,
groupId: nil, categoryId: categoryId,
shopId: shopId,
productUrl: productUrl.isEmpty ? nil : productUrl,
fieldValues: fv
)
)
fieldValues = Self.stringValues(current.fieldValues)
} else {
current = try await APIClient.shared.updateProduct( current = try await APIClient.shared.updateProduct(
id: current.id, id: current.id,
ProductUpdateRequest( ProductUpdateRequest(
@@ -181,6 +270,7 @@ struct ProductDetailView: View {
categoryId: categoryId categoryId: categoryId
) )
) )
}
status = "Gespeichert." status = "Gespeichert."
} catch { } catch {
self.error = error.localizedDescription self.error = error.localizedDescription

View File

@@ -32,6 +32,7 @@
A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; };
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; }; AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.swift */; };
D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; }; D70CB183C868FF0143A6A5E7 /* DisplaySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAC97A555856C6C5DD353E25 /* DisplaySettings.swift */; };
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */; };
DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; }; DFA55EF4ACA34537F445E998 /* Session.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD2F9406BD0D4F0D30FED345 /* Session.swift */; };
E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; }; E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.swift */; };
ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; }; ED61DAE4824165D6D87F8C1D /* DateScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 717C8EB336170526F5F3E695 /* DateScanView.swift */; };
@@ -57,6 +58,7 @@
660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; }; 660ACEFA22BC77D52FBE736A /* AppDashboards.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDashboards.swift; sourceTree = "<group>"; };
6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = "<group>"; };
717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = "<group>"; };
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = "<group>"; };
7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = "<group>"; };
8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = "<group>"; };
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; }; 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = "<group>"; };
@@ -100,6 +102,7 @@
163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */, 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */,
3731608B8D48960DC98912BC /* NotificationSettings.swift */, 3731608B8D48960DC98912BC /* NotificationSettings.swift */,
939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */,
7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */,
6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */,
369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */,
4741D0E95875919C921945CF /* RootView.swift */, 4741D0E95875919C921945CF /* RootView.swift */,
@@ -222,6 +225,7 @@
38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */, 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */,
4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */, 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */,
17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */,
D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */,
AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */,
0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */,
9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */,

View File

@@ -12,6 +12,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 Categories from "./pages/Categories"; import Categories from "./pages/Categories";
import Shops from "./pages/Shops";
import Locations from "./pages/Locations"; import Locations from "./pages/Locations";
import PackageTypes from "./pages/PackageTypes"; import PackageTypes from "./pages/PackageTypes";
import Units from "./pages/Units"; import Units from "./pages/Units";
@@ -80,6 +81,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="/shops" icon="cart" label="Shops" />
<NavItem to="/units" icon="box" label="Einheiten" /> <NavItem to="/units" icon="box" label="Einheiten" />
<NavItem to="/package-types" icon="package" label="Gebinde" /> <NavItem to="/package-types" icon="package" label="Gebinde" />
<NavItem to="/users" icon="users" label="Benutzer" /> <NavItem to="/users" icon="users" label="Benutzer" />
@@ -136,6 +138,7 @@ export default function App() {
<Route path="/categories" element={<Protected><Categories /></Protected>} /> <Route path="/categories" element={<Protected><Categories /></Protected>} />
<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="/shops" element={<Protected adminOnly><Shops /></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="/units" element={<Protected adminOnly><Units /></Protected>} />
<Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} /> <Route path="/package-types" element={<Protected adminOnly><PackageTypes /></Protected>} />

View File

@@ -152,6 +152,10 @@ export const api = {
checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkIn: (body) => request("/stock/checkin", { method: "POST", body }),
checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }), checkInBatch: (body) => request("/stock/checkin/batch", { method: "POST", body }),
checkOut: (body) => request("/stock/checkout", { method: "POST", body }), checkOut: (body) => request("/stock/checkout", { method: "POST", body }),
// Gegenstände: umlagern (ohne Grund) und entfernen (Grund Pflicht)
relocateStock: (body) => request("/stock/relocate", { method: "POST", body }),
removeStock: (body) => request("/stock/remove", { method: "POST", body }),
productRemovals: (id) => request(`/products/${id}/removals`),
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 }), updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }),
@@ -187,11 +191,27 @@ export const api = {
createLocation: (body) => request("/locations", { method: "POST", body }), createLocation: (body) => request("/locations", { method: "POST", body }),
deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }), deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }),
// Kategorien: reine Ordnungshilfe, verschachtelbar (siehe Gruppen fuer Bestaende) // Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar
listCategories: () => request("/categories"), listCategories: () => request("/categories"),
createCategory: (body) => request("/categories", { method: "POST", body }), createCategory: (body) => request("/categories", { method: "POST", body }),
updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }),
deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }), deleteCategory: (id) => request(`/categories/${id}`, { method: "DELETE" }),
// Effektive (vererbte) Felder einer Kategorie für das Artikelformular.
categoryFields: (id) => request(`/categories/${id}/fields`),
// Selbst definierte Felder je Kategorie
listFieldDefinitions: (categoryId) =>
request(`/field-definitions${categoryId != null ? `?category_id=${categoryId}` : ""}`),
createFieldDefinition: (body) => request("/field-definitions", { method: "POST", body }),
updateFieldDefinition: (id, body) =>
request(`/field-definitions/${id}`, { method: "PATCH", body }),
deleteFieldDefinition: (id) => request(`/field-definitions/${id}`, { method: "DELETE" }),
// Shops / Bezugsquellen (nur für Gegenstände)
listShops: () => request("/shops"),
createShop: (body) => request("/shops", { method: "POST", body }),
updateShop: (id, body) => request(`/shops/${id}`, { method: "PATCH", body }),
deleteShop: (id) => request(`/shops/${id}`, { method: "DELETE" }),
// Gebinde (Einzahl/Mehrzahl): "Glas" -> "Gläser" // Gebinde (Einzahl/Mehrzahl): "Glas" -> "Gläser"
listPackageTypes: () => request("/package-types"), listPackageTypes: () => request("/package-types"),

View File

@@ -0,0 +1,295 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useToast } from "../toast";
import Icon from "./Icon";
import { fmt } from "../units";
// Gründe zum Entfernen (Wert = Backend-Enum, Label = Anzeige).
export const REASONS = [
{ value: "broken", label: "kaputt" },
{ value: "lost", label: "verloren" },
{ value: "given_away", label: "verschenkt" },
{ value: "sold", label: "verkauft" },
{ value: "used_up", label: "aufgebraucht" },
{ value: "other", label: "sonstiges" },
];
const reasonLabel = (v) => REASONS.find((r) => r.value === v)?.label || v;
/**
* Bestand eines Gegenstands je Lagerort das Gegenstück zur Chargen-Karte der
* Lebensmittel. Erlaubt Hinzufügen, Umlagern (ohne Grund) und Entfernen (mit
* Pflicht-Grund) und zeigt eine kleine Entnahme-Statistik.
*/
export default function ObjektBestand({ product, lots, locations, isAdmin, onChanged, onError }) {
const confirm = useConfirm();
const toast = useToast();
const einheit = product?.unit_name || "Stück";
const nameById = Object.fromEntries((locations || []).map((l) => [l.id, l.name]));
const ortName = (id) => (id == null ? "Ohne Lagerort" : nameById[id] || `Ort ${id}`);
const [addForm, setAddForm] = useState({ location_id: "", quantity: "" });
const [move, setMove] = useState(null); // { from, to, quantity } | null
const [remove, setRemove] = useState(null); // { location_id, quantity, reason, note } | null
const [editId, setEditId] = useState(null);
const [editQty, setEditQty] = useState("");
const [removals, setRemovals] = useState({ stats: [], history: [] });
async function ladeRemovals() {
try {
setRemovals(await api.productRemovals(product.id));
} catch { /* Statistik ist optional */ }
}
useEffect(() => { ladeRemovals(); /* eslint-disable-next-line */ }, [product.id, lots]);
async function nachAktion() {
await onChanged();
await ladeRemovals();
}
async function hinzufuegen(e) {
e.preventDefault();
const menge = Number(addForm.quantity);
if (!menge || menge <= 0) return;
try {
await api.checkIn({
product_id: product.id,
quantity: menge,
unit: einheit,
location_id: addForm.location_id === "" ? null : Number(addForm.location_id),
});
setAddForm({ location_id: addForm.location_id, quantity: "" });
await nachAktion();
} catch (err) { onError(err.message); }
}
async function speichereMenge(lot) {
const menge = Number(editQty);
try {
await api.updateLot(lot.id, { quantity: menge });
setEditId(null);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function loescheZeile(lot) {
const ok = await confirm({
title: `Bestand in „${ortName(lot.location_id)}“ entfernen?`,
message: "Die Menge wird als Korrektur im Verlauf protokolliert.",
confirmLabel: "Entfernen", danger: true,
});
if (!ok) return;
try {
await api.deleteLot(lot.id);
await nachAktion();
} catch (err) { onError(err.message); }
}
async function umlagern(e) {
e.preventDefault();
const menge = Number(move.quantity);
if (!menge || menge <= 0) return;
try {
await api.relocateStock({
product_id: product.id,
quantity: menge,
from_location_id: move.from === "" ? null : Number(move.from),
to_location_id: move.to === "" ? null : Number(move.to),
});
setMove(null);
toast("Umgelagert.");
await nachAktion();
} catch (err) { onError(err.message); }
}
async function entfernen(e) {
e.preventDefault();
const menge = Number(remove.quantity);
if (!menge || menge <= 0) return;
try {
await api.removeStock({
product_id: product.id,
quantity: menge,
location_id: remove.location_id === "" ? null : Number(remove.location_id),
reason: remove.reason,
note: remove.note || null,
});
setRemove(null);
toast("Aus dem Bestand entfernt.");
await nachAktion();
} catch (err) { onError(err.message); }
}
const gesamt = lots.reduce((s, l) => s + l.quantity, 0);
return (
<section className="card">
<div className="card-head">
<Icon name="location" /><h2>Bestand je Lagerort</h2>
<span className="muted small" style={{ marginLeft: "auto" }}>
gesamt {fmt(gesamt)} {einheit}
</span>
</div>
<div className="table-wrap">
<table className="table">
<thead>
<tr><th>Lagerort</th><th className="num">Menge</th><th></th></tr>
</thead>
<tbody>
{lots.map((l) => (
<tr key={l.id}>
<td data-label="Lagerort">{ortName(l.location_id)}</td>
<td data-label="Menge" className="num">
{editId === l.id ? (
<input type="number" step="any" min="0" value={editQty} style={{ marginTop: 0, width: 90 }}
onChange={(e) => setEditQty(e.target.value)} />
) : (
<>{fmt(l.quantity)} <span className="muted small">{einheit}</span></>
)}
</td>
<td className="num">
{isAdmin && (editId === l.id ? (
<div className="btn-pair">
<button className="btn sm primary" onClick={() => speichereMenge(l)}>OK</button>
<button className="btn sm" onClick={() => setEditId(null)}>Abbrechen</button>
</div>
) : (
<div className="field-inline" style={{ justifyContent: "flex-end" }}>
<button className="btn-icon" title="Menge korrigieren"
onClick={() => { setEditId(l.id); setEditQty(String(l.quantity)); }}>
<Icon name="edit" size={16} />
</button>
<button className="btn-icon" title="Von hier umlagern"
onClick={() => setMove({ from: l.location_id ?? "", to: "", quantity: "" })}>
<Icon name="checkout" size={16} />
</button>
<button className="btn-icon" title="Mit Grund entfernen"
onClick={() => setRemove({ location_id: l.location_id ?? "", quantity: "", reason: "broken", note: "" })}>
<Icon name="trash" size={16} />
</button>
</div>
))}
</td>
</tr>
))}
{lots.length === 0 && <tr><td colSpan={3} className="empty">Noch kein Bestand.</td></tr>}
</tbody>
</table>
</div>
{isAdmin && (
<form onSubmit={hinzufuegen} className="row" style={{ marginTop: "var(--sp-3)" }}>
<label className="grow">
Lagerort
<select value={addForm.location_id}
onChange={(e) => setAddForm({ ...addForm, location_id: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 120 }}>
Menge
<input type="number" step="any" min="0" value={addForm.quantity}
onChange={(e) => setAddForm({ ...addForm, quantity: e.target.value })} placeholder="z.B. 3" />
</label>
<button className="btn primary" style={{ alignSelf: "end" }}>
<Icon name="plus" size={16} />Hinzufügen
</button>
</form>
)}
{/* Umlagern-Dialog */}
{isAdmin && move && (
<form onSubmit={umlagern} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="checkout" /><h3>Umlagern</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setMove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Von
<select value={move.from} onChange={(e) => setMove({ ...move, from: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label className="grow">Nach
<select value={move.to} onChange={(e) => setMove({ ...move, to: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={move.quantity}
onChange={(e) => setMove({ ...move, quantity: e.target.value })} />
</label>
</div>
<button className="btn primary">Umlagern</button>
</form>
)}
{/* Entfernen-Dialog (Grund Pflicht) */}
{isAdmin && remove && (
<form onSubmit={entfernen} className="card-sub" style={{ marginTop: "var(--sp-3)" }}>
<div className="card-head"><Icon name="trash" /><h3>Aus Bestand entfernen</h3>
<button type="button" className="btn-icon" style={{ marginLeft: "auto" }}
onClick={() => setRemove(null)}><Icon name="close" size={16} /></button>
</div>
<div className="row">
<label className="grow">Lagerort
<select value={remove.location_id}
onChange={(e) => setRemove({ ...remove, location_id: e.target.value })}>
<option value=""> ohne Lagerort </option>
{locations.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
</select>
</label>
<label style={{ width: 110 }}>Menge
<input type="number" step="any" min="0" value={remove.quantity}
onChange={(e) => setRemove({ ...remove, quantity: e.target.value })} />
</label>
<label className="grow">Grund
<select value={remove.reason}
onChange={(e) => setRemove({ ...remove, reason: e.target.value })}>
{REASONS.map((r) => <option key={r.value} value={r.value}>{r.label}</option>)}
</select>
</label>
</div>
<label>Notiz (optional)
<input value={remove.note} placeholder="z.B. runtergefallen"
onChange={(e) => setRemove({ ...remove, note: e.target.value })} />
</label>
<button className="btn danger">Entfernen</button>
</form>
)}
{/* Entnahme-Statistik */}
{removals.stats.length > 0 && (
<div style={{ marginTop: "var(--sp-3)" }}>
<div className="feldkopf" style={{ marginBottom: "var(--sp-2)" }}>
<span className="muted small">Bereits entnommen:</span>
{removals.stats.map((s) => (
<span key={s.reason} className="badge nowrap" title={`${s.count}×`}>
{reasonLabel(s.reason)}: {fmt(s.quantity)}
</span>
))}
</div>
{removals.history.length > 0 && (
<ul className="clean-list">
{removals.history.slice(0, 5).map((h, i) => (
<li key={i}>
<span className="badge nowrap">{reasonLabel(h.reason)}</span>
<span>{fmt(h.quantity)} {einheit}</span>
{h.location_name && <span className="muted small">aus {h.location_name}</span>}
{h.note && <span className="muted small"> {h.note}</span>}
<span className="muted small" style={{ marginLeft: "auto" }}>
{new Date(h.created_at).toLocaleDateString("de-DE")}
</span>
</li>
))}
</ul>
)}
</div>
)}
</section>
);
}

99
web/src/fields.jsx Normal file
View File

@@ -0,0 +1,99 @@
// Selbst definierte Felder: Typen-Registry und die passenden Eingabe-Elemente.
// Bewusst schlank gehalten und aus einer Konfiguration heraus gerendert analog
// zur Karten-Registry des Dashboards (dashboard/cards.jsx).
// Auswahl im Feld-Verwaltungs-Dialog (Reihenfolge = Anzeige).
export const FIELD_TYPES = [
{ value: "text", label: "Text (einzeilig)" },
{ value: "textarea", label: "Text (mehrzeilig)" },
{ value: "number", label: "Zahl (mit Einheit)" },
{ value: "date", label: "Datum" },
{ value: "select", label: "Auswahlliste" },
{ value: "boolean", label: "Ja/Nein" },
];
export function fieldTypeLabel(value) {
return FIELD_TYPES.find((t) => t.value === value)?.label || value;
}
/** Eine einzelne Eingabe passend zum Feldtyp. */
export function FieldInput({ field, value, onChange, disabled }) {
const v = value ?? "";
switch (field.field_type) {
case "textarea":
return (
<textarea rows={3} value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
);
case "number":
return (
<span className="field-inline">
<input type="number" step="any" value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
{field.unit && <span className="muted">{field.unit}</span>}
</span>
);
case "date":
return (
<input type="date" value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
);
case "select":
return (
<select value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)}>
<option value=""> keine </option>
{(field.options || []).map((o) => (
<option key={o} value={o}>{o}</option>
))}
</select>
);
case "boolean":
return (
<label className="check-inline">
<input type="checkbox" checked={v === "true"} disabled={disabled}
onChange={(e) => onChange(e.target.checked ? "true" : "false")} />
<span className="muted">Ja</span>
</label>
);
default:
return (
<input value={v} disabled={disabled}
onChange={(e) => onChange(e.target.value)} />
);
}
}
/**
* Rendert die effektiven (vererbten) Felder einer Kategorie als Formularblock.
*
* ``values`` ist eine Map { String(feld_id): Wert }, wie sie das Backend liefert
* und erwartet. ``onChange(feldId, wert)`` meldet Änderungen zurück.
*/
export function DynamicFields({ fields, values, onChange, disabled }) {
if (!fields || fields.length === 0) return null;
return (
<div className="stack">
{fields.map((f) => (
<label key={f.id}>
<span className="feldkopf">
{f.label}
{f.field_type === "number" && f.unit ? ` (${f.unit})` : ""}
{f.required ? " *" : ""}
{f.inherited && (
<span className="badge nowrap" title={"geerbt von einer Oberkategorie"}>
geerbt
</span>
)}
</span>
<FieldInput
field={f}
value={values[String(f.id)]}
onChange={(val) => onChange(String(f.id), val)}
disabled={disabled}
/>
</label>
))}
</div>
);
}

View File

@@ -4,21 +4,25 @@ import { useConfirm } from "../confirm";
import { useAuth } from "../auth"; import { useAuth } from "../auth";
import Icon from "../components/Icon"; import Icon from "../components/Icon";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
import { FIELD_TYPES, fieldTypeLabel } from "../fields";
const MODUS = { food: "Lebensmittel", object: "Gegenstand" };
/** /**
* Kategorien ordnen die Artikelliste mehr nicht. * Kategorien ordnen die Artikelliste und bestimmen die Verwaltungsart:
* * „Lebensmittel“ (Chargen + MHD) oder „Gegenstand“ (Menge je Lagerort). Zusätzlich
* Bewusst ohne Bestand, Mindestbestand und EAN-Codes: Das ist Sache der * lassen sich je Kategorie beliebig viele eigene Felder festlegen, die an ihre
* Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein. * Unterkategorien vererbt werden.
*/ */
export default function Categories() { export default function Categories() {
const confirm = useConfirm(); const confirm = useConfirm();
const { isAdmin } = useAuth(); const { isAdmin } = useAuth();
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [form, setForm] = useState({ name: "", parent_id: "" }); const [form, setForm] = useState({ name: "", parent_id: "", tracking: "" });
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Eingeklappte Oberkategorien; ihre Unterkategorien werden ausgeblendet.
const [collapsed, setCollapsed] = useState(() => new Set()); const [collapsed, setCollapsed] = useState(() => new Set());
// Kategorie, deren Felder gerade verwaltet werden.
const [feldKat, setFeldKat] = useState(null);
function toggle(id) { function toggle(id) {
setCollapsed((alt) => { setCollapsed((alt) => {
@@ -46,8 +50,9 @@ export default function Categories() {
await api.createCategory({ await api.createCategory({
name: form.name, name: form.name,
parent_id: form.parent_id === "" ? null : Number(form.parent_id), parent_id: form.parent_id === "" ? null : Number(form.parent_id),
tracking: form.tracking === "" ? null : form.tracking,
}); });
setForm({ name: "", parent_id: form.parent_id }); setForm({ name: "", parent_id: form.parent_id, tracking: form.tracking });
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -70,13 +75,14 @@ export default function Categories() {
: ""; : "";
const ok = await confirm({ const ok = await confirm({
title: `Kategorie „${category.name}“ löschen?`, title: `Kategorie „${category.name}“ löschen?`,
message: `Unterkategorien rücken eine Ebene nach oben.${hinweis}`, message: `Unterkategorien rücken eine Ebene nach oben. Eigene Felder dieser Kategorie werden entfernt.${hinweis}`,
confirmLabel: "Löschen", confirmLabel: "Löschen",
danger: true, danger: true,
}); });
if (!ok) return; if (!ok) return;
try { try {
await api.deleteCategory(category.id); await api.deleteCategory(category.id);
if (feldKat && feldKat.id === category.id) setFeldKat(null);
load(); load();
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
@@ -85,9 +91,6 @@ export default function Categories() {
const tree = asTree(categories); const tree = asTree(categories);
const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name])); const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name]));
// Verschachtelung kann mehrstufig sein: eine Zeile ist versteckt, sobald
// irgendein Vorfahre eingeklappt ist nicht nur der direkte Elternteil.
const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id])); const parentOf = Object.fromEntries(categories.map((c) => [c.id, c.parent_id]));
const childCount = (id) => categories.filter((c) => c.parent_id === id).length; const childCount = (id) => categories.filter((c) => c.parent_id === id).length;
function versteckt(id) { function versteckt(id) {
@@ -106,9 +109,9 @@ export default function Categories() {
<div> <div>
<h1>Kategorien</h1> <h1>Kategorien</h1>
<div className="sub"> <div className="sub">
Ordnen die Artikelliste für den Überblick zeig mir alle Süßwaren. Ordnen die Artikelliste und legen die Verwaltungsart fest:
Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das Lebensmittel (Chargen + MHD) oder Gegenstand (Menge je Lagerort).
übernehmen die Gruppen. Je Kategorie lassen sich eigene Felder festlegen, die Unterkategorien erben.
</div> </div>
</div> </div>
</div> </div>
@@ -121,6 +124,7 @@ export default function Categories() {
<thead> <thead>
<tr> <tr>
<th>Kategorie</th> <th>Kategorie</th>
<th>Art</th>
<th className="num">Artikel</th> <th className="num">Artikel</th>
<th></th> <th></th>
</tr> </tr>
@@ -130,7 +134,7 @@ export default function Categories() {
const kinder = childCount(c.id); const kinder = childCount(c.id);
const zu = collapsed.has(c.id); const zu = collapsed.has(c.id);
return ( return (
<tr key={c.id}> <tr key={c.id} className={feldKat && feldKat.id === c.id ? "row-active" : ""}>
<td data-label="Kategorie"> <td data-label="Kategorie">
<span className="cell-row" style={{ paddingLeft: c.depth * 22 }}> <span className="cell-row" style={{ paddingLeft: c.depth * 22 }}>
{kinder > 0 ? ( {kinder > 0 ? (
@@ -155,8 +159,23 @@ export default function Categories() {
)} )}
</span> </span>
</td> </td>
<td data-label="Art">
{isAdmin ? (
<select value={c.tracking} style={{ marginTop: 0, minWidth: 130 }}
onChange={(e) => patch(c, { tracking: e.target.value })}>
<option value="food">Lebensmittel</option>
<option value="object">Gegenstand</option>
</select>
) : (
<span className="badge">{MODUS[c.tracking] || c.tracking}</span>
)}
</td>
<td data-label="Artikel" className="num muted">{c.product_count}</td> <td data-label="Artikel" className="num muted">{c.product_count}</td>
<td className="num"> <td className="num">
<button className="btn-icon" title="Felder verwalten"
onClick={() => setFeldKat(feldKat && feldKat.id === c.id ? null : c)}>
<Icon name="tag" size={16} />
</button>
{isAdmin && ( {isAdmin && (
<button className="btn-icon danger" onClick={() => remove(c)} title="Löschen"> <button className="btn-icon danger" onClick={() => remove(c)} title="Löschen">
<Icon name="trash" size={16} /> <Icon name="trash" size={16} />
@@ -167,20 +186,20 @@ export default function Categories() {
); );
})} })}
{categories.length === 0 && ( {categories.length === 0 && (
<tr><td colSpan={3} className="empty">Noch keine Kategorien.</td></tr> <tr><td colSpan={4} className="empty">Noch keine Kategorien.</td></tr>
)} )}
</tbody> </tbody>
</table> </table>
</div> </div>
</div> </div>
<div> <div className="stack">
{isAdmin && ( {isAdmin && (
<form className="card" onSubmit={add}> <form className="card" onSubmit={add}>
<div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div> <div className="card-head"><Icon name="tag" /><h2>Neue Kategorie</h2></div>
<label> <label>
Name Name
<input value={form.name} placeholder="z.B. Süßwaren & Snacks" <input value={form.name} placeholder="z.B. Elektronik"
onChange={(e) => setForm({ ...form, name: e.target.value })} required /> onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label> </label>
<label> <label>
@@ -195,16 +214,166 @@ export default function Categories() {
))} ))}
</select> </select>
</label> </label>
<label>
Verwaltungsart
<select value={form.tracking}
onChange={(e) => setForm({ ...form, tracking: e.target.value })}>
<option value="">automatisch (erbt bzw. Gegenstand)</option>
<option value="food">Lebensmittel (Chargen + MHD)</option>
<option value="object">Gegenstand (Menge je Lagerort)</option>
</select>
</label>
<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">
Unterkategorien sind beliebig tief möglich. Filterst du später auf eine Unterkategorien erben Art und Felder der Oberkategorie. Auf eine
Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Oberkategorie gefiltert erscheinen auch die Artikel ihrer Unterkategorien.
Namen lassen sich in der Tabelle direkt ändern.
</p> </p>
</form> </form>
)} )}
{feldKat && (
<CategoryFields
key={feldKat.id}
category={feldKat}
isAdmin={isAdmin}
onClose={() => setFeldKat(null)}
/>
)}
</div> </div>
</div> </div>
</div> </div>
); );
} }
const EMPTY_FIELD = { label: "", field_type: "text", unit: "", options: "", required: false };
/** Verwaltung der eigenen Felder einer Kategorie (inkl. der geerbten zur Info). */
function CategoryFields({ category, isAdmin, onClose }) {
const confirm = useConfirm();
const [fields, setFields] = useState([]);
const [form, setForm] = useState(EMPTY_FIELD);
const [error, setError] = useState(null);
async function load() {
try {
setFields(await api.categoryFields(category.id));
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, [category.id]);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createFieldDefinition({
category_id: category.id,
label: form.label,
field_type: form.field_type,
unit: form.field_type === "number" ? (form.unit || null) : null,
options: form.field_type === "select"
? form.options.split(",").map((o) => o.trim()).filter(Boolean)
: null,
required: form.required,
});
setForm(EMPTY_FIELD);
load();
} catch (err) {
setError(err.message);
}
}
async function remove(field) {
const ok = await confirm({
title: `Feld „${field.label}“ löschen?`,
message: "Die zu diesem Feld erfassten Werte gehen an allen Artikeln verloren.",
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteFieldDefinition(field.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div className="card">
<div className="card-head">
<Icon name="tag" />
<h2>Felder: {category.name}</h2>
<button className="btn-icon" style={{ marginLeft: "auto" }} title="Schließen" onClick={onClose}>
<Icon name="close" size={16} />
</button>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
{fields.length === 0 ? (
<p className="muted small">Noch keine Felder für diese Kategorie.</p>
) : (
<ul className="clean-list">
{fields.map((f) => (
<li key={f.id} className="cell-row">
<span className="strong">{f.label}</span>
<span className="badge nowrap">{fieldTypeLabel(f.field_type)}</span>
{f.field_type === "number" && f.unit && <span className="muted small">{f.unit}</span>}
{f.required && <span className="badge warn nowrap">Pflicht</span>}
{f.inherited ? (
<span className="badge nowrap" title="stammt aus einer Oberkategorie">geerbt</span>
) : (
isAdmin && (
<button className="btn-icon danger" style={{ marginLeft: "auto" }}
title="Feld löschen" onClick={() => remove(f)}>
<Icon name="trash" size={15} />
</button>
)
)}
</li>
))}
</ul>
)}
{isAdmin && (
<form onSubmit={add} className="stack" style={{ marginTop: "var(--sp-3)" }}>
<label>
Feldname
<input value={form.label} placeholder="z.B. Kapazität"
onChange={(e) => setForm({ ...form, label: e.target.value })} required />
</label>
<label>
Typ
<select value={form.field_type}
onChange={(e) => setForm({ ...form, field_type: e.target.value })}>
{FIELD_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</label>
{form.field_type === "number" && (
<label>
Einheit (optional)
<input value={form.unit} placeholder="z.B. mAh"
onChange={(e) => setForm({ ...form, unit: e.target.value })} />
</label>
)}
{form.field_type === "select" && (
<label>
Auswahlmöglichkeiten (mit Komma trennen)
<input value={form.options} placeholder="z.B. S, M, L, XL"
onChange={(e) => setForm({ ...form, options: e.target.value })} />
</label>
)}
<label className="check-inline">
<input type="checkbox" checked={form.required}
onChange={(e) => setForm({ ...form, required: e.target.checked })} />
<span>Pflichtfeld</span>
</label>
<button className="btn primary"><Icon name="plus" size={16} />Feld hinzufügen</button>
</form>
)}
</div>
);
}

View File

@@ -10,6 +10,8 @@ import ProduktBild from "../components/ProduktBild";
import { useToast } from "../toast"; import { useToast } from "../toast";
import { useSettings } from "../settings"; import { useSettings } from "../settings";
import { asTree } from "../categoryTree"; import { asTree } from "../categoryTree";
import { DynamicFields } from "../fields";
import ObjektBestand from "../components/ObjektBestand";
import { import {
daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry, daysUntil, expiryRowClass, fmt, fromMonthInput, gebinde, isExpired, relativeExpiry,
toMonthInput, unitShort, toMonthInput, unitShort,
@@ -18,7 +20,7 @@ import {
const EMPTY = { const EMPTY = {
barcode: "", name: "", brand: "", image_url: "", barcode: "", name: "", brand: "", image_url: "",
unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "", unit_id: "", package_size: "", package_label: "", date_precision: "day", min_stock: "",
group_id: "", category_id: "", group_id: "", category_id: "", shop_id: "", product_url: "",
}; };
// (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes) // (Gebinde kommen jetzt aus der Verwaltung, siehe api.listPackageTypes)
@@ -45,6 +47,11 @@ export default function ProductForm() {
const [categories, setCategories] = useState([]); const [categories, setCategories] = useState([]);
const [units, setUnits] = useState([]); const [units, setUnits] = useState([]);
const [gebindearten, setGebindearten] = useState([]); const [gebindearten, setGebindearten] = useState([]);
const [locations, setLocations] = useState([]);
const [shops, setShops] = useState([]);
// Gegenstände: effektive (vererbte) Felder der Kategorie + deren Werte am Artikel.
const [effFields, setEffFields] = useState([]);
const [fieldValues, setFieldValues] = useState({});
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
// Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit. // Einheit für die Mindestbestand-Eingabe: "package" oder die ID einer Einheit.
@@ -59,10 +66,20 @@ export default function ProductForm() {
const unitFactor = selectedUnit ? selectedUnit.factor : 1; const unitFactor = selectedUnit ? selectedUnit.factor : 1;
const unitName = selectedUnit ? selectedUnit.name : ""; const unitName = selectedUnit ? selectedUnit.name : "";
// Die gewählte Kategorie bestimmt die Verwaltungsart. Ohne Kategorie: Lebensmittel.
const currentCategory =
categories.find((c) => String(c.id) === String(form.category_id)) || null;
const mode = currentCategory ? currentCategory.tracking : "food";
const isObject = mode === "object";
function set(k, v) { function set(k, v) {
setForm((f) => ({ ...f, [k]: v })); setForm((f) => ({ ...f, [k]: v }));
} }
function setField(fieldId, value) {
setFieldValues((v) => ({ ...v, [fieldId]: value }));
}
function canonicalUnitId(unitList, baseUnit) { function canonicalUnitId(unitList, baseUnit) {
const name = CANONICAL_NAME[baseUnit]; const name = CANONICAL_NAME[baseUnit];
const hit = unitList.find((u) => u.name === name); const hit = unitList.find((u) => u.name === name);
@@ -203,13 +220,16 @@ export default function ProductForm() {
useEffect(() => { useEffect(() => {
async function load() { async function load() {
try { try {
const [gs, us, cs, pts] = await Promise.all([ const [gs, us, cs, pts, locs, shs] = await Promise.all([
api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(), api.listGroups(), api.listUnits(), api.listCategories(), api.listPackageTypes(),
api.listLocations(), api.listShops(),
]); ]);
setGroups(gs); setGroups(gs);
setUnits(us); setUnits(us);
setCategories(cs); setCategories(cs);
setGebindearten(pts); setGebindearten(pts);
setLocations(locs);
setShops(shs);
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");
@@ -238,7 +258,10 @@ export default function ProductForm() {
min_stock: p.min_stock != null ? p.min_stock / f : "", min_stock: p.min_stock != null ? p.min_stock / f : "",
group_id: p.group_id ?? "", group_id: p.group_id ?? "",
category_id: p.category_id ?? "", category_id: p.category_id ?? "",
shop_id: p.shop_id ?? "",
product_url: p.product_url || "",
}); });
setFieldValues(p.field_values || {});
setLots(await api.listLots(id)); setLots(await api.listLots(id));
} else { } else {
const bc = searchParams.get("barcode"); const bc = searchParams.get("barcode");
@@ -255,17 +278,47 @@ export default function ProductForm() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [id]); }, [id]);
// Wechselt die Kategorie, die geltenden (vererbten) Felder nachladen
// aber nur für Gegenstände. Lebensmittel haben keine eigenen Felder.
useEffect(() => {
const cat = categories.find((c) => String(c.id) === String(form.category_id)) || null;
if (!cat || cat.tracking !== "object") {
setEffFields([]);
return;
}
let aktiv = true;
api.categoryFields(cat.id).then((fs) => { if (aktiv) setEffFields(fs); }).catch(() => {});
return () => { aktiv = false; };
}, [form.category_id, categories]);
function buildPayload() { function buildPayload() {
const sel = minUnit || form.unit_id; const sel = minUnit || form.unit_id;
let minBase = null; let minBase = null;
if (form.min_stock !== "") { if (form.min_stock !== "") {
minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units); minBase = Number(form.min_stock) * minFactor(sel, form.package_size, units);
} }
return { const base = {
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,
category_id: form.category_id === "" ? null : Number(form.category_id),
shop_id: form.shop_id === "" ? null : Number(form.shop_id),
product_url: form.product_url.trim() || null,
};
if (isObject) {
// Gegenstände: keine Lebensmittel-Felder (Einheit/Packung/MHD/Gruppe).
// Nur die geltenden eigenen Felder mitschicken Werte fremder Kategorien
// (nach einem Kategoriewechsel) fallen dabei weg.
const erlaubt = new Set(effFields.map((f) => String(f.id)));
const fv = {};
Object.entries(fieldValues).forEach(([k, v]) => {
if (erlaubt.has(String(k))) fv[k] = v;
});
return { ...base, field_values: fv };
}
return {
...base,
unit_id: form.unit_id === "" ? null : Number(form.unit_id), 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),
package_label: form.package_label.trim() || null, package_label: form.package_label.trim() || null,
@@ -274,7 +327,6 @@ export default function ProductForm() {
min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel), min_stock_unit_id: sel === "package" || sel === "" ? null : Number(sel),
min_stock_in_packages: sel === "package", min_stock_in_packages: sel === "package",
group_id: form.group_id === "" ? null : Number(form.group_id), group_id: form.group_id === "" ? null : Number(form.group_id),
category_id: form.category_id === "" ? null : Number(form.category_id),
}; };
} }
@@ -306,7 +358,7 @@ export default function ProductForm() {
async function remove() { async function remove() {
const ok = await confirm({ const ok = await confirm({
title: "Produkt löschen?", title: "Produkt löschen?",
message: "Alle Chargen dieses Produkts gehen dabei verloren.", message: "Der gesamte Bestand dieses Produkts geht dabei verloren.",
confirmLabel: "Löschen", confirmLabel: "Löschen",
danger: true, danger: true,
}); });
@@ -393,6 +445,7 @@ export default function ProductForm() {
onSchliessen={() => setOffDaten(null)} onSchliessen={() => setOffDaten(null)}
/> />
)} )}
{!isObject && (<>
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
Einheit Einheit
@@ -471,22 +524,58 @@ export default function ProductForm() {
</select> </select>
</label> </label>
</div> </div>
</>)}
<div className="row"> <div className="row">
<label className="grow"> <label className="grow">
<span className="tip" title="Nur für den Überblick in der Artikelliste ohne Einfluss auf Bestände."> <span className="tip" title="Bestimmt zugleich die Verwaltungsart: Gegenstands-Kategorien blenden MHD/Chargen aus und zeigen Menge je Lagerort.">
Kategorie Kategorie
</span> </span>
<select value={form.category_id} onChange={(e) => set("category_id", e.target.value)} <select value={form.category_id} onChange={(e) => set("category_id", e.target.value)}
disabled={readOnly}> disabled={readOnly}>
<option value=""> keine </option> <option value=""> keine </option>
{asTree(categories).map((c) => ( {asTree(categories).map((c) => (
<option key={c.id} value={c.id}>{"— ".repeat(c.depth)}{c.name}</option> <option key={c.id} value={c.id}>
{"— ".repeat(c.depth)}{c.name}{c.tracking === "object" ? " · Gegenstand" : ""}
</option>
))} ))}
</select> </select>
</label> </label>
<div className="grow" /> <div className="grow" />
</div> </div>
{isObject && (<>
<div className="row">
<label className="grow">
Gekauft bei
<select value={form.shop_id} onChange={(e) => set("shop_id", e.target.value)}
disabled={readOnly}>
<option value=""> unbekannt / mehrere </option>
{shops.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</label>
<label className="grow">
Produktlink
<div className="field-inline">
<input type="url" value={form.product_url} placeholder="https://…"
onChange={(e) => set("product_url", e.target.value)} disabled={readOnly} />
{form.product_url && (
<a className="btn" href={form.product_url} target="_blank" rel="noreferrer"
title="Im Onlineshop öffnen">Öffnen</a>
)}
</div>
</label>
</div>
{effFields.length > 0 && (
<div style={{ marginTop: "var(--sp-2)" }}>
<div className="card-head" style={{ marginBottom: "var(--sp-2)" }}>
<Icon name="tag" /><h2>Eigene Felder</h2>
</div>
<DynamicFields fields={effFields} values={fieldValues}
onChange={setField} disabled={readOnly} />
</div>
)}
</>)}
{isAdmin && ( {isAdmin && (
<div className="field-inline" style={{ marginTop: "var(--sp-2)" }}> <div className="field-inline" style={{ marginTop: "var(--sp-2)" }}>
<button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button> <button className="btn primary" disabled={busy}>{busy ? "Speichern…" : "Speichern"}</button>
@@ -500,7 +589,21 @@ export default function ProductForm() {
{readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>} {readOnly && <p className="muted small mt-0">Nur Administratoren können Produkte bearbeiten.</p>}
</form> </form>
{!isNew && ( {!isNew && (isObject ? (
product && (
<ObjektBestand
product={product}
lots={lots}
locations={locations}
isAdmin={isAdmin}
onChanged={async () => {
setLots(await api.listLots(id));
setProduct(await api.getProduct(id));
}}
onError={setError}
/>
)
) : (
<LotsCard <LotsCard
product={product} product={product}
lots={lots} lots={lots}
@@ -513,7 +616,7 @@ export default function ProductForm() {
}} }}
onError={setError} onError={setError}
/> />
)} ))}
</div> </div>
{/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen {/* Unterhalb der beiden Spalten: die Codes braucht man selten, sie sollen

131
web/src/pages/Shops.jsx Normal file
View File

@@ -0,0 +1,131 @@
import { useEffect, useState } from "react";
import { api } from "../api";
import { useConfirm } from "../confirm";
import { useAuth } from "../auth";
import Icon from "../components/Icon";
/**
* Shops / Bezugsquellen: verwaltbare Liste für „gekauft bei“ an Gegenständen.
* Am Artikel ist die Auswahl optional (unbekannt / mehrere Herkünfte = leer).
*/
export default function Shops() {
const confirm = useConfirm();
const { isAdmin } = useAuth();
const [shops, setShops] = useState([]);
const [form, setForm] = useState({ name: "", website: "" });
const [error, setError] = useState(null);
async function load() {
try {
setShops(await api.listShops());
} catch (err) {
setError(err.message);
}
}
useEffect(() => { load(); }, []);
async function add(e) {
e.preventDefault();
setError(null);
try {
await api.createShop({ name: form.name, website: form.website || null });
setForm({ name: "", website: "" });
load();
} catch (err) {
setError(err.message);
}
}
async function patch(shop, body) {
setError(null);
try {
await api.updateShop(shop.id, body);
load();
} catch (err) {
setError(err.message);
}
}
async function remove(shop) {
const hinweis = shop.product_count
? ` ${shop.product_count} Artikel verlieren die Bezugsquelle, bleiben aber erhalten.`
: "";
const ok = await confirm({
title: `Shop „${shop.name}“ löschen?`,
message: `Die Liste ist nur eine Auswahlhilfe.${hinweis}`,
confirmLabel: "Löschen",
danger: true,
});
if (!ok) return;
try {
await api.deleteShop(shop.id);
load();
} catch (err) {
setError(err.message);
}
}
return (
<div>
<div className="page-head">
<div>
<h1>Shops</h1>
<div className="sub">
Bezugsquellen für gekauft bei an Gegenständen einmal anlegen, überall auswählbar.
</div>
</div>
</div>
{error && <div className="alert error"><Icon name="alert" size={16} />{error}</div>}
<div className="card form-narrow">
{isAdmin && (
<form onSubmit={add}>
<div className="row">
<label className="grow">
Name
<input placeholder="z.B. MediaMarkt" value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })} required />
</label>
<label className="grow">
Website (optional)
<input placeholder="https://…" value={form.website}
onChange={(e) => setForm({ ...form, website: e.target.value })} />
</label>
</div>
<button className="btn primary"><Icon name="plus" size={16} />Hinzufügen</button>
</form>
)}
<ul className="simple-list">
{shops.map((s) => (
<li key={s.id}>
<span style={{ display: "flex", alignItems: "center", gap: 8, flex: 1 }}>
<Icon name="cart" size={15} className="muted" />
{isAdmin ? (
<input defaultValue={s.name} style={{ marginTop: 0, minWidth: 140 }}
onBlur={(e) => {
const v = e.target.value.trim();
if (v && v !== s.name) patch(s, { name: v });
}} />
) : <span className="strong">{s.name}</span>}
{s.website && (
<a href={s.website} target="_blank" rel="noreferrer" className="muted small">
{s.website.replace(/^https?:\/\//, "")}
</a>
)}
{s.product_count > 0 && <span className="badge nowrap">{s.product_count} Artikel</span>}
</span>
{isAdmin && (
<button className="btn-icon danger" onClick={() => remove(s)} title="Löschen">
<Icon name="trash" size={16} />
</button>
)}
</li>
))}
{shops.length === 0 && <li className="empty">Noch keine Shops.</li>}
</ul>
</div>
</div>
);
}

View File

@@ -839,3 +839,22 @@ select.zeitraum:hover { color: var(--text); }
.aktion-zeile { display: flex; gap: 8px; align-items: center; } .aktion-zeile { display: flex; gap: 8px; align-items: center; }
.aktion-zeile .btn { flex: 1; justify-content: center; } .aktion-zeile .btn { flex: 1; justify-content: center; }
.aktion-menge { width: 72px; text-align: right; } .aktion-menge { width: 72px; text-align: right; }
/* ---- Gegenstände (Non-Food): eigene Felder, Bestand je Ort, Umlagern ---- */
.stack { display: flex; flex-direction: column; gap: var(--sp-3); }
.clean-list { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.clean-list li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 8px; background: var(--surface-2); border-radius: 8px; }
.check-inline { display: flex; align-items: center; gap: var(--sp-2); }
.check-inline input { width: auto; margin: 0; }
.feldkopf { display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
tr.row-active { background: var(--surface-2); }
/* Bestand je Lagerort */
.ort-zeile { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; }
.ort-zeile .name { flex: 1; }
.ort-menge { width: 90px; text-align: right; }
/* Eingebettetes Unter-Panel (Umlagern-/Entfernen-Dialog) */
.card-sub { border: 1px solid var(--border); border-radius: var(--radius); padding: var(--sp-3); background: var(--surface-2); }
.card-sub .card-head { margin-top: 0; }
.card-sub h3 { margin: 0; font-size: 15px; }