Die Endpunkte standen seit dem Backend-Commit, aber eintragen liess sich
nichts - die Mengen gingen nur per API rein. Damit war "2 EL Mehl" im
Verbrauchen-Dialog zwar vorgesehen, aber fuer niemanden erreichbar.
Zwei Stellen dafuer:
Stammdaten > Masse verwaltet nur die NAMEN ("Essloeffel", "Tasse"). Die
Seite ist absichtlich kurz, denn die Mengen gehoeren nicht an das Mass:
Ein Essloeffel Mehl sind etwa 10 g, einer Zucker etwa 12 g, und eine
Tasse Milch ist Milliliter. Ein globaler Wert waere bei jedem zweiten
Artikel falsch.
Kategorien > "Masse & Frist" ist deshalb der Ort, an dem die Arbeit
passiert. Beides steckt in einem Bedienfeld, weil es dieselbe Mechanik
ist: Der Wert gilt fuer die Kategorie und alles darunter, und der
naechste Vorfahr mit einem Eintrag gewinnt. Leere Felder erben - und
zeigen als Platzhalter, WAS sie erben ("erbt 5 Tage (Kategorie: Mehl &
Zucker)", "10 g" mit Abzeichen "geerbt"). Ohne das muesste man den
Kategorienbaum im Kopf verfolgen, um eine leere Eingabe zu verstehen.
Dafuer zwei kleine Ergaenzungen am Backend: CategoryOut nennt die
geerbte Frist samt Herkunft, und /categories/{id}/measures nimmt
?vererbt=true, um neben den eigenen auch die von oben geerbten Mengen zu
liefern. Die Bearbeitungsansicht ohne Flagge bleibt wie sie war.
Von Hand durchgeklickt - und dabei einen echten Fehler gefunden: Der
API-Aufruf im Browser hat das vererbt-Flag verschluckt, weil die
Funktion nur ein Argument annahm. Die Unterkategorie zeigte dadurch
leere Felder, obwohl der Server die geerbten Werte lieferte. Jetzt
stehen dort "10 g" und "3 g" mit der Quelle im Tooltip.
4 neue Tests, Suite bei 516.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
8.4 KiB
Python
244 lines
8.4 KiB
Python
"""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)
|