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:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
]
|
||||||
|
|||||||
143
backend/app/routers/field_definitions.py
Normal file
143
backend/app/routers/field_definitions.py
Normal 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()
|
||||||
@@ -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:
|
||||||
|
|||||||
88
backend/app/routers/shops.py
Normal file
88
backend/app/routers/shops.py
Normal 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()
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
139
backend/app/services/fields.py
Normal file
139
backend/app/services/fields.py
Normal 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
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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 = (
|
||||||
|
|||||||
238
backend/tests/test_gegenstaende.py
Normal file
238
backend/tests/test_gegenstaende.py
Normal 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
327
ios/Sources/ObjectStockView.swift
Normal file
327
ios/Sources/ObjectStockView.swift
Normal 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 */,
|
||||||
|
|||||||
@@ -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>} />
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
295
web/src/components/ObjektBestand.jsx
Normal file
295
web/src/components/ObjektBestand.jsx
Normal 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
99
web/src/fields.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
131
web/src/pages/Shops.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user