"""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