diff --git a/backend/app/crud.py b/backend/app/crud.py index 487d9e6..003d2cb 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -30,6 +30,7 @@ def product_to_out(db: Session, product: Product) -> ProductOut: BarcodeOut.model_validate(b) 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.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 1c77946..f51d0d2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -10,6 +10,7 @@ from .routers import ( api_tokens, auth, branding, + categories, groups, locations, products, @@ -20,7 +21,7 @@ from .routers import ( users, views, ) -from .seed import ensure_builtin_units, ensure_first_admin +from .seed import ensure_builtin_categories, ensure_builtin_units, ensure_first_admin settings = get_settings() @@ -49,6 +50,8 @@ def _ensure_schema() -> None: "NOT NULL DEFAULT 'day'", "ALTER TABLE lots ADD COLUMN IF NOT EXISTS best_before_precision VARCHAR(8) " "NOT NULL DEFAULT 'day'", + "ALTER TABLE products ADD COLUMN IF NOT EXISTS category_id INTEGER " + "REFERENCES categories(id) ON DELETE SET NULL", ] with engine.begin() as conn: for stmt in stmts: @@ -63,6 +66,7 @@ async def lifespan(app: FastAPI): db = SessionLocal() try: ensure_builtin_units(db) + ensure_builtin_categories(db) ensure_first_admin(db) finally: db.close() @@ -100,3 +104,4 @@ app.include_router(transfer.router) app.include_router(api_tokens.router) app.include_router(settings_router.router) app.include_router(branding.router) +app.include_router(categories.router) diff --git a/backend/app/models.py b/backend/app/models.py index 0503700..9e04685 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -111,6 +111,27 @@ class Location(Base): ) +class Category(Base): + """Einordnung eines Artikels **allein für den Überblick**. + + Nicht zu verwechseln mit :class:`Group`: Eine Gruppe fasst Bestände mehrerer + Marken zusammen (5 kg Mehl, egal von wem) und trägt Mindestbestand und + EAN-Codes. Eine Kategorie tut nichts dergleichen – sie hilft nur, in einer + langen Artikelliste "zeig mir alle Süßwaren" sagen zu können. + + Beliebig tief verschachtelbar (Süßwaren → Schokolade), wie :class:`Location`. + """ + + __tablename__ = "categories" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + parent_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id", ondelete="SET NULL"), nullable=True + ) + is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + class Product(Base): __tablename__ = "products" __table_args__ = (UniqueConstraint("barcode", name="uq_products_barcode"),) @@ -140,9 +161,15 @@ class Product(Base): server_default=DatePrecision.day.value, ) + # Gruppe: zaehlt Bestaende mehrerer Marken zusammen. group_id: Mapped[int | None] = mapped_column( ForeignKey("groups.id", ondelete="SET NULL"), nullable=True ) + # Kategorie: reine Ordnungshilfe fuer die Artikelliste, voellig unabhaengig + # von der Gruppe. Ein Artikel kann beides, eines oder keines haben. + category_id: Mapped[int | None] = mapped_column( + ForeignKey("categories.id", ondelete="SET NULL"), nullable=True + ) min_stock: Mapped[float | None] = mapped_column(Float, nullable=True) # in base units # In welcher Einheit der Mindestbestand erfasst wurde (nur für die Anzeige). min_stock_unit_id: Mapped[int | None] = mapped_column( @@ -157,6 +184,7 @@ class Product(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) group: Mapped[Group | None] = relationship(back_populates="products") + category: Mapped[Category | 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( diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py new file mode 100644 index 0000000..202586a --- /dev/null +++ b/backend/app/routers/categories.py @@ -0,0 +1,125 @@ +"""Kategorien: Einordnung der Artikel allein für den Überblick. + +Bewusst ohne Bestand, Mindestbestand und EAN-Codes – das ist Sache der Gruppen +(siehe routers/groups.py). Kategorien sind verschachtelbar wie Lagerorte. +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import Response +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 + +router = APIRouter(prefix="/categories", tags=["categories"]) + + +def descendant_ids(db: Session, category_id: int) -> set[int]: + """Die Kategorie samt allen Unterkategorien. + + Wird für den Produktfilter gebraucht: Wer auf "Süßwaren" filtert, will auch + die Artikel aus "Schokolade" sehen. Die Menge ist klein, deshalb in Python + statt mit einer rekursiven Abfrage. + """ + alle = db.query(Category.id, Category.parent_id).all() + kinder: dict[int, list[int]] = {} + for kid, parent in alle: + kinder.setdefault(parent, []).append(kid) + + gefunden: set[int] = set() + offen = [category_id] + while offen: + aktuell = offen.pop() + if aktuell in gefunden: + continue # schützt zusätzlich vor einem Ring in den Daten + gefunden.add(aktuell) + offen.extend(kinder.get(aktuell, [])) + return gefunden + + +def _to_out(db: Session, category: Category) -> CategoryOut: + out = CategoryOut.model_validate(category) + out.product_count = ( + db.query(Product).filter(Product.category_id == category.id).count() + ) + return out + + +@router.get("", response_model=list[CategoryOut]) +def list_categories( + db: Session = Depends(get_db), _: User = Depends(get_current_user) +) -> list[CategoryOut]: + rows = db.query(Category).order_by(Category.name).all() + return [_to_out(db, c) for c in rows] + + +@router.post("", response_model=CategoryOut, status_code=status.HTTP_201_CREATED) +def create_category( + payload: CategoryCreate, + 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) + db.add(category) + db.commit() + db.refresh(category) + return _to_out(db, category) + + +@router.patch("/{category_id}", response_model=CategoryOut) +def update_category( + category_id: int, + payload: CategoryUpdate, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> CategoryOut: + category = db.get(Category, category_id) + if category is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden") + + data = payload.model_dump(exclude_unset=True) + if "parent_id" in data and data["parent_id"] is not None: + neuer_elternteil = data["parent_id"] + if db.get(Category, neuer_elternteil) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Oberkategorie nicht gefunden") + # Ohne diese Prüfung entstünde ein Ring und jede Baumdarstellung + # (Oberfläche wie Filter) liefe endlos. + if neuer_elternteil in descendant_ids(db, category_id): + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Eine Kategorie kann nicht sich selbst oder einer ihrer " + "Unterkategorien untergeordnet werden.", + ) + + for field, value in data.items(): + setattr(category, field, value) + db.commit() + db.refresh(category) + return _to_out(db, category) + + +@router.delete("/{category_id}") +def delete_category( + category_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> Response: + """Artikel und Unterkategorien bleiben bestehen, sie verlieren nur die Zuordnung.""" + category = db.get(Category, category_id) + if category is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden") + + # SQLite setzt Fremdschlüssel nicht ohne Weiteres um, deshalb ausdrücklich. + db.query(Product).filter(Product.category_id == category_id).update( + {Product.category_id: None} + ) + db.query(Category).filter(Category.parent_id == category_id).update( + {Category.parent_id: None} + ) + db.delete(category) + db.commit() + return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/groups.py b/backend/app/routers/groups.py index 554549a..dddf363 100644 --- a/backend/app/routers/groups.py +++ b/backend/app/routers/groups.py @@ -111,23 +111,40 @@ def update_group( return _group_to_out(db, group) -def _describe_conflict(db: Session, code: str) -> str | None: +def _describe_conflict(db: Session, code: str, group_id: int) -> str | None: """Sagt, wem ein Code schon gehört – "bereits vergeben" allein half nicht weiter.""" product = db.query(Product).filter(Product.barcode == code).first() existing = db.query(Barcode).filter(Barcode.code == code).first() if existing is not None and existing.product_id: product = db.get(Product, existing.product_id) or product + if product is not None: + if product.group_id == group_id: + # Der Code steht durch den Artikel ohnehin schon in dieser Gruppe. + return ( + f"Dieser Code gehört zum Artikel \"{product.name}\" und ist über ihn " + "bereits in dieser Gruppe." + ) + if product.group_id is not None: + other = db.get(Group, product.group_id) + name = other.name if other else "einer anderen Gruppe" + return ( + f"Dieser Code gehört zum Artikel \"{product.name}\", der in der Gruppe " + f"\"{name}\" liegt. Ändere die Gruppe des Artikels, statt den Code hier " + "einzutragen." + ) return ( - f"Dieser Code gehört bereits zum Artikel \"{product.name}\". " - "Artikel-Codes müssen hier nicht eingetragen werden – beim Scannen " - "wird immer zuerst der Artikel gefunden, und dessen Kategorie zählt." + f"Dieser Code gehört bereits zum Artikel \"{product.name}\". Ordne den " + "Artikel dieser Gruppe zu – sein Code erscheint dann automatisch hier." ) + + if existing is not None and existing.group_id == group_id: + return "Dieser Code ist in dieser Gruppe bereits hinterlegt." if existing is not None and existing.group_id: other = db.get(Group, existing.group_id) - name = other.name if other else "einer anderen Kategorie" - return f"Dieser Code ist bereits der Kategorie \"{name}\" zugeordnet." + name = other.name if other else "einer anderen Gruppe" + return f"Dieser Code ist bereits der Gruppe \"{name}\" zugeordnet." if existing is not None: return "Dieser Code ist bereits vergeben." return None @@ -145,7 +162,7 @@ def add_group_barcode( if group is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Gruppe nicht gefunden") code = payload.code.strip() - conflict = _describe_conflict(db, code) + conflict = _describe_conflict(db, code, group.id) if conflict: raise HTTPException(status.HTTP_409_CONFLICT, conflict) db.add(Barcode(code=code, note=(payload.note or None), group_id=group.id)) diff --git a/backend/app/routers/products.py b/backend/app/routers/products.py index be48cf1..921f2de 100644 --- a/backend/app/routers/products.py +++ b/backend/app/routers/products.py @@ -4,10 +4,13 @@ from sqlalchemy.orm import Session from ..crud import product_to_out from ..database import get_db from ..deps import get_current_user, require_admin -from ..models import Barcode, BaseUnit, Group, Product, User +from ..models import Barcode, BaseUnit, Category, Group, Product, User from ..off import lookup_barcode from ..schemas import BarcodeCreate, LookupResult, ProductCreate, ProductOut, ProductUpdate +from ..services.categories import suggest_category +from .categories import descendant_ids from ..services.conversion import ConversionError, resolve_product_unit +from ..services.group_codes import detach as detach_group_code, sync as sync_group_code router = APIRouter(prefix="/products", tags=["products"]) @@ -15,13 +18,24 @@ router = APIRouter(prefix="/products", tags=["products"]) @router.get("", response_model=list[ProductOut]) def list_products( q: str | None = None, + category_id: int | None = None, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[ProductOut]: + """Artikel auflisten, optional nach Name und Kategorie eingegrenzt. + + ``category_id=0`` liefert die Artikel ohne Kategorie. Bei einer echten + Kategorie zählen die Unterkategorien mit: Wer auf "Süßwaren" filtert, will + auch "Schokolade" sehen. + """ query = db.query(Product) if q: like = f"%{q}%" query = query.filter(Product.name.ilike(like)) + if category_id == 0: + query = query.filter(Product.category_id.is_(None)) + elif category_id is not None: + query = query.filter(Product.category_id.in_(descendant_ids(db, category_id))) products = query.order_by(Product.name).all() return [product_to_out(db, p) for p in products] @@ -52,9 +66,14 @@ def lookup( group_id, group_name = group.id, group.name suggestion = lookup_barcode(code) + # Die OFF-Einordnung steuert die KATEGORIE (Ueberblick), nicht die Gruppe. + category = suggest_category(db, suggestion) + category_id = category.id if category else None + category_name = category.name if category else None if suggestion: return LookupResult( - found=True, suggestion=suggestion, group_id=group_id, group_name=group_name + found=True, suggestion=suggestion, group_id=group_id, group_name=group_name, + category_id=category_id, category_name=category_name, ) return LookupResult(found=False, group_id=group_id, group_name=group_name) @@ -101,12 +120,15 @@ def create_product( package_label=payload.package_label or None, date_precision=payload.date_precision.value, group_id=payload.group_id, + category_id=payload.category_id, min_stock=payload.min_stock, min_stock_unit_id=payload.min_stock_unit_id, min_stock_in_packages=bool(payload.min_stock_in_packages), source="manual", ) db.add(product) + db.flush() # product.id fuer den Gruppen-Code + sync_group_code(db, product) db.commit() db.refresh(product) return product_to_out(db, product) @@ -153,6 +175,8 @@ def update_product( data["date_precision"] = data["date_precision"].value for field, value in data.items(): setattr(product, field, value) + # Gruppe oder Barcode koennen sich geaendert haben - Code nachziehen. + sync_group_code(db, product) db.commit() db.refresh(product) return product_to_out(db, product) @@ -207,5 +231,6 @@ def delete_product( product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") + detach_group_code(db, product) db.delete(product) db.commit() diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index 6c5e9ee..e7de3d8 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -18,8 +18,10 @@ from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, require_admin from ..services.dates import MONTH, clean_precision, normalize_best_before +from ..services.group_codes import sync as sync_group_code from ..models import ( BaseUnit, + Category, DatePrecision, Group, Location, @@ -37,7 +39,7 @@ router = APIRouter(tags=["transfer"]) CSV_FIELDS = [ "barcode", "name", "marke", "einheit", "packungsgroesse", "gebinde", - "gruppe", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort", + "gruppe", "kategorie", "mindestbestand", "menge", "menge_einheit", "mhd", "lagerort", ] PACKAGE_TOKENS = {"packung", "package", "pkg", "pack"} @@ -73,6 +75,7 @@ def export_stock_csv( product.package_size if product.package_size else "", product.package_label or "", product.group.name if product.group else "", + _category_path(db, product.category), product.min_stock if product.min_stock is not None else "", ] lots = ( @@ -149,6 +152,7 @@ def export_backup_json( "package_label": p.package_label, "date_precision": p.date_precision, "group": p.group.name if p.group else None, + "category": _category_path(db, p.category), "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), @@ -269,6 +273,45 @@ def _get_or_create_location(db: Session, name: str | None) -> Location | None: return loc +def _category_path(db: Session, category: Category | None) -> str: + """Unterkategorien als "Süßwaren & Snacks > Schokolade" in EINE Spalte. + + So bleibt die CSV in Excel bedienbar, statt für jede Ebene eine eigene + Spalte zu brauchen. + """ + if category is None: + return "" + teile = [category.name] + aktuell, tiefe = category, 0 + while aktuell.parent_id is not None and tiefe < 10: # Schutz vor einem Ring + aktuell = db.get(Category, aktuell.parent_id) + if aktuell is None: + break + teile.append(aktuell.name) + tiefe += 1 + 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.""" + path = (path or "").strip() + if not path: + return None + parent: Category | None = None + for name in [t.strip() for t in path.split(">") if t.strip()]: + query = db.query(Category).filter(Category.name == name) + query = query.filter( + Category.parent_id == (parent.id if parent else None) + ) + node = query.first() + if node is None: + node = Category(name=name, parent_id=parent.id if parent else None) + db.add(node) + db.flush() + parent = node + return parent + + def _get_or_create_group(db: Session, name: str | None) -> Group | None: name = (name or "").strip() if not name: @@ -300,6 +343,7 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc if unit is None: raise ValueError(f"Unbekannte Einheit: {row.get('einheit')}") group = _get_or_create_group(db, row.get("gruppe")) + category = _get_or_create_category(db, row.get("kategorie")) product = Product( barcode=barcode, name=name, @@ -311,11 +355,15 @@ def _get_or_create_product(db: Session, row: dict, created: list[str]) -> Produc # Nur im JSON-Backup enthalten; aus der CSV kommt hier nichts. date_precision=clean_precision((row.get("mhd_genauigkeit") or "").strip() or None), group_id=group.id if group else None, + category_id=category.id if category else None, min_stock=_num(row.get("mindestbestand")), source="import", ) db.add(product) db.flush() + # Damit ein eingelesenes Backup denselben Stand erzeugt wie das Anlegen + # ueber die Oberflaeche. + sync_group_code(db, product) created.append(product.name) return product @@ -454,6 +502,7 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict: "packungsgroesse": entry.get("package_size") or "", "gebinde": entry.get("package_label") or "", "mhd_genauigkeit": entry.get("date_precision") or "", + "kategorie": entry.get("category") or "", "gruppe": entry.get("group") or "", "mindestbestand": entry.get("min_stock") if entry.get("min_stock") is not None else "", } diff --git a/backend/app/schemas.py b/backend/app/schemas.py index dea2c67..1937548 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -116,6 +116,27 @@ class GroupUpdate(BaseModel): min_stock_unit_id: int | None = None +# ---- Categories ---- +class CategoryOut(BaseModel): + """Reine Ordnungshilfe – kein Bestand, kein Mindestbestand, keine EAN-Codes.""" + model_config = ConfigDict(from_attributes=True) + id: int + name: str + parent_id: int | None = None + is_builtin: bool = False + product_count: int = 0 + + +class CategoryCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + parent_id: int | None = None + + +class CategoryUpdate(BaseModel): + name: str | None = Field(default=None, min_length=1, max_length=120) + parent_id: int | None = None + + # ---- Locations ---- class LocationOut(BaseModel): model_config = ConfigDict(from_attributes=True) @@ -144,6 +165,8 @@ class ProductBase(BaseModel): # Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat). date_precision: DatePrecision = DatePrecision.day group_id: int | None = None + # Kategorie: nur fuer den Ueberblick, unabhaengig von der Gruppe. + category_id: int | None = None min_stock: float | None = Field(default=None, ge=0) # immer in Basiseinheiten # Nur für die Anzeige: in welcher Einheit der Mindestbestand erfasst wurde. min_stock_unit_id: int | None = None @@ -165,6 +188,7 @@ class ProductUpdate(BaseModel): package_label: str | None = Field(default=None, max_length=32) date_precision: DatePrecision | None = None group_id: int | None = None + category_id: int | None = None min_stock: float | None = Field(default=None, ge=0) min_stock_unit_id: int | None = None min_stock_in_packages: bool | None = None @@ -183,6 +207,8 @@ class ProductOut(BaseModel): package_label: str | None = None date_precision: DatePrecision = DatePrecision.day group_id: int | None + category_id: int | None = None + category_name: str | None = None min_stock: float | None min_stock_unit_id: int | None = None min_stock_in_packages: bool = False @@ -208,6 +234,9 @@ class LookupResult(BaseModel): # Ist der Code einer Gruppe zugeordnet (z.B. "Mehl"), wird sie hier gemeldet. group_id: int | None = None group_name: str | None = None + # Aus der Open-Food-Facts-Kategorie abgeleiteter Vorschlag (nur Vorschlag). + category_id: int | None = None + category_name: str | None = None class BarcodeCreate(BaseModel): diff --git a/backend/app/seed.py b/backend/app/seed.py index 3d1fa18..2e27e8b 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -3,7 +3,7 @@ from sqlalchemy.orm import Session from .config import get_settings -from .models import Role, Unit, UnitKind, User +from .models import Category, Role, Unit, UnitKind, User from .security import hash_password # (Name, Art, Faktor zur kanonischen Basiseinheit) @@ -26,6 +26,43 @@ def ensure_builtin_units(db: Session) -> None: db.commit() +# Startliste für den Überblick über den Vorrat. Zweistufig, damit die +# Verschachtelung von Anfang an sichtbar ist. Alles frei änderbar – +# umbenennen, löschen und eigene ergänzen ist ausdrücklich vorgesehen. +BUILTIN_CATEGORIES: list[tuple[str, list[str]]] = [ + ("Obst & Gemüse", []), + ("Molkereiprodukte", ["Käse", "Joghurt & Quark"]), + ("Fleisch & Fisch", []), + ("Brot & Backwaren", []), + ("Trockenwaren & Teigwaren", ["Mehl & Zucker", "Nudeln & Reis"]), + ("Konserven & Gläser", []), + ("Tiefkühl", []), + ("Süßwaren & Snacks", ["Schokolade", "Chips & Salziges"]), + ("Getränke", ["Kaffee & Tee"]), + ("Gewürze & Saucen", []), + ("Frühstück", []), + ("Haushalt & Hygiene", []), +] + + +def ensure_builtin_categories(db: Session) -> None: + """Legt nur an, was fehlt – gelöschte Kategorien kommen nicht zurück. + + Anders als bei den Einheiten prüfen wir zusätzlich, ob überhaupt schon + Kategorien existieren: Wer die Startliste bewusst leergeräumt hat, soll sie + beim nächsten Start nicht wiederfinden. + """ + if db.query(Category).first() is not None: + return + for name, children in BUILTIN_CATEGORIES: + parent = Category(name=name, is_builtin=True) + db.add(parent) + db.flush() + for child in children: + db.add(Category(name=child, parent_id=parent.id, is_builtin=True)) + db.commit() + + def ensure_first_admin(db: Session) -> None: settings = get_settings() has_users = db.query(User).first() is not None diff --git a/backend/app/services/categories.py b/backend/app/services/categories.py new file mode 100644 index 0000000..8bc1f1a --- /dev/null +++ b/backend/app/services/categories.py @@ -0,0 +1,70 @@ +"""Schlägt aus den Open-Food-Facts-Angaben eine Kategorie vor. + +OFF liefert die Einordnung auf Englisch und sehr fein gegliedert +("Plant-based foods and beverages, Cereals and potatoes, Breakfasts"). Diese +Werte direkt als Kategorien zu übernehmen, ergäbe hunderte unbrauchbare +Einträge – deshalb eine Zuordnung auf die überschaubare eigene Liste. + +Der Vorschlag ist nur ein Vorschlag: In der Oberfläche ist er vorbelegt und +jederzeit änderbar. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..models import Category + +# Stichwort -> Name der Kategorie. Reihenfolge zählt: Die erste Zeile, die +# passt, gewinnt. Feineres steht deshalb oben, damit "chocolate" in der +# Unterkategorie "Schokolade" landet und nicht im allgemeinen "Süßwaren". +KEYWORD_MAP: list[tuple[tuple[str, ...], str]] = [ + (("chocolat", "schokolade", "cocoa"), "Schokolade"), + (("chips", "crisps", "salty-snack", "apéritif", "aperitif"), "Chips & Salziges"), + (("cheese", "fromage", "käse", "kaese"), "Käse"), + (("yogurt", "yoghurt", "joghurt", "quark", "skyr"), "Joghurt & Quark"), + (("flour", "sugar", "mehl", "zucker", "farine"), "Mehl & Zucker"), + (("pasta", "noodle", "rice", "nudel", "reis", "spaghetti"), "Nudeln & Reis"), + (("coffee", "tea", "kaffee", "tee"), "Kaffee & Tee"), + + (("dairy", "dairies", "milk", "milch", "cream", "rahm", "butter"), "Molkereiprodukte"), + (("sweet", "candy", "candies", "biscuit", "cookie", "confectioner", + "süßwaren", "bonbon"), "Süßwaren & Snacks"), + (("fruit", "vegetable", "obst", "gemüse", "gemuese", "salad"), "Obst & Gemüse"), + (("meat", "fish", "seafood", "fleisch", "wurst", "sausage", "poultry"), "Fleisch & Fisch"), + (("bread", "bakery", "brot", "backwaren", "pastry"), "Brot & Backwaren"), + (("canned", "conserve", "konserve", "tinned"), "Konserven & Gläser"), + (("frozen", "tiefkühl", "tiefkuehl", "surgelé", "surgele"), "Tiefkühl"), + (("beverage", "drink", "getränk", "getraenk", "water", "juice", "soda"), "Getränke"), + (("spice", "sauce", "condiment", "gewürz", "gewuerz", "vinegar", "oil"), "Gewürze & Saucen"), + (("breakfast", "cereal", "müsli", "muesli", "jam", "honey", "frühstück"), "Frühstück"), + (("cleaning", "hygiene", "detergent", "haushalt", "soap"), "Haushalt & Hygiene"), +] + + +def suggest_category(db: Session, suggestion: dict | None) -> Category | None: + """Passende vorhandene Kategorie zu einem OFF-Vorschlag, sonst ``None``. + + Es wird nichts angelegt: Fehlt die Kategorie (weil sie gelöscht oder + umbenannt wurde), gibt es eben keinen Vorschlag. + """ + if not suggestion: + return None + + haystack = " ".join( + [ + str(suggestion.get("category_suggestion") or ""), + *[str(t) for t in (suggestion.get("category_tags") or [])], + ] + ).lower() + if not haystack: + return None + + for keywords, name in KEYWORD_MAP: + if any(word in haystack for word in keywords): + treffer = db.query(Category).filter(Category.name == name).first() + if treffer is not None: + return treffer + # Kategorie gibt es nicht (mehr) - weitersuchen, vielleicht passt + # eine gröbere Zeile weiter unten. + return None diff --git a/backend/app/services/group_codes.py b/backend/app/services/group_codes.py new file mode 100644 index 0000000..f04657f --- /dev/null +++ b/backend/app/services/group_codes.py @@ -0,0 +1,74 @@ +"""Pflegt die EAN-Codes an einer Gruppe automatisch mit. + +Bisher entstand ein Gruppen-Code ausschließlich von Hand. Wurde ein Artikel +einer Gruppe zugeordnet, blieb dessen EAN-Code dort unsichtbar – die Gruppe +zeigte „0 EANs", obwohl Artikel darin lagen, und der Versuch, den Code +nachzutragen, scheiterte mit „bereits vergeben" (er hing ja am Artikel). + +Jetzt wandert der Haupt-Barcode eines Artikels automatisch an die Gruppe: + +* beim Anlegen und beim Zuordnen einer Gruppe → Code kommt hinzu +* beim Wechsel der Gruppe → Code wandert mit +* beim Entfernen der Gruppe oder Löschen des Artikels → Code verschwindet + +Beim Scannen ändert das nichts an der Reihenfolge: ``routers.products.lookup`` +findet weiterhin zuerst den Artikel, dessen Gruppe zählt. Der Gruppen-Eintrag +ist Beleg in der Verwaltung und Rückfall, falls der Artikel später verschwindet. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from ..models import Barcode, Product + + +def _group_row(db: Session, code: str) -> Barcode | None: + """Vorhandene Gruppen-Zeile zu diesem Code (nicht: Artikel-Alias).""" + return ( + db.query(Barcode) + .filter(Barcode.code == code, Barcode.group_id.isnot(None)) + .first() + ) + + +def sync(db: Session, product: Product) -> None: + """Bringt den Gruppen-Code eines Artikels auf den aktuellen Stand. + + Deckt Anlegen, Ändern und Entfernen der Gruppe gleichermaßen ab, damit die + Aufrufer sich nicht merken müssen, was vorher galt. + """ + code = (product.barcode or "").strip() + if not code: + return + + row = _group_row(db, code) + + # Keine Gruppe mehr: einen früher angelegten Eintrag wieder entfernen. + if product.group_id is None: + if row is not None: + db.delete(row) + return + + if row is not None: + # Gruppe gewechselt: Zeile umhängen statt löschen und neu anlegen, + # damit eine hinterlegte Notiz erhalten bleibt. + row.group_id = product.group_id + return + + # Hängt der Code bereits als Alias an einem Artikel, gehört er dort hin - + # code ist eindeutig, eine zweite Zeile wäre nicht möglich. + if db.query(Barcode).filter(Barcode.code == code).first() is not None: + return + + db.add(Barcode(code=code, group_id=product.group_id)) + + +def detach(db: Session, product: Product) -> None: + """Vor dem Löschen eines Artikels dessen Gruppen-Code aufräumen.""" + code = (product.barcode or "").strip() + if not code: + return + row = _group_row(db, code) + if row is not None: + db.delete(row) diff --git a/backend/tests/test_categories.py b/backend/tests/test_categories.py new file mode 100644 index 0000000..74389e5 --- /dev/null +++ b/backend/tests/test_categories.py @@ -0,0 +1,173 @@ +"""Kategorien (Überblick) und die automatische EAN-Pflege an der Gruppe. + +Kategorie und Gruppe sind bewusst zwei verschiedene Dinge: Die Gruppe zählt +Bestände mehrerer Marken zusammen, die Kategorie ordnet nur die Artikelliste. +""" + +from app.models import Barcode, Category, Group, Product +from app.routers.categories import descendant_ids +from app.services.categories import suggest_category +from app.services.group_codes import detach, sync +from app.seed import ensure_builtin_categories + + +def _baum(db): + """Süßwaren → Schokolade → Zartbitter.""" + ober = Category(name="Süßwaren & Snacks") + db.add(ober) + db.flush() + mitte = Category(name="Schokolade", parent_id=ober.id) + db.add(mitte) + db.flush() + unten = Category(name="Zartbitter", parent_id=mitte.id) + db.add(unten) + db.commit() + return ober, mitte, unten + + +# ---- Verschachtelung ---- + +def test_filter_schliesst_unterkategorien_ein(db): + ober, mitte, unten = _baum(db) + assert descendant_ids(db, ober.id) == {ober.id, mitte.id, unten.id} + + +def test_filter_einer_unterkategorie_bleibt_eng(db): + _, mitte, unten = _baum(db) + assert descendant_ids(db, mitte.id) == {mitte.id, unten.id} + + +def test_blatt_hat_nur_sich_selbst(db): + _, _, unten = _baum(db) + assert descendant_ids(db, unten.id) == {unten.id} + + +# ---- Vorschlag aus Open Food Facts ---- + +def test_off_vorschlag_bevorzugt_die_feinere_kategorie(db): + ensure_builtin_categories(db) + treffer = suggest_category(db, {"category_tags": ["en:sweet-snacks", "en:chocolates"]}) + # "chocolates" ist genauer als "sweet-snacks" und gewinnt deshalb. + assert treffer is not None and treffer.name == "Schokolade" + + +def test_off_vorschlag_faellt_auf_die_obere_kategorie_zurueck(db): + ensure_builtin_categories(db) + treffer = suggest_category(db, {"category_tags": ["en:sweet-snacks", "en:candies"]}) + assert treffer is not None and treffer.name == "Süßwaren & Snacks" + + +def test_off_vorschlag_ohne_treffer_ist_none(db): + ensure_builtin_categories(db) + assert suggest_category(db, {"category_tags": ["en:voelliges-neuland"]}) is None + + +def test_off_vorschlag_ohne_angaben_ist_none(db): + ensure_builtin_categories(db) + assert suggest_category(db, None) is None + assert suggest_category(db, {}) is None + + +def test_startliste_wird_nicht_doppelt_angelegt(db): + ensure_builtin_categories(db) + anzahl = db.query(Category).count() + ensure_builtin_categories(db) + assert db.query(Category).count() == anzahl + + +# ---- EAN-Codes an der Gruppe ---- + +def _artikel(db, gruppe, code="4001"): + product = Product(name="Aldi Mehl", barcode=code, group_id=gruppe.id if gruppe else None) + db.add(product) + db.commit() + return product + + +def _gruppen_codes(db, gruppe): + return { + b.code for b in db.query(Barcode).filter(Barcode.group_id == gruppe.id).all() + } + + +def test_code_landet_beim_anlegen_in_der_gruppe(db): + mehl = Group(name="Mehl") + db.add(mehl) + db.commit() + product = _artikel(db, mehl) + sync(db, product) + db.commit() + assert _gruppen_codes(db, mehl) == {"4001"} + + +def test_code_wandert_beim_gruppenwechsel_mit(db): + mehl, back = Group(name="Mehl"), Group(name="Backzutaten") + db.add_all([mehl, back]) + db.commit() + product = _artikel(db, mehl) + sync(db, product) + db.commit() + + product.group_id = back.id + sync(db, product) + db.commit() + + assert _gruppen_codes(db, mehl) == set() + assert _gruppen_codes(db, back) == {"4001"} + + +def test_code_verschwindet_ohne_gruppe(db): + mehl = Group(name="Mehl") + db.add(mehl) + db.commit() + product = _artikel(db, mehl) + sync(db, product) + db.commit() + + product.group_id = None + sync(db, product) + db.commit() + assert _gruppen_codes(db, mehl) == set() + + +def test_code_verschwindet_beim_loeschen_des_artikels(db): + mehl = Group(name="Mehl") + db.add(mehl) + db.commit() + product = _artikel(db, mehl) + sync(db, product) + db.commit() + + detach(db, product) + db.delete(product) + db.commit() + assert _gruppen_codes(db, mehl) == set() + + +def test_artikel_ohne_barcode_erzeugt_keinen_eintrag(db): + mehl = Group(name="Mehl") + db.add(mehl) + db.commit() + product = Product(name="Lose Ware", group_id=mehl.id) + db.add(product) + db.commit() + sync(db, product) + db.commit() + assert _gruppen_codes(db, mehl) == set() + + +def test_fremder_alias_wird_nicht_ueberschrieben(db): + """Hängt der Code schon als Alias an einem anderen Artikel, bleibt er dort.""" + mehl = Group(name="Mehl") + anderer = Product(name="Anderer Artikel") + db.add_all([mehl, anderer]) + db.commit() + db.add(Barcode(code="4001", product_id=anderer.id)) + db.commit() + + product = _artikel(db, mehl) + sync(db, product) + db.commit() + + assert _gruppen_codes(db, mehl) == set() + assert db.query(Barcode).filter(Barcode.code == "4001").one().product_id == anderer.id diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 7c1e04e..92c38d7 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -54,8 +54,16 @@ Selfhostbare Lebensmittel-Lagerverwaltung. Aufbau in mehreren Schritten. - **MHD wahlweise nur mit Monat und Jahr.** Gespeichert wird weiterhin ein echtes Datum (Monatsletzter), damit FEFO unverändert arbeitet; die Genauigkeit steht getrennt an Charge und Produkt. Export/Import bleiben verlustfrei. -- **Gruppen heißen in der Oberfläche jetzt Kategorien.** Tabelle, Feld `group_id` - und die Endpunkte behalten ihren Namen, damit bestehende Zugriffe weiterlaufen. +- **Kategorien als eigene Ebene neben den Gruppen.** Zuvor wurden beide Begriffe + vermischt – aus der Open-Food-Facts-Kategorie wurde sogar eine *Gruppe* geraten. + Jetzt getrennt: Die **Gruppe** zählt Bestände mehrerer Marken zusammen + (Mindestbestand, EAN-Codes), die **Kategorie** ordnet nur die Artikelliste und + ist verschachtelbar (Süßwaren → Schokolade). Mitgelieferte Startliste, + Vorschlag aus der OFF-Einordnung, Filter in App und Web. +- **EAN-Codes an der Gruppe werden automatisch gepflegt:** Beim Zuordnen eines + Artikels landet sein Code dort, beim Gruppenwechsel wandert er mit, beim + Löschen verschwindet er. Vorher blieb die Codeliste einer Gruppe leer, obwohl + Artikel darin lagen. - **Eigenes Logo und Favicon** je Installation (Einstellungen → Darstellung). ## Schritt 3 – Weitere Ideen – offen diff --git a/ios/README.md b/ios/README.md index 38b854c..a5f64a7 100644 --- a/ios/README.md +++ b/ios/README.md @@ -69,12 +69,20 @@ Beide Wege öffnen die App **direkt in der Kamera**. | `ListViews.swift` | Einkaufsliste, „bald ablaufend", Produktliste | | `DateScanView.swift` / `BestBeforeText.swift` | MHD per Texterkennung ablesen | | `DisplaySettings.swift` | Datumsformat und Einheiten-Beschriftungen vom Server | +| `CategoryPicker.swift` | Kategorie-Auswahl und Gruppe anlegen | -## Kategorien -Was in der Datenbank und in der Schnittstelle `group` heißt, trägt in der -Oberfläche den Namen **Kategorie** („Molkereiprodukte", „Brot", …). Der Name in -API und Tabelle blieb bewusst unverändert, damit bestehende Zugriffe – etwa aus -Home Assistant – weiterlaufen. +## Gruppe und Kategorie – zwei verschiedene Dinge +**Gruppe** dient der *Bestandsrechnung*: Mehl von Rewe, Aldi und Migros zählen +zusammen, wichtig ist „ich habe 5 kg Mehl“. Eine Gruppe hat deshalb einen +gemeinsamen Mindestbestand mit Einheit und EAN-Codes. Ordnest du einen Artikel +einer Gruppe zu, erscheint sein EAN-Code automatisch dort; wechselt er die +Gruppe, wandert der Code mit. + +**Kategorie** dient *allein dem Überblick* („Süßwaren“, „Molkereiprodukte“) und +ist verschachtelbar wie ein Lagerort (Süßwaren → Schokolade). Sie hat keinen +Bestand, keinen Mindestbestand und keine EAN-Codes. Filterst du auf eine +Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. Beim Anlegen +schlägt der Server eine Kategorie aus der Open-Food-Facts-Einordnung vor. ## Noch offen - Push-Benachrichtigungen bei ablaufenden Produkten diff --git a/ios/Sources/APIClient.swift b/ios/Sources/APIClient.swift index b2e37d2..d08ccae 100644 --- a/ios/Sources/APIClient.swift +++ b/ios/Sources/APIClient.swift @@ -87,9 +87,17 @@ actor APIClient { try await send(try makeRequest("/locations"), as: [StorageLocation].self) } - func searchProducts(_ query: String) async throws -> [Product] { - let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" - return try await send(try makeRequest("/products?q=\(escaped)"), as: [Product].self) + /// `categoryId == 0` liefert Artikel ohne Kategorie; eine echte ID schliesst + /// die Unterkategorien mit ein. + func searchProducts(_ query: String, categoryId: Int? = nil) async throws -> [Product] { + var parts: [String] = [] + if !query.isEmpty { + let escaped = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + parts.append("q=\(escaped)") + } + if let categoryId { parts.append("category_id=\(categoryId)") } + let suffix = parts.isEmpty ? "" : "?" + parts.joined(separator: "&") + return try await send(try makeRequest("/products" + suffix), as: [Product].self) } func product(id: Int) async throws -> Product { @@ -117,6 +125,16 @@ actor APIClient { try await send(try makeRequest("/groups"), as: [GroupItem].self) } + func categories() async throws -> [CategoryItem] { + try await send(try makeRequest("/categories"), as: [CategoryItem].self) + } + + func createGroup(_ payload: NewGroupRequest) async throws -> GroupItem { + var request = try makeRequest("/groups", method: "POST") + try jsonBody(&request, payload) + return try await send(request, as: GroupItem.self) + } + func settings() async throws -> [SettingEntry] { try await send(try makeRequest("/settings"), as: [SettingEntry].self) } diff --git a/ios/Sources/CategoryPicker.swift b/ios/Sources/CategoryPicker.swift new file mode 100644 index 0000000..49d5a64 --- /dev/null +++ b/ios/Sources/CategoryPicker.swift @@ -0,0 +1,105 @@ +import SwiftUI + +/// Auswahl einer Kategorie mit eingerueckten Unterkategorien. +/// +/// Kategorie und Gruppe sind zwei verschiedene Dinge: Die Kategorie ordnet nur +/// die Artikelliste, die Gruppe zaehlt Bestaende mehrerer Marken zusammen. +struct CategoryPicker: View { + let categories: [CategoryItem] + @Binding var selection: Int? + + var body: some View { + Picker("Kategorie", selection: $selection) { + Text("– keine –").tag(Int?.none) + ForEach(flattened(), id: \.item.id) { eintrag in + Text(String(repeating: " ", count: eintrag.depth) + eintrag.item.name) + .tag(Int?.some(eintrag.item.id)) + } + } + } + + private func flattened() -> [(item: CategoryItem, depth: Int)] { + let ids = Set(categories.map(\.id)) + var result: [(CategoryItem, Int)] = [] + + func walk(_ node: CategoryItem, _ depth: Int) { + result.append((node, depth)) + for child in categories.filter({ $0.parentId == node.id }) { + walk(child, depth + 1) + } + } + for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { + walk(root, 0) + } + return result + } +} + +/// Legt eine Gruppe an Ort und Stelle an, damit man das Formular nicht +/// verlassen muss. Nur fuer Administratoren - POST /groups verlangt das. +struct NewGroupSheet: View { + let units: [Unit] + var onCreated: (GroupItem) -> Void + + @Environment(\.dismiss) private var dismiss + + @State private var name = "" + @State private var minStock = "" + @State private var unitId: Int? + @State private var busy = false + @State private var error: String? + + var body: some View { + Form { + Section { + LabeledField(label: "Name", text: $name) + } footer: { + Text("Eine Gruppe zählt Bestände mehrerer Marken zusammen – " + + "z. B. Mehl von verschiedenen Herstellern als „5 kg Mehl“.") + } + + Section("Mindestbestand (optional)") { + QuantityField(label: "Menge", text: $minStock) + Picker("Einheit", selection: $unitId) { + Text("– Basiseinheit –").tag(Int?.none) + ForEach(units) { unit in + Text(unit.name).tag(Int?.some(unit.id)) + } + } + } + + if let error { + Section { Text(error).foregroundStyle(.red).font(.callout) } + } + + Section { + Button(busy ? "Anlegen…" : "Anlegen") { Task { await create() } } + .disabled(busy || name.trimmingCharacters(in: .whitespaces).isEmpty) + } + } + .navigationTitle("Neue Gruppe") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarLeading) { Button("Abbrechen") { dismiss() } } + } + } + + private func create() async { + error = nil + busy = true + defer { busy = false } + do { + let created = try await APIClient.shared.createGroup( + NewGroupRequest( + name: name.trimmingCharacters(in: .whitespaces), + minStock: Double(minStock.replacingOccurrences(of: ",", with: ".")), + minStockUnitId: unitId + ) + ) + onCreated(created) + dismiss() + } catch { + self.error = error.localizedDescription + } + } +} diff --git a/ios/Sources/CheckInFormView.swift b/ios/Sources/CheckInFormView.swift index 10b4a68..2c35fb2 100644 --- a/ios/Sources/CheckInFormView.swift +++ b/ios/Sources/CheckInFormView.swift @@ -6,6 +6,23 @@ struct LineRef: Identifiable { let id: UUID } +extension View { + /// Dezente Umrandung fuer Eingabefelder in einem Formular. + /// + /// `.textFieldStyle(.roundedBorder)` zeichnet in der dunklen Darstellung + /// einen fast schwarzen Kasten, der sich hart von der Zeile abhebt. Eine + /// Systemfuellung passt sich Hell und Dunkel an und bleibt zurueckhaltend, + /// macht das Feld aber weiterhin als Eingabe erkennbar. + func fieldBox(width: CGFloat) -> some View { + self + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: width) + .background(Color(.tertiarySystemFill)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} + /// Textzeile mit Beschriftung links und sichtbarem Rahmen. struct LabeledField: View { let label: String @@ -19,8 +36,7 @@ struct LabeledField: View { TextField(label, text: $text) .keyboardType(keyboard) .multilineTextAlignment(.trailing) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 190) + .fieldBox(width: 190) } } } @@ -41,8 +57,7 @@ struct QuantityField: View { TextField(label, text: $text) .keyboardType(.decimalPad) .multilineTextAlignment(.trailing) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 110) + .fieldBox(width: 110) if let suffix { Text(suffix) .foregroundStyle(.secondary) diff --git a/ios/Sources/CheckInView.swift b/ios/Sources/CheckInView.swift index 79e5c02..4dc3651 100644 --- a/ios/Sources/CheckInView.swift +++ b/ios/Sources/CheckInView.swift @@ -12,6 +12,7 @@ struct CheckInView: View { @State private var suggestion: LookupResult.Suggestion? @State private var suggestedGroupId: Int? @State private var suggestedGroupName: String? + @State private var suggestedCategoryName: String? @State private var unknownCode: String? @State private var manualCodeShown = false @State private var manualCode = "" @@ -111,23 +112,33 @@ struct CheckInView: View { .padding(.horizontal) } - /// Sagt vor dem Anlegen, welche Kategorie der Artikel bekommt und warum. + /// Sagt vor dem Anlegen, welche Gruppe der Artikel bekommt und warum. @ViewBuilder - private func categoryNote() -> some View { - if let name = suggestedGroupName { - HStack(alignment: .top, spacing: 6) { - Image(systemName: "tag") - VStack(alignment: .leading, spacing: 1) { - Text("Wird der Kategorie „\(name)“ zugeordnet").font(.caption).bold() - Text("Dieser EAN-Code ist dort hinterlegt.") - .font(.caption2).foregroundStyle(.secondary) - } - } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.accentColor.opacity(0.15)) - .clipShape(RoundedRectangle(cornerRadius: 8)) + private func groupNote() -> some View { + if let kategorie = suggestedCategoryName { + noteRow(symbol: "square.grid.2x2", + title: "Kategorie „\(kategorie)“ vorgeschlagen", + detail: "Aus der Einordnung bei Open Food Facts.") } + if let name = suggestedGroupName { + noteRow(symbol: "tag", + title: "Wird der Gruppe „\(name)“ zugeordnet", + detail: "Dieser EAN-Code ist dort hinterlegt.") + } + } + + private func noteRow(symbol: String, title: String, detail: String) -> some View { + HStack(alignment: .top, spacing: 6) { + Image(systemName: symbol) + VStack(alignment: .leading, spacing: 1) { + Text(title).font(.caption).bold() + Text(detail).font(.caption2).foregroundStyle(.secondary) + } + } + .padding(8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.accentColor.opacity(0.15)) + .clipShape(RoundedRectangle(cornerRadius: 8)) } private func suggestionCard(_ item: LookupResult.Suggestion) -> some View { @@ -137,7 +148,7 @@ struct CheckInView: View { .font(.caption).foregroundStyle(.secondary) Text("Bei Open Food Facts gefunden, noch nicht im Katalog.") .font(.caption2).foregroundStyle(.secondary) - categoryNote() + groupNote() NavigationLink { ProductFormView(prefillBarcode: item.barcode, groupId: suggestedGroupId, suggestion: item) { created in @@ -161,7 +172,7 @@ struct CheckInView: View { Text("Unbekannter Code \(code)").font(.headline) Text("Weder im Katalog noch bei Open Food Facts.") .font(.caption).foregroundStyle(.secondary) - categoryNote() + groupNote() NavigationLink { ProductFormView(prefillBarcode: code, groupId: suggestedGroupId) { created in unknownCode = nil @@ -191,6 +202,7 @@ struct CheckInView: View { let result = try await APIClient.shared.lookup(barcode: code) suggestedGroupId = result.groupId suggestedGroupName = result.groupName + suggestedCategoryName = result.categoryName if let existing = result.existingProduct { product = existing } else if let hint = result.suggestion { diff --git a/ios/Sources/ListViews.swift b/ios/Sources/ListViews.swift index bab71c4..1d13146 100644 --- a/ios/Sources/ListViews.swift +++ b/ios/Sources/ListViews.swift @@ -5,10 +5,10 @@ func formatAmount(_ value: Double) -> String { value == value.rounded() ? String(Int(value)) : String(format: "%.2f", value) } -/// Kennzeichnung "Kategorie" an einer Listenzeile. -struct CategoryBadge: View { +/// Kennzeichnung "Gruppe" an einer Listenzeile. +struct GroupBadge: View { var body: some View { - Text("Kategorie") + Text("Gruppe") .font(.caption2) .padding(.horizontal, 6) .padding(.vertical, 2) @@ -18,7 +18,7 @@ struct CategoryBadge: View { } } -/// Zeile mit Symbol links, damit Artikel und Kategorie unterscheidbar sind. +/// Zeile mit Symbol links, damit Artikel und Gruppe unterscheidbar sind. struct ListRow: View { let systemImage: String let title: String @@ -70,13 +70,13 @@ struct ShoppingListView: View { } } if !groups.isEmpty { - Section("Kategorien unter Mindestbestand") { + Section("Gruppen unter Mindestbestand") { ForEach(groups) { group in ListRow( systemImage: "square.stack.3d.up", title: group.name, subtitle: "fehlt \(formatAmount(group.deficit)) \(group.unitName) · \(group.productCount) Artikel" - ) { CategoryBadge() } + ) { GroupBadge() } } } } @@ -173,23 +173,49 @@ struct ExpiringView: View { struct ProductListView: View { @State private var query = "" @State private var products: [Product] = [] + @State private var categories: [CategoryItem] = [] + /// nil = alle, 0 = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit). + @State private var categoryId: Int? @State private var busy = false + private var filterLabel: String { + if categoryId == 0 { return "Ohne Kategorie" } + if let id = categoryId, let treffer = categories.first(where: { $0.id == id }) { + return treffer.name + } + return "Alle Kategorien" + } + var body: some View { List { - ForEach(products) { product in - NavigationLink { - ProductDetailView(product: product) + Section { + Menu { + Button("Alle Kategorien") { pick(nil) } + Button("Ohne Kategorie") { pick(0) } + Divider() + ForEach(categoryTree()) { eintrag in + Button(eintrag.label) { pick(eintrag.item.id) } + } } label: { - ListRow( - systemImage: "shippingbox", - title: product.name, - subtitle: "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)" - ) { EmptyView() } + HStack { + Label("Kategorie", systemImage: "line.3.horizontal.decrease.circle") + Spacer() + Text(filterLabel).foregroundStyle(.secondary) + } } } - if products.isEmpty && !busy { - Text("Keine Treffer").foregroundStyle(.secondary) + + Section { + ForEach(products) { product in + NavigationLink { + ProductDetailView(product: product) + } label: { + ProductRow(product: product) + } + } + if products.isEmpty && !busy { + Text("Keine Treffer").foregroundStyle(.secondary) + } } } .searchable(text: $query, prompt: "Artikel suchen") @@ -197,12 +223,89 @@ struct ProductListView: View { .navigationTitle("Produkte") .navigationBarTitleDisplayMode(.inline) .refreshable { await load() } - .task { await load() } + .task { + categories = (try? await APIClient.shared.categories()) ?? [] + await load() + } + } + + private func pick(_ id: Int?) { + categoryId = id + Task { await load() } + } + + /// Kategorien in Baumreihenfolge mit eingerueckter Beschriftung. + private struct TreeEntry: Identifiable { + let item: CategoryItem + let depth: Int + var id: Int { item.id } + var label: String { String(repeating: " ", count: depth) + item.name } + } + + private func categoryTree() -> [TreeEntry] { + let ids = Set(categories.map(\.id)) + var result: [TreeEntry] = [] + + func walk(_ node: CategoryItem, _ depth: Int) { + result.append(TreeEntry(item: node, depth: depth)) + for child in categories.filter({ $0.parentId == node.id }) { + walk(child, depth + 1) + } + } + // Waisen (Oberkategorie geloescht) gelten als oberste Ebene. + for root in categories.filter({ $0.parentId == nil || !ids.contains($0.parentId!) }) { + walk(root, 0) + } + return result } private func load() async { busy = true defer { busy = false } - products = (try? await APIClient.shared.searchProducts(query)) ?? [] + products = (try? await APIClient.shared.searchProducts(query, categoryId: categoryId)) ?? [] + } +} + +/// Artikelzeile mit Gebinde und Hinweis auf den Mindestbestand. +struct ProductRow: View { + let product: Product + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "shippingbox") + .font(.body).foregroundStyle(.secondary).frame(width: 24) + VStack(alignment: .leading, spacing: 2) { + Text(product.name) + Text(bestand) + .font(.caption).foregroundStyle(.secondary) + if let hinweis = mindestbestand { + Text(hinweis).font(.caption2).foregroundStyle(farbe) + } + } + Spacer() + } + } + + private var bestand: String { + var text = "\(formatAmount(product.stockInArticleUnits)) \(product.articleUnitLabel)" + // Bei einem Gebinde die Basismenge dahinter, damit "2 Glas" greifbar wird. + if let size = product.packageSize, size > 0 { + text += " · à \(formatAmount(size)) \(DisplaySettings.baseUnitLabel(product.baseUnit))" + } + return text + } + + private var mindestbestand: String? { + guard let anzeige = product.minStockDisplay else { return nil } + let einheit = product.minStockUnitLabel ?? "" + switch product.stockLevel { + case .below: return "unter Mindestbestand (\(formatAmount(anzeige)) \(einheit))" + case .close: return "bald nachkaufen (Minimum \(formatAmount(anzeige)) \(einheit))" + case .ok, .none: return nil + } + } + + private var farbe: Color { + product.stockLevel == .below ? .red : .orange } } diff --git a/ios/Sources/Models.swift b/ios/Sources/Models.swift index 398cb0d..d25c30a 100644 --- a/ios/Sources/Models.swift +++ b/ios/Sources/Models.swift @@ -53,10 +53,20 @@ struct Product: Codable, Identifiable, Hashable { let barcodes: [BarcodeEntry] /// Welche MHD-Genauigkeit bei diesem Produkt ueblich ist ("day"/"month"). let datePrecision: String? + /// Kategorie: reine Ordnungshilfe fuer die Artikelliste (nicht die Gruppe). + let categoryId: Int? + let categoryName: String? + /// Mindestbestand in der erfassten Einheit, fuer die Anzeige. + let minStockDisplay: Double? + let minStockUnitLabel: String? enum CodingKeys: String, CodingKey { case id, barcode, name, brand, stock, kind, barcodes case datePrecision = "date_precision" + case categoryId = "category_id" + case categoryName = "category_name" + case minStockDisplay = "min_stock_display" + case minStockUnitLabel = "min_stock_unit_label" case imageUrl = "image_url" case baseUnit = "base_unit" case packageSize = "package_size" @@ -81,6 +91,16 @@ struct Product: Codable, Identifiable, Hashable { } var stockInArticleUnits: Double { stock / articleUnitFactor } + + /// Wie es um den Mindestbestand steht. Beides in Basiseinheiten verglichen. + enum StockLevel { case none, ok, close, below } + + var stockLevel: StockLevel { + guard let minimum = minStock, minimum > 0 else { return .none } + if stock < minimum { return .below } + // Weniger als ein Viertel Luft: bald nachkaufen. + return stock < minimum * 1.25 ? .close : .ok + } } /// Auswahleintrag fuer Einheiten. @@ -102,14 +122,20 @@ struct LookupResult: Codable { let found: Bool let existingProduct: Product? let suggestion: Suggestion? + /// Gruppe: nur gesetzt, wenn der Code dort ausdruecklich hinterlegt ist. let groupId: Int? let groupName: String? + /// Kategorie: aus der Open-Food-Facts-Einordnung vorgeschlagen. + let categoryId: Int? + let categoryName: String? enum CodingKeys: String, CodingKey { case found, suggestion case existingProduct = "existing_product" case groupId = "group_id" case groupName = "group_name" + case categoryId = "category_id" + case categoryName = "category_name" } struct Suggestion: Codable { @@ -215,6 +241,7 @@ struct NewProductRequest: Codable { let packageLabel: String? let datePrecision: String let groupId: Int? + let categoryId: Int? enum CodingKeys: String, CodingKey { case barcode, name, brand @@ -224,6 +251,7 @@ struct NewProductRequest: Codable { case packageLabel = "package_label" case datePrecision = "date_precision" case groupId = "group_id" + case categoryId = "category_id" } } @@ -316,8 +344,8 @@ struct ProductUpdateRequest: Encodable { var packageSize: Double? var packageLabel: String? var datePrecision: String? - /// Kategorie (heisst in der API weiterhin group_id). var groupId: Int? + var categoryId: Int? enum CodingKeys: String, CodingKey { case name, brand @@ -325,6 +353,7 @@ struct ProductUpdateRequest: Encodable { case packageLabel = "package_label" case datePrecision = "date_precision" case groupId = "group_id" + case categoryId = "category_id" } func encode(to encoder: Encoder) throws { @@ -335,6 +364,7 @@ struct ProductUpdateRequest: Encodable { try container.encode(packageLabel, forKey: .packageLabel) try container.encode(datePrecision, forKey: .datePrecision) try container.encode(groupId, forKey: .groupId) + try container.encode(categoryId, forKey: .categoryId) } } @@ -356,9 +386,33 @@ struct SettingEntry: Codable { let value: String } -/// Kategorie (heisst in der Datenbank und in der API weiterhin "group"; -/// nur die Beschriftung in der Oberflaeche wurde auf "Kategorie" geaendert). +/// Gruppe: fasst Bestaende mehrerer Marken zusammen (z.B. "Mehl"). struct GroupItem: Codable, Identifiable, Hashable { let id: Int let name: String } + +/// Kategorie: ordnet nur die Artikelliste, verschachtelbar (Suesswaren -> Schokolade). +struct CategoryItem: Codable, Identifiable, Hashable { + let id: Int + let name: String + let parentId: Int? + + enum CodingKeys: String, CodingKey { + case id, name + case parentId = "parent_id" + } +} + +/// Neue Gruppe anlegen (nur fuer Administratoren). +struct NewGroupRequest: Codable { + let name: String + let minStock: Double? + let minStockUnitId: Int? + + enum CodingKeys: String, CodingKey { + case name + case minStock = "min_stock" + case minStockUnitId = "min_stock_unit_id" + } +} diff --git a/ios/Sources/ProductDetailView.swift b/ios/Sources/ProductDetailView.swift index dfb8c41..91f2c68 100644 --- a/ios/Sources/ProductDetailView.swift +++ b/ios/Sources/ProductDetailView.swift @@ -19,7 +19,9 @@ struct ProductDetailView: View { @State private var packageLabel = "" @State private var datePrecision = "day" @State private var groupId: Int? - @State private var categories: [GroupItem] = [] + @State private var categoryId: Int? + @State private var groups: [GroupItem] = [] + @State private var categories: [CategoryItem] = [] @State private var editLot: Lot? @@ -58,18 +60,30 @@ struct ProductDetailView: View { Picker("Bezeichnung", selection: $packageLabel) { ForEach(labels, id: \.self) { Text($0.isEmpty ? "Packung (Standard)" : $0).tag($0) } } - Picker("Kategorie", selection: $groupId) { - Text("– keine –").tag(Int?.none) - ForEach(categories) { kategorie in - Text(kategorie.name).tag(Int?.some(kategorie.id)) - } - } 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.") + } + + 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") { LabeledContent("Barcode", value: current.barcode ?? "–") if !current.barcodes.isEmpty { @@ -129,10 +143,12 @@ struct ProductDetailView: View { packageLabel = current.packageLabel ?? "" datePrecision = current.datePrecision == "month" ? "month" : "day" groupId = current.groupId + categoryId = current.categoryId } private func reload() async { - categories = (try? await APIClient.shared.groups()) ?? [] + groups = (try? await APIClient.shared.groups()) ?? [] + categories = (try? await APIClient.shared.categories()) ?? [] lots = (try? await APIClient.shared.lots(productId: current.id)) ?? [] if let frisch = try? await APIClient.shared.product(id: current.id) { current = frisch @@ -153,7 +169,8 @@ struct ProductDetailView: View { packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, - groupId: groupId + groupId: groupId, + categoryId: categoryId ) ) status = "Gespeichert." diff --git a/ios/Sources/ProductViews.swift b/ios/Sources/ProductViews.swift index 24be9c1..b311c2c 100644 --- a/ios/Sources/ProductViews.swift +++ b/ios/Sources/ProductViews.swift @@ -57,6 +57,7 @@ struct ProductFormView: View { var onCreated: (Product) -> Void @Environment(\.dismiss) private var dismiss + @EnvironmentObject private var session: Session @State private var barcode = "" @State private var name = "" @@ -66,7 +67,11 @@ struct ProductFormView: View { @State private var packageLabel = "" @State private var datePrecision = "day" @State private var selectedGroupId: Int? - @State private var categories: [GroupItem] = [] + @State private var selectedCategoryId: Int? + @State private var groups: [GroupItem] = [] + @State private var categories: [CategoryItem] = [] + @State private var units: [Unit] = [] + @State private var newGroupShown = false @State private var busy = false @State private var error: String? @@ -80,12 +85,29 @@ struct ProductFormView: View { LabeledField(label: "Barcode", text: $barcode, keyboard: .numberPad) LabeledField(label: "Name", text: $name) LabeledField(label: "Marke", text: $brand) - Picker("Kategorie", selection: $selectedGroupId) { + } + Section { + CategoryPicker(categories: categories, selection: $selectedCategoryId) + } footer: { + Text("Kategorie: nur für den Überblick in der Artikelliste.") + } + Section { + Picker("Gruppe", selection: $selectedGroupId) { Text("– keine –").tag(Int?.none) - ForEach(categories) { kategorie in - Text(kategorie.name).tag(Int?.some(kategorie.id)) + ForEach(groups) { gruppe in + Text(gruppe.name).tag(Int?.some(gruppe.id)) } } + if session.isAdmin { + Button { + newGroupShown = true + } label: { + Label("Neue Gruppe anlegen", systemImage: "plus") + } + } + } footer: { + Text("Gruppe: zählt Bestände mehrerer Marken zusammen. Der EAN-Code " + + "dieses Artikels erscheint danach automatisch bei der Gruppe.") } Section("Einheit") { Picker("Basiseinheit", selection: $baseUnit) { @@ -114,12 +136,24 @@ struct ProductFormView: View { .navigationTitle("Neuer Artikel") .navigationBarTitleDisplayMode(.inline) .onAppear(perform: prefill) - .task { categories = (try? await APIClient.shared.groups()) ?? [] } + .task { + groups = (try? await APIClient.shared.groups()) ?? [] + categories = (try? await APIClient.shared.categories()) ?? [] + units = (try? await APIClient.shared.units()) ?? [] + } + .sheet(isPresented: $newGroupShown) { + NavigationStack { + NewGroupSheet(units: units) { created in + groups.append(created) + selectedGroupId = created.id + } + } + } } private func prefill() { barcode = suggestion?.barcode ?? prefillBarcode ?? "" - // Aus dem gescannten Code vorgeschlagene Kategorie uebernehmen. + // Aus dem gescannten Code vorgeschlagene Gruppe uebernehmen. selectedGroupId = groupId if let suggestion { name = suggestion.name @@ -144,7 +178,8 @@ struct ProductFormView: View { packageSize: Double(packageSize.replacingOccurrences(of: ",", with: ".")), packageLabel: packageLabel.isEmpty ? nil : packageLabel, datePrecision: datePrecision, - groupId: selectedGroupId + groupId: selectedGroupId, + categoryId: selectedCategoryId ) ) onCreated(product) diff --git a/web/src/App.jsx b/web/src/App.jsx index 49869df..d37e2ac 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -9,6 +9,7 @@ import ProductForm from "./pages/ProductForm"; import CheckIn from "./pages/CheckIn"; import CheckOut from "./pages/CheckOut"; import Groups from "./pages/Groups"; +import Categories from "./pages/Categories"; import Locations from "./pages/Locations"; import Units from "./pages/Units"; import Users from "./pages/Users"; @@ -47,7 +48,8 @@ function Sidebar() { - + + {isAdmin && ( @@ -105,6 +107,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.js b/web/src/api.js index 0a31d2a..87322e2 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -98,7 +98,16 @@ export const api = { me: () => request("/auth/me"), // Produkte - listProducts: (q) => request(`/products${q ? `?q=${encodeURIComponent(q)}` : ""}`), + listProducts: (q, categoryId) => { + // categoryId 0 = "ohne Kategorie"; eine echte ID schliesst Unterkategorien ein. + const params = new URLSearchParams(); + if (q) params.set("q", q); + if (categoryId !== undefined && categoryId !== null && categoryId !== "") { + params.set("category_id", categoryId); + } + const qs = params.toString(); + return request(`/products${qs ? `?${qs}` : ""}`); + }, getProduct: (id) => request(`/products/${id}`), lookup: (barcode) => request(`/products/lookup?barcode=${encodeURIComponent(barcode)}`), createProduct: (body) => request("/products", { method: "POST", body }), @@ -128,6 +137,12 @@ 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) + 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" }), + listGroups: () => request("/groups"), createGroup: (body) => request("/groups", { method: "POST", body }), updateGroup: (id, body) => request(`/groups/${id}`, { method: "PATCH", body }), diff --git a/web/src/categoryTree.js b/web/src/categoryTree.js new file mode 100644 index 0000000..fa1317a --- /dev/null +++ b/web/src/categoryTree.js @@ -0,0 +1,28 @@ +// Kategorien sind verschachtelbar (Süßwaren → Schokolade). Damit Liste, +// Auswahlfeld und Filter dieselbe Reihenfolge und Einrückung zeigen, steht die +// Aufbereitung hier einmal. Gleiches Vorgehen wie bei den Lagerorten. + +/** + * Bringt eine flache Liste in Baumreihenfolge und ergänzt `depth`. + * Kategorien, deren Elternteil fehlt, gelten als oberste Ebene – so geht + * nichts verloren, falls eine Oberkategorie gelöscht wurde. + */ +export function asTree(categories = []) { + const ids = new Set(categories.map((c) => c.id)); + const isRoot = (c) => !c.parent_id || !ids.has(c.parent_id); + const ordered = []; + + function walk(node, depth) { + ordered.push({ ...node, depth }); + for (const child of categories.filter((c) => c.parent_id === node.id)) { + walk(child, depth + 1); + } + } + for (const root of categories.filter(isRoot)) walk(root, 0); + return ordered; +} + +/** Einrückung für Auswahlfelder – dort geht kein CSS-Abstand. */ +export function indentLabel(category) { + return `${"  ".repeat(category.depth || 0)}${category.name}`; +} diff --git a/web/src/components/BarcodeList.jsx b/web/src/components/BarcodeList.jsx index 7b46194..bc26b4e 100644 --- a/web/src/components/BarcodeList.jsx +++ b/web/src/components/BarcodeList.jsx @@ -7,7 +7,7 @@ import Icon from "./Icon"; * Zwei Herkünfte in einer Liste: Codes, die über einen Artikel dazugehören * (nur Anzeige – sie stehen am Artikel, nicht hier), und selbst hinterlegte * Codes, die noch keinem Artikel gehören. Vorher waren nur letztere sichtbar, - * weshalb eine Kategorie mit Artikeln „0 EANs“ anzeigte. + * weshalb eine Gruppe mit Artikeln „0 EANs“ anzeigte. * * onAdd({ code, note }) und onDelete(code) werden vom Aufrufer bereitgestellt. */ diff --git a/web/src/offUtils.js b/web/src/offUtils.js index d065073..9ccc650 100644 --- a/web/src/offUtils.js +++ b/web/src/offUtils.js @@ -1,19 +1,12 @@ // Gemeinsame Helfer rund um Open-Food-Facts-Vorschläge. -// Versucht, aus einem OFF-Kategorietext eine vorhandene Gruppe zu erraten. -export function guessGroup(groups, suggestion) { - if (!suggestion) return ""; - const haystack = [ - suggestion.category_suggestion || "", - ...(suggestion.category_tags || []), - suggestion.name || "", - ].join(" ").toLowerCase(); - const hit = groups.find((g) => haystack.includes(g.name.toLowerCase())); - return hit ? String(hit.id) : ""; -} +// Hinweis: Aus der OFF-Kategorie wurde frueher eine GRUPPE geraten. Das war die +// Verwechslung zweier Dinge - die OFF-Einordnung beschreibt eine Kategorie +// (Ueberblick), keine Gruppe (Bestandsrechnung). Den Vorschlag liefert jetzt +// das Backend als category_id/category_name im Lookup. // Baut aus einem OFF-Vorschlag ein Produkt-Anlage-Payload. -export function suggestionToProduct(s, groupId = "") { +export function suggestionToProduct(s, groupId = "", categoryId = "") { return { barcode: s.barcode || null, name: s.name, @@ -23,5 +16,6 @@ export function suggestionToProduct(s, groupId = "") { package_size: s.package_size ?? null, min_stock: null, group_id: groupId === "" || groupId == null ? null : Number(groupId), + category_id: categoryId === "" || categoryId == null ? null : Number(categoryId), }; } diff --git a/web/src/pages/Categories.jsx b/web/src/pages/Categories.jsx new file mode 100644 index 0000000..87536de --- /dev/null +++ b/web/src/pages/Categories.jsx @@ -0,0 +1,165 @@ +import { useEffect, useState } from "react"; +import { api } from "../api"; +import { useAuth } from "../auth"; +import Icon from "../components/Icon"; +import { asTree } from "../categoryTree"; + +/** + * Kategorien ordnen die Artikelliste – mehr nicht. + * + * Bewusst ohne Bestand, Mindestbestand und EAN-Codes: Das ist Sache der + * Gruppen. Genau dieser Unterschied soll auf den ersten Blick sichtbar sein. + */ +export default function Categories() { + const { isAdmin } = useAuth(); + const [categories, setCategories] = useState([]); + const [form, setForm] = useState({ name: "", parent_id: "" }); + const [error, setError] = useState(null); + + async function load() { + try { + setCategories(await api.listCategories()); + } catch (err) { + setError(err.message); + } + } + + useEffect(() => { load(); }, []); + + async function add(e) { + e.preventDefault(); + setError(null); + try { + await api.createCategory({ + name: form.name, + parent_id: form.parent_id === "" ? null : Number(form.parent_id), + }); + setForm({ name: "", parent_id: form.parent_id }); + load(); + } catch (err) { + setError(err.message); + } + } + + async function patch(category, body) { + setError(null); + try { + await api.updateCategory(category.id, body); + load(); + } catch (err) { + setError(err.message); + } + } + + async function remove(category) { + const hinweis = category.product_count + ? ` ${category.product_count} Artikel verlieren die Zuordnung, bleiben aber erhalten.` + : ""; + if (!confirm(`Kategorie "${category.name}" löschen?${hinweis}`)) return; + try { + await api.deleteCategory(category.id); + load(); + } catch (err) { + setError(err.message); + } + } + + const tree = asTree(categories); + const nameById = Object.fromEntries(categories.map((c) => [c.id, c.name])); + + return ( +
+
+
+

Kategorien

+
+ Ordnen die Artikelliste für den Überblick – „zeig mir alle Süßwaren". + Kategorien haben bewusst keinen Bestand und keine EAN-Codes; das + übernehmen die Gruppen. +
+
+
+ {error &&
{error}
} + +
+
+
+ + + + + + + + + + {tree.map((c) => ( + + + + + + ))} + {categories.length === 0 && ( + + )} + +
KategorieArtikel
+ + {c.depth > 0 && } + {isAdmin ? ( + { + const v = e.target.value.trim(); + if (v && v !== c.name) patch(c, { name: v }); + }} /> + ) : {c.name}} + {c.depth > 0 && c.parent_id && nameById[c.parent_id] && ( + in {nameById[c.parent_id]} + )} + + {c.product_count} + {isAdmin && ( + + )} +
Noch keine Kategorien.
+
+
+ +
+ {isAdmin && ( +
+

Neue Kategorie

+ + + +

+ Unterkategorien sind beliebig tief möglich. Filterst du später auf eine + Oberkategorie, erscheinen die Artikel ihrer Unterkategorien mit. + Namen lassen sich in der Tabelle direkt ändern. +

+
+ )} +
+
+
+ ); +} diff --git a/web/src/pages/CheckIn.jsx b/web/src/pages/CheckIn.jsx index 1e54183..f4a5eaa 100644 --- a/web/src/pages/CheckIn.jsx +++ b/web/src/pages/CheckIn.jsx @@ -3,7 +3,8 @@ import { Link } from "react-router-dom"; import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; -import { guessGroup, suggestionToProduct } from "../offUtils"; +import { suggestionToProduct } from "../offUtils"; +import { asTree } from "../categoryTree"; import { buildUnitOptions, fmt, fromMonthInput, isExpired, monthInputToLastDay } from "../units"; const emptyLine = () => ({ quantity: "", best_before: "" }); @@ -13,13 +14,19 @@ export default function CheckIn() { const [barcode, setBarcode] = useState(""); const [product, setProduct] = useState(null); const [suggestion, setSuggestion] = useState(null); - // Kategorie, die diesem Code zugeordnet ist (heisst in der API weiterhin "group"). + // Gruppe, die diesem Code zugeordnet ist (z.B. alle Mehl-Marken in "Mehl"). const [suggestionGroup, setSuggestionGroup] = useState({ id: "", name: "" }); const [unknownBarcode, setUnknownBarcode] = useState(null); const [results, setResults] = useState([]); const [search, setSearch] = useState(""); const [locations, setLocations] = useState([]); const [groups, setGroups] = useState([]); + const [categories, setCategories] = useState([]); + // Auswahl fuer den Artikel, der aus dem Scan angelegt werden soll. + const [newGroupId, setNewGroupId] = useState(""); + const [newCategoryId, setNewCategoryId] = useState(""); + // Kleines Formular, um eine Gruppe direkt hier anzulegen. + const [groupDraft, setGroupDraft] = useState(null); const [units, setUnits] = useState([]); const [unit, setUnit] = useState(""); @@ -36,12 +43,14 @@ export default function CheckIn() { useEffect(() => { api.listLocations().then(setLocations).catch(() => {}); api.listGroups().then(setGroups).catch(() => {}); + api.listCategories().then(setCategories).catch(() => {}); api.listUnits().then(setUnits).catch(() => {}); }, []); function resetLookup() { setSuggestion(null); setUnknownBarcode(null); + setGroupDraft(null); } function selectProduct(p) { @@ -65,12 +74,14 @@ export default function CheckIn() { if (res.found && res.existing_product) { selectProduct(res.existing_product); setInfo(`Produkt erkannt: ${res.existing_product.name}`); - } else if (res.found && res.suggestion) { - setSuggestion(res.suggestion); - setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" }); } else { - setUnknownBarcode(barcode.trim()); + // Gruppe kommt ausschliesslich aus einem hinterlegten Gruppen-Code, + // die Kategorie aus der Open-Food-Facts-Einordnung. setSuggestionGroup({ id: res.group_id ? String(res.group_id) : "", name: res.group_name || "" }); + setNewGroupId(res.group_id ? String(res.group_id) : ""); + setNewCategoryId(res.category_id ? String(res.category_id) : ""); + if (res.found && res.suggestion) setSuggestion(res.suggestion); + else setUnknownBarcode(barcode.trim()); } } catch (err) { setError(err.message); @@ -80,15 +91,17 @@ export default function CheckIn() { async function createFromSuggestion() { setError(null); setBusy(true); try { - // Ist der Code einer Kategorie zugeordnet, hat das Vorrang vor dem Rateversuch aus OFF. - const groupId = suggestionGroup.id || guessGroup(groups, suggestion); - const payload = suggestionToProduct(suggestion, groupId); + const payload = suggestionToProduct(suggestion, newGroupId, newCategoryId); const created = await api.createProduct(payload); selectProduct(created); - const kategorie = groups.find((g) => String(g.id) === String(created.group_id)); + const gruppe = groups.find((g) => String(g.id) === String(created.group_id)); + const kategorie = categories.find((c) => String(c.id) === String(created.category_id)); setInfo( `Produkt "${created.name}" angelegt` + - (kategorie ? ` und der Kategorie "${kategorie.name}" zugeordnet.` : " (ohne Kategorie).") + (kategorie ? `, Kategorie "${kategorie.name}"` : "") + + (gruppe + ? `, Gruppe "${gruppe.name}" – der EAN-Code steht jetzt auch bei der Gruppe.` + : " (ohne Gruppe).") ); } catch (err) { setError(err.message); @@ -97,6 +110,92 @@ export default function CheckIn() { } } + /** Gruppe an Ort und Stelle anlegen, damit man den Vorgang nicht verlassen muss. */ + async function createGroup(e) { + e.preventDefault(); + setError(null); + try { + const created = await api.createGroup({ + name: groupDraft.name, + min_stock: groupDraft.min_stock === "" ? null : Number(groupDraft.min_stock), + min_stock_unit_id: + groupDraft.min_stock_unit_id === "" ? null : Number(groupDraft.min_stock_unit_id), + }); + setGroups(await api.listGroups()); + setNewGroupId(String(created.id)); + setGroupDraft(null); + } catch (err) { + setError(err.message); + } + } + + /** Zuordnung des neu anzulegenden Artikels: Kategorie und Gruppe. */ + function assignFields() { + return ( +
+
+ + +
+ + {isAdmin && !groupDraft && ( + + )} + + {groupDraft && ( +
+

Neue Gruppe

+
+ + + +
+
+ + +
+
+ )} +
+ ); + } + async function doSearch(e) { e.preventDefault(); try { @@ -191,7 +290,7 @@ export default function CheckIn() { {suggestionGroup.name && (
- Wird automatisch der Kategorie {suggestionGroup.name} zugeordnet + Wird automatisch der Gruppe {suggestionGroup.name} zugeordnet – weil dieser EAN-Code dort hinterlegt ist.
)} @@ -206,6 +305,8 @@ export default function CheckIn() { )} + {suggestion && isAdmin && assignFields()} + {unknownBarcode && (
@@ -218,13 +319,15 @@ export default function CheckIn() { {suggestionGroup.name && (
- Wird automatisch der Kategorie {suggestionGroup.name} zugeordnet + Wird automatisch der Gruppe {suggestionGroup.name} zugeordnet – weil dieser EAN-Code dort hinterlegt ist.
)}
)} + + {unknownBarcode && isAdmin && assignFields()}
diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index de00430..9b286e0 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -120,7 +120,7 @@ export default function Dashboard() { {groupShopping.map((it) => ( - Kategorie {it.name} + Gruppe {it.name} {fmt(it.stock)} {it.unit_name} {fmt(it.deficit)} {it.unit_name} diff --git a/web/src/pages/Groups.jsx b/web/src/pages/Groups.jsx index 58deda9..dfaad8f 100644 --- a/web/src/pages/Groups.jsx +++ b/web/src/pages/Groups.jsx @@ -52,7 +52,7 @@ export default function Groups() { } async function remove(group) { - if (!confirm(`Kategorie "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return; + if (!confirm(`Gruppe "${group.name}" löschen? Produkte bleiben erhalten, verlieren aber die Zuordnung.`)) return; try { await api.deleteGroup(group.id); if (selectedId === group.id) setSelectedId(null); @@ -68,9 +68,9 @@ export default function Groups() {
-

Kategorien

+

Gruppen

- Produkte zusammenfassen, Mindestbestand je Kategorie setzen und EAN-Codes hinterlegen + Bestände mehrerer Marken zusammenzählen – z.B. Mehl von Rewe, Aldi und Migros als „5 kg Mehl". Mit gemeinsamem Mindestbestand; die EAN-Codes der zugeordneten Artikel erscheinen automatisch hier.
@@ -82,7 +82,7 @@ export default function Groups() { - + @@ -133,7 +133,7 @@ export default function Groups() { ); })} - {groups.length === 0 && } + {groups.length === 0 && }
KategorieGruppe Produkte Bestand Mindestbestand @@ -148,7 +148,7 @@ export default function Groups() {
Noch keine Kategorien.
Noch keine Gruppen.
@@ -159,16 +159,15 @@ export default function Groups() {
-

EAN-Codes: {selected.name}

+

EAN-Codes der Gruppe: {selected.name}

{ try { await api.addGroupBarcode(selected.id, body); @@ -188,7 +187,7 @@ export default function Groups() { {isAdmin && (
-

Neue Kategorie

+

Neue Gruppe

+
+ +
+
{isAdmin && (
diff --git a/web/src/pages/Products.jsx b/web/src/pages/Products.jsx index f21d5f3..262b442 100644 --- a/web/src/pages/Products.jsx +++ b/web/src/pages/Products.jsx @@ -4,6 +4,7 @@ import { api } from "../api"; import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { fmt, unitShort } from "../units"; +import { asTree } from "../categoryTree"; // Wie viele Basiseinheiten eine "Artikeleinheit" umfasst (Gebinde oder Produkteinheit). function unitCount(p) { @@ -19,11 +20,14 @@ export default function Products() { const { isAdmin } = useAuth(); const [products, setProducts] = useState([]); const [q, setQ] = useState(""); + const [categories, setCategories] = useState([]); + // "" = alle, "0" = ohne Kategorie, sonst die ID (Unterkategorien zaehlen mit). + const [categoryId, setCategoryId] = useState(""); const [error, setError] = useState(null); - async function load() { + async function load(cat = categoryId) { try { - setProducts(await api.listProducts(q)); + setProducts(await api.listProducts(q, cat)); } catch (err) { setError(err.message); } @@ -31,6 +35,7 @@ export default function Products() { useEffect(() => { load(); + api.listCategories().then(setCategories).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -53,10 +58,19 @@ export default function Products() { {error &&
{error}
} -
+ +
@@ -68,6 +82,7 @@ export default function Products() { Name Marke + Kategorie Einheit Bestand Einheiten (ist / soll) @@ -93,6 +108,7 @@ export default function Products() { )} {p.brand || "–"} + {p.category_name || "–"} {p.unit_name || unitShort(p.base_unit)} {p.package_size diff --git a/web/src/pages/ShoppingList.jsx b/web/src/pages/ShoppingList.jsx index e08b8f4..9252d06 100644 --- a/web/src/pages/ShoppingList.jsx +++ b/web/src/pages/ShoppingList.jsx @@ -26,7 +26,7 @@ export default function ShoppingList() {

Einkaufsliste

-
Produkte und Kategorien unter ihrem Mindestbestand
+
Produkte und Gruppen unter ihrem Mindestbestand
{error &&
{error}
} @@ -42,7 +42,7 @@ export default function ShoppingList() {
  • diff --git a/web/src/pages/Transfer.jsx b/web/src/pages/Transfer.jsx index fca2f06..5b37eb4 100644 --- a/web/src/pages/Transfer.jsx +++ b/web/src/pages/Transfer.jsx @@ -69,7 +69,7 @@ export default function Transfer() {

    CSV enthält eine Zeile je Charge und lässt sich in Excel oder LibreOffice bearbeiten. JSON ist ein vollständiges Backup inklusive - Einheiten, Kategorien und Lagerorten. + Einheiten, Gruppen und Lagerorten.