"""Hausmaße: die Namen (Stammdaten) und ihre Mengen je Kategorie und Artikel. Der Name ist global („Esslöffel"), die Menge nicht: 1 EL Mehl sind 10 g, 1 EL Zucker 12 g. Deshalb zwei Ebenen – ein Maß anlegen ist Stammdatenpflege, ihm eine Menge geben hängt an Kategorie oder Artikel. """ # ACHTUNG: Hier steht bewusst KEIN ``from __future__ import annotations`` – # aus demselben Grund wie in package_types.py (``-> None`` wuerde als # Antwortmodell gewertet und bricht mit 204 den Start des Dienstes ab). from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session from ..database import get_db from ..deps import get_current_user, require_admin from ..models import ( Category, CategoryMeasure, Measure, Product, ProductMeasure, User, ) from ..schemas import ( MeasureAmountIn, MeasureAmountOut, MeasureIn, MeasureOut, ) from ..services.conversion import PACKAGE_TOKENS, find_unit from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse router = APIRouter(tags=["measures"]) def _measure_or_404(db: Session, measure_id: int) -> Measure: measure = db.get(Measure, measure_id) if measure is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Maß nicht gefunden") return measure def _pruefe_namen(db: Session, name: str, short: str | None, ausser_id: int | None = None) -> None: """Name und Kurzform müssen eindeutig und frei von Kollisionen sein. Ein Maß, das genauso heißt wie eine verwaltete Einheit oder wie „Packung", wäre dauerhaft unerreichbar: ``to_base`` prüft beide vorher. Lieber hier ablehnen als still etwas anlegen, das nie greift. """ for wert, feld in ((name, "Name"), (short, "Kurzform")): if not wert: continue t = wert.strip().lower() if t in PACKAGE_TOKENS: raise HTTPException( status.HTTP_409_CONFLICT, f"'{wert}' ist als Packungs-Angabe vergeben und würde nie greifen.", ) if find_unit(db, t) is not None: raise HTTPException( status.HTTP_409_CONFLICT, f"'{wert}' ist bereits eine verwaltete Einheit. Als Maß würde es " f"nie greifen, weil Einheiten zuerst geprüft werden.", ) doppelt = db.query(Measure).filter( (func.lower(Measure.name) == t) | (func.lower(Measure.short) == t) ) if ausser_id is not None: doppelt = doppelt.filter(Measure.id != ausser_id) if doppelt.first() is not None: raise HTTPException(status.HTTP_409_CONFLICT, f"{feld} '{wert}' gibt es schon.") # -------------------------------------------------------------------------- # Stammdaten # -------------------------------------------------------------------------- @router.get("/measures", response_model=list[MeasureOut]) def list_measures( db: Session = Depends(get_db), _: User = Depends(get_current_user) ) -> list[Measure]: return db.query(Measure).order_by(Measure.position, Measure.id).all() @router.post("/measures", response_model=MeasureOut, status_code=status.HTTP_201_CREATED) def create_measure( payload: MeasureIn, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Measure: _pruefe_namen(db, payload.name, payload.short) measure = Measure( name=payload.name.strip(), short=(payload.short or "").strip() or None, position=payload.position, ) db.add(measure) db.commit() db.refresh(measure) return measure @router.patch("/measures/{measure_id}", response_model=MeasureOut) def update_measure( measure_id: int, payload: MeasureIn, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> Measure: """Umbenennen – auch bei eingebauten Maßen, wie bei Einheiten und Gebinden. Die Mengen hängen an der ID, nicht am Namen, und wandern deshalb mit. """ measure = _measure_or_404(db, measure_id) _pruefe_namen(db, payload.name, payload.short, ausser_id=measure.id) measure.name = payload.name.strip() measure.short = (payload.short or "").strip() or None measure.position = payload.position db.commit() db.refresh(measure) return measure @router.delete("/measures/{measure_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_measure( measure_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: """Maß löschen – samt aller Mengen, die daran hängen. Die Mengen werden ausdrücklich abgeräumt: SQLite setzt Fremdschlüssel nicht ohne Weiteres um, sonst blieben Waisen stehen (gleiche Begründung wie in ``delete_category``). """ measure = _measure_or_404(db, measure_id) if measure.is_builtin: raise HTTPException( status.HTTP_400_BAD_REQUEST, "Eingebaute Maße lassen sich nicht löschen – umbenennen geht aber.", ) db.query(CategoryMeasure).filter(CategoryMeasure.measure_id == measure.id).delete() db.query(ProductMeasure).filter(ProductMeasure.measure_id == measure.id).delete() db.delete(measure) db.commit() # -------------------------------------------------------------------------- # Mengen je Kategorie und Artikel # -------------------------------------------------------------------------- @router.get("/products/{product_id}/measures", response_model=list[MeasureAmountOut]) def product_measures( product_id: int, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[MeasureAmountOut]: """Die für diesen Artikel WIRKSAMEN Maße, mit Herkunft. Grundlage für die Einheitenauswahl beim Verbrauchen – dort soll „EL" nur auftauchen, wenn dafür auch eine Menge bekannt ist. """ product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden") return [ MeasureAmountOut( measure_id=a.measure.id, name=a.measure.name, short=a.measure.short, amount=a.amount, base=a.base, herkunft=a.herkunft, ) for a in verfuegbare_masse(db, product) ] @router.put("/products/{product_id}/measures/{measure_id}", response_model=MeasureAmountOut) def set_product_measure( product_id: int, measure_id: int, payload: MeasureAmountIn, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> MeasureAmountOut: """Menge nur für diesen Artikel – schlägt die der Kategorie.""" product = db.get(Product, product_id) if product is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden") measure = _measure_or_404(db, measure_id) row = ( db.query(ProductMeasure) .filter(ProductMeasure.product_id == product.id, ProductMeasure.measure_id == measure.id) .first() ) if row is None: row = ProductMeasure(product_id=product.id, measure_id=measure.id, amount=payload.amount, base=payload.base.value) db.add(row) else: row.amount = payload.amount row.base = payload.base.value db.commit() return MeasureAmountOut( measure_id=measure.id, name=measure.name, short=measure.short, amount=row.amount, base=row.base, herkunft="Artikel", ) @router.delete("/products/{product_id}/measures/{measure_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_product_measure( product_id: int, measure_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: """Eigene Menge entfernen – danach gilt wieder die der Kategorie.""" db.query(ProductMeasure).filter( ProductMeasure.product_id == product_id, ProductMeasure.measure_id == measure_id, ).delete() db.commit() @router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut]) def category_measures( category_id: int, vererbt: bool = False, db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[MeasureAmountOut]: """Die an DIESER Kategorie hinterlegten Mengen. Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit ``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende bekommt, zeigt ``/products/{id}/measures``. """ if db.get(Category, category_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden") rows = ( db.query(CategoryMeasure, Measure) .join(Measure, CategoryMeasure.measure_id == Measure.id) .filter(CategoryMeasure.category_id == category_id) .order_by(Measure.position, Measure.id) .all() ) eigene = [ MeasureAmountOut( measure_id=m.id, name=m.name, short=m.short, amount=cm.amount, base=cm.base, herkunft="Kategorie", ) for cm, m in rows ] if not vererbt: return eigene # Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit # beides: was hier steht und was gelten wuerde, wenn man es loescht. category = db.get(Category, category_id) gesetzt = {e.measure_id for e in eigene} for measure in db.query(Measure).order_by(Measure.position, Measure.id).all(): if measure.id in gesetzt: continue angabe = geerbtes_mass_kategorie(db, category, measure.id) if angabe is None: continue eigene.append(MeasureAmountOut( measure_id=measure.id, name=measure.name, short=measure.short, amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft, )) return eigene @router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut) def set_category_measure( category_id: int, measure_id: int, payload: MeasureAmountIn, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> MeasureAmountOut: """Menge für eine Kategorie – gilt auch für ihre Unterkategorien.""" if db.get(Category, category_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden") measure = _measure_or_404(db, measure_id) row = ( db.query(CategoryMeasure) .filter(CategoryMeasure.category_id == category_id, CategoryMeasure.measure_id == measure.id) .first() ) if row is None: row = CategoryMeasure(category_id=category_id, measure_id=measure.id, amount=payload.amount, base=payload.base.value) db.add(row) else: row.amount = payload.amount row.base = payload.base.value db.commit() return MeasureAmountOut( measure_id=measure.id, name=measure.name, short=measure.short, amount=row.amount, base=row.base, herkunft="Kategorie", ) @router.delete("/categories/{category_id}/measures/{measure_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_category_measure( category_id: int, measure_id: int, db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> None: db.query(CategoryMeasure).filter( CategoryMeasure.category_id == category_id, CategoryMeasure.measure_id == measure_id, ).delete() db.commit()