"""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, 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 = _resolved_ingredient_qty(cur, profile_id, raw_name, 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_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 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 = [str(r["id"]) for r in recipes] 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 """, (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["id"] = str(rec["id"]) rec["ingredients"] = by_r.get(rec["id"], []) return recipes def get_recipe(cur, profile_id: str, recipe_id: str) -> dict[str, Any] | None: for rec in list_recipes(cur, profile_id): if rec["id"] == str(recipe_id): return rec return None 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: 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, 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