diff --git a/backend/app/config.py b/backend/app/config.py index c13c057..8c36368 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -35,6 +35,10 @@ class Settings(BaseSettings): # Behaviour expiry_warning_days_default: int = 7 + # Wie lange eine angebrochene Packung haelt, wenn weder Artikel noch + # Kategorie etwas sagen. Im Betrieb entscheidet die Einstellung + # "open_days"; dieser Wert gilt nur, solange dort nichts gesetzt ist. + open_days_default: int = 3 # CORS (comma separated); "*" allows all cors_origins: str = "*" diff --git a/backend/app/crud.py b/backend/app/crud.py index a4aaf2f..97a5ec7 100644 --- a/backend/app/crud.py +++ b/backend/app/crud.py @@ -27,6 +27,7 @@ from .services.conversion import ( zweit_faktor, ) from .services.loans import nicht_verliehen +from .services.vererbung import frist_herkunft from .services.min_stock import UEBERALL_NAME, lies_ueberall from .services.stock import current_stock, location_subtree_stock_base @@ -87,6 +88,9 @@ def product_tracking(db: Session, product: Product) -> str: def product_to_out(db: Session, product: Product) -> ProductOut: out = ProductOut.model_validate(product) out.stock = current_stock(db, product.id) + # Wirksame Oeffnungsfrist samt Herkunft – das Formular soll den geerbten + # Wert als Platzhalter zeigen koennen, ohne die Vererbung nachzubauen. + out.effective_open_days, out.open_days_source = frist_herkunft(db, product) out.expired_count = ( db.query(Lot) .filter( diff --git a/backend/app/main.py b/backend/app/main.py index 964e817..8ec72b6 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -19,6 +19,7 @@ from .routers import ( loans, locations, maintenance, + measures, oidc, package_types, products, @@ -32,6 +33,7 @@ from .routers import ( ) from .seed import ( ensure_builtin_categories, + ensure_builtin_measures, ensure_builtin_etiketten_vorlagen, ensure_builtin_package_types, ensure_builtin_units, @@ -228,6 +230,17 @@ def _ensure_schema() -> None: "ALTER TABLE items ADD COLUMN IF NOT EXISTS parent_item_id INTEGER " "REFERENCES items(id) ON DELETE SET NULL", "CREATE INDEX IF NOT EXISTS ix_items_parent_item_id ON items (parent_item_id)", + # Angebrochene Packungen: Oeffnungstag, daraus gerechnetes + # Verbrauchsdatum und die Menge beim Oeffnen (fuer „noch 400 von 1000 ml"). + # Alle drei nullbar – NULL heisst ungeoeffnet, und genau das sind alle + # bestehenden Chargen. + "ALTER TABLE lots ADD COLUMN IF NOT EXISTS opened_on DATE", + "ALTER TABLE lots ADD COLUMN IF NOT EXISTS consume_by DATE", + "ALTER TABLE lots ADD COLUMN IF NOT EXISTS opened_quantity DOUBLE PRECISION", + # Frist nach dem Anbrechen. NULL = erbt (Artikel -> Kategorie -> + # Oberkategorie -> globale Einstellung), siehe services/vererbung.py. + "ALTER TABLE products ADD COLUMN IF NOT EXISTS open_days INTEGER", + "ALTER TABLE categories ADD COLUMN IF NOT EXISTS open_days INTEGER", ] with engine.begin() as conn: # Zuerst die Lagerort-ID auf den Code umstellen (einmalig, idempotent), @@ -256,6 +269,7 @@ async def lifespan(app: FastAPI): db = SessionLocal() try: ensure_builtin_units(db) + ensure_builtin_measures(db) ensure_builtin_package_types(db) ensure_builtin_etiketten_vorlagen(db) ensure_builtin_categories(db) @@ -310,6 +324,7 @@ app.include_router(settings_router.router) app.include_router(branding.router) app.include_router(categories.router) app.include_router(maintenance.router) +app.include_router(measures.router) app.include_router(dashboard.router) app.include_router(shops.router) app.include_router(field_definitions.router) diff --git a/backend/app/models.py b/backend/app/models.py index 49c8aea..12c9cf7 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -349,6 +349,10 @@ class Category(Base): default=CategoryTracking.object.value, server_default=CategoryTracking.food.value, ) + # Wie lange eine angebrochene Packung dieser Kategorie noch haelt. + # NULL = erbt von der Oberkategorie, zuletzt vom globalen Wert + # (services/vererbung.py::oeffnungsfrist). + open_days: Mapped[int | None] = mapped_column(Integer, nullable=True) class Product(Base): @@ -385,6 +389,11 @@ class Product(Base): secondary_base: Mapped[str | None] = mapped_column(String(16), nullable=True) secondary_count: Mapped[float | None] = mapped_column(Float, nullable=True) secondary_amount: Mapped[float | None] = mapped_column(Float, nullable=True) + # Frist nach dem Anbrechen, nur fuer diesen Artikel. NULL = erbt von der + # Kategorie (und die von ihren Oberkategorien, zuletzt vom globalen Wert). + # Noetig fuer Ausnahmen: H-Milch haelt geoeffnet so lange wie Frischmilch, + # steckt aber in derselben Kategorie. + open_days: Mapped[int | None] = mapped_column(Integer, nullable=True) # Welche MHD-Genauigkeit bei diesem Produkt sinnvoll ist. Steuert nur die # Voreinstellung der Eingabe (z.B. Konserven: nur Monat/Jahr aufgedruckt). date_precision: Mapped[str] = mapped_column( @@ -511,10 +520,35 @@ class Lot(Base): location_id: Mapped[str | None] = mapped_column( String(10), ForeignKey("locations.id", ondelete="SET NULL"), nullable=True ) + # Angebrochen: NULL = ungeoeffnet. Bewusst KEIN eigenes Statusfeld – das + # Datum traegt den Zustand mit und wird fuer die Frist sowieso gebraucht; + # ein zweites Feld koennte ihm widersprechen. + opened_on: Mapped[date | None] = mapped_column(Date, nullable=True) + # „Zu verbrauchen bis" = opened_on + Frist (services/vererbung.py). Beim + # Oeffnen gerechnet und GESPEICHERT: so bleibt das Datum an der offenen + # Packung stehen, wenn die Frist spaeter geaendert wird, und die + # Ablaufliste muss nicht je Zeile die Kategorie-Vererbung aufloesen. + consume_by: Mapped[date | None] = mapped_column(Date, nullable=True) + # Menge beim Oeffnen, damit „noch 400 ml von 1 l" anzeigbar ist. + opened_quantity: Mapped[float | None] = mapped_column(Float, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) product: Mapped[Product] = relationship(back_populates="lots") + @property + def ist_angebrochen(self) -> bool: + return self.opened_on is not None + + @property + def faellig_am(self) -> date | None: + """Das frueheste der beiden Daten – danach wird gewarnt. + + MHD und Verbrauchsdatum bleiben getrennt sichtbar; fuer Ablaufliste, + Zustand und Erinnerungen zaehlt aber immer das naehere von beiden. + """ + termine = [d for d in (self.best_before, self.consume_by) if d is not None] + return min(termine) if termine else None + class Movement(Base): __tablename__ = "movements" @@ -561,6 +595,81 @@ class PackageType(Base): is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) +class Measure(Base): + """Ein Hausmaß wie „Esslöffel" – nur der NAME, ohne Menge. + + Die Menge gehört nicht an den Namen, sondern an die Kategorie bzw. den + Artikel: 1 EL Mehl sind 10 g, 1 EL Zucker 12 g, 1 EL Öl 15 ml. Ein globaler + Faktor wie bei :class:`Unit` wäre also schlicht falsch. Auch die ART + (Gramm/Milliliter/Stück) steckt deshalb in der Menge, nicht hier. + + Der Name ist trotzdem global, damit „EL" überall dasselbe Maß bezeichnet – + sonst wären „EL" und „Esslöffel" in zwei Kategorien zwei verschiedene Dinge + und ein Rezept fände keines davon wieder. + """ + + __tablename__ = "measures" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(40), nullable=False, unique=True) + # Kurzform fuer Rezepte und schmale Spalten: „EL", „TL". + short: Mapped[str | None] = mapped_column(String(10), nullable=True) + position: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + is_builtin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +class CategoryMeasure(Base): + """Was ein Hausmaß in DIESER Kategorie wiegt bzw. fasst. + + Gilt über den Kategorie-Baum auch für die Unterkategorien; der nächste + Vorfahr mit Eintrag gewinnt (services/vererbung.py). Eigene Tabelle statt + nullbarer Spalten in einer gemeinsamen, wie es + :class:`ProductLocationMinStock` und :class:`GroupLocationMinStock` schon + vormachen. + """ + + __tablename__ = "category_measures" + __table_args__ = ( + UniqueConstraint("measure_id", "category_id", name="uq_cat_measure"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + measure_id: Mapped[int] = mapped_column( + ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True + ) + category_id: Mapped[int] = mapped_column( + ForeignKey("categories.id", ondelete="CASCADE"), nullable=False, index=True + ) + # Menge in ``base`` – also 10 fuer „1 EL = 10 g". + amount: Mapped[float] = mapped_column(Float, nullable=False) + # Art der Menge: BaseUnit-Wert (piece/gram/milliliter). Als String wie + # ``Product.secondary_base``, damit kein Postgres-Enumtyp nachzuziehen ist. + base: Mapped[str] = mapped_column(String(16), nullable=False) + + +class ProductMeasure(Base): + """Hausmaß-Menge für EINEN Artikel – schlägt die der Kategorie. + + Für Ausnahmen innerhalb einer Kategorie: Puderzucker ist deutlich leichter + als Kristallzucker, steckt aber in derselben Kategorie. + """ + + __tablename__ = "product_measures" + __table_args__ = ( + UniqueConstraint("measure_id", "product_id", name="uq_prod_measure"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + measure_id: Mapped[int] = mapped_column( + ForeignKey("measures.id", ondelete="CASCADE"), nullable=False, index=True + ) + product_id: Mapped[int] = mapped_column( + ForeignKey("products.id", ondelete="CASCADE"), nullable=False, index=True + ) + amount: Mapped[float] = mapped_column(Float, nullable=False) + base: Mapped[str] = mapped_column(String(16), nullable=False) + + class ProductImage(Base): """Lokale Kopie des Artikelbilds. diff --git a/backend/app/routers/categories.py b/backend/app/routers/categories.py index a4a832d..68fb0f7 100644 --- a/backend/app/routers/categories.py +++ b/backend/app/routers/categories.py @@ -12,6 +12,7 @@ from ..database import get_db from ..deps import get_current_user, require_admin from ..models import ( Category, + CategoryMeasure, CategoryTracking, FieldDefinition, Product, @@ -82,7 +83,10 @@ def create_category( tracking = parent.tracking else: tracking = CategoryTracking.object.value - category = Category(name=payload.name, parent_id=payload.parent_id, tracking=tracking) + category = Category( + name=payload.name, parent_id=payload.parent_id, tracking=tracking, + open_days=payload.open_days, + ) db.add(category) db.commit() db.refresh(category) @@ -156,6 +160,11 @@ def delete_category( db.query(FieldDefinition).filter( FieldDefinition.category_id == category_id ).delete(synchronize_session=False) + # Hausmass-Mengen dieser Kategorie – aus demselben Grund ausdruecklich: + # sonst blieben Waisen stehen und heften sich an neu vergebene IDs. + db.query(CategoryMeasure).filter( + CategoryMeasure.category_id == category_id + ).delete(synchronize_session=False) db.delete(category) db.commit() return Response(status_code=status.HTTP_204_NO_CONTENT) diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index e47ce31..3aed98f 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -283,14 +283,28 @@ def set_enforced( # -------------------------------------------------------------------------- # Auswertungen # -------------------------------------------------------------------------- -def _zustand(best_before: date | None, heute: date, warnfrist: int) -> str: - """Ablaufzustand einer Charge: ohne MHD / abgelaufen / bald / in Ordnung.""" +def _zustand( + best_before: date | None, heute: date, warnfrist: int, *, angebrochen: bool = False +) -> str: + """Ablaufzustand einer Charge: abgelaufen / bald / angebrochen / ohne MHD / ok. + + ``best_before`` ist hier das WIRKSAME Datum – das frühere aus aufgedrucktem + MHD und Verbrauchsdatum (``Lot.faellig_am``). + + Vorrang: abgelaufen > bald ablaufend > angebrochen > in Ordnung. + Dringlichkeit schlägt Zustand: Eine angebrochene Packung, die morgen weg + muss, gehört in „bald ablaufend" – sonst verstoeckte der ruhigere Zustand + die Warnung. „angebrochen" ist damit das, was offen ist und noch Zeit hat. + """ + if best_before is not None: + if best_before < heute: + return "expired" + if (best_before - heute).days <= warnfrist: + return "soon" + if angebrochen: + return "opened" if best_before is None: return "no_date" - if best_before < heute: - return "expired" - if (best_before - heute).days <= warnfrist: - return "soon" return "ok" @@ -321,7 +335,10 @@ def _bestand_beitraege( continue faktor, _label = article_unit(product) menge = lot.quantity / (faktor or 1.0) - beitraege.append((product, menge, _zustand(lot.best_before, heute, warnfrist))) + beitraege.append(( + product, menge, + _zustand(lot.faellig_am, heute, warnfrist, angebrochen=lot.ist_angebrochen), + )) for product in db.query(Product).filter(Product.individual.is_(True)).all(): # Verliehene Stuecke sind nicht im Haus und zaehlen deshalb nicht mit. @@ -374,7 +391,7 @@ def expiry_split( ) -> ExpirySplit: heute = date.today() warnfrist = get_expiry_warning_days(db) - summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0} + summen = {"ok": 0.0, "soon": 0.0, "expired": 0.0, "opened": 0.0, "no_date": 0.0} for _product, menge, zustand in _bestand_beitraege(db, heute, warnfrist): summen[zustand] += menge return ExpirySplit(**{k: round(v, 3) for k, v in summen.items()}) @@ -417,7 +434,10 @@ def by_category( rollup_cache[cid] = ziel return ziel - leer = lambda: {"article_units": 0.0, "ok": 0.0, "soon": 0.0, "expired": 0.0, "no_date": 0.0} + leer = lambda: { + "article_units": 0.0, "ok": 0.0, "soon": 0.0, + "expired": 0.0, "opened": 0.0, "no_date": 0.0, + } eimer: dict[int | None, dict] = defaultdict(leer) for product, menge, zustand in _bestand_beitraege(db, heute, warnfrist): topf = eimer[rollup(product.category_id)] diff --git a/backend/app/routers/measures.py b/backend/app/routers/measures.py new file mode 100644 index 0000000..3085bba --- /dev/null +++ b/backend/app/routers/measures.py @@ -0,0 +1,297 @@ +"""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 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, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> list[MeasureAmountOut]: + """Die an DIESER Kategorie hinterlegten Mengen (ohne die geerbten). + + Bewusst ohne Vererbung: Diese Liste ist die Bearbeitungsansicht. Was ein + Artikel am Ende wirklich 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() + ) + return [ + MeasureAmountOut( + measure_id=m.id, name=m.name, short=m.short, + amount=cm.amount, base=cm.base, herkunft="Kategorie", + ) + for cm, m in rows + ] + + +@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() diff --git a/backend/app/routers/settings.py b/backend/app/routers/settings.py index 32a2f9c..8feb0ac 100644 --- a/backend/app/routers/settings.py +++ b/backend/app/routers/settings.py @@ -6,6 +6,7 @@ from ..database import get_db from ..deps import get_current_user, require_admin from ..models import Setting, User from ..schemas import SettingOut +from ..services.vererbung import globale_oeffnungsfrist router = APIRouter(prefix="/settings", tags=["settings"]) @@ -19,6 +20,11 @@ SESSION_DAYS_KEY = "session_days" # Zehn Jahre. Wer laenger will, nimmt "unbegrenzt" – ein Token mit einem # Ablauf im naechsten Jahrhundert waere dasselbe, nur unehrlicher. SESSION_DAYS_MAX = 3650 +# Wie lange eine angebrochene Packung gilt, wenn weder Artikel noch Kategorie +# etwas sagen. Pflichtwert: Ohne ihn haette eine offene Packung kein +# Verbrauchsdatum, und das Anbrechen waere sinnlos. +OPEN_DAYS_KEY = "open_days" +OPEN_DAYS_MAX = 3650 def get_expiry_warning_days(db: Session) -> int: @@ -76,6 +82,7 @@ def list_settings( known.setdefault(EXPIRY_WARNING_KEY, str(get_expiry_warning_days(db))) known.setdefault(RECEIPT_THRESHOLD_KEY, str(get_receipt_match_threshold(db))) known.setdefault(SESSION_DAYS_KEY, str(get_session_days(db))) + known.setdefault(OPEN_DAYS_KEY, str(globale_oeffnungsfrist(db))) return [SettingOut(key=k, value=v) for k, v in known.items()] @@ -86,6 +93,17 @@ def set_setting( db: Session = Depends(get_db), _: User = Depends(require_admin), ) -> SettingOut: + if key == OPEN_DAYS_KEY: + # Gleiche Begruendung wie unten: ein Tippfehler darf nicht still auf die + # Vorgabe zurueckfallen, waehrend der Admin glaubt, er haette etwas gesetzt. + try: + tage = int(value) + except ValueError: + raise HTTPException(400, "Haltbarkeit nach dem Anbrechen muss eine Zahl in Tagen sein") + if not 1 <= tage <= OPEN_DAYS_MAX: + raise HTTPException(400, f"Haltbarkeit nach dem Anbrechen: 1 bis {OPEN_DAYS_MAX} Tage") + value = str(tage) + if key == SESSION_DAYS_KEY: # Ohne diese Pruefung wuerde get_session_days einen Tippfehler still # auf die Vorgabe zurueckdrehen – und der Admin glaubte, es sei gesetzt. diff --git a/backend/app/routers/stock.py b/backend/app/routers/stock.py index 15976c1..b081a35 100644 --- a/backend/app/routers/stock.py +++ b/backend/app/routers/stock.py @@ -13,6 +13,9 @@ from ..schemas import ( CheckInResponse, CheckOutRequest, CheckOutResponse, + LotConsume, + LotConsumeOut, + LotOpen, LotOut, LotRow, LotSplit, @@ -25,6 +28,8 @@ from ..services.conversion import ConversionError, display_unit_info from ..services.dates import clean_precision, normalize_best_before from ..services.stock import ( StockError, + abspalten, + anbrechen, check_in, check_out, check_out_lot, @@ -33,6 +38,7 @@ from ..services.stock import ( object_add, object_relocate, object_remove, + verbrauchen, ) router = APIRouter(tags=["stock"]) @@ -321,32 +327,16 @@ def split_lot( lot = db.get(Lot, lot_id) if lot is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") - # Nur echt weniger als die Gesamtmenge lässt sich abteilen – die ganze Menge - # zu verschieben ist Sache des Lagerort-Feldes, nicht des Aufteilens. - if payload.quantity >= lot.quantity - 1e-9: - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "So viel enthält die Charge nicht – zum kompletten Umlagern das " - "Lagerort-Feld nutzen.", - ) - if (payload.location_id or None) == (lot.location_id or None): - raise HTTPException( - status.HTTP_400_BAD_REQUEST, - "Bitte einen anderen Lagerort als den aktuellen wählen.", - ) if payload.location_id is not None and db.get(Location, payload.location_id) is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Lagerort nicht gefunden") - lot.quantity -= payload.quantity - neu = Lot( - product_id=lot.product_id, - quantity=payload.quantity, - best_before=lot.best_before, - best_before_precision=lot.best_before_precision, - location_id=payload.location_id, - ) - db.add(neu) - db.flush() + # Die eigentliche Arbeit macht der Dienst; hier gilt weiter die strengere + # Variante: nur echt weniger als die Gesamtmenge, und nur an einen ANDEREN + # Ort. Das Anbrechen benutzt dieselbe Funktion mit gelockerten Regeln. + try: + neu = abspalten(db, lot, payload.quantity, payload.location_id) + except StockError as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler # Liegt am Zielort schon eine Charge mit gleichem MHD, verschmelzen beide. survivor = consolidate_lot_group( db, lot.product_id, neu.best_before, neu.best_before_precision, payload.location_id @@ -356,6 +346,83 @@ def split_lot( return survivor +@router.post("/lots/{lot_id}/open", response_model=LotOut) +def open_lot( + lot_id: int, + payload: LotOpen, + db: Session = Depends(get_db), + _: User = Depends(get_current_user), +) -> Lot: + """Eine Packung der Charge anbrechen und als eigene Charge führen. + + Aus 4 × 1 l wird eine offene Charge mit 1 l (mit Verbrauchsdatum) und eine + ungeöffnete mit 3 l. Bewusst **ohne** anschließende Konsolidierung: Die + offene Packung soll gerade nicht mit der ungeöffneten verschmelzen. + """ + lot = db.get(Lot, lot_id) + if lot is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") + product = db.get(Product, lot.product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden") + if product_tracking(db, product) == OBJECT: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Nur Chargen lassen sich anbrechen. Gegenstände, die nur als Menge " + "je Lagerort geführt werden, haben keine Packung.", + ) + try: + offen = anbrechen(db, product, lot, payload.quantity) + except StockError as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + db.commit() + db.refresh(offen) + return offen + + +@router.post("/lots/{lot_id}/consume", response_model=LotConsumeOut) +def consume_lot( + lot_id: int, + payload: LotConsume, + db: Session = Depends(get_db), + user: User = Depends(get_current_user), +) -> LotConsumeOut: + """Teilmenge aus einer Charge verbrauchen („100 ml verwendet", „2 EL"). + + War die Charge noch zu, wird sie dabei angebrochen (``auto_open``) – die + Rückfrage dazu stellt die Oberfläche. Die Einheit kennt neben den + verwalteten Einheiten auch die Hausmaße der Kategorie. + """ + lot = db.get(Lot, lot_id) + if lot is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Charge nicht gefunden") + product = db.get(Product, lot.product_id) + if product is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Artikel nicht gefunden") + if product_tracking(db, product) == OBJECT: + raise HTTPException( + status.HTTP_400_BAD_REQUEST, + "Gegenstände werden über 'Entfernen' (mit Grund) gebucht, nicht verbraucht.", + ) + try: + affected, offen, geoeffnet = verbrauchen( + db, product, lot, payload.quantity, payload.unit, user, + payload.note, payload.auto_open, + ) + except ConversionError as fehler: + raise HTTPException(status.HTTP_400_BAD_REQUEST, str(fehler)) from fehler + except StockError as fehler: + raise HTTPException(status.HTTP_409_CONFLICT, str(fehler)) from fehler + db.commit() + if offen is not None: + db.refresh(offen) + return LotConsumeOut( + affected_lots=affected, + lot=LotOut.model_validate(offen) if offen is not None else None, + opened=geoeffnet, + ) + + @router.patch("/lots/{lot_id}", response_model=LotOut) def update_lot( lot_id: int, diff --git a/backend/app/routers/transfer.py b/backend/app/routers/transfer.py index e2ae800..1cd46e5 100644 --- a/backend/app/routers/transfer.py +++ b/backend/app/routers/transfer.py @@ -260,6 +260,11 @@ def export_backup_json( "best_before": lot.best_before.isoformat() if lot.best_before else None, "best_before_precision": lot.best_before_precision, "location": loc_name.get(lot.location_id), + # Angebrochene Packungen sollen eine Sicherung ueberleben – + # sonst waere nach dem Zurueckspielen alles wieder zu. + "opened_on": lot.opened_on.isoformat() if lot.opened_on else None, + "consume_by": lot.consume_by.isoformat() if lot.consume_by else None, + "opened_quantity": lot.opened_quantity, } for lot in lots ], @@ -346,6 +351,27 @@ def export_labels( # -------------------------------------------------------------------------- # Import (nur additiv) # -------------------------------------------------------------------------- +def _parse_tag(value) -> "date | None": + """ISO-Datum aus einer Sicherung lesen; Muell wird still zu None. + + Der Import ist bewusst additiv und soll nicht an einer einzelnen Zeile + scheitern – dieselbe Haltung wie bei ``_parse_best_before``. + """ + if not value: + return None + try: + return date.fromisoformat(str(value)[:10]) + except ValueError: + return None + + +def _zahl_oder_none(value) -> float | None: + try: + return float(value) if value is not None else None + except (TypeError, ValueError): + return None + + def _num(value) -> float | None: if value is None: return None @@ -989,6 +1015,9 @@ def _import_json(db: Session, content: bytes, user: User, mode: str) -> dict: best_before=best_before, best_before_precision=precision, location_id=location.id if location else None, + opened_on=_parse_tag(lot_entry.get("opened_on")), + consume_by=_parse_tag(lot_entry.get("consume_by")), + opened_quantity=_zahl_oder_none(lot_entry.get("opened_quantity")), ) db.add(lot) db.flush() diff --git a/backend/app/routers/views.py b/backend/app/routers/views.py index 34089bf..c30ea4a 100644 --- a/backend/app/routers/views.py +++ b/backend/app/routers/views.py @@ -5,6 +5,7 @@ from datetime import date, timedelta from typing import Callable, TypeVar from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import and_, or_ from sqlalchemy.orm import Session from ..database import get_db @@ -531,14 +532,26 @@ def expiring( today = date.today() threshold = today + timedelta(days=days) + # Gewarnt wird nach dem FRÜHEREN von MHD und Verbrauchsdatum. Die frühere + # Vorbedingung „best_before IS NOT NULL" musste weg: Eine angebrochene + # Packung ohne aufgedrucktes MHD hat nur ein Verbrauchsdatum – und genau um + # die geht es hier. lots = ( db.query(Lot) - .filter(Lot.best_before.isnot(None), Lot.best_before <= threshold, Lot.quantity > 0) - .order_by(Lot.best_before) + .filter( + Lot.quantity > 0, + or_( + and_(Lot.best_before.isnot(None), Lot.best_before <= threshold), + and_(Lot.consume_by.isnot(None), Lot.consume_by <= threshold), + ), + ) .all() ) result: list[ExpiringItem] = [] for lot in lots: + faellig = lot.faellig_am + if faellig is None: + continue # kann nicht vorkommen, der Filter oben schliesst es aus product = lot.product unit_name, unit_factor = display_unit_info(product) result.append( @@ -550,13 +563,23 @@ def expiring( base_unit=product.base_unit, best_before=lot.best_before, best_before_precision=lot.best_before_precision, - days_left=(lot.best_before - today).days, + opened_on=lot.opened_on, + consume_by=lot.consume_by, + warn_date=faellig, + warn_source=( + "consume_by" if lot.consume_by is not None and faellig == lot.consume_by + else "best_before" + ), + days_left=(faellig - today).days, package_size=product.package_size, package_label=product.package_label, unit_name=unit_name, unit_factor=unit_factor, ) ) + # Nach dem wirksamen Datum sortieren – in Python, wie in _fefo_lots, damit + # SQLite und Postgres dasselbe liefern. + result.sort(key=lambda e: (e.warn_date, e.lot_id)) return result diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 535b450..7f0781a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -275,6 +275,9 @@ class CategoryOut(BaseModel): # "food" = Chargen+MHD, "object" = Menge je Lagerort. tracking: CategoryTracking = CategoryTracking.food product_count: int = 0 + # Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der + # Oberkategorie, zuletzt vom globalen Wert. + open_days: int | None = None class CategoryCreate(BaseModel): @@ -282,12 +285,16 @@ class CategoryCreate(BaseModel): parent_id: int | None = None # None: erbt vom Elternteil bzw. neue Oberkategorie = Gegenstand. tracking: CategoryTracking | None = None + open_days: int | None = Field(default=None, ge=1) class CategoryUpdate(BaseModel): name: str | None = Field(default=None, min_length=1, max_length=120) parent_id: int | None = None tracking: CategoryTracking | None = None + # 0 ist nicht erlaubt: „sofort verbrauchen" waere keine Frist, und None + # heisst schon „erben" – zwei Bedeutungen fuer leer gaebe es sonst. + open_days: int | None = Field(default=None, ge=1) # ---- Shops (Bezugsquellen, nur für Gegenstände) ---- @@ -403,6 +410,9 @@ class ProductBase(BaseModel): secondary_base: BaseUnit | None = None secondary_count: float | None = Field(default=None, gt=0) secondary_amount: float | None = Field(default=None, gt=0) + # Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel. + # None = erbt von der Kategorie, zuletzt vom globalen Wert. + open_days: int | None = Field(default=None, ge=1) # Voreingestellte MHD-Genauigkeit dieses Produkts (z.B. Konserven: nur Monat). date_precision: DatePrecision = DatePrecision.day group_id: int | None = None @@ -442,6 +452,9 @@ class ProductUpdate(BaseModel): secondary_base: BaseUnit | None = None secondary_count: float | None = Field(default=None, gt=0) secondary_amount: float | None = Field(default=None, gt=0) + # Haltbarkeit nach dem Anbrechen, nur fuer diesen Artikel. + # None = erbt von der Kategorie, zuletzt vom globalen Wert. + open_days: int | None = Field(default=None, ge=1) date_precision: DatePrecision | None = None group_id: int | None = None category_id: int | None = None @@ -470,6 +483,12 @@ class ProductOut(BaseModel): secondary_base: BaseUnit | None = None secondary_count: float | None = None secondary_amount: float | None = None + open_days: int | None = None + # Die tatsaechlich geltende Frist und woher sie kommt – damit das + # Formular „geerbt: 3 Tage (Kategorie: Molkerei)" zeigen kann, ohne + # die Vererbung selbst nachzubauen. + effective_open_days: int | None = None + open_days_source: str = "" # Abgeleitet: Zweit-Basiseinheiten je EINER Basiseinheit des Artikels – # damit die Oberflächen nicht selbst dividieren müssen. secondary_factor: float | None = None @@ -574,6 +593,11 @@ class LotOut(BaseModel): best_before_precision: DatePrecision = DatePrecision.day location_id: str | None created_at: datetime + # Angebrochen: NULL = zu. MHD und Verbrauchsdatum stehen bewusst + # nebeneinander – gewarnt wird nach dem fruheren (Lot.faellig_am). + opened_on: date | None = None + consume_by: date | None = None + opened_quantity: float | None = None class LotUpdate(BaseModel): @@ -601,6 +625,9 @@ class LotRow(BaseModel): best_before_precision: DatePrecision = DatePrecision.day location_id: str | None = None created_at: datetime + opened_on: date | None = None + consume_by: date | None = None + opened_quantity: float | None = None class BulkLotLocation(BaseModel): @@ -616,6 +643,72 @@ class LotSplit(BaseModel): location_id: str | None = None +class LotOpen(BaseModel): + """Eine Packung der Charge anbrechen. + + ``quantity`` in Basiseinheiten; leer = eine Artikeleinheit (Packung), bzw. + die ganze Charge, wenn sie kleiner ist. + """ + quantity: float | None = Field(default=None, gt=0) + + +class LotConsume(BaseModel): + """Teilmenge aus einer Charge verbrauchen. + + ``unit`` nimmt alles, was auch beim Auslagern geht – Basiseinheit, verwaltete + Einheit, „package" und die Hausmaße der Kategorie („EL", „Tasse"). + ``auto_open`` bricht eine noch geschlossene Charge dabei an; die Rückfrage + dazu stellt die Oberfläche, nicht der Server. + """ + quantity: float = Field(gt=0) + unit: str + note: str | None = Field(default=None, max_length=255) + auto_open: bool = True + + +class LotConsumeOut(BaseModel): + """Was der Verbrauch bewirkt hat.""" + affected_lots: list[dict] = [] + # Die offene Charge danach – None, wenn sie dabei leer geworden ist. + lot: LotOut | None = None + # Ob die Charge durch diesen Vorgang angebrochen wurde. + opened: bool = False + + +# ---- Hausmaße ---- +class MeasureIn(BaseModel): + """Name eines Hausmaßes. Die Menge steht an Kategorie bzw. Artikel.""" + name: str = Field(min_length=1, max_length=40) + short: str | None = Field(default=None, max_length=10) + position: int = 0 + + +class MeasureOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + short: str | None = None + position: int = 0 + is_builtin: bool = False + + +class MeasureAmountIn(BaseModel): + """Was ein Hausmaß hier wiegt bzw. fasst: ``amount`` in ``base``.""" + amount: float = Field(gt=0) + base: BaseUnit + + +class MeasureAmountOut(BaseModel): + """Ein für einen Artikel (oder eine Kategorie) geltendes Maß samt Herkunft.""" + measure_id: int + name: str + short: str | None = None + amount: float + base: BaseUnit + # „Artikel", „Kategorie: Mehl & Zucker" oder „–" (nicht hinterlegt). + herkunft: str = "" + + # ---- Kassenzettel-Abgleich ---- class MatchRequest(BaseModel): """Kassenzettel-Zeilen (OCR) → beste Lebensmittel-Treffer je Zeile.""" @@ -956,8 +1049,17 @@ class ExpiringItem(BaseModel): product_name: str quantity: float # in Basiseinheiten base_unit: BaseUnit - best_before: date + # Darf fehlen: Eine angebrochene Packung ohne aufgedrucktes MHD hat nur ein + # Verbrauchsdatum – und genau die soll hier auftauchen. + best_before: date | None = None best_before_precision: DatePrecision = DatePrecision.day + # Angebrochen: Öffnungstag und das daraus gerechnete Verbrauchsdatum. + opened_on: date | None = None + consume_by: date | None = None + # Das Datum, nach dem gewarnt wird (das frühere der beiden), und welches es + # ist – damit die Anzeige „läuft ab" von „angebrochen" unterscheiden kann. + warn_date: date + warn_source: str = "best_before" # best_before | consume_by days_left: int # Für die Anzeige in Artikeleinheiten: package_size: float | None = None @@ -1113,6 +1215,9 @@ class ExpirySplit(BaseModel): ok: float soon: float expired: float + # Angebrochen und noch nicht in der Warnfrist. Was offen ist UND bald + # abläuft, zählt als "soon" – Dringlichkeit schlägt Zustand. + opened: float = 0.0 no_date: float # Chargen ohne MHD zählen separat, nicht als "ok" @@ -1123,6 +1228,7 @@ class CategoryShare(BaseModel): ok: float soon: float expired: float + opened: float = 0.0 no_date: float diff --git a/backend/app/seed.py b/backend/app/seed.py index 68762e0..9784e9d 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -10,6 +10,7 @@ from .models import ( CategoryTracking, EtikettenVorlage, FieldDefinition, + Measure, PackageType, Role, Unit, @@ -72,6 +73,30 @@ def ensure_builtin_units(db: Session) -> None: db.commit() +# Hausmaße: nur die Namen. Was eines davon WIEGT, steht an der Kategorie bzw. +# am Artikel (models.py::CategoryMeasure) – 1 EL Mehl ist nicht 1 EL Zucker. +# Ohne hinterlegte Menge taucht ein Maß in der Eingabe gar nicht auf, die Liste +# hier schadet also nicht, wenn man sie nicht braucht. +BUILTIN_MEASURES: list[tuple[str, str | None]] = [ + ("Esslöffel", "EL"), + ("Teelöffel", "TL"), + ("Tasse", None), + ("Prise", None), + ("Messerspitze", None), + ("Handvoll", None), +] + + +def ensure_builtin_measures(db: Session) -> None: + changed = False + for position, (name, short) in enumerate(BUILTIN_MEASURES): + if not db.query(Measure).filter(Measure.name == name).first(): + db.add(Measure(name=name, short=short, position=position, is_builtin=True)) + changed = True + if changed: + 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. diff --git a/backend/app/services/conversion.py b/backend/app/services/conversion.py index 6f8ec8e..9e169fc 100644 --- a/backend/app/services/conversion.py +++ b/backend/app/services/conversion.py @@ -98,8 +98,27 @@ def in_artikel_basis(product: Product, menge: float, quell_base: BaseUnit) -> fl return menge * anzahl / zweit -def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> float: - """Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um.""" +def to_base( + db: Session, + product: Product, + quantity: float, + unit_token: str, + *, + hausmasse: bool = False, +) -> float: + """Rechnet eine Menge (in unit_token) in die Basiseinheit des Produkts um. + + Reihenfolge: Packung → verwaltete Einheit → Hausmaß → Zweiteinheit-Brücke. + Die Einheitentabelle steht VOR den Hausmaßen, weil sie die harte Physik ist: + Ein versehentlich angelegtes Hausmaß namens „Liter" würde sonst die echte + Einheit dauerhaft verdecken. + + ``hausmasse=True`` lässt Hausmaße überhaupt zu („2 EL"). Vorgabe ist False, + weil ein Esslöffel ein Schätzwert ist: Beim **Verbrauchen und Auslagern** ist + das richtig, beim **Einlagern** und bei **Mindestbeständen** ausdrücklich + nicht. Der Schalter hält das in der Signatur fest, statt es der Disziplin an + jeder Aufrufstelle zu überlassen. + """ if quantity <= 0: raise ConversionError("Menge muss größer als 0 sein") @@ -113,6 +132,10 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> unit = find_unit(db, t) if unit is None: + if hausmasse: + gerechnet = _hausmass_to_base(db, product, quantity, t) + if gerechnet is not None: + return gerechnet raise ConversionError(f"Unbekannte Einheit: {unit_token}") if unit.kind != kind_of_product(product): # Artfremde Einheit: geht nur über die Zweiteinheit des Artikels. @@ -130,6 +153,50 @@ def to_base(db: Session, product: Product, quantity: float, unit_token: str) -> return quantity * unit.factor +def _hausmass_to_base( + db: Session, product: Product, quantity: float, token: str +) -> float | None: + """Hausmaß-Token auflösen. ``None`` = kein Hausmaß dieses Namens. + + Unterschied, der wichtig ist: Ein **unbekannter Name** gibt ``None`` zurück, + damit danach die normale Einheitensuche greift. Ein **bekanntes Maß ohne + Menge** für diesen Artikel ist dagegen ein Fehler mit klarer Ansage – genau + die Meldung, aus der der Rezeptabgleich sein „Maß jetzt anlegen" macht. + """ + from .vererbung import mass_aufloesen, mass_in_basiseinheit + + angabe = mass_aufloesen(db, product, token) + if angabe is None: + bekannt = _bekanntes_mass(db, token) + if bekannt is not None: + # Den richtigen Namen nennen, nicht das klein geschriebene Token – + # die Meldung steht so in der Oberflaeche. + raise ConversionError( + f"Für diese Kategorie ist kein Maß '{bekannt.name}' hinterlegt." + ) + return None + ergebnis = mass_in_basiseinheit(db, product, token, quantity) + if ergebnis is None: + raise ConversionError( + f"Das Maß '{angabe.measure.name}' ist in " + f"{BASE_LABEL[BaseUnit(angabe.base)]} hinterlegt und passt nicht zur " + f"Art dieses Artikels ({BASE_LABEL[product.base_unit]}). Mit einer " + f"Zweiteinheit am Artikel ginge es." + ) + return ergebnis + + +def _bekanntes_mass(db: Session, token: str): + """Das Hausmaß mit diesem Namen oder dieser Kurzform – sonst ``None``.""" + from ..models import Measure + + t = (token or "").strip().lower() + for measure in db.query(Measure).all(): + if t == measure.name.lower() or (measure.short and t == measure.short.lower()): + return measure + return None + + def resolve_product_unit(db: Session, unit_id: int) -> tuple[BaseUnit, int]: """Ermittelt zu einer gewählten Einheit die kanonische Basiseinheit + Anzeige-ID.""" unit = db.get(Unit, unit_id) diff --git a/backend/app/services/fields.py b/backend/app/services/fields.py index 257a5f5..f86080a 100644 --- a/backend/app/services/fields.py +++ b/backend/app/services/fields.py @@ -40,8 +40,13 @@ def options_list(fd: FieldDefinition) -> list[str]: return [str(o) for o in data] if isinstance(data, list) else [] -def _ancestry(db: Session, category_id: int) -> list[Category]: - """Kategorien von der Wurzel bis zur angegebenen (einschließlich).""" +def ancestry(db: Session, category_id: int) -> list[Category]: + """Kategorien von der Wurzel bis zur angegebenen (einschließlich). + + Auch von ``services/vererbung.py`` benutzt – dort allerdings umgedreht, + weil ein einzelner geerbter Wert vom NÄCHSTEN Vorfahren kommen soll, + während Felder von allen Vorfahren gesammelt werden. + """ chain: list[Category] = [] seen: set[int] = set() cid: int | None = category_id @@ -67,7 +72,7 @@ def effective_field_definitions( if category_id is None: return [] result: list[tuple[FieldDefinition, bool]] = [] - for cat in _ancestry(db, category_id): + for cat in ancestry(db, category_id): defs = ( db.query(FieldDefinition) .filter(FieldDefinition.category_id == cat.id) diff --git a/backend/app/services/stock.py b/backend/app/services/stock.py index 9563e62..eabd878 100644 --- a/backend/app/services/stock.py +++ b/backend/app/services/stock.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import Sequence -from datetime import date +from datetime import date, timedelta from sqlalchemy import asc, func from sqlalchemy.orm import Session @@ -18,7 +18,7 @@ from ..models import ( Product, User, ) -from .conversion import to_base +from .conversion import article_unit, to_base from .dates import clean_precision, normalize_best_before from .loans import nicht_verliehen @@ -414,10 +414,17 @@ def consolidate_lot_group( Wird nach jedem Umlagern/Aufteilen aufgerufen – zwei gleich gelagerte Chargen mit demselben MHD sind für den Nutzer dieselbe Charge und sollen als eine erscheinen. Der Bestand bleibt unverändert, die Historie (Bewegungen) auch. + + **Angebrochene Chargen sind davon ausgenommen und werden NIE zusammengefasst.** + Eine offene Packung mit eigenem Verbrauchsdatum ist etwas anderes als eine + ungeöffnete mit demselben MHD; ohne diese Ausnahme würde die offene Packung + beim nächsten Einlagern am selben Ort still einkassiert und ihr kürzeres + Verbrauchsdatum ginge verloren. Bitte beim nächsten Umbau hier stehen lassen. """ query = db.query(Lot).filter( Lot.product_id == product_id, Lot.best_before_precision == best_before_precision, + Lot.opened_on.is_(None), ) query = ( query.filter(Lot.best_before.is_(None)) @@ -452,6 +459,8 @@ def consolidate_duplicate_lots(db: Session) -> int: db.query( Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id ) + # Angebrochene bleiben ausgenommen, siehe consolidate_lot_group. + .filter(Lot.opened_on.is_(None)) .group_by( Lot.product_id, Lot.best_before, Lot.best_before_precision, Lot.location_id ) @@ -467,6 +476,7 @@ def consolidate_duplicate_lots(db: Session) -> int: Lot.best_before_precision == precision, Lot.best_before.is_(None) if best_before is None else Lot.best_before == best_before, Lot.location_id.is_(None) if location_id is None else Lot.location_id == location_id, + Lot.opened_on.is_(None), ) .scalar() ) @@ -487,7 +497,8 @@ def check_out_lot( note: str | None = None, ) -> list[dict]: """Bucht gezielt von EINER Charge ab (manuelle Auswahl statt FEFO).""" - needed = to_base(db, product, quantity, unit) + # Hausmasse sind beim Auslagern erlaubt („2 EL"), beim Einlagern nicht. + needed = to_base(db, product, quantity, unit, hausmasse=True) if needed > lot.quantity + 1e-9: raise StockError( f"Diese Charge hat nur {lot.quantity:g} {product.base_unit.value} " @@ -512,19 +523,140 @@ def check_out_lot( return affected +# -------------------------------------------------------------------------- +# Anbrechen und Teilverbrauch +# -------------------------------------------------------------------------- +def abspalten( + db: Session, + lot: Lot, + quantity: float, + location_id: str | None, + *, + gleicher_ort_erlaubt: bool = False, +) -> Lot: + """``quantity`` Basiseinheiten als neue Charge mit gleichem MHD abzweigen. + + Reine Umbuchung, keine Bewegung – der Gesamtbestand ändert sich nicht. Gibt + die neue Charge zurück (noch nicht konsolidiert; das entscheidet der + Aufrufer, denn beim Anbrechen darf gerade NICHT konsolidiert werden). + + ``gleicher_ort_erlaubt`` ist der Unterschied zwischen den beiden Anlässen: + Beim Aufteilen auf einen anderen Lagerort wäre derselbe Ort sinnlos, beim + Anbrechen ist er der Normalfall – die offene Packung bleibt dort liegen, + wo sie war. + """ + if quantity <= 0: + raise StockError("Menge muss größer als 0 sein") + if quantity > lot.quantity - 1e-9: + raise StockError( + f"So viel enthält die Charge nicht (vorhanden: {lot.quantity:g})." + ) + if not gleicher_ort_erlaubt and (location_id or None) == (lot.location_id or None): + raise StockError("Bitte einen anderen Lagerort als den aktuellen wählen.") + + lot.quantity -= quantity + neu = Lot( + product_id=lot.product_id, + quantity=quantity, + best_before=lot.best_before, + best_before_precision=lot.best_before_precision, + location_id=location_id, + ) + db.add(neu) + db.flush() + return neu + + +def anbrechen(db: Session, product: Product, lot: Lot, quantity: float | None = None) -> Lot: + """Eine Packung der Charge anbrechen und als eigene Charge führen. + + Aus 4000 ml (4 × 1 l) wird eine offene Charge mit 1000 ml und eine + ungeöffnete mit 3000 ml. Ohne das Abspalten würden die restlichen 3 l das + kurze Verbrauchsdatum der geöffneten Packung mittragen, obwohl sie noch zu + sind. + + ``quantity`` leer = eine Artikeleinheit (Packung, sonst Anzeigeeinheit); + ist die Charge kleiner, wird sie als Ganzes angebrochen. + """ + from .vererbung import oeffnungsfrist + + if lot.opened_on is not None: + return lot # schon offen – nichts zu tun + + menge = quantity + if menge is None: + faktor, _label = article_unit(product) + menge = faktor or lot.quantity + menge = min(menge, lot.quantity) + + ziel = lot if menge >= lot.quantity - 1e-9 else abspalten( + db, lot, menge, lot.location_id, gleicher_ort_erlaubt=True + ) + + heute = date.today() + ziel.opened_on = heute + ziel.opened_quantity = ziel.quantity + ziel.consume_by = heute + timedelta(days=oeffnungsfrist(db, product)) + db.flush() + return ziel + + +def verbrauchen( + db: Session, + product: Product, + lot: Lot, + quantity: float, + unit: str, + user: User | None, + note: str | None = None, + auto_open: bool = True, +) -> tuple[list[dict], Lot | None, bool]: + """Teilmenge aus einer Charge verbrauchen, bei Bedarf vorher anbrechen. + + Gibt (betroffene Chargen, offene Charge oder None wenn leer, angebrochen?) + zurück. Die Umrechnung läuft über ``to_base`` und kennt damit auch die + Hausmaße („2 EL") – deshalb wird hier zuerst gerechnet und danach + angebrochen: Scheitert die Einheit, soll keine Packung offen sein. + """ + # Erst rechnen: Scheitert die Einheit, soll keine Packung grundlos offen sein. + menge_base = to_base(db, product, quantity, unit, hausmasse=True) + angebrochen = False + ziel = lot + if lot.opened_on is None and auto_open: + ziel = anbrechen(db, product, lot, max(menge_base, article_unit(product)[0] or 0.0)) + angebrochen = True + # Bewusst mit der ROH-Eingabe weiter, nicht mit menge_base: ``unit_used`` an + # der Bewegung soll festhalten, was der Nutzer getippt hat („2 EL"), nicht + # das Ergebnis der Umrechnung. Die zweite Umrechnung liefert denselben Wert. + affected = check_out_lot(db, product, ziel, quantity, unit, user, note) + offen = None if ziel.quantity <= 1e-9 else ziel + return affected, offen, angebrochen + + def _fefo_lots(db: Session, product_id: int) -> list[Lot]: - """Lots eines Produkts, sortiert nach Ablaufdatum (NULL zuletzt), dann Alter.""" + """Lots eines Produkts, sortiert nach dem WIRKSAMEN Datum (NULL zuletzt). + + Wirksam ist das frühere aus MHD und Verbrauchsdatum (``Lot.faellig_am``). + Eine angebrochene Packung kommt damit meist von selbst zuerst, weil ihr + Verbrauchsdatum näher liegt – aber eine ungeöffnete Charge, die morgen + abläuft, geht vor. Erst bei gleichem Datum wird das Angebrochene vorgezogen, + damit nicht eine zweite Packung aufgeht, während eine offen ist. + """ lots = ( db.query(Lot) .filter(Lot.product_id == product_id, Lot.quantity > 0) .order_by(asc(Lot.created_at)) .all() ) - # NULL-best_before ans Ende (nach Datum aufsteigend). In Python sortieren, damit - # es über SQLite und Postgres identisch funktioniert. + # In Python sortieren, damit es über SQLite und Postgres identisch arbeitet. return sorted( lots, - key=lambda lot: (lot.best_before is None, lot.best_before or date.max, lot.id), + key=lambda lot: ( + lot.faellig_am is None, + lot.faellig_am or date.max, + lot.opened_on is None, + lot.id, + ), ) @@ -541,7 +673,7 @@ def check_out( Gibt die Liste der betroffenen Chargen mit abgebuchter Menge zurück. Wirft StockError, wenn der Gesamtbestand nicht ausreicht. """ - needed = to_base(db, product, quantity, unit) + needed = to_base(db, product, quantity, unit, hausmasse=True) available = current_stock(db, product.id) if needed > available + 1e-9: raise StockError( diff --git a/backend/app/services/vererbung.py b/backend/app/services/vererbung.py new file mode 100644 index 0000000..14963be --- /dev/null +++ b/backend/app/services/vererbung.py @@ -0,0 +1,205 @@ +"""Werte, die über den Kategorie-Baum geerbt werden: Öffnungsfrist und Hausmaße. + +Die Regel ist bei beiden dieselbe und bewusst eine andere als bei den selbst +definierten Feldern: Dort werden die Felder **aller** Vorfahren gesammelt +(``services/fields.py::effective_field_definitions``), hier gewinnt der +**nächste** Vorfahr mit einem Eintrag. Ein einzelner Wert kann nur einmal +gelten — „Backwaren: 1 EL = 10 g" darf von „Mehl: 1 EL = 9 g" überschrieben +werden, ohne dass beide gleichzeitig zählen. + +Reihenfolge immer: Artikel → nächste Kategorie mit Wert → … → global. +""" + +from __future__ import annotations + +from typing import Callable, TypeVar + +from sqlalchemy.orm import Session + +from ..models import ( + BaseUnit, + CategoryMeasure, + Measure, + Product, + ProductMeasure, + Setting, +) +from .fields import ancestry + +T = TypeVar("T") + +# Globale Öffnungsfrist. Pflichtwert: Ohne ihn hätte eine angebrochene Packung +# ohne Kategorie-Eintrag kein Verbrauchsdatum, und der ganze Zweck entfiele. +OPEN_DAYS_KEY = "open_days" + + +def _vorfahren_abwaerts(db: Session, category_id: int | None): + """Kategorien vom Artikel aufwärts: nächste zuerst, Wurzel zuletzt. + + ``ancestry`` liefert die Kette Wurzel-zuerst (dort richtig, weil Felder von + oben nach unten gelesen werden); hier wird sie umgedreht. + """ + if category_id is None: + return [] + return list(reversed(ancestry(db, category_id))) + + +def naechster_wert( + db: Session, category_id: int | None, lese: Callable[[int], T | None] +) -> T | None: + """Ersten gesetzten Wert entlang der Kategorie-Kette nach oben suchen.""" + for cat in _vorfahren_abwaerts(db, category_id): + wert = lese(cat.id) + if wert is not None: + return wert + return None + + +# -------------------------------------------------------------------------- +# Öffnungsfrist +# -------------------------------------------------------------------------- +def globale_oeffnungsfrist(db: Session) -> int: + """Globaler Wert in Tagen – Muster wie ``get_expiry_warning_days``.""" + from ..config import get_settings + + vorgabe = get_settings().open_days_default + row = db.get(Setting, OPEN_DAYS_KEY) + if row is None: + return vorgabe + try: + tage = int(row.value) + except (TypeError, ValueError): + return vorgabe + return tage if tage > 0 else vorgabe + + +def oeffnungsfrist(db: Session, product: Product) -> int: + """Wie viele Tage eine angebrochene Packung dieses Artikels noch hält.""" + if product.open_days is not None and product.open_days > 0: + return product.open_days + geerbt = naechster_wert( + db, + product.category_id, + lambda cid: _kategorie_frist(db, cid), + ) + return geerbt if geerbt is not None else globale_oeffnungsfrist(db) + + +def _kategorie_frist(db: Session, category_id: int) -> int | None: + from ..models import Category + + cat = db.get(Category, category_id) + if cat is None or cat.open_days is None or cat.open_days <= 0: + return None + return cat.open_days + + +def frist_herkunft(db: Session, product: Product) -> tuple[int, str]: + """Frist plus Klartext, woher sie kommt – für den Platzhalter in der Maske.""" + if product.open_days is not None and product.open_days > 0: + return product.open_days, "Artikel" + for cat in _vorfahren_abwaerts(db, product.category_id): + if cat.open_days is not None and cat.open_days > 0: + return cat.open_days, f"Kategorie: {cat.name}" + return globale_oeffnungsfrist(db), "Einstellungen" + + +# -------------------------------------------------------------------------- +# Hausmaße +# -------------------------------------------------------------------------- +class MassAngabe: + """Ein für einen Artikel auflösbares Hausmaß samt Herkunft.""" + + __slots__ = ("measure", "amount", "base", "herkunft") + + def __init__(self, measure: Measure, amount: float, base: str, herkunft: str): + self.measure = measure + self.amount = amount + self.base = base + self.herkunft = herkunft + + +def _mass_fuer_artikel(db: Session, product: Product, measure_id: int) -> MassAngabe | None: + eigen = ( + db.query(ProductMeasure) + .filter( + ProductMeasure.product_id == product.id, + ProductMeasure.measure_id == measure_id, + ) + .first() + ) + measure = db.get(Measure, measure_id) + if measure is None: + return None + if eigen is not None: + return MassAngabe(measure, eigen.amount, eigen.base, "Artikel") + for cat in _vorfahren_abwaerts(db, product.category_id): + row = ( + db.query(CategoryMeasure) + .filter( + CategoryMeasure.category_id == cat.id, + CategoryMeasure.measure_id == measure_id, + ) + .first() + ) + if row is not None: + return MassAngabe(measure, row.amount, row.base, f"Kategorie: {cat.name}") + return None + + +def verfuegbare_masse(db: Session, product: Product) -> list[MassAngabe]: + """Alle Hausmaße, die für diesen Artikel eine Menge haben. + + Grundlage für die Einheitenauswahl im Verbrauchen-Dialog und für den + Hinweis, woher die Menge stammt. + """ + ergebnis: list[MassAngabe] = [] + for measure in db.query(Measure).order_by(Measure.position, Measure.id).all(): + angabe = _mass_fuer_artikel(db, product, measure.id) + if angabe is not None: + ergebnis.append(angabe) + return ergebnis + + +def mass_aufloesen(db: Session, product: Product, token: str) -> MassAngabe | None: + """Ein Eingabe-Token („EL", „esslöffel", „Tasse") zum Hausmaß auflösen. + + Vergleicht Name und Kurzform ohne Rücksicht auf Groß-/Kleinschreibung. + ``None`` heißt: Entweder gibt es das Maß nicht, oder es ist für diesen + Artikel (und seine Kategorien) ohne Menge — beides behandelt der Aufrufer + gleich, nämlich als „Maß hier nicht hinterlegt". + """ + t = (token or "").strip().lower() + if not t: + return None + for measure in db.query(Measure).all(): + namen = {measure.name.lower()} + if measure.short: + namen.add(measure.short.lower()) + if t in namen: + return _mass_fuer_artikel(db, product, measure.id) + return None + + +def mass_in_basiseinheit(db: Session, product: Product, token: str, menge: float) -> float | None: + """Menge in einem Hausmaß in die Basiseinheit des Artikels umrechnen. + + ``None`` heißt „Maß für diesen Artikel nicht hinterlegt" — der Aufrufer + entscheidet, ob das ein Fehler ist (Buchung) oder nur eine Lücke, die man + anlegen kann (Rezeptabgleich). + """ + angabe = mass_aufloesen(db, product, token) + if angabe is None: + return None + from .conversion import in_artikel_basis + + roh = menge * angabe.amount + try: + quell = BaseUnit(angabe.base) + except ValueError: + return None + if quell == product.base_unit: + return roh + # Art passt nicht zur Basiseinheit des Artikels: derselbe Weg wie bei jeder + # artfremden Einheit, naemlich ueber die Zweiteinheit des Artikels. + return in_artikel_basis(product, roh, quell) diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index f4b1064..c41839e 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -5,7 +5,7 @@ from sqlalchemy.pool import StaticPool from app.database import Base from app.models import BaseUnit, Product -from app.seed import ensure_builtin_units +from app.seed import ensure_builtin_measures, ensure_builtin_units @pytest.fixture() @@ -19,6 +19,7 @@ def db(): TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False) session = TestingSession() ensure_builtin_units(session) # Einheiten (Gramm, Kilogramm, …) für Umrechnung + ensure_builtin_measures(session) # Hausmaße (EL, TL, Tasse …) – ohne Mengen try: yield session finally: diff --git a/backend/tests/test_ablauf_zwei_daten.py b/backend/tests/test_ablauf_zwei_daten.py new file mode 100644 index 0000000..446c269 --- /dev/null +++ b/backend/tests/test_ablauf_zwei_daten.py @@ -0,0 +1,173 @@ +"""Zwei Daten an einer Charge: aufgedrucktes MHD und „zu verbrauchen bis". + +Beide bleiben getrennt sichtbar, gewarnt wird nach dem früheren. Der heikle +Fall ist die angebrochene Packung **ohne** aufgedrucktes MHD – die fiel vorher +durch den Filter `best_before IS NOT NULL`. +""" + +from datetime import date, timedelta + +import pytest + +from app.models import BaseUnit, Lot, Product, Role, User +from app.routers.dashboard import _zustand +from app.routers.stock import open_lot +from app.routers.views import expiring +from app.schemas import LotOpen +from app.services.stock import _fefo_lots + + +@pytest.fixture() +def user(db): + person = User(username="tester", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@pytest.fixture() +def milch(db): + product = Product( + name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000, + source="manual", + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _lot(db, product, menge, best_before=None, opened_on=None, consume_by=None): + lot = Lot( + product_id=product.id, quantity=menge, best_before=best_before, + opened_on=opened_on, consume_by=consume_by, + ) + db.add(lot) + db.commit() + db.refresh(lot) + return lot + + +# -------------------------------------------------------------------------- +# Ablaufliste +# -------------------------------------------------------------------------- +def test_angebrochene_charge_ohne_mhd_taucht_auf(db, user, milch): + """Der Fall, den der alte Filter verschluckt hätte.""" + _lot(db, milch, 500, best_before=None, + opened_on=date.today(), consume_by=date.today() + timedelta(days=2)) + + liste = expiring(days=7, db=db, _=user) + + assert len(liste) == 1 + assert liste[0].best_before is None + assert liste[0].warn_source == "consume_by" + assert liste[0].days_left == 2 + + +def test_beide_daten_stehen_getrennt_in_der_liste(db, user, milch): + mhd = date.today() + timedelta(days=30) + verbrauch = date.today() + timedelta(days=3) + _lot(db, milch, 500, best_before=mhd, opened_on=date.today(), consume_by=verbrauch) + + eintrag = expiring(days=7, db=db, _=user)[0] + + assert eintrag.best_before == mhd # bleibt sichtbar + assert eintrag.consume_by == verbrauch + assert eintrag.warn_date == verbrauch # gewarnt wird nach dem früheren + assert eintrag.days_left == 3 + + +def test_frueheres_mhd_gewinnt_gegen_verbrauchsdatum(db, user, milch): + mhd = date.today() + timedelta(days=1) + _lot(db, milch, 500, best_before=mhd, + opened_on=date.today(), consume_by=date.today() + timedelta(days=5)) + + eintrag = expiring(days=7, db=db, _=user)[0] + + assert eintrag.warn_date == mhd + assert eintrag.warn_source == "best_before" + + +def test_ungeoeffnete_charge_verhaelt_sich_wie_bisher(db, user, milch): + mhd = date.today() + timedelta(days=3) + _lot(db, milch, 1000, best_before=mhd) + + eintrag = expiring(days=7, db=db, _=user)[0] + + assert eintrag.warn_date == mhd + assert eintrag.warn_source == "best_before" + assert eintrag.consume_by is None + + +def test_ausserhalb_der_frist_bleibt_draussen(db, user, milch): + _lot(db, milch, 500, opened_on=date.today(), + consume_by=date.today() + timedelta(days=30)) + + assert expiring(days=7, db=db, _=user) == [] + + +def test_liste_ist_nach_dem_wirksamen_datum_sortiert(db, user, milch): + _lot(db, milch, 100, best_before=date.today() + timedelta(days=6)) + _lot(db, milch, 100, opened_on=date.today(), consume_by=date.today() + timedelta(days=1)) + _lot(db, milch, 100, best_before=date.today() + timedelta(days=3)) + + tage = [e.days_left for e in expiring(days=7, db=db, _=user)] + + assert tage == [1, 3, 6] + + +# -------------------------------------------------------------------------- +# Zustand fürs Dashboard +# -------------------------------------------------------------------------- +def test_zustand_angebrochen_ist_ein_eigener(db): + heute = date.today() + assert _zustand(heute + timedelta(days=30), heute, 7, angebrochen=True) == "opened" + + +def test_dringlichkeit_schlaegt_angebrochen(db): + heute = date.today() + # Angebrochen UND bald ablaufend: die Warnung muss gewinnen. + assert _zustand(heute + timedelta(days=2), heute, 7, angebrochen=True) == "soon" + assert _zustand(heute - timedelta(days=1), heute, 7, angebrochen=True) == "expired" + + +def test_zustand_ohne_daten_bleibt_no_date(db): + heute = date.today() + assert _zustand(None, heute, 7) == "no_date" + # Angebrochen ohne jedes Datum ist trotzdem angebrochen. + assert _zustand(None, heute, 7, angebrochen=True) == "opened" + + +# -------------------------------------------------------------------------- +# FEFO +# -------------------------------------------------------------------------- +def test_fefo_nimmt_das_fruehere_wirksame_datum(db, milch): + spaet = _lot(db, milch, 1000, best_before=date.today() + timedelta(days=30)) + offen = _lot(db, milch, 500, best_before=date.today() + timedelta(days=30), + opened_on=date.today(), consume_by=date.today() + timedelta(days=2)) + + reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)] + + assert reihenfolge == [offen.id, spaet.id] + + +def test_knappes_mhd_geht_vor_der_offenen_packung(db, milch): + """Verderb schlägt Bequemlichkeit: Was morgen abläuft, kommt zuerst.""" + morgen = _lot(db, milch, 1000, best_before=date.today() + timedelta(days=1)) + offen = _lot(db, milch, 500, opened_on=date.today(), + consume_by=date.today() + timedelta(days=5)) + + reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)] + + assert reihenfolge == [morgen.id, offen.id] + + +def test_bei_gleichem_datum_gewinnt_das_angebrochene(db, milch): + gleich = date.today() + timedelta(days=3) + zu = _lot(db, milch, 1000, best_before=gleich) + offen = _lot(db, milch, 500, opened_on=date.today(), consume_by=gleich) + + reihenfolge = [lot.id for lot in _fefo_lots(db, milch.id)] + + assert reihenfolge == [offen.id, zu.id] diff --git a/backend/tests/test_anbrechen.py b/backend/tests/test_anbrechen.py new file mode 100644 index 0000000..60c69d6 --- /dev/null +++ b/backend/tests/test_anbrechen.py @@ -0,0 +1,276 @@ +"""Angebrochene Packungen: Abspalten, Verbrauchsdatum, Teilverbrauch, Frist-Vererbung.""" + +from datetime import date, timedelta + +import pytest +from fastapi import HTTPException + +from app.models import ( + BaseUnit, + Category, + CategoryTracking, + Lot, + Movement, + MovementType, + Product, + Role, + Setting, + User, +) +from app.routers.stock import consume_lot, open_lot +from app.schemas import LotConsume, LotOpen +from app.services.stock import check_in, current_stock +from app.services.vererbung import frist_herkunft, oeffnungsfrist + + +@pytest.fixture() +def user(db): + person = User(username="tester", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@pytest.fixture() +def milch(db): + """Milliliter-Artikel mit 1000 ml je Packung, in einer Lebensmittel-Kategorie.""" + cat = Category(name="Molkereiprodukte", tracking=CategoryTracking.food.value) + db.add(cat) + db.flush() + product = Product( + name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000, + category_id=cat.id, source="manual", + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _charge(db, product, menge_base, best_before=None): + lot = Lot(product_id=product.id, quantity=menge_base, best_before=best_before) + db.add(lot) + db.commit() + db.refresh(lot) + return lot + + +# -------------------------------------------------------------------------- +# Anbrechen spaltet eine Packung ab +# -------------------------------------------------------------------------- +def test_anbrechen_spaltet_eine_packung_ab(db, user, milch): + lot = _charge(db, milch, 4000) # 4 x 1 l in einer Zeile + + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + assert offen.quantity == 1000 + assert offen.opened_on == date.today() + assert offen.opened_quantity == 1000 + # Der Rest bleibt zu – sonst trüge er das kurze Verbrauchsdatum mit. + rest = db.query(Lot).filter(Lot.id != offen.id).one() + assert rest.quantity == 3000 + assert rest.opened_on is None + assert rest.consume_by is None + # Der Gesamtbestand ändert sich beim Anbrechen nicht. + assert current_stock(db, milch.id) == 4000 + + +def test_anbrechen_einer_einzelnen_packung_spaltet_nichts(db, user, milch): + lot = _charge(db, milch, 1000) + + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + assert offen.id == lot.id + assert db.query(Lot).count() == 1 + assert offen.quantity == 1000 + + +def test_anbrechen_nimmt_hoechstens_die_ganze_charge(db, user, milch): + lot = _charge(db, milch, 400) # weniger als eine Packung + + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + assert offen.quantity == 400 + assert db.query(Lot).count() == 1 + + +def test_anbrechen_mit_eigener_menge(db, user, milch): + lot = _charge(db, milch, 4000) + + offen = open_lot(lot.id, LotOpen(quantity=500), db=db, _=user) + + assert offen.quantity == 500 + assert db.query(Lot).filter(Lot.id != offen.id).one().quantity == 3500 + + +def test_zweimal_anbrechen_aendert_nichts(db, user, milch): + lot = _charge(db, milch, 4000) + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + nochmal = open_lot(offen.id, LotOpen(), db=db, _=user) + + assert nochmal.id == offen.id + assert nochmal.quantity == 1000 + assert db.query(Lot).count() == 2 + + +def test_verbrauchsdatum_kommt_aus_der_frist(db, user, milch): + db.add(Setting(key="open_days", value="5")) + db.commit() + lot = _charge(db, milch, 1000) + + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + assert offen.consume_by == date.today() + timedelta(days=5) + + +def test_mhd_bleibt_neben_dem_verbrauchsdatum_stehen(db, user, milch): + mhd = date.today() + timedelta(days=20) + lot = _charge(db, milch, 1000, best_before=mhd) + + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + assert offen.best_before == mhd # unangetastet + assert offen.consume_by is not None + # Gewarnt wird nach dem früheren der beiden. + frisch = db.get(Lot, offen.id) + assert frisch.faellig_am == frisch.consume_by + + +def test_frueheres_mhd_schlaegt_das_verbrauchsdatum(db, user, milch): + mhd = date.today() + timedelta(days=1) # läuft vor der Öffnungsfrist ab + lot = _charge(db, milch, 1000, best_before=mhd) + + open_lot(lot.id, LotOpen(), db=db, _=user) + + frisch = db.query(Lot).one() + assert frisch.faellig_am == mhd + + +# -------------------------------------------------------------------------- +# Verbrauchen +# -------------------------------------------------------------------------- +def test_verbrauchen_oeffnet_in_einem_schritt(db, user, milch): + lot = _charge(db, milch, 4000) + + ergebnis = consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user) + + assert ergebnis.opened is True + assert ergebnis.lot is not None + assert ergebnis.lot.quantity == 900 # 1000 geöffnet, 100 verbraucht + assert ergebnis.lot.opened_quantity == 1000 + assert current_stock(db, milch.id) == 3900 + # Der Verbrauch ist eine Bewegung, das Anbrechen allein nicht. + bewegungen = db.query(Movement).all() + assert len(bewegungen) == 1 + assert bewegungen[0].type == MovementType.out + assert bewegungen[0].quantity == 100 + + +def test_verbrauchen_ohne_anbrechen_wenn_abgewaehlt(db, user, milch): + lot = _charge(db, milch, 4000) + + ergebnis = consume_lot( + lot.id, LotConsume(quantity=100, unit="ml", auto_open=False), db=db, user=user + ) + + assert ergebnis.opened is False + assert db.query(Lot).count() == 1 + assert db.query(Lot).one().opened_on is None + assert current_stock(db, milch.id) == 3900 + + +def test_weiterer_verbrauch_nutzt_die_offene_packung(db, user, milch): + lot = _charge(db, milch, 4000) + consume_lot(lot.id, LotConsume(quantity=100, unit="ml"), db=db, user=user) + offen = db.query(Lot).filter(Lot.opened_on.isnot(None)).one() + + consume_lot(offen.id, LotConsume(quantity=200, unit="ml"), db=db, user=user) + + assert db.get(Lot, offen.id).quantity == 700 + assert db.query(Lot).count() == 2 + + +def test_leere_offene_packung_verschwindet(db, user, milch): + lot = _charge(db, milch, 1000) + open_lot(lot.id, LotOpen(), db=db, _=user) + + ergebnis = consume_lot(lot.id, LotConsume(quantity=1000, unit="ml"), db=db, user=user) + + assert ergebnis.lot is None + assert db.query(Lot).count() == 0 + assert current_stock(db, milch.id) == 0 + + +def test_mehr_verbrauchen_als_drin_ist_scheitert(db, user, milch): + lot = _charge(db, milch, 1000) + + with pytest.raises(HTTPException) as fehler: + consume_lot(lot.id, LotConsume(quantity=2000, unit="ml"), db=db, user=user) + assert fehler.value.status_code == 409 + + +def test_unbekannte_einheit_bricht_nichts_an(db, user, milch): + """Erst rechnen, dann anbrechen – sonst stünde eine Packung grundlos offen.""" + lot = _charge(db, milch, 4000) + + with pytest.raises(HTTPException) as fehler: + consume_lot(lot.id, LotConsume(quantity=1, unit="furlong"), db=db, user=user) + + assert fehler.value.status_code == 400 + assert db.query(Lot).one().opened_on is None + + +def test_verbrauchen_in_packungen(db, user, rice, milch): + """„1 Packung" muss weiter gehen – die Hausmaße dürfen das nicht verdrängen.""" + lot = check_in(db, rice, 3, "package", None, None, None) + db.commit() + + consume_lot(lot.id, LotConsume(quantity=1, unit="package"), db=db, user=user) + + assert current_stock(db, rice.id) == 1000 + + +# -------------------------------------------------------------------------- +# Frist: Artikel > Kategorie > Oberkategorie > global +# -------------------------------------------------------------------------- +def test_frist_vererbung_ueber_die_ebenen(db, milch): + # 1. Ohne alles gilt der Standard aus der Konfiguration. + assert oeffnungsfrist(db, milch) == 3 + assert frist_herkunft(db, milch)[1] == "Einstellungen" + + # 2. Globale Einstellung. + db.add(Setting(key="open_days", value="7")) + db.commit() + assert oeffnungsfrist(db, milch) == 7 + + # 3. Oberkategorie. + ober = db.get(Category, milch.category_id) + unter = Category(name="Frischmilch", parent_id=ober.id, + tracking=CategoryTracking.food.value) + db.add(unter) + db.flush() + milch.category_id = unter.id + ober.open_days = 4 + db.commit() + assert oeffnungsfrist(db, milch) == 4 + assert frist_herkunft(db, milch)[1] == "Kategorie: Molkereiprodukte" + + # 4. Die nähere Kategorie gewinnt. + unter.open_days = 2 + db.commit() + assert oeffnungsfrist(db, milch) == 2 + assert frist_herkunft(db, milch)[1] == "Kategorie: Frischmilch" + + # 5. Der Artikel schlägt alles. + milch.open_days = 1 + db.commit() + assert oeffnungsfrist(db, milch) == 1 + assert frist_herkunft(db, milch)[1] == "Artikel" + + +def test_kaputte_globale_einstellung_faellt_auf_den_standard(db, milch): + db.add(Setting(key="open_days", value="viele")) + db.commit() + assert oeffnungsfrist(db, milch) == 3 diff --git a/backend/tests/test_hausmasse.py b/backend/tests/test_hausmasse.py new file mode 100644 index 0000000..8ed62ae --- /dev/null +++ b/backend/tests/test_hausmasse.py @@ -0,0 +1,255 @@ +"""Hausmaße: „2 EL" wiegt bei Mehl anders als bei Zucker. + +Die Menge gehört an die Kategorie bzw. den Artikel, nicht an den Namen – ein +globaler Faktor wie bei den verwalteten Einheiten wäre hier zwangsläufig falsch. +""" + +import pytest + +from app.models import ( + BaseUnit, + Category, + CategoryMeasure, + CategoryTracking, + Measure, + Product, + ProductMeasure, +) +from app.seed import ensure_builtin_measures +from app.services.conversion import ConversionError, to_base +from app.services.stock import check_in, check_out, current_stock +from app.services.vererbung import mass_aufloesen, verfuegbare_masse + + +@pytest.fixture() +def masse(db): + ensure_builtin_measures(db) + return {m.name: m for m in db.query(Measure).all()} + + +def _kategorie(db, name, parent=None): + cat = Category( + name=name, + parent_id=parent.id if parent else None, + tracking=CategoryTracking.food.value, + ) + db.add(cat) + db.commit() + db.refresh(cat) + return cat + + +def _artikel(db, name, cat, base=BaseUnit.gram, package_size=1000): + product = Product( + name=name, base_unit=base, package_size=package_size, + category_id=cat.id, source="manual", + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _mass_an_kategorie(db, measure, cat, amount, base=BaseUnit.gram): + db.add(CategoryMeasure( + measure_id=measure.id, category_id=cat.id, amount=amount, base=base.value + )) + db.commit() + + +# -------------------------------------------------------------------------- +# Derselbe Löffel, zwei Gewichte +# -------------------------------------------------------------------------- +def test_esslöffel_wiegt_je_kategorie_anders(db, masse): + el = masse["Esslöffel"] + mehl_kat = _kategorie(db, "Mehl") + zucker_kat = _kategorie(db, "Zucker") + mehl = _artikel(db, "Weissmehl", mehl_kat) + zucker = _artikel(db, "Kristallzucker", zucker_kat) + _mass_an_kategorie(db, el, mehl_kat, 10) + _mass_an_kategorie(db, el, zucker_kat, 12) + + assert to_base(db, mehl, 2, "EL", hausmasse=True) == 20 + assert to_base(db, zucker, 2, "EL", hausmasse=True) == 24 + + +def test_kurzform_und_grossschreibung(db, masse): + el = masse["Esslöffel"] + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat) + _mass_an_kategorie(db, el, kat, 10) + + for token in ("EL", "el", "Esslöffel", "esslöffel", " EL "): + assert to_base(db, mehl, 1, token, hausmasse=True) == 10 + + +def test_tasse_in_millilitern(db, masse): + tasse = masse["Tasse"] + kat = _kategorie(db, "Molkerei") + milch = _artikel(db, "Vollmilch", kat, base=BaseUnit.milliliter) + _mass_an_kategorie(db, tasse, kat, 200, base=BaseUnit.milliliter) + + assert to_base(db, milch, 1.5, "Tasse", hausmasse=True) == 300 + + +# -------------------------------------------------------------------------- +# Vererbung: Artikel > nächste Kategorie > Oberkategorie +# -------------------------------------------------------------------------- +def test_unterkategorie_erbt_von_oben(db, masse): + el = masse["Esslöffel"] + trocken = _kategorie(db, "Trockenwaren") + mehl_kat = _kategorie(db, "Mehl", parent=trocken) + mehl = _artikel(db, "Weissmehl", mehl_kat) + _mass_an_kategorie(db, el, trocken, 11) + + assert to_base(db, mehl, 1, "EL", hausmasse=True) == 11 + angabe = mass_aufloesen(db, mehl, "EL") + assert angabe.herkunft == "Kategorie: Trockenwaren" + + +def test_naehere_kategorie_schlaegt_die_obere(db, masse): + el = masse["Esslöffel"] + trocken = _kategorie(db, "Trockenwaren") + mehl_kat = _kategorie(db, "Mehl", parent=trocken) + mehl = _artikel(db, "Weissmehl", mehl_kat) + _mass_an_kategorie(db, el, trocken, 11) + _mass_an_kategorie(db, el, mehl_kat, 9) + + assert to_base(db, mehl, 1, "EL", hausmasse=True) == 9 + + +def test_artikel_schlaegt_die_kategorie(db, masse): + el = masse["Esslöffel"] + kat = _kategorie(db, "Zucker") + puder = _artikel(db, "Puderzucker", kat) + _mass_an_kategorie(db, el, kat, 12) + db.add(ProductMeasure( + measure_id=el.id, product_id=puder.id, amount=8, base=BaseUnit.gram.value + )) + db.commit() + + assert to_base(db, puder, 1, "EL", hausmasse=True) == 8 + assert mass_aufloesen(db, puder, "EL").herkunft == "Artikel" + + +# -------------------------------------------------------------------------- +# Lücken und Fehler +# -------------------------------------------------------------------------- +def test_mass_ohne_menge_meldet_sich_deutlich(db, masse): + """Die Meldung ist die Grundlage für „Maß jetzt anlegen" im Rezeptabgleich.""" + kat = _kategorie(db, "Hülsenfrüchte") + linsen = _artikel(db, "Berglinsen", kat) + + with pytest.raises(ConversionError) as fehler: + to_base(db, linsen, 1, "Tasse", hausmasse=True) + assert "kein Maß 'Tasse' hinterlegt" in str(fehler.value) + + +def test_voellig_unbekannte_einheit_bleibt_unbekannte_einheit(db, masse): + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat) + + with pytest.raises(ConversionError) as fehler: + to_base(db, mehl, 1, "furlong", hausmasse=True) + assert "Unbekannte Einheit" in str(fehler.value) + + +def test_artfremdes_mass_braucht_die_zweiteinheit(db, masse): + """Ein Gramm-Maß an einem Stück-Artikel geht nur über die Brücke.""" + el = masse["Esslöffel"] + kat = _kategorie(db, "Backwaren") + broetchen = _artikel(db, "Brötchen", kat, base=BaseUnit.piece, package_size=6) + _mass_an_kategorie(db, el, kat, 20) # in Gramm + + with pytest.raises(ConversionError) as fehler: + to_base(db, broetchen, 1, "EL", hausmasse=True) + assert "Zweiteinheit" in str(fehler.value) + + # Mit Brücke „3 Stück ≙ 150 g" sind 20 g dann 0,4 Stück. + broetchen.secondary_base = BaseUnit.gram.value + broetchen.secondary_count = 3 + broetchen.secondary_amount = 150 + db.commit() + assert to_base(db, broetchen, 1, "EL", hausmasse=True) == pytest.approx(0.4) + + +def test_packung_schlaegt_das_hausmass(db, masse): + """„package" wird zuerst geprüft – daran darf ein Hausmaß nichts ändern.""" + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat, package_size=1000) + neu = Measure(name="Packung", short="Pck", position=99) + db.add(neu) + db.commit() + _mass_an_kategorie(db, neu, kat, 7) + + assert to_base(db, mehl, 1, "package", hausmasse=True) == 1000 + + +def test_ohne_schalter_gilt_das_hausmass_nicht(db, masse): + """Einlagern und Mindestbestände dürfen keine Schätzwerte annehmen.""" + el = masse["Esslöffel"] + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat) + _mass_an_kategorie(db, el, kat, 10) + + with pytest.raises(ConversionError) as fehler: + to_base(db, mehl, 2, "EL") # ohne hausmasse=True + assert "Unbekannte Einheit" in str(fehler.value) + + +def test_einlagern_nimmt_kein_hausmass(db, masse): + el = masse["Esslöffel"] + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat) + _mass_an_kategorie(db, el, kat, 10) + + with pytest.raises(ConversionError): + check_in(db, mehl, 2, "EL", None, None, None) + + +def test_auslagern_nimmt_ein_hausmass(db, masse): + el = masse["Esslöffel"] + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat) + _mass_an_kategorie(db, el, kat, 10) + check_in(db, mehl, 1, "package", None, None, None) + db.commit() + + check_out(db, mehl, 2, "EL", None) + db.commit() + + assert current_stock(db, mehl.id) == 980 + + +def test_einheit_schlaegt_gleichnamiges_hausmass(db, masse): + """Ein Maß „Liter" darf die echte Einheit nicht verdecken.""" + kat = _kategorie(db, "Getränke") + saft = _artikel(db, "Apfelsaft", kat, base=BaseUnit.milliliter, package_size=1000) + liter_mass = Measure(name="Liter", short="Ltr", position=99) + db.add(liter_mass) + db.commit() + _mass_an_kategorie(db, liter_mass, kat, 5, base=BaseUnit.milliliter) + + assert to_base(db, saft, 1, "Liter", hausmasse=True) == 1000 + + +def test_verfuegbare_masse_listet_nur_hinterlegte(db, masse): + el = masse["Esslöffel"] + kat = _kategorie(db, "Mehl") + mehl = _artikel(db, "Weissmehl", kat) + _mass_an_kategorie(db, el, kat, 10) + + liste = verfuegbare_masse(db, mehl) + + assert [a.measure.name for a in liste] == ["Esslöffel"] + assert liste[0].amount == 10 + assert liste[0].herkunft == "Kategorie: Mehl" + + +def test_artikel_ohne_kategorie_hat_keine_masse(db, masse): + product = Product(name="Irgendwas", base_unit=BaseUnit.gram, source="manual") + db.add(product) + db.commit() + + assert verfuegbare_masse(db, product) == [] + assert mass_aufloesen(db, product, "EL") is None diff --git a/backend/tests/test_konsolidierung.py b/backend/tests/test_konsolidierung.py new file mode 100644 index 0000000..642e29d --- /dev/null +++ b/backend/tests/test_konsolidierung.py @@ -0,0 +1,182 @@ +"""Zusammenfassen gleichartiger Chargen – und die Ausnahme für angebrochene. + +Diese Logik war bisher ungetestet, obwohl vier Stellen sie aufrufen +(`check_in`, `bulk_lot_location`, `split_lot`, `update_lot`). Mit dem Anbrechen +wird sie gefährlich: Ohne die Ausnahme würde eine offene Packung beim nächsten +Einlagern am selben Ort still mit einer ungeöffneten verschmolzen und ihr +kürzeres Verbrauchsdatum verlieren. +""" + +from datetime import date, timedelta + +import pytest + +from app.models import BaseUnit, Location, Lot, Movement, MovementType, Product, Role, User +from app.routers.stock import bulk_lot_location, open_lot +from app.schemas import BulkLotLocation, LotOpen +from app.services.stock import ( + check_in, + consolidate_duplicate_lots, + consolidate_lot_group, + current_stock, +) + + +@pytest.fixture() +def user(db): + person = User(username="tester", password_hash="x", role=Role.admin) + db.add(person) + db.commit() + db.refresh(person) + return person + + +@pytest.fixture() +def keller(db): + ort = Location(name="Keller") + db.add(ort) + db.commit() + db.refresh(ort) + return ort + + +@pytest.fixture() +def milch(db): + product = Product( + name="Vollmilch", base_unit=BaseUnit.milliliter, package_size=1000, + source="manual", + ) + db.add(product) + db.commit() + db.refresh(product) + return product + + +def _lot(db, product, menge, best_before=None, ort=None): + lot = Lot( + product_id=product.id, quantity=menge, best_before=best_before, + location_id=ort.id if ort else None, + ) + db.add(lot) + db.commit() + db.refresh(lot) + return lot + + +# -------------------------------------------------------------------------- +# Das bisherige Verhalten muss bleiben +# -------------------------------------------------------------------------- +def test_gleiche_chargen_werden_zusammengefasst(db, milch, keller): + mhd = date.today() + timedelta(days=10) + _lot(db, milch, 1000, mhd, keller) + _lot(db, milch, 2000, mhd, keller) + + survivor = consolidate_lot_group(db, milch.id, mhd, "day", keller.id) + db.commit() + + assert survivor.quantity == 3000 + assert db.query(Lot).count() == 1 + + +def test_verschiedenes_mhd_bleibt_getrennt(db, milch, keller): + _lot(db, milch, 1000, date.today() + timedelta(days=5), keller) + _lot(db, milch, 1000, date.today() + timedelta(days=9), keller) + + consolidate_duplicate_lots(db) + + assert db.query(Lot).count() == 2 + + +def test_bewegungen_haengen_an_der_verbleibenden_charge(db, milch, keller, user): + mhd = date.today() + timedelta(days=10) + alt = _lot(db, milch, 1000, mhd, keller) + neu = _lot(db, milch, 1000, mhd, keller) + db.add(Movement(product_id=milch.id, lot_id=neu.id, type=MovementType.in_, + quantity=1000, unit_used="ml")) + db.commit() + + survivor = consolidate_lot_group(db, milch.id, mhd, "day", keller.id) + db.commit() + + assert survivor.id == alt.id + assert db.query(Movement).one().lot_id == alt.id + + +def test_einlagern_fasst_mit_der_bestehenden_charge_zusammen(db, milch, keller): + mhd = date.today() + timedelta(days=10) + check_in(db, milch, 1, "package", mhd, keller.id, None) + check_in(db, milch, 1, "package", mhd, keller.id, None) + db.commit() + + assert db.query(Lot).count() == 1 + assert current_stock(db, milch.id) == 2000 + + +# -------------------------------------------------------------------------- +# Die Ausnahme: Angebrochenes bleibt für sich +# -------------------------------------------------------------------------- +def test_angebrochene_charge_wird_nicht_eingesammelt(db, milch, keller, user): + mhd = date.today() + timedelta(days=20) + lot = _lot(db, milch, 2000, mhd, keller) + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + verbrauchsdatum = offen.consume_by + + consolidate_lot_group(db, milch.id, mhd, "day", keller.id) + db.commit() + + assert db.query(Lot).count() == 2 + frisch = db.get(Lot, offen.id) + assert frisch is not None + assert frisch.consume_by == verbrauchsdatum + + +def test_einlagern_laesst_die_offene_packung_in_ruhe(db, milch, keller, user): + """Der gefährlichste Pfad: nachkaufen, während eine Packung offen ist.""" + mhd = date.today() + timedelta(days=20) + lot = _lot(db, milch, 2000, mhd, keller) + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + + check_in(db, milch, 1, "package", mhd, keller.id, None) + db.commit() + + frisch = db.get(Lot, offen.id) + assert frisch.quantity == 1000 + assert frisch.opened_on is not None + # Die zugekaufte Packung verschmilzt mit der verbliebenen ungeöffneten. + zu = db.query(Lot).filter(Lot.opened_on.is_(None)).all() + assert len(zu) == 1 + assert zu[0].quantity == 2000 + assert current_stock(db, milch.id) == 3000 + + +def test_umlagern_mehrerer_chargen_laesst_offenes_getrennt(db, milch, keller, user): + mhd = date.today() + timedelta(days=20) + lot = _lot(db, milch, 3000, mhd, None) + offen = open_lot(lot.id, LotOpen(), db=db, _=user) + zweite = _lot(db, milch, 1000, mhd, None) + + bulk_lot_location( + BulkLotLocation(lot_ids=[offen.id, zweite.id, lot.id], location_id=keller.id), + db=db, _=user, + ) + + offene = db.query(Lot).filter(Lot.opened_on.isnot(None)).all() + assert len(offene) == 1 + assert offene[0].quantity == 1000 + assert offene[0].location_id == keller.id + assert current_stock(db, milch.id) == 4000 + + +def test_start_aufraeumer_ruehrt_offenes_nicht_an(db, milch, keller, user): + mhd = date.today() + timedelta(days=20) + lot = _lot(db, milch, 2000, mhd, keller) + open_lot(lot.id, LotOpen(), db=db, _=user) + # Zwei ungeöffnete Dubletten dazu, die zusammengefasst werden sollen. + _lot(db, milch, 500, mhd, keller) + + entfernt = consolidate_duplicate_lots(db) + + assert entfernt == 1 + assert db.query(Lot).filter(Lot.opened_on.isnot(None)).count() == 1 + assert db.query(Lot).filter(Lot.opened_on.is_(None)).count() == 1 + assert current_stock(db, milch.id) == 2500