mitai-jinkendo/backend/data_layer/food_recipes.py
Lars 86f15e6957
All checks were successful
Deploy Development / deploy (push) Successful in 1m9s
Build Test / pytest-backend (push) Successful in 5s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s
feat: FDDB-Listen auflösen und Katalog nach Name suchen
Tagebuchzeilen eigener Rezepte werden über den Listen-Import in Zutaten zerlegt. Zuordnen erfolgt im Namens-Popup statt per BLS-Code.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-12 15:31:20 +02:00

193 lines
6.5 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, normalize_food_name
from data_layer.nutrition_items import catalog_macros_for_item, _f
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
cur.execute(
"""
INSERT INTO food_recipe_ingredients
(id, recipe_id, source_name_raw, source_name_normalized,
quantity_raw, quantity_g, sort_order)
VALUES (%s,%s,%s,%s,%s,%s,%s)
""",
(
str(uuid.uuid4()), rid, ing["source_name_raw"], inorm,
ing.get("quantity_raw"), ing.get("quantity_g"), 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 DISTINCT i.date::text AS date
FROM nutrition_items i
JOIN food_recipes r ON r.profile_id = i.profile_id
AND i.source_name_normalized = r.name_normalized
WHERE i.profile_id = %s AND i.food_id IS NULL
""",
(profile_id,),
)
dates = [r["date"] for r in cur.fetchall()]
cur.execute(
"""
UPDATE nutrition_items i
SET recipe_id = r.id, updated_at = NOW()
FROM food_recipes r
WHERE i.profile_id = %s AND r.profile_id = %s
AND i.source_name_normalized = r.name_normalized
AND i.food_id IS NULL
""",
(profile_id, profile_id),
)
return cur.rowcount or 0, dates
def list_recipes(cur, profile_id: str) -> 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 = [r["id"] for r in recipes]
cur.execute(
"""
SELECT recipe_id, source_name_raw, source_name_normalized, quantity_raw, quantity_g, sort_order
FROM food_recipe_ingredients
WHERE recipe_id = ANY(%s)
ORDER BY sort_order, source_name_raw
""",
(ids,),
)
by_r: dict[str, list] = {str(i): [] for i in ids}
for row in cur.fetchall():
by_r.setdefault(str(row["recipe_id"]), []).append(dict(row))
for rec in recipes:
rec["ingredients"] = by_r.get(str(rec["id"]), [])
return recipes
def apply_recipe_to_items(cur, profile_id: str, source_name_normalized: str, recipe_id: str) -> int:
cur.execute(
"""
UPDATE nutrition_items
SET recipe_id = %s, food_id = NULL, mapping_id = NULL, value_origin = 'fddb', updated_at = NOW()
WHERE profile_id = %s AND source_name_normalized = %s
""",
(recipe_id, profile_id, source_name_normalized),
)
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
FROM food_recipe_ingredients
WHERE recipe_id = %s
ORDER BY sort_order
""",
(recipe_id,),
)
ings = cur.fetchall()
if not ings:
return None
total_g = sum(_f(i.get("quantity_g")) for i in 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
out = []
for ing in ings:
mapping = get_food_mapping_with_cursor(cur, ing["source_name_raw"], profile_id)
if not mapping:
return None
out.append({
"food_id": mapping["food_id"],
"quantity_g": _f(ing.get("quantity_g")) * scale,
})
return out
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