"""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" def geerbte_kategorie_frist(db: Session, category) -> tuple[int, str]: """Die Frist, die fuer diese Kategorie gelten WUERDE, haette sie keine eigene. Gefragt wird also nach dem Platzhalter in der Maske: Was passiert, wenn ich das Feld leer lasse? Deshalb beginnt die Suche beim Elternteil, nicht bei der Kategorie selbst. """ for cat in _vorfahren_abwaerts(db, category.parent_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 geerbtes_mass_kategorie( db: Session, category, measure_id: int ) -> MassAngabe | None: """Was eine Kategorie fuer dieses Mass ERBT, ohne ihren eigenen Eintrag. Dasselbe wie bei der Frist: Die Maske soll zeigen, was gilt, wenn das Feld leer bleibt. Deshalb beginnt die Suche beim Elternteil. """ measure = db.get(Measure, measure_id) if measure is None: return None for cat in _vorfahren_abwaerts(db, category.parent_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)