Hausmasse und Oeffnungsfrist bekommen eine Oberflaeche
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>
This commit is contained in:
co-authored by
Claude Opus 5
parent
795e233a1b
commit
f61c76034c
@@ -21,6 +21,7 @@ from ..models import (
|
||||
)
|
||||
from ..schemas import CategoryCreate, CategoryOut, CategoryUpdate, FieldDefinitionOut
|
||||
from ..services.fields import effective_field_definitions
|
||||
from ..services.vererbung import geerbte_kategorie_frist
|
||||
from .field_definitions import to_out as field_to_out
|
||||
|
||||
router = APIRouter(prefix="/categories", tags=["categories"])
|
||||
@@ -54,6 +55,7 @@ def _to_out(db: Session, category: Category) -> CategoryOut:
|
||||
out.product_count = (
|
||||
db.query(Product).filter(Product.category_id == category.id).count()
|
||||
)
|
||||
out.inherited_open_days, out.open_days_source = geerbte_kategorie_frist(db, category)
|
||||
return out
|
||||
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ from ..schemas import (
|
||||
MeasureOut,
|
||||
)
|
||||
from ..services.conversion import PACKAGE_TOKENS, find_unit
|
||||
from ..services.vererbung import verfuegbare_masse
|
||||
from ..services.vererbung import geerbtes_mass_kategorie, verfuegbare_masse
|
||||
|
||||
router = APIRouter(tags=["measures"])
|
||||
|
||||
@@ -224,13 +224,17 @@ def delete_product_measure(
|
||||
@router.get("/categories/{category_id}/measures", response_model=list[MeasureAmountOut])
|
||||
def category_measures(
|
||||
category_id: int,
|
||||
vererbt: bool = False,
|
||||
db: Session = Depends(get_db),
|
||||
_: User = Depends(get_current_user),
|
||||
) -> list[MeasureAmountOut]:
|
||||
"""Die an DIESER Kategorie hinterlegten Mengen (ohne die geerbten).
|
||||
"""Die an DIESER Kategorie hinterlegten Mengen.
|
||||
|
||||
Bewusst ohne Vererbung: Diese Liste ist die Bearbeitungsansicht. Was ein
|
||||
Artikel am Ende wirklich bekommt, zeigt ``/products/{id}/measures``.
|
||||
Ohne ``vererbt`` nur die eigenen – das ist die Bearbeitungsansicht. Mit
|
||||
``vererbt=true`` kommen die von oben geerbten dazu, erkennbar an ihrer
|
||||
Herkunft („Kategorie: Mehl & Zucker"); die Maske zeigt damit auch, was
|
||||
gelten wuerde, wenn man den eigenen Eintrag loescht. Was ein ARTIKEL am Ende
|
||||
bekommt, zeigt ``/products/{id}/measures``.
|
||||
"""
|
||||
if db.get(Category, category_id) is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, "Kategorie nicht gefunden")
|
||||
@@ -241,13 +245,31 @@ def category_measures(
|
||||
.order_by(Measure.position, Measure.id)
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
eigene = [
|
||||
MeasureAmountOut(
|
||||
measure_id=m.id, name=m.name, short=m.short,
|
||||
amount=cm.amount, base=cm.base, herkunft="Kategorie",
|
||||
)
|
||||
for cm, m in rows
|
||||
]
|
||||
if not vererbt:
|
||||
return eigene
|
||||
|
||||
# Zusaetzlich, was diese Kategorie von oben ERBT. Die Maske zeigt damit
|
||||
# beides: was hier steht und was gelten wuerde, wenn man es loescht.
|
||||
category = db.get(Category, category_id)
|
||||
gesetzt = {e.measure_id for e in eigene}
|
||||
for measure in db.query(Measure).order_by(Measure.position, Measure.id).all():
|
||||
if measure.id in gesetzt:
|
||||
continue
|
||||
angabe = geerbtes_mass_kategorie(db, category, measure.id)
|
||||
if angabe is None:
|
||||
continue
|
||||
eigene.append(MeasureAmountOut(
|
||||
measure_id=measure.id, name=measure.name, short=measure.short,
|
||||
amount=angabe.amount, base=angabe.base, herkunft=angabe.herkunft,
|
||||
))
|
||||
return eigene
|
||||
|
||||
|
||||
@router.put("/categories/{category_id}/measures/{measure_id}", response_model=MeasureAmountOut)
|
||||
|
||||
@@ -278,6 +278,11 @@ class CategoryOut(BaseModel):
|
||||
# Haltbarkeit nach dem Anbrechen in Tagen. None = erbt von der
|
||||
# Oberkategorie, zuletzt vom globalen Wert.
|
||||
open_days: int | None = None
|
||||
# Was gelten wuerde, wenn ``open_days`` leer bleibt – samt Herkunft
|
||||
# („Kategorie: Molkerei" / „Einstellungen"). Fuer den Platzhalter in der
|
||||
# Maske: Ein leeres Feld soll zeigen, was es erbt, statt nur leer zu sein.
|
||||
inherited_open_days: int = 0
|
||||
open_days_source: str = ""
|
||||
|
||||
|
||||
class CategoryCreate(BaseModel):
|
||||
|
||||
@@ -104,6 +104,19 @@ def frist_herkunft(db: Session, product: Product) -> tuple[int, str]:
|
||||
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
|
||||
# --------------------------------------------------------------------------
|
||||
@@ -147,6 +160,31 @@ def _mass_fuer_artikel(db: Session, product: Product, measure_id: int) -> MassAn
|
||||
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.
|
||||
|
||||
|
||||
@@ -15,10 +15,15 @@ from app.models import (
|
||||
Product,
|
||||
ProductMeasure,
|
||||
)
|
||||
from app.routers.measures import category_measures
|
||||
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
|
||||
from app.services.vererbung import (
|
||||
geerbte_kategorie_frist,
|
||||
mass_aufloesen,
|
||||
verfuegbare_masse,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
@@ -253,3 +258,54 @@ def test_artikel_ohne_kategorie_hat_keine_masse(db, masse):
|
||||
|
||||
assert verfuegbare_masse(db, product) == []
|
||||
assert mass_aufloesen(db, product, "EL") is None
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Bearbeitungsansicht: eigene und geerbte Mengen
|
||||
# --------------------------------------------------------------------------
|
||||
def test_kategorie_sieht_ihre_eigenen_mengen(db, masse):
|
||||
"""Ohne Vererbung ist die Liste die reine Bearbeitungsansicht."""
|
||||
ober = _kategorie(db, "Mehl & Zucker")
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
|
||||
|
||||
liste = category_measures(ober.id, db=db, _=None)
|
||||
|
||||
assert [(e.name, e.amount, e.herkunft) for e in liste] == [("Esslöffel", 10, "Kategorie")]
|
||||
|
||||
|
||||
def test_unterkategorie_zeigt_das_geerbte_als_solches(db, masse):
|
||||
"""Der Platzhalter in der Maske: Was gilt, wenn ich das Feld leer lasse?"""
|
||||
ober = _kategorie(db, "Mehl & Zucker")
|
||||
unter = _kategorie(db, "Backzutaten", parent=ober)
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
|
||||
|
||||
ohne = category_measures(unter.id, db=db, _=None)
|
||||
mit = category_measures(unter.id, vererbt=True, db=db, _=None)
|
||||
|
||||
assert ohne == []
|
||||
assert [(e.name, e.amount, e.herkunft) for e in mit] == [
|
||||
("Esslöffel", 10, "Kategorie: Mehl & Zucker"),
|
||||
]
|
||||
|
||||
|
||||
def test_eigener_wert_verdeckt_den_geerbten_in_der_liste(db, masse):
|
||||
"""Sonst stünde dieselbe Zeile zweimal da, mit verschiedenen Zahlen."""
|
||||
ober = _kategorie(db, "Mehl & Zucker")
|
||||
unter = _kategorie(db, "Backzutaten", parent=ober)
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], ober, 10)
|
||||
_mass_an_kategorie(db, masse["Esslöffel"], unter, 12)
|
||||
|
||||
liste = category_measures(unter.id, vererbt=True, db=db, _=None)
|
||||
|
||||
assert [(e.amount, e.herkunft) for e in liste] == [(12, "Kategorie")]
|
||||
|
||||
|
||||
def test_geerbte_frist_nennt_die_kategorie_die_sie_setzt(db):
|
||||
ober = _kategorie(db, "Molkerei")
|
||||
unter = _kategorie(db, "Frischmilch", parent=ober)
|
||||
ober.open_days = 5
|
||||
db.commit()
|
||||
|
||||
assert geerbte_kategorie_frist(db, unter) == (5, "Kategorie: Molkerei")
|
||||
# Die Kategorie, die den Wert selbst setzt, erbt ihn NICHT von sich.
|
||||
assert geerbte_kategorie_frist(db, ober)[1] == "Einstellungen"
|
||||
|
||||
Reference in New Issue
Block a user