From 5b952524d7c20da50db620139570f6659f163d8f Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Sat, 25 Jul 2026 15:33:56 +0200 Subject: [PATCH] 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 --- backend/app/config.py | 4 +- backend/app/crud.py | 17 +- backend/app/main.py | 18 ++ backend/app/models.py | 129 +++++++++ backend/app/off.py | 31 ++- backend/app/routers/categories.py | 61 ++++- backend/app/routers/field_definitions.py | 143 ++++++++++ backend/app/routers/products.py | 101 ++++++- backend/app/routers/shops.py | 88 ++++++ backend/app/routers/stock.py | 90 ++++++- backend/app/routers/transfer.py | 131 ++++++++- backend/app/schemas.py | 146 +++++++++- backend/app/seed.py | 96 ++++++- backend/app/services/fields.py | 139 ++++++++++ backend/app/services/stock.py | 175 ++++++++++++ backend/tests/test_gegenstaende.py | 238 +++++++++++++++++ ios/Sources/APIClient.swift | 37 +++ ios/Sources/Models.swift | 159 ++++++++++- ios/Sources/ObjectStockView.swift | 327 +++++++++++++++++++++++ ios/Sources/ProductDetailView.swift | 196 ++++++++++---- ios/Vorrania.xcodeproj/project.pbxproj | 4 + web/src/App.jsx | 3 + web/src/api.js | 22 +- web/src/components/ObjektBestand.jsx | 295 ++++++++++++++++++++ web/src/fields.jsx | 99 +++++++ web/src/pages/Categories.jsx | 211 +++++++++++++-- web/src/pages/ProductForm.jsx | 121 ++++++++- web/src/pages/Shops.jsx | 131 +++++++++ web/src/styles.css | 19 ++ 29 files changed, 3116 insertions(+), 115 deletions(-) create mode 100644 backend/app/routers/field_definitions.py create mode 100644 backend/app/routers/shops.py create mode 100644 backend/app/services/fields.py create mode 100644 backend/tests/test_gegenstaende.py create mode 100644 ios/Sources/ObjectStockView.swift create mode 100644 web/src/components/ObjektBestand.jsx create mode 100644 web/src/fields.jsx create mode 100644 web/src/pages/Shops.jsx diff --git a/backend/app/config.py b/backend/app/config.py index 281ee29..f7de33c 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -18,8 +18,10 @@ class Settings(BaseSettings): admin_username: str = "admin" admin_password: str = "changeme" - # Open Food Facts + # Open Food Facts (Lebensmittel) 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 # Behaviour diff --git a/backend/app/crud.py b/backend/app/crud.py index 003d2cb..c902c95 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -7,12 +7,25 @@ from datetime import date from fastapi import HTTPException, status 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 .services.conversion import KIND_OF_BASE, display_unit_info 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: out = ProductOut.model_validate(product) 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() ] 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 name, factor = display_unit_info(product) out.unit_name = name diff --git a/backend/app/main.py b/backend/app/main.py index ed2f374..a8f6be6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,12 +12,14 @@ from .routers import ( branding, categories, dashboard, + field_definitions, groups, locations, maintenance, package_types, products, settings as settings_router, + shops, stock, transfer, units, @@ -28,6 +30,7 @@ from .seed import ( ensure_builtin_categories, ensure_builtin_package_types, ensure_builtin_units, + ensure_example_object_categories, ensure_first_admin, ) from .services.group_codes import backfill as backfill_group_codes @@ -66,6 +69,18 @@ def _ensure_schema() -> None: "NOT NULL DEFAULT 'Übersicht'", "ALTER TABLE dashboard_layouts ADD COLUMN IF NOT EXISTS position INTEGER " "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: for stmt in stmts: @@ -93,6 +108,7 @@ async def lifespan(app: FastAPI): ensure_builtin_units(db) ensure_builtin_package_types(db) ensure_builtin_categories(db) + ensure_example_object_categories(db) ensure_first_admin(db) # Codes bestehender Gruppen-Zuordnungen nachziehen. backfill_group_codes(db) @@ -136,3 +152,5 @@ app.include_router(branding.router) app.include_router(categories.router) app.include_router(maintenance.router) app.include_router(dashboard.router) +app.include_router(shops.router) +app.include_router(field_definitions.router) diff --git a/backend/app/models.py b/backend/app/models.py index 1eabea9..8d582ef 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -60,6 +60,39 @@ class UnitKind(str, enum.Enum): 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): """Vom Admin verwaltbare Einheit mit Umrechnungsfaktor zur kanonischen Basis. @@ -130,6 +163,15 @@ class Category(Base): ForeignKey("categories.id", ondelete="SET NULL"), nullable=True ) 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): @@ -181,15 +223,26 @@ class Product(Base): 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 + + # 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) group: Mapped[Group | None] = relationship(back_populates="products") category: Mapped[Category | None] = relationship() + shop: Mapped[Shop | None] = relationship() display_unit: Mapped[Unit | None] = relationship(foreign_keys=[display_unit_id]) min_stock_unit: Mapped[Unit | None] = relationship(foreign_keys=[min_stock_unit_id]) lots: Mapped[list[Lot]] = relationship( back_populates="product", cascade="all, delete-orphan" ) + field_values: Mapped[list[ProductFieldValue]] = relationship( + back_populates="product", cascade="all, delete-orphan" + ) class ApiToken(Base): @@ -274,6 +327,14 @@ class Movement(Base): quantity: Mapped[float] = mapped_column(Float, nullable=False) # in base units unit_used: Mapped[str] = mapped_column(String(32), nullable=False) # what user entered note: Mapped[str | None] = mapped_column(String(255), nullable=True) + # 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) @@ -369,3 +430,71 @@ class Setting(Base): key: Mapped[str] = mapped_column(String(64), primary_key=True) 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() diff --git a/backend/app/off.py b/backend/app/off.py index 07ed1be..de0b9bb 100644 --- a/backend/app/off.py +++ b/backend/app/off.py @@ -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) -def lookup_barcode(barcode: str) -> dict | None: - """Fragt Open Food Facts nach einem Barcode. +def _lookup_at(barcode: str, base_url: str, source: str) -> dict | None: + """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 # (HTTP 200). Die v2-API hat kein status-Feld und antwortet mit HTTP 404, # 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: resp = httpx.get( url, @@ -96,6 +97,26 @@ def lookup_barcode(barcode: str) -> dict | None: "quantity_text": product.get("quantity"), "category_suggestion": categories.split(",")[0].strip() if categories else None, "category_tags": category_tags, - "source": "off", + "source": source, "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 diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index 202586a..a4a832d 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -10,8 +10,17 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, require_admin -from ..models import Category, Product, User -from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate +from ..models import ( + 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"]) @@ -61,9 +70,19 @@ def create_category( db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> CategoryOut: - if payload.parent_id is not None and db.get(Category, payload.parent_id) is None: - raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden") - category = Category(name=payload.name, parent_id=payload.parent_id) + 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") + # 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.commit() db.refresh(category) @@ -95,6 +114,9 @@ def update_category( "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(): setattr(category, field, value) db.commit() @@ -120,6 +142,35 @@ def delete_category( db.query(Category).filter(Category.parent_id == category_id).update( {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.commit() 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) + ] diff --git a/backend/app/routers/field_definitions.py b/backend/app/routers/field_definitions.py new file mode 100644 index 0000000..feec84d --- /dev/null +++ b/backend/app/routers/field_definitions.py @@ -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() diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index 9f0701c..e962c1a 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -2,17 +2,41 @@ from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import Response 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 ..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 ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate +from ..schemas import ( + BarcodeCreate, + LookupResult, + ProductCreate, + ProductOut, + ProductUpdate, + RemovalHistoryItem, + RemovalStat, + RemovalSummary, +) from ..services import images from ..services.categories import suggest_category from .categories import descendant_ids 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.stock import removal_stats router = APIRouter(prefix="/products", tags=["products"]) @@ -92,6 +116,56 @@ def get_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) def off_vergleich( product_id: int, @@ -123,8 +197,10 @@ def off_vergleich( "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: - suggestion = lookup_barcode(code) + suggestion = lookup_barcode(code, prefer=prefer) if suggestion: category = suggest_category(db, suggestion) return LookupResult( @@ -183,6 +259,8 @@ def create_product( base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc + 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( barcode=payload.barcode or None, name=payload.name, @@ -198,11 +276,17 @@ def create_product( min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, min_stock_in_packages=bool(payload.min_stock_in_packages), + shop_id=payload.shop_id, + product_url=payload.product_url or None, source="manual", ) db.add(product) db.flush() # product.id fuer den Gruppen-Code 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: # Gleich beim Anlegen holen. Schlaegt es fehl, entsteht kein Fehler: # 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") 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"]: clash = ( db.query(Product) @@ -234,6 +320,8 @@ def update_product( raise HTTPException( 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. if "unit_id" in data: unit_id = data.pop("unit_id") @@ -253,6 +341,11 @@ def update_product( data["date_precision"] = data["date_precision"].value for field, value in data.items(): 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. sync_group_code(db, product) if "image_url" in data: diff --git a/backend/app/routers/shops.py b/backend/app/routers/shops.py new file mode 100644 index 0000000..bdc4ee3 --- /dev/null +++ b/backend/app/routers/shops.py @@ -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() diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index 3522f7b..8e7c8af 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -1,10 +1,10 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.orm import Session -from ..crud import resolve_product +from ..crud import product_tracking, resolve_product from ..database import get_db from ..deps import get_current_user -from ..models import Lot, Movement, MovementType, User +from ..models import CategoryTracking, Lot, Movement, MovementType, User from ..schemas import ( BatchCheckInRequest, BatchCheckInResponse, @@ -14,6 +14,9 @@ from ..schemas import ( CheckOutResponse, LotOut, LotUpdate, + RelocateRequest, + RemoveRequest, + StockActionResponse, ) from ..services.conversion import ConversionError from ..services.dates import clean_precision, normalize_best_before @@ -23,10 +26,15 @@ from ..services.stock import ( check_out, check_out_lot, current_stock, + object_add, + object_relocate, + object_remove, ) router = APIRouter(tags=["stock"]) +OBJECT = CategoryTracking.object.value + @router.post("/stock/checkin", response_model=CheckInResponse) def stock_checkin( @@ -35,6 +43,21 @@ def stock_checkin( user: User = Depends(get_current_user), ) -> CheckInResponse: 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: lot = check_in( db, @@ -103,6 +126,12 @@ def stock_checkout( user: User = Depends(get_current_user), ) -> CheckOutResponse: 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: if payload.lot_id is not None: 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]) def list_lots( product_id: int | None = None, diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 709b11e..482daf3 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -22,18 +22,28 @@ from ..services.group_codes import sync as sync_group_code from ..models import ( BaseUnit, Category, + CategoryTracking, DatePrecision, + FieldDefinition, Group, Location, Lot, Movement, MovementType, Product, + Shop, Unit, UnitKind, User, ) 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"]) @@ -128,7 +138,7 @@ def export_backup_json( loc_name = {loc.id: loc.name for loc in locations} data = { - "version": 1, + "version": 2, "exported_at": datetime.now(timezone.utc).isoformat(), "exported_at_local": datetime.now().isoformat(timespec="seconds"), "units": [ @@ -147,6 +157,32 @@ def export_backup_json( {"name": loc.name, "parent": loc_name.get(loc.parent_id)} 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": [], } @@ -168,6 +204,12 @@ def export_backup_json( "min_stock": p.min_stock, "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), + # 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": [ { "quantity": lot.quantity, @@ -307,8 +349,16 @@ def _category_path(db: Session, category: Category | None) -> str: return " > ".join(reversed(teile)) -def _get_or_create_category(db: Session, path: str | None) -> Category | None: - """Legt den ganzen Pfad an, falls Teile davon fehlen.""" +def _get_or_create_category( + 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() if not path: return None @@ -320,13 +370,29 @@ def _get_or_create_category(db: Session, path: str | None) -> Category | None: ) node = query.first() 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.flush() parent = node 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: name = (name or "").strip() 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: 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", []): try: 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 "", } 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: _clear_lots(db, product, user) cleared.add(product.id) diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 2b23cad..61cd1e3 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -2,9 +2,17 @@ from __future__ import annotations 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 ---- @@ -131,23 +139,86 @@ class GroupUpdate(BaseModel): # ---- Categories ---- class CategoryOut(BaseModel): - """Reine Ordnungshilfe – kein Bestand, kein Mindestbestand, keine EAN-Codes.""" + """Ordnungshilfe plus Verwaltungsart (Lebensmittel/Gegenstand).""" model_config = ConfigDict(from_attributes=True) id: int name: str parent_id: int | None = None is_builtin: bool = False + # "food" = Chargen+MHD, "object" = Menge je Lagerort. + tracking: CategoryTracking = CategoryTracking.food product_count: int = 0 class CategoryCreate(BaseModel): name: str = Field(min_length=1, max_length=120) parent_id: int | None = None + # None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand. + tracking: CategoryTracking | None = None class CategoryUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) 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 ---- @@ -207,6 +278,11 @@ class ProductBase(BaseModel): # Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde. min_stock_unit_id: int | None = None 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): @@ -228,6 +304,9 @@ class ProductUpdate(BaseModel): min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | 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): @@ -249,6 +328,8 @@ class ProductOut(BaseModel): min_stock_unit_id: int | None = None min_stock_in_packages: bool = False source: str + shop_id: int | None = None + product_url: str | None = None created_at: datetime # angereichert: stock: float = 0.0 @@ -261,6 +342,18 @@ class ProductOut(BaseModel): min_stock_unit_label: str = "" # Zusätzliche EAN-Codes (neben dem Haupt-Barcode): 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): @@ -358,6 +451,53 @@ class CheckOutResponse(BaseModel): 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 ---- class ShoppingItem(BaseModel): product_id: int diff --git a/backend/app/seed.py b/backend/app/seed.py index bdc16be..1a03fc5 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -1,10 +1,13 @@ """Startup-Seeds: erster Admin-Benutzer und die eingebauten Einheiten.""" +import json + from sqlalchemy.orm import Session 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 .services.fields import slugify # (Name, Art, Faktor zur kanonischen Basiseinheit) 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: return + food = CategoryTracking.food.value 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.flush() 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() diff --git a/backend/app/services/fields.py b/backend/app/services/fields.py new file mode 100644 index 0000000..257a5f5 --- /dev/null +++ b/backend/app/services/fields.py @@ -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 + ) + ) diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index d6e035b..0db6bf9 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -16,6 +16,181 @@ class StockError(ValueError): """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: """Summe der Lot-Mengen eines Produkts (in Basiseinheiten).""" total = ( diff --git a/backend/tests/test_gegenstaende.py b/backend/tests/test_gegenstaende.py new file mode 100644 index 0000000..07cfcb1 --- /dev/null +++ b/backend/tests/test_gegenstaende.py @@ -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 diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index c0bc7b2..ca759c6 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -188,6 +188,43 @@ actor APIClient { 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 func dashboardStats() async throws -> DashboardStats { diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 870f699..dd6a5a3 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -66,9 +66,17 @@ struct Product: Codable, Identifiable, Hashable { /// Mindestbestand in der erfassten Einheit, fuer die Anzeige. let minStockDisplay: Double? 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 { - case id, barcode, name, brand, stock, kind, barcodes + case id, barcode, name, brand, stock, kind, barcodes, tracking case datePrecision = "date_precision" case categoryId = "category_id" case categoryName = "category_name" @@ -83,8 +91,15 @@ struct Product: Codable, Identifiable, Hashable { case expiredCount = "expired_count" case unitName = "unit_name" 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). var articleUnitLabel: String { if let size = packageSize, size > 0 { return packageLabel ?? "Packung" } @@ -353,6 +368,10 @@ struct ProductUpdateRequest: Encodable { var datePrecision: String? var groupId: 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 { case name, brand @@ -361,6 +380,9 @@ struct ProductUpdateRequest: Encodable { case datePrecision = "date_precision" case groupId = "group_id" case categoryId = "category_id" + case shopId = "shop_id" + case productUrl = "product_url" + case fieldValues = "field_values" } func encode(to encoder: Encoder) throws { @@ -372,6 +394,10 @@ struct ProductUpdateRequest: Encodable { try container.encode(datePrecision, forKey: .datePrecision) try container.encode(groupId, forKey: .groupId) 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 } -/// 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 { let id: Int let name: String let parentId: Int? + let tracking: String? enum CodingKeys: String, CodingKey { - case id, name + case id, name, tracking 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 diff --git a/ios/Sources/ObjectStockView.swift b/ios/Sources/ObjectStockView.swift new file mode 100644 index 0000000..13aa4b5 --- /dev/null +++ b/ios/Sources/ObjectStockView.swift @@ -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 { + 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 { + 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 } + } +} diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index 59ec3db..98accf9 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -23,6 +23,14 @@ struct ProductDetailView: View { @State private var groups: [GroupItem] = [] @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? private let labels = ["", "Glas", "Tüte", "Flasche", "Dose", "Tube", "Becher", "Beutel", "Karton"] @@ -41,12 +49,17 @@ struct ProductDetailView: View { Section { Text(error).foregroundStyle(.red).font(.callout) } } - Section("Bestand") { - LabeledContent("Vorrat", - value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") - if current.expiredCount > 0 { - LabeledContent("Abgelaufen", value: "\(current.expiredCount)") - .foregroundStyle(.red) + if current.isObject { + ObjectStockSection(product: current, locations: locations, lots: lots, + onChanged: { await reload() }) + } else { + Section("Bestand") { + LabeledContent("Vorrat", + value: "\(formatAmount(current.stockInArticleUnits)) \(current.articleUnitLabel)") + if current.expiredCount > 0 { + LabeledContent("Abgelaufen", value: "\(current.expiredCount)") + .foregroundStyle(.red) + } } } @@ -55,33 +68,57 @@ struct ProductDetailView: View { Section("Artikel") { LabeledField(label: "Name", text: $name) LabeledField(label: "Marke", text: $brand) - QuantityField(label: "Packungsgröße", text: $packageSize, - suffix: display.baseUnitLabel(current.baseUnit)) - Picker("Bezeichnung", selection: $packageLabel) { - ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } - } - Picker("MHD-Angabe", selection: $datePrecision) { - Text("Tagesdatum").tag("day") - Text("nur Monat/Jahr").tag("month") + if !current.isObject { + QuantityField(label: "Packungsgröße", text: $packageSize, + suffix: display.baseUnitLabel(current.baseUnit)) + Picker("Bezeichnung", selection: $packageLabel) { + ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } + } + Picker("MHD-Angabe", selection: $datePrecision) { + Text("Tagesdatum").tag("day") + Text("nur Monat/Jahr").tag("month") + } } } Section { CategoryPicker(categories: categories, selection: $categoryId) } 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.") } - Section { - Picker("Gruppe", selection: $groupId) { - Text("– keine –").tag(Int?.none) - ForEach(groups) { gruppe in - Text(gruppe.name).tag(Int?.some(gruppe.id)) + 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) } } - } footer: { - Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " - + "wandert bei einem Wechsel mit.") + if !fieldDefs.isEmpty { + Section("Eigene Felder") { + ForEach(fieldDefs) { def in + ObjectFieldRow(def: def, value: fieldBinding(def)) + } + } + } + } else { + Section { + Picker("Gruppe", selection: $groupId) { + Text("– keine –").tag(Int?.none) + ForEach(groups) { gruppe in + Text(gruppe.name).tag(Int?.some(gruppe.id)) + } + } + } footer: { + Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " + + "wandert bei einem Wechsel mit.") + } } Section("Erkennung") { @@ -96,28 +133,30 @@ struct ProductDetailView: View { .disabled(busy || name.isEmpty) } - Section("Chargen") { - ForEach(lots) { lot in - Button { - editLot = lot - } label: { - HStack { - VStack(alignment: .leading, spacing: 2) { - Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") - Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") - .font(.caption).foregroundStyle(.secondary) + if !current.isObject { + Section("Chargen") { + ForEach(lots) { lot in + Button { + editLot = lot + } label: { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("MHD \(display.formatBestBefore(lot.bestBefore, precision: lot.bestBeforePrecision))") + Text("\(formatAmount(lot.quantity / current.articleUnitFactor)) \(current.articleUnitLabel)") + .font(.caption).foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right").foregroundStyle(.tertiary) } - Spacer() - Image(systemName: "chevron.right").foregroundStyle(.tertiary) } + .foregroundStyle(.primary) + } + .onDelete { indexSet in + Task { await deleteLots(at: indexSet) } + } + if lots.isEmpty { + Text("Keine Chargen im Bestand.").foregroundStyle(.secondary) } - .foregroundStyle(.primary) - } - .onDelete { indexSet in - Task { await deleteLots(at: indexSet) } - } - if lots.isEmpty { - Text("Keine Chargen im Bestand.").foregroundStyle(.secondary) } } @@ -142,6 +181,16 @@ struct ProductDetailView: View { fillForm() 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() { @@ -152,15 +201,37 @@ struct ProductDetailView: View { datePrecision = current.datePrecision == "month" ? "month" : "day" groupId = current.groupId 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 { + let key = String(def.id) + return Binding(get: { fieldValues[key] ?? "" }, set: { fieldValues[key] = $0 }) } private func reload() async { groups = (try? await APIClient.shared.groups()) ?? [] categories = (try? await APIClient.shared.categories()) ?? [] 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) { current = frisch } + if current.isObject, let cid = current.categoryId { + fieldDefs = (try? await APIClient.shared.categoryFields(categoryId: cid)) ?? [] + } else { + fieldDefs = [] + } } private func save() async { @@ -169,18 +240,37 @@ struct ProductDetailView: View { busy = true defer { busy = false } do { - current = try await APIClient.shared.updateProduct( - id: current.id, - ProductUpdateRequest( - name: name, - brand: brand.isEmpty ? nil : brand, - packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), - packageLabel: packageLabel.isEmpty ? nil : packageLabel, - datePrecision: datePrecision, - groupId: groupId, - categoryId: categoryId + 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( + id: current.id, + ProductUpdateRequest( + name: name, + brand: brand.isEmpty ? nil : brand, + packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), + packageLabel: packageLabel.isEmpty ? nil : packageLabel, + datePrecision: datePrecision, + groupId: groupId, + categoryId: categoryId + ) + ) + } status = "Gespeichert." } catch { self.error = error.localizedDescription diff --git a/ios/Vorrania.xcodeproj/project.pbxproj b/ios/Vorrania.xcodeproj/project.pbxproj index 0aa1b26..4c47b2e 100644 --- a/ios/Vorrania.xcodeproj/project.pbxproj +++ b/ios/Vorrania.xcodeproj/project.pbxproj @@ -32,6 +32,7 @@ A7017E6C0532A650439AED7B /* BestBeforeText.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA022A27118BEEEC5912ECDC /* BestBeforeText.swift */; }; AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F574168AA0F849D46C384EE /* ProductDetailView.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 */; }; E9EA455CACB6ED5951B114C3 /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A75926511854BB8EE316ED3A /* LoginView.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 = ""; }; 6F574168AA0F849D46C384EE /* ProductDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProductDetailView.swift; sourceTree = ""; }; 717C8EB336170526F5F3E695 /* DateScanView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateScanView.swift; sourceTree = ""; }; + 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectStockView.swift; sourceTree = ""; }; 7DACFBB69CB536BA7CBBD484 /* HistoryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryView.swift; sourceTree = ""; }; 8AF19A735AC17451B57BF5BB /* ScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScannerView.swift; sourceTree = ""; }; 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationSettingsView.swift; sourceTree = ""; }; @@ -100,6 +102,7 @@ 163F5DCB00387215F0EA1F21 /* NotificationScheduler.swift */, 3731608B8D48960DC98912BC /* NotificationSettings.swift */, 939C7B7C2F2F58927ED5F2F1 /* NotificationSettingsView.swift */, + 7B5BB49FBEFF88768B0BC902 /* ObjectStockView.swift */, 6F574168AA0F849D46C384EE /* ProductDetailView.swift */, 369B9841E43E727ACA2E2A2A /* ProductViews.swift */, 4741D0E95875919C921945CF /* RootView.swift */, @@ -222,6 +225,7 @@ 38133EE3EC920B462BDAA29F /* NotificationScheduler.swift in Sources */, 4B4A74E8A2964FF8F3D2CE02 /* NotificationSettings.swift in Sources */, 17032C3F3B89BAD6CB249443 /* NotificationSettingsView.swift in Sources */, + D7717F44D2CDB75DC2693B59 /* ObjectStockView.swift in Sources */, AAF27320D2A67C994C29C26F /* ProductDetailView.swift in Sources */, 0D8629839E465D80EC58E74B /* ProductViews.swift in Sources */, 9B0A1A7C19764857BDC6ED26 /* RootView.swift in Sources */, diff --git a/web/src/App.jsx b/web/src/App.jsx index 63c223e..c7fef98 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -12,6 +12,7 @@ import CheckIn from "./pages/CheckIn"; import CheckOut from "./pages/CheckOut"; import Groups from "./pages/Groups"; import Categories from "./pages/Categories"; +import Shops from "./pages/Shops"; import Locations from "./pages/Locations"; import PackageTypes from "./pages/PackageTypes"; import Units from "./pages/Units"; @@ -80,6 +81,7 @@ function Sidebar() { <>
Verwaltung
+ @@ -136,6 +138,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 365bdc6..8a88fe0 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -152,6 +152,10 @@ export const api = { checkIn: (body) => request("/stock/checkin", { method: "POST", body }), checkInBatch: (body) => request("/stock/checkin/batch", { 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) => request(`/lots${productId ? `?product_id=${productId}` : ""}`), updateLot: (id, body) => request(`/lots/${id}`, { method: "PATCH", body }), @@ -187,11 +191,27 @@ export const api = { createLocation: (body) => request("/locations", { method: "POST", body }), deleteLocation: (id) => request(`/locations/${id}`, { method: "DELETE" }), - // Kategorien: reine Ordnungshilfe, verschachtelbar (siehe Gruppen fuer Bestaende) + // Kategorien: Ordnungshilfe + Verwaltungsart (food/object), verschachtelbar listCategories: () => request("/categories"), createCategory: (body) => request("/categories", { method: "POST", body }), updateCategory: (id, body) => request(`/categories/${id}`, { method: "PATCH", body }), 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" listPackageTypes: () => request("/package-types"), diff --git a/web/src/components/ObjektBestand.jsx b/web/src/components/ObjektBestand.jsx new file mode 100644 index 0000000..abef4e3 --- /dev/null +++ b/web/src/components/ObjektBestand.jsx @@ -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 ( +
+
+

Bestand je Lagerort

+ + gesamt {fmt(gesamt)} {einheit} + +
+ +
+ + + + + + {lots.map((l) => ( + + + + + + ))} + {lots.length === 0 && } + +
LagerortMenge
{ortName(l.location_id)} + {editId === l.id ? ( + setEditQty(e.target.value)} /> + ) : ( + <>{fmt(l.quantity)} {einheit} + )} + + {isAdmin && (editId === l.id ? ( +
+ + +
+ ) : ( +
+ + + +
+ ))} +
Noch kein Bestand.
+
+ + {isAdmin && ( +
+ + + +
+ )} + + {/* Umlagern-Dialog */} + {isAdmin && move && ( +
+

Umlagern

+ +
+
+ + + +
+ +
+ )} + + {/* Entfernen-Dialog (Grund Pflicht) */} + {isAdmin && remove && ( +
+

Aus Bestand entfernen

+ +
+
+ + + +
+ + +
+ )} + + {/* Entnahme-Statistik */} + {removals.stats.length > 0 && ( +
+
+ Bereits entnommen: + {removals.stats.map((s) => ( + + {reasonLabel(s.reason)}: {fmt(s.quantity)} + + ))} +
+ {removals.history.length > 0 && ( +
    + {removals.history.slice(0, 5).map((h, i) => ( +
  • + {reasonLabel(h.reason)} + {fmt(h.quantity)} {einheit} + {h.location_name && aus {h.location_name}} + {h.note && – {h.note}} + + {new Date(h.created_at).toLocaleDateString("de-DE")} + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/web/src/fields.jsx b/web/src/fields.jsx new file mode 100644 index 0000000..ac86ec7 --- /dev/null +++ b/web/src/fields.jsx @@ -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 ( +