import difflib import re from fastapi import ( APIRouter, BackgroundTasks, Depends, File, HTTPException, Request, UploadFile, status, ) from fastapi.responses import Response from sqlalchemy.orm import Session, joinedload, selectinload from ..crud import product_to_out, product_tracking, products_to_out_bulk from ..database import get_db from ..deps import get_current_user, require_admin from ..models import ( Barcode, BaseUnit, Category, CategoryTracking, Group, Location, Movement, MovementType, Product, ProductImage, ProductLocationMinStock, RemovalReason, Shop, User, ) from ..off import lookup_barcode from ..schemas import ( BarcodeCreate, LocationMinStockIn, LookupResult, MatchCandidate, MatchLine, MatchRequest, ProductCreate, ProductOut, ProductUpdate, RemovalHistoryItem, RemovalStat, RemovalSummary, ) from ..services import images from ..services.categories import suggest_category from .categories import descendant_ids from .settings import get_receipt_match_threshold 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.min_stock import schreibe_ueberall from ..services.stock import removal_stats router = APIRouter(prefix="/products", tags=["products"]) # --------------------------------------------------------------------------- # Kassenzettel-Abgleich: eine OCR-Zeile den ähnlichsten Lebensmitteln zuordnen. # --------------------------------------------------------------------------- _UMLAUTE = str.maketrans({"ä": "ae", "ö": "oe", "ü": "ue", "ß": "ss"}) def _normalisieren(text: str) -> str: """Klein, Umlaute aufgelöst, nur Buchstaben/Ziffern – Preise/Sonderzeichen weg.""" text = (text or "").lower().translate(_UMLAUTE) return re.sub(r"[^a-z0-9]+", " ", text).strip() def _match_score(zeile: str, name: str) -> int: """Ähnlichkeit 0–100 zwischen Kassenzeile und Produktname. Kombiniert die Gesamt-Ähnlichkeit mit einem Token-/Präfix-Abgleich, damit abgekürzte Kassennamen ("MÜHLEN SCHNITZ") auf den vollen Namen passen. """ a, b = _normalisieren(zeile), _normalisieren(name) if not a or not b: return 0 gesamt = difflib.SequenceMatcher(None, a, b).ratio() a_tok, b_tok = a.split(), b.split() bester = [] for t in a_tok: m = 0.0 for u in b_tok: if u.startswith(t) or t.startswith(u): m = max(m, min(len(t), len(u)) / max(len(t), len(u))) else: m = max(m, difflib.SequenceMatcher(None, t, u).ratio()) bester.append(m) token = sum(bester) / len(bester) if bester else 0.0 return round(100 * max(gesamt, token)) @router.post("/match", response_model=list[MatchLine]) def match_receipt( payload: MatchRequest, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[MatchLine]: """Kassenzettel-Zeilen den ähnlichsten LEBENSMITTELN zuordnen (Score 0–100). Gegenstände (auch Verbrauchsgegenstände) bleiben außen vor; je Zeile die besten Treffer über dem Schwellwert (aus Request oder Einstellung).""" schwelle = payload.threshold if payload.threshold is not None else get_receipt_match_threshold(db) schwelle = max(0, min(100, schwelle)) lebensmittel = [ p for p in db.query(Product).options(joinedload(Product.category)).all() if product_tracking(db, p) != CategoryTracking.object.value ] ergebnis: list[MatchLine] = [] for zeile in payload.lines: text = (zeile or "").strip() treffer = [(p, _match_score(text, p.name)) for p in lebensmittel] treffer = sorted( (ps for ps in treffer if ps[1] >= schwelle), key=lambda ps: ps[1], reverse=True, ) ergebnis.append(MatchLine( text=text, candidates=[ MatchCandidate( product_id=p.id, name=p.name, brand=p.brand, score=s, package_size=p.package_size, base_unit=p.base_unit.value, ) for p, s in treffer[:5] ], )) return ergebnis @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).options( # Relationen vorab laden, damit products_to_out_bulk kein N+1 auslöst. joinedload(Product.category), joinedload(Product.group), joinedload(Product.shop), joinedload(Product.display_unit), joinedload(Product.min_stock_unit), selectinload(Product.field_values), selectinload(Product.location_min_stocks).joinedload(ProductLocationMinStock.location), ) 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 products_to_out_bulk(db, products) @router.get("/lookup", response_model=LookupResult) def lookup( barcode: str, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> LookupResult: """Barcode auflösen: Produkt (Haupt- oder Alias-Code), Gruppen-Code, sonst OFF.""" code = barcode.strip() existing = db.query(Product).filter(Product.barcode == code).first() if existing is None: alias = db.query(Barcode).filter(Barcode.code == code).first() if alias is not None and alias.product_id: existing = db.get(Product, alias.product_id) if existing: return LookupResult(found=True, existing_product=product_to_out(db, existing)) # Code kann einer Gruppe zugeordnet sein (z.B. alle Mehl-Marken in "Mehl"). group_id = group_name = None alias = db.query(Barcode).filter(Barcode.code == code).first() if alias is not None and alias.group_id: group = db.get(Group, alias.group_id) if group is not None: 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, category_id=category_id, category_name=category_name, ) return LookupResult(found=False, group_id=group_id, group_name=group_name) @router.get("/{product_id}", response_model=ProductOut) def get_product( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> ProductOut: product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") return product_to_out(db, product) @router.put("/{product_id}/location-min-stock", response_model=ProductOut) def set_product_location_min_stock( product_id: int, payload: list[LocationMinStockIn], db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Mindestbestände je Lagerort komplett ersetzen (Menge 0 = Eintrag entfällt).""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") db.query(ProductLocationMinStock).filter( ProductLocationMinStock.product_id == product_id ).delete() gesehen: set[int] = set() for eintrag in payload: if eintrag.min_stock <= 0 or eintrag.location_id in gesehen: continue if db.get(Location, eintrag.location_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") gesehen.add(eintrag.location_id) db.add(ProductLocationMinStock( product_id=product_id, location_id=eintrag.location_id, min_stock=eintrag.min_stock, )) db.commit() db.refresh(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, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> LookupResult: """Open Food Facts erneut befragen – auch für einen bereits angelegten Artikel. ``/lookup`` kann das nicht: Kennt es den Barcode schon, meldet es den eigenen Artikel und fragt OFF gar nicht erst. Zum Vergleichen braucht es aber genau die fremden Daten. Neben dem Haupt-Barcode werden die zusätzlichen EAN-Codes durchprobiert – oft ist nur einer davon bei OFF hinterlegt. """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") codes = [product.barcode] if product.barcode else [] codes += [ b.code for b in db.query(Barcode).filter(Barcode.product_id == product_id).all() if b.code not in codes ] if not codes: raise HTTPException( status.HTTP_400_BAD_REQUEST, "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, prefer=prefer) if suggestion: category = suggest_category(db, suggestion) return LookupResult( found=True, suggestion=suggestion, category_id=category.id if category else None, category_name=category.name if category else None, ) return LookupResult(found=False) @router.get("/{product_id}/image") def get_product_image( product_id: int, request: Request, background_tasks: BackgroundTasks, thumb: bool = False, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> Response: """Artikelbild aus der eigenen Datenbank – nie von Open Food Facts. Anders als das Logo verlangt diese Route eine Anmeldung: Aus den Bildern liesse sich sonst ohne Konto ablesen, was im Vorrat liegt. Diese Route wird je Tabellenzeile einmal aufgerufen. Sie muss deshalb schnell sein und darf unter keinen Umstaenden warten – siehe Modulkopf von ``services/images.py``. Drei Zusagen: * ``thumb=true`` liefert das Vorschaubild (~3 KB) statt des Originals. * Eine unveraenderte Kopie beantwortet sie mit 304, ohne Daten zu senden. * Fehlt die lokale Kopie, wird sie NACH der Antwort im Hintergrund geholt. """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") bild = db.get(ProductImage, product_id) if bild is None: if product.image_url and images.nachholen_faellig(product_id): background_tasks.add_task(images.nachholen, product_id, product.image_url) raise HTTPException(status.HTTP_404_NOT_FOUND, "Kein Bild vorhanden") # Vorschau und Original brauchen VERSCHIEDENE Marken – sonst liefert der # Zwischenspeicher des Browsers die Briefmarke fuer das grosse Bild aus. marke = f'"bild-{product_id}-{int(bild.updated_at.timestamp())}{"-v" if thumb else ""}"' kopf = {"Cache-Control": "private, max-age=300", "ETag": marke} if request.headers.get("if-none-match") == marke: return Response(status_code=status.HTTP_304_NOT_MODIFIED, headers=kopf) daten, typ = bild.data, bild.content_type if thumb: klein = bild.thumb if bild.thumb is not None else images.vorschau_nachziehen(db, bild) if klein is not None: daten, typ = klein, images.THUMB_TYPE return Response(content=daten, media_type=typ, headers=kopf) @router.put("/{product_id}/image", response_model=ProductOut) async def upload_product_image( product_id: int, file: UploadFile = File(...), db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Eigenes Foto hochladen (Kamera oder Galerie) und beim Artikel ablegen. ``source_url = None`` markiert das Bild als selbst hochgeladen – so ersetzt es der Bildabgleich beim Speichern nicht durch ein Bild aus einer Adresse (solange keine Bildadresse gesetzt ist). """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") if file.content_type not in images.ALLOWED_TYPES: erlaubt = ", ".join(sorted(t.split("/")[-1] for t in images.ALLOWED_TYPES)) raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Dieses Bildformat wird nicht unterstützt. Erlaubt sind: {erlaubt}.", ) data = await file.read() if not data: raise HTTPException(status.HTTP_400_BAD_REQUEST, "Die Datei ist leer.") if len(data) > images.MAX_BYTES: raise HTTPException( status.HTTP_400_BAD_REQUEST, f"Das Bild ist zu groß ({len(data) // 1024} KB). " f"Erlaubt sind höchstens {images.MAX_BYTES // 1024} KB.", ) klein = images.vorschau(data, file.content_type) bild = db.get(ProductImage, product.id) if bild is None: bild = ProductImage( product_id=product.id, content_type=file.content_type, data=data, thumb=klein, source_url=None, ) db.add(bild) else: bild.content_type, bild.data, bild.source_url = file.content_type, data, None bild.thumb = klein db.commit() db.refresh(product) return product_to_out(db, product) @router.delete("/{product_id}/image", status_code=status.HTTP_204_NO_CONTENT) def delete_product_image( product_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: """Foto entfernen. Eine gesetzte Bildadresse bleibt bestehen.""" bild = db.get(ProductImage, product_id) if bild is not None: db.delete(bild) db.commit() def _pruefe_zweiteinheit(base_unit, basis, anzahl, menge) -> None: """Die Zweiteinheit muss vollstaendig und auf eine ANDERE Art zeigen. Eine halbe Bruecke waere schlimmer als keine: sie saehe im Formular richtig aus, wuerde aber nirgends greifen (``zweit_faktor`` liefert dann None). """ gesetzt = [x for x in (basis, anzahl, menge) if x is not None] if not gesetzt: return if len(gesetzt) != 3: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Zweiteinheit unvollständig: Art, Anzahl und Menge gehören zusammen.", ) if basis == base_unit: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Die Zweiteinheit muss eine ANDERE Art sein als die Einheit des " "Artikels – sonst gibt es nichts umzurechnen.", ) @router.post("", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def create_product( payload: ProductCreate, background_tasks: BackgroundTasks, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: if payload.barcode: exists = db.query(Product).filter(Product.barcode == payload.barcode).first() if exists: raise HTTPException( status.HTTP_409_CONFLICT, "Ein Produkt mit diesem Barcode existiert bereits" ) base_unit = payload.base_unit display_unit_id = None if payload.unit_id is not None: try: base_unit, display_unit_id = resolve_product_unit(db, payload.unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc 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") _pruefe_zweiteinheit( base_unit, payload.secondary_base, payload.secondary_count, payload.secondary_amount ) product = Product( barcode=payload.barcode or None, name=payload.name, brand=payload.brand, image_url=payload.image_url, base_unit=base_unit, display_unit_id=display_unit_id, package_size=payload.package_size, package_label=payload.package_label or None, secondary_base=payload.secondary_base.value if payload.secondary_base else None, secondary_count=payload.secondary_count, secondary_amount=payload.secondary_amount, date_precision=payload.date_precision.value, group_id=payload.group_id, category_id=payload.category_id, 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, individual=bool(payload.individual), bulk=bool(payload.bulk), source="manual", ) db.add(product) db.flush() # product.id fuer den Gruppen-Code # Der Mindestbestand ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile. schreibe_ueberall(db, product, payload.min_stock) 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 db.commit() db.refresh(product) if product.image_url: # Bild erst NACH dem Anlegen im Hintergrund holen – der Netzwerk-Abruf # soll das Anlegen nicht mehrere Sekunden blockieren. Schlägt er fehl, # bleibt der Artikel trotzdem angelegt (nur ohne Bild). background_tasks.add_task(images.nachholen, product.id, product.image_url) return product_to_out(db, product) @router.patch("/{product_id}", response_model=ProductOut) def update_product( product_id: int, payload: ProductUpdate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: product = db.get(Product, product_id) if product is None: 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) .filter(Product.barcode == data["barcode"], Product.id != product_id) .first() ) if clash: 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") data.pop("base_unit", None) # unit_id hat Vorrang if unit_id is None: product.display_unit_id = None else: try: product.base_unit, product.display_unit_id = resolve_product_unit(db, unit_id) except ConversionError as exc: raise HTTPException(status.HTTP_400_BAD_REQUEST, str(exc)) from exc # ``min_stock`` ist kein Artikelfeld mehr, sondern die „Ueberall"-Zeile. # Der Vertrag nach aussen bleibt aber gleich (Basiseinheiten), damit # aeltere App-Versionen und das Web-Formular unveraendert weiterlaufen. ueberall_gesetzt = "min_stock" in data ueberall_wert = data.pop("min_stock", None) # Zweiteinheit: Enum -> String, und nur vollstaendig oder gar nicht. if "secondary_base" in data: _pruefe_zweiteinheit( data.get("base_unit") or product.base_unit, data["secondary_base"], data.get("secondary_count", product.secondary_count), data.get("secondary_amount", product.secondary_amount), ) data["secondary_base"] = ( data["secondary_base"].value if data["secondary_base"] else None ) # Bruecke entfernt: die beiden Zahlen muessen mit, sonst bliebe eine # halbe Angabe stehen, die nirgends greift. if data["secondary_base"] is None: data["secondary_count"] = None data["secondary_amount"] = None if data.get("min_stock_in_packages") is None: data.pop("min_stock_in_packages", None) # Spalte ist NOT NULL if data.get("date_precision") is None: data.pop("date_precision", None) # Spalte ist NOT NULL else: data["date_precision"] = data["date_precision"].value for field, value in data.items(): setattr(product, field, value) if ueberall_gesetzt: schreibe_ueberall(db, product, ueberall_wert) 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: # Massgeblich ist, woher die vorhandene Kopie stammt - nicht, ob sich # die Adresse am Artikel geaendert hat. Sonst passierte genau dann # nichts, wenn die Adresse schon stimmte, der Abruf damals aber # fehlschlug: Der Artikel bliebe dauerhaft ohne Bild. alt = db.get(ProductImage, product.id) if alt is not None and alt.source_url != product.image_url: db.delete(alt) db.flush() alt = None if alt is None and product.image_url: images.store(db, product, product.image_url) db.commit() db.refresh(product) return product_to_out(db, product) @router.post("/{product_id}/barcodes", response_model=ProductOut, status_code=status.HTTP_201_CREATED) def add_product_barcode( product_id: int, payload: BarcodeCreate, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> ProductOut: """Weiteren EAN-Code zu einem Produkt hinzufügen.""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Produkt nicht gefunden") code = payload.code.strip() if db.query(Barcode).filter(Barcode.code == code).first() or ( db.query(Product).filter(Product.barcode == code).first() ): raise HTTPException(status.HTTP_409_CONFLICT, "Dieser Code ist bereits vergeben") db.add(Barcode(code=code, note=(payload.note or None), product_id=product.id)) db.commit() db.refresh(product) return product_to_out(db, product) @router.delete("/{product_id}/barcodes/{code}", status_code=status.HTTP_204_NO_CONTENT) def delete_product_barcode( product_id: int, code: str, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: entry = ( db.query(Barcode) .filter(Barcode.product_id == product_id, Barcode.code == code) .first() ) if entry is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Code nicht gefunden") db.delete(entry) db.commit() @router.delete("/{product_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_product( product_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: 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()