mitai-jinkendo/backend/data_layer/food_recipes.py
Lars a5adff6196
All checks were successful
Deploy Development / deploy (push) Successful in 59s
Build Test / pytest-backend (push) Successful in 4s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 23s
feat: Zutaten im Listendialog dem BLS zuordnen
Mapping-Status pro Zutat, Suche zuerst Listen und eigene Foods.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-13 14:38:42 +02:00

432 lines
15 KiB
Python

"""FDDB recipe lists: upsert, link to diary items, catalog macros via ingredients."""
from __future__ import annotations
import uuid
from typing import Any
from data_layer.food_mapping import (
get_food_mapping_with_cursor,
names_match_list,
normalize_food_name,
resolve_quantity,
)
from data_layer.nutrition_items import catalog_macros_for_item, _f
def _resolved_ingredient_qty(cur, profile_id: str, source_name_raw: str, ing: dict[str, Any]) -> dict[str, Any]:
mapping = get_food_mapping_with_cursor(cur, source_name_raw, profile_id)
gpu = ing.get("grams_per_unit")
if gpu in (None, ""):
gpu = mapping.get("grams_per_unit") if mapping else None
try:
gpu = float(gpu) if gpu not in (None, "") else None
except (TypeError, ValueError):
gpu = None
return resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
grams_per_unit=gpu,
)
def upsert_recipes(cur, profile_id: str, recipes: list[dict[str, Any]]) -> dict[str, int]:
inserted = updated = ingredients = 0
for rec in recipes:
norm = rec.get("name_normalized") or normalize_food_name(rec.get("name_raw"))
if not norm:
continue
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
row = cur.fetchone()
if row:
rid = row["id"]
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s
""",
(rec["name_raw"], rec.get("portions") or 1, rec.get("description"), rid),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (rid,))
updated += 1
else:
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,'fddb_list')
""",
(rid, profile_id, rec["name_raw"], norm, rec.get("portions") or 1, rec.get("description")),
)
inserted += 1
for ing in rec.get("ingredients") or []:
inorm = ing.get("source_name_normalized") or normalize_food_name(ing.get("source_name_raw"))
if not inorm:
continue
raw_name = ing.get("source_name_raw") or inorm
qty = resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
)
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, raw_name, inorm,
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"],
ing.get("sort_order") or 0,
),
)
ingredients += 1
linked, dates = link_recipes_to_items(cur, profile_id)
return {
"inserted": inserted,
"updated": updated,
"ingredients": ingredients,
"items_linked": linked,
"dates_linked": dates,
}
def link_recipes_to_items(cur, profile_id: str) -> tuple[int, list[str]]:
cur.execute(
"SELECT id, name_raw, name_normalized FROM food_recipes WHERE profile_id = %s",
(profile_id,),
)
recipes = [dict(r) for r in cur.fetchall()]
if not recipes:
return 0, []
cur.execute(
"""
SELECT id, date::text AS date, source_name_raw, source_name_normalized
FROM nutrition_items
WHERE profile_id = %s AND food_id IS NULL AND recipe_id IS NULL
""",
(profile_id,),
)
by_rid: dict[str, list[str]] = {}
dates: set[str] = set()
for item in cur.fetchall():
rid = next(
(
str(rec["id"])
for rec in recipes
if names_match_list(item.get("source_name_raw"), item.get("source_name_normalized"), rec.get("name_normalized"))
),
None,
)
if not rid:
continue
by_rid.setdefault(rid, []).append(str(item["id"]))
if item.get("date"):
dates.add(str(item["date"]))
linked = 0
for rid, ids in by_rid.items():
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, updated_at = NOW()
WHERE id = ANY(%s::uuid[])
""",
(rid, ids),
)
linked += cur.rowcount or 0
return linked, list(dates)
def list_known_ingredient_names(cur, profile_id: str, limit: int = 2000) -> list[dict[str, Any]]:
"""Distinct list-ingredient names, with mapping if already learned."""
cur.execute(
"""
SELECT i.source_name_raw, i.source_name_normalized,
MIN(m.food_id::text) AS food_id,
MIN(f.name_de) AS food_name_de,
MIN(f.bls_code) AS bls_code,
BOOL_OR(m.id IS NOT NULL) AS mapped
FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m
ON m.profile_id = r.profile_id
AND m.source_name_normalized = i.source_name_normalized
LEFT JOIN food_catalog f ON f.id = m.food_id
WHERE r.profile_id = %s
GROUP BY i.source_name_raw, i.source_name_normalized
ORDER BY i.source_name_normalized
LIMIT %s
""",
(profile_id, limit),
)
out = []
for row in cur.fetchall():
rec = dict(row)
rec["mapped"] = bool(rec.get("mapped"))
out.append(rec)
return out
def list_recipes(cur, profile_id: str, with_ingredients: bool = True) -> list[dict[str, Any]]:
cur.execute(
"""
SELECT id, name_raw, name_normalized, portions, description, source
FROM food_recipes
WHERE profile_id = %s
ORDER BY name_normalized
""",
(profile_id,),
)
recipes = [dict(r) for r in cur.fetchall()]
if not recipes:
return []
ids = [str(r["id"]) for r in recipes]
if not with_ingredients:
cur.execute(
"""
SELECT recipe_id, COUNT(*) AS n
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s::uuid[])
GROUP BY recipe_id
""",
(ids,),
)
counts = {str(r["recipe_id"]): int(r["n"]) for r in cur.fetchall()}
for rec in recipes:
rec["id"] = str(rec["id"])
rec["ingredient_count"] = counts.get(rec["id"], 0)
rec["ingredients"] = []
return recipes
by_r = _ingredients_by_recipe(cur, ids)
for rec in recipes:
rec["id"] = str(rec["id"])
rec["ingredients"] = by_r.get(rec["id"], [])
rec["ingredient_count"] = len(rec["ingredients"])
return recipes
def _ingredients_by_recipe(cur, recipe_ids: list[str]) -> dict[str, list]:
if not recipe_ids:
return {}
cur.execute(
"""
SELECT recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s::uuid[])
ORDER BY sort_order, source_name_raw
""",
(recipe_ids,),
)
by_r: dict[str, list] = {str(i): [] for i in recipe_ids}
for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
return by_r
def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None:
cur.execute(
"""
SELECT id, name_raw, name_normalized, portions, description, source
FROM food_recipes
WHERE profile_id = %s AND id = %s
""",
(profile_id, recipe_id),
)
rec = cur.fetchone()
if not rec:
return None
rec = dict(rec)
rec["id"] = str(rec["id"])
rec["ingredients"] = _ingredients_by_recipe(cur, [rec["id"]]).get(rec["id"], [])
rec["ingredient_count"] = len(rec["ingredients"])
return rec
def save_recipe(cur, profile_id: str, rec: dict[str, Any], recipe_id: str | None = None) -> dict[str, Any]:
name_raw = (rec.get("name_raw") or "").strip()
norm = rec.get("name_normalized") or normalize_food_name(name_raw)
if not name_raw or not norm:
raise ValueError("Rezeptname fehlt")
try:
portions = float(rec.get("portions") or 1)
except (TypeError, ValueError):
portions = 1.0
if portions <= 0:
portions = 1.0
description = rec.get("description")
source = (rec.get("source") or "manual").strip() or "manual"
if recipe_id:
cur.execute(
"SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
cur.execute(
"""
SELECT id FROM food_recipes
WHERE profile_id = %s AND name_normalized = %s AND id <> %s
""",
(profile_id, norm, recipe_id),
)
if cur.fetchone():
raise ValueError("Ein Rezept mit diesem Namen existiert bereits")
cur.execute(
"""
UPDATE food_recipes
SET name_raw=%s, name_normalized=%s, portions=%s, description=%s, updated_at=NOW()
WHERE id=%s AND profile_id=%s
""",
(name_raw, norm, portions, description, recipe_id, profile_id),
)
cur.execute("DELETE FROM food_recipe_ingredients WHERE recipe_id = %s", (recipe_id,))
rid = recipe_id
else:
cur.execute(
"SELECT id FROM food_recipes WHERE profile_id = %s AND name_normalized = %s",
(profile_id, norm),
)
existing = cur.fetchone()
if existing:
return save_recipe(cur, profile_id, rec, str(existing["id"]))
rid = str(uuid.uuid4())
cur.execute(
"""
INSERT INTO food_recipes
(id, profile_id, name_raw, name_normalized, portions, description, source)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(rid, profile_id, name_raw, norm, portions, description, source),
)
for i, ing in enumerate(rec.get("ingredients") or []):
raw = (ing.get("source_name_raw") or "").strip()
inorm = ing.get("source_name_normalized") or normalize_food_name(raw)
if not inorm:
continue
qty = _resolved_ingredient_qty(cur, profile_id, raw or inorm, ing)
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, quantity_amount, source_unit, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, raw or inorm, inorm,
qty["quantity_raw"], qty["quantity_g"], qty["quantity_amount"], qty["source_unit"], i,
),
)
link_recipes_to_items(cur, profile_id)
saved = get_recipe(cur, profile_id, rid)
if not saved:
raise ValueError("Rezept konnte nicht gelesen werden")
return saved
def delete_recipe(cur, profile_id: str, recipe_id: str) -> None:
cur.execute(
"DELETE FROM food_recipes WHERE id = %s AND profile_id = %s RETURNING id",
(recipe_id, profile_id),
)
if not cur.fetchone():
raise KeyError("Rezept nicht gefunden")
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
target = normalize_food_name(source_name_normalized) or (source_name_normalized or "")
cur.execute(
"""
SELECT id, source_name_raw, source_name_normalized
FROM nutrition_items
WHERE profile_id = %s AND food_id IS NULL
""",
(profile_id,),
)
ids = [
str(row["id"])
for row in cur.fetchall()
if names_match_list(row.get("source_name_raw"), row.get("source_name_normalized"), target)
]
if not ids:
return 0
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE id = ANY(%s::uuid[])
""",
(recipe_id, ids),
)
return cur.rowcount or 0
def mapped_ingredient_quantities(
cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None
) -> list[dict[str, Any]] | None:
cur.execute(
"SELECT portions FROM food_recipes WHERE id = %s AND profile_id = %s",
(recipe_id, profile_id),
)
rec = cur.fetchone()
if not rec:
return None
cur.execute(
"""
SELECT source_name_raw, quantity_g, quantity_raw, quantity_amount, source_unit
FROM food_recipe_ingredients
WHERE recipe_id = %s
ORDER BY sort_order
""",
(recipe_id,),
)
ings = cur.fetchall()
if not ings:
return None
resolved_ings = []
for ing in ings:
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
if not mapping:
return None
qty = resolve_quantity(
quantity_raw=ing.get("quantity_raw"),
quantity_amount=ing.get("quantity_amount"),
source_unit=ing.get("source_unit"),
quantity_g=ing.get("quantity_g"),
grams_per_unit=mapping.get("grams_per_unit"),
)
grams = _f(qty.get("quantity_g"))
if grams <= 0:
return None
resolved_ings.append({"food_id": mapping["food_id"], "quantity_g": grams})
total_g = sum(i["quantity_g"] for i in resolved_ings)
if eaten_qty_g and eaten_qty_g > 0 and total_g > 0:
scale = float(eaten_qty_g) / total_g
else:
portions = float(rec.get("portions") or 1) or 1.0
scale = 1.0 / portions
return [{**i, "quantity_g": i["quantity_g"] * scale} for i in resolved_ings]
def catalog_macros_for_recipe(cur, profile_id: str, recipe_id: str, eaten_qty_g: float | None) -> dict[str, float] | None:
parts = mapped_ingredient_quantities(cur, profile_id, recipe_id, eaten_qty_g)
if not parts:
return None
acc = {"kcal": 0.0, "protein_g": 0.0, "fat_g": 0.0, "carbs_g": 0.0}
for part in parts:
cat = catalog_macros_for_item(cur, part["food_id"], part["quantity_g"])
if not cat:
return None
for k in acc:
acc[k] += cat[k]
return acc