diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index 9ccbdd3..a9f42b1 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -6,6 +6,14 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmittelschlüssel (BLS) 4.0 plus manuelle Katalogerweiterung, lernendes Mapping von FDDB-Bezeichnern, persistierte Tagebuchzeilen. Reine Tagesmakros bleiben First Class. +## Zuordnung (UX) + +Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. + +## FDDB-Listen / eigene Rezepte + +FDDB-Tagebuchexport fasst selbst angelegte Listen oft zu **einer Zeile** (Rezeptname + Menge) zusammen. Die Zutaten stehen in einem **separaten Listen-Export** (`lists_*.csv`, Spalte `produkte`). Ablauf: Listen importieren → passende Tagebuchzeilen werden als Rezept verknüpft → **Zutaten** zuordnen, nicht das Rezept als Ganzes. Unvollständige Zutaten-Mappings fallen auf die FDDB-Makros der Tagebuchzeile zurück. + ## Fachliche Regeln - BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports. diff --git a/.claude/docs/technical/BLS_FOOD_REFERENCE.md b/.claude/docs/technical/BLS_FOOD_REFERENCE.md index d34e39d..4400b37 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -1,6 +1,6 @@ # BLS Food Reference – technische Spec -**Stand:** 2026-09-12 · Migration **062** +**Stand:** 2026-09-12 · Migration **062** + **063** ## Tabellen @@ -11,11 +11,14 @@ - `nutrition_items` — Tagebuchzeilen inkl. `logged_at` - `nutrition_daily_nutrients` — Tages-Rollup numerischer Attribute - `nutrition_day_marks` — `fasting` | `incomplete` +- `food_recipes` / `food_recipe_ingredients` — FDDB-Listen; `nutrition_items.recipe_id` ## Layer 1 - `data_layer/food_mapping.py` — Normalisierung, Lookup, Learn, Apply, Delete - `data_layer/nutrition_items.py` — Ingest, drei Makro-Summen, Policy, `resolve_*_attributes` +- `data_layer/food_recipes.py` — Listen-Upsert, Link auf Tagebuchzeilen, Rezept-Makros (Skala: gegessen_g / Summe Zutaten, sonst 1/Portionen) +- `bls/recipe_parser.py` — `produkte`-Feld: Split nur vor nächstem `\d+ (g|kg|ml|l)` (Kommas im Namen bleiben) ## Import @@ -29,3 +32,6 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - `/api/admin/bls/*` — Import, Katalog, Attribute - `/api/admin/food-mappings` — Admin-CRUD - `/api/nutrition/*` — Items, Unmapped, Bulk-Map, Marken, Konflikt-Resolve +- `GET /api/nutrition/recipes`, `POST /api/nutrition/recipes/import-fddb-lists`, `POST /api/nutrition/recipes/{id}/apply` +- Unmapped = Tagebuchzeilen ohne `food_id`/`recipe_id` **plus** Rezeptzutaten ohne Mapping +- Frontend: `FoodSearchModal` (Name-Suche), Listen-Import auf dem Tab Zuordnen diff --git a/CLAUDE.md b/CLAUDE.md index 38ee625..9029d62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -121,7 +121,7 @@ frontend/src/ - **Migration 062:** `food_catalog` (BLS-Code bleibt Identität), dynamische `food_attributes` + EAV, `food_name_mappings`, `nutrition_items`, `nutrition_daily_nutrients`, `nutrition_day_marks`, Import-Policy am Profil. - **Admin:** Gruppe Ernährung — BLS-Import, Katalog, Attribute, Mappings. -- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen; Fasten/Lücke; Import-Abgleich. +- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; Fasten/Lücke; Import-Abgleich. - **Gitea #106:** BLS-Stammdaten, FDDB-Mapping, Item-Tagebuch — http://192.168.2.144:3000/Lars/mitai-jinkendo/issues/106 - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75. diff --git a/backend/bls/recipe_parser.py b/backend/bls/recipe_parser.py new file mode 100644 index 0000000..4b2ee11 --- /dev/null +++ b/backend/bls/recipe_parser.py @@ -0,0 +1,79 @@ +"""Parse FDDB lists_*.csv (name;…;produkte) into recipe + ingredients.""" +from __future__ import annotations + +import csv +import io +import re +from typing import Any + +from data_layer.food_mapping import normalize_food_name, parse_quantity_g + +ING_RE = re.compile( + r"(?P\d+(?:[.,]\d+)?)\s*(?Pg|kg|ml|l)\b\s*(?P.+?)" + r"(?=,\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l)\b|$)", + re.IGNORECASE | re.DOTALL, +) + + +def parse_fddb_produkte(text: str) -> list[dict[str, Any]]: + raw = (text or "").strip().strip('"') + if not raw: + return [] + out: list[dict[str, Any]] = [] + for i, m in enumerate(ING_RE.finditer(raw)): + qty = float(m.group("qty").replace(",", ".")) + unit = m.group("unit").lower() + name = re.sub(r"\s+", " ", m.group("name")).strip(" ,;") + if not name: + continue + grams = qty + if unit == "kg": + grams = qty * 1000.0 + elif unit == "l": + grams = qty * 1000.0 + elif unit == "ml": + grams = qty + out.append({ + "source_name_raw": name, + "source_name_normalized": normalize_food_name(name), + "quantity_raw": f"{m.group('qty').replace(',', '.')} {unit}", + "quantity_g": round(grams, 3), + "sort_order": i, + }) + return out + + +def parse_fddb_lists_csv(text: str) -> list[dict[str, Any]]: + if text.startswith("\ufeff"): + text = text[1:] + reader = csv.DictReader(io.StringIO(text), delimiter=";") + recipes = [] + for row in reader: + name = (row.get("name") or "").strip().strip('"') + if not name: + continue + try: + portions = float(str(row.get("anzahl_portionen") or "1").replace(",", ".")) + except ValueError: + portions = 1.0 + if portions <= 0: + portions = 1.0 + ingredients = parse_fddb_produkte(row.get("produkte") or "") + if not ingredients: + leftover = (row.get("produkte") or "").strip().strip('"') + if leftover: + ingredients = [{ + "source_name_raw": leftover, + "source_name_normalized": normalize_food_name(leftover), + "quantity_raw": None, + "quantity_g": parse_quantity_g(leftover), + "sort_order": 0, + }] + recipes.append({ + "name_raw": name, + "name_normalized": normalize_food_name(name), + "portions": portions, + "description": (row.get("beschreibung") or "").strip() or None, + "ingredients": ingredients, + }) + return recipes diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index 128402d..e9776bd 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -17,6 +17,7 @@ def normalize_food_name(raw: str | None) -> str: if not raw: return "" s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'") + s = s.lstrip("!") s = LEADING_QTY_RE.sub("", s) s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s) s = MULTISPACE_RE.sub(" ", s).strip().lower() @@ -133,6 +134,7 @@ def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, fo UPDATE nutrition_items SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW() WHERE profile_id = %s AND source_name_normalized = %s + AND recipe_id IS NULL """, (food_id, mapping_id, origin, profile_id, source_name_normalized), ) @@ -166,8 +168,11 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = q = (query or "").strip() if not q: return [] - like = f"%{q}%" - norm = normalize_food_name(q) + primary = q.split(",")[0].strip() or q + like_full = f"%{q}%" + like_primary = f"%{primary}%" + prefix = f"{primary}%" + norm = normalize_food_name(primary) cur.execute( """ SELECT id, bls_code, name_de, name_en, catalog_kind, food_group @@ -178,17 +183,24 @@ def suggest_catalog_foods(cur, query: str, profile_id: str | None, limit: int = OR owner_profile_id = %s ) AND ( - name_de ILIKE %s OR COALESCE(name_en, '') ILIKE %s + name_de ILIKE %s OR name_de ILIKE %s + OR COALESCE(name_en, '') ILIKE %s OR COALESCE(name_en, '') ILIKE %s OR COALESCE(bls_code, '') ILIKE %s OR lower(name_de) = %s ) ORDER BY - CASE WHEN lower(name_de) = %s THEN 0 - WHEN COALESCE(bls_code, '') ILIKE %s THEN 1 - ELSE 2 END, + CASE + WHEN lower(name_de) = %s THEN 0 + WHEN name_de ILIKE %s THEN 1 + WHEN COALESCE(bls_code, '') ILIKE %s THEN 2 + ELSE 3 END, name_de LIMIT %s """, - (profile_id, like, like, like, norm, norm, q, limit), + ( + profile_id, + like_full, like_primary, like_full, like_primary, like_full, norm, + norm, prefix, q, limit, + ), ) return [dict(r) for r in cur.fetchall()] diff --git a/backend/data_layer/food_recipes.py b/backend/data_layer/food_recipes.py new file mode 100644 index 0000000..69e4bfc --- /dev/null +++ b/backend/data_layer/food_recipes.py @@ -0,0 +1,192 @@ +"""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 diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py index 949f1fd..6b83798 100644 --- a/backend/data_layer/nutrition_items.py +++ b/backend/data_layer/nutrition_items.py @@ -99,7 +99,7 @@ def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, A ) cur.execute( """ - SELECT food_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin + SELECT food_id, recipe_id, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, value_origin FROM nutrition_items WHERE profile_id = %s AND date = %s """, @@ -115,7 +115,11 @@ def compute_day_macro_sums(cur, profile_id: str, day: date | str) -> dict[str, A fddb["protein_g"] += _f(it.get("fddb_protein_g")) fddb["fat_g"] += _f(it.get("fddb_fat_g")) fddb["carbs_g"] += _f(it.get("fddb_carbs_g")) - cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g")) + if it.get("recipe_id") and not it.get("food_id"): + from data_layer.food_recipes import catalog_macros_for_recipe + cat = catalog_macros_for_recipe(cur, profile_id, it["recipe_id"], it.get("quantity_g")) + else: + cat = catalog_macros_for_item(cur, it.get("food_id"), it.get("quantity_g")) if cat: mapped += 1 used_bls = True @@ -200,6 +204,24 @@ def apply_nutrition_day_macros( return "created" +def _accumulate_food_qty(cur, acc: dict[int, list[float]], food_id: str, quantity_g: float) -> None: + if not food_id or not quantity_g or quantity_g <= 0: + return + cur.execute( + """ + SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type + FROM food_attribute_values v + JOIN food_attributes a ON a.id = v.attribute_id + WHERE v.food_id = %s AND a.data_type = 'num_per_100g' + AND v.value_num IS NOT NULL AND v.is_trace = false + """, + (food_id,), + ) + factor = float(quantity_g) / 100.0 + for row in cur.fetchall(): + acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor) + + def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None: cur.execute( "DELETE FROM nutrition_daily_nutrients WHERE profile_id = %s AND date = %s", @@ -207,28 +229,24 @@ def rebuild_daily_nutrients(cur, profile_id: str, day: date | str) -> None: ) cur.execute( """ - SELECT i.food_id, i.quantity_g - FROM nutrition_items i - WHERE i.profile_id = %s AND i.date = %s - AND i.food_id IS NOT NULL AND i.quantity_g IS NOT NULL AND i.quantity_g > 0 + SELECT food_id, recipe_id, quantity_g + FROM nutrition_items + WHERE profile_id = %s AND date = %s """, (profile_id, day), ) acc: dict[int, list[float]] = {} + from data_layer.food_recipes import mapped_ingredient_quantities for it in cur.fetchall(): - cur.execute( - """ - SELECT v.attribute_id, v.value_num, v.is_trace, a.data_type - FROM food_attribute_values v - JOIN food_attributes a ON a.id = v.attribute_id - WHERE v.food_id = %s AND a.data_type = 'num_per_100g' - AND v.value_num IS NOT NULL AND v.is_trace = false - """, - (it["food_id"],), - ) - factor = float(it["quantity_g"]) / 100.0 - for row in cur.fetchall(): - acc.setdefault(row["attribute_id"], []).append(float(row["value_num"]) * factor) + if it.get("food_id"): + _accumulate_food_qty(cur, acc, it["food_id"], _f(it.get("quantity_g"))) + continue + if it.get("recipe_id"): + parts = mapped_ingredient_quantities(cur, profile_id, it["recipe_id"], it.get("quantity_g")) + if not parts: + continue + for part in parts: + _accumulate_food_qty(cur, acc, part["food_id"], part["quantity_g"]) for attr_id, vals in acc.items(): cur.execute( """ @@ -302,9 +320,9 @@ def replace_csv_items_for_dates( id, profile_id, date, logged_at, source_name_raw, source_name_normalized, source_system, quantity_raw, quantity_g, fddb_kcal, fddb_protein_g, fddb_fat_g, fddb_carbs_g, - food_id, mapping_id, value_origin, source + food_id, mapping_id, value_origin, source, recipe_id ) VALUES ( - %s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv' + %s,%s,%s,%s,%s,%s,'fddb',%s,%s,%s,%s,%s,%s,%s,%s,%s,'csv',%s ) """, ( @@ -323,9 +341,12 @@ def replace_csv_items_for_dates( mapping["food_id"] if mapping else None, mapping["mapping_id"] if mapping else None, _item_value_origin(mapping), + None, ), ) items_written += 1 + from data_layer.food_recipes import link_recipes_to_items + link_recipes_to_items(cur, profile_id) days_written += 1 sums = compute_day_macro_sums(cur, profile_id, iso) rebuild_daily_nutrients(cur, profile_id, iso) @@ -431,7 +452,12 @@ def dates_for_normalized_name(cur, profile_id: str, source_name_normalized: str) SELECT DISTINCT date::text AS date FROM nutrition_items WHERE profile_id = %s AND source_name_normalized = %s + UNION + SELECT DISTINCT i.date::text AS date + FROM nutrition_items i + JOIN food_recipe_ingredients ri ON ri.recipe_id = i.recipe_id + WHERE i.profile_id = %s AND ri.source_name_normalized = %s """, - (profile_id, source_name_normalized), + (profile_id, source_name_normalized, profile_id, source_name_normalized), ) return [r["date"] for r in cur.fetchall()] diff --git a/backend/migrations/063_fddb_recipes.sql b/backend/migrations/063_fddb_recipes.sql new file mode 100644 index 0000000..1c9b727 --- /dev/null +++ b/backend/migrations/063_fddb_recipes.sql @@ -0,0 +1,43 @@ +-- Migration 063: FDDB-Listen/Rezepte + Verknüpfung an nutrition_items + +CREATE TABLE IF NOT EXISTS food_recipes ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + profile_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + name_raw VARCHAR(500) NOT NULL, + name_normalized VARCHAR(500) NOT NULL, + portions NUMERIC(8,2) NOT NULL DEFAULT 1, + description TEXT, + source VARCHAR(20) NOT NULL DEFAULT 'fddb_list', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uq_food_recipe_profile_name UNIQUE (profile_id, name_normalized) +); + +CREATE INDEX IF NOT EXISTS idx_food_recipes_profile ON food_recipes (profile_id); + +CREATE TABLE IF NOT EXISTS food_recipe_ingredients ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + recipe_id UUID NOT NULL REFERENCES food_recipes(id) ON DELETE CASCADE, + source_name_raw VARCHAR(500) NOT NULL, + source_name_normalized VARCHAR(500) NOT NULL, + quantity_raw VARCHAR(80), + quantity_g NUMERIC(10,3), + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_recipe ON food_recipe_ingredients (recipe_id); +CREATE INDEX IF NOT EXISTS idx_food_recipe_ing_norm ON food_recipe_ingredients (source_name_normalized); + +ALTER TABLE nutrition_items + ADD COLUMN IF NOT EXISTS recipe_id UUID REFERENCES food_recipes(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_nutrition_items_recipe + ON nutrition_items (recipe_id) + WHERE recipe_id IS NOT NULL; + +COMMENT ON TABLE food_recipes IS 'FDDB-Listen/Rezepte; Tagebuchzeile kann statt Einzel-BLS auf ein Rezept zeigen'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 063: FDDB recipes + nutrition_items.recipe_id'; +END $$; diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index d3bdede..673f04f 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -354,16 +354,109 @@ def list_unmapped_foods( cur = get_cursor(conn) cur.execute( """ - SELECT source_name_raw, source_name_normalized, - COUNT(*) AS count, MIN(date) AS first_date, MAX(date) AS last_date - FROM nutrition_items - WHERE profile_id=%s AND food_id IS NULL - GROUP BY source_name_raw, source_name_normalized - ORDER BY count DESC, source_name_normalized + SELECT i.source_name_raw, i.source_name_normalized, + COUNT(*) AS count, MIN(i.date) AS first_date, MAX(i.date) AS last_date, + MIN(r.id::text) AS matching_recipe_id + FROM nutrition_items i + LEFT JOIN food_recipes r + ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized + WHERE i.profile_id=%s AND i.food_id IS NULL AND i.recipe_id IS NULL + GROUP BY i.source_name_raw, i.source_name_normalized + ORDER BY count DESC, i.source_name_normalized """, (pid,), ) - return [r2d(r) for r in cur.fetchall()] + diary = [r2d(r) | {"kind": "diary"} for r in cur.fetchall()] + cur.execute( + """ + SELECT i.source_name_raw, i.source_name_normalized, + COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date + 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 + WHERE r.profile_id = %s AND m.id IS NULL + GROUP BY i.source_name_raw, i.source_name_normalized + ORDER BY count DESC, i.source_name_normalized + """, + (pid,), + ) + ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()] + seen = {d["source_name_normalized"] for d in diary} + for ing in ings: + if ing["source_name_normalized"] not in seen: + diary.append(ing) + seen.add(ing["source_name_normalized"]) + diary.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or "")) + return diary + + +@router.get("/recipes") +def list_food_recipes( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_recipes import list_recipes + pid = get_pid(x_profile_id) + with get_db() as conn: + return list_recipes(get_cursor(conn), pid) + + +@router.post("/recipes/import-fddb-lists") +async def import_fddb_lists( + file: UploadFile = File(...), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from bls.recipe_parser import parse_fddb_lists_csv + from data_layer.food_recipes import upsert_recipes + + pid = get_pid(x_profile_id) + raw = await file.read() + if not raw: + raise HTTPException(400, "Leere Datei") + try: + text = raw.decode("utf-8-sig") + except UnicodeDecodeError: + text = raw.decode("latin-1") + recipes = parse_fddb_lists_csv(text) + if not recipes: + raise HTTPException(400, "Keine Rezepte in der Datei erkannt") + with get_db() as conn: + cur = get_cursor(conn) + stats = upsert_recipes(cur, pid, recipes) + from data_layer.nutrition_items import rebuild_daily_nutrients + for d in stats.pop("dates_linked", []) or []: + rebuild_daily_nutrients(cur, pid, d) + return {"ok": True, "recipes": len(recipes), **stats} + + +@router.post("/recipes/{recipe_id}/apply") +def apply_recipe_name( + recipe_id: str, + body: dict, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + from data_layer.food_mapping import normalize_food_name + from data_layer.food_recipes import apply_recipe_to_items + from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_nutrients + + pid = get_pid(x_profile_id) + source_name = (body.get("source_name") or "").strip() + if not source_name: + raise HTTPException(400, "source_name fehlt") + norm = normalize_food_name(source_name) + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("SELECT id FROM food_recipes WHERE id = %s AND profile_id = %s", (recipe_id, pid)) + if not cur.fetchone(): + raise HTTPException(404, "Rezept nicht gefunden") + n = apply_recipe_to_items(cur, pid, norm, recipe_id) + for d in dates_for_normalized_name(cur, pid, norm): + rebuild_daily_nutrients(cur, pid, d) + return {"ok": True, "items_updated": n} @router.post("/import-conflicts/resolve") diff --git a/backend/tests/test_recipe_parser.py b/backend/tests/test_recipe_parser.py new file mode 100644 index 0000000..d741705 --- /dev/null +++ b/backend/tests/test_recipe_parser.py @@ -0,0 +1,43 @@ +from bls.recipe_parser import parse_fddb_lists_csv, parse_fddb_produkte +from data_layer.food_mapping import normalize_food_name + + +PORRIDGE = ( + "160 g Apfel, Braeburn, 5 ml Omega-3 Vegan Algenöl, 100 g Wildheidelbeeren, " + "30 g Haferflocken, 100% Hafer-Vollkorn, 40 g Hafer Flocken, Großblatt, 11 g Flohsamenschalen" +) + + +def test_parse_fddb_produkte_splits_on_next_quantity_not_commas(): + ings = parse_fddb_produkte(PORRIDGE) + names = [i["source_name_raw"] for i in ings] + assert names == [ + "Apfel, Braeburn", + "Omega-3 Vegan Algenöl", + "Wildheidelbeeren", + "Haferflocken, 100% Hafer-Vollkorn", + "Hafer Flocken, Großblatt", + "Flohsamenschalen", + ] + assert ings[0]["quantity_g"] == 160.0 + assert ings[1]["quantity_g"] == 5.0 + assert ings[1]["quantity_raw"] == "5 ml" + + +def test_parse_fddb_lists_csv_porridge_and_portions(): + text = ( + "name;beschreibung;anzahl_portionen;zeit_vorbereitung;zeit_kochen;produkte;\n" + '"!PorridgeBreakfast ";"";"1";"0";"0";"' + PORRIDGE + '";\n' + '"Aloo gobi";"";"4";"0";"0";"32 ml Rapso Rapsöl, 83 g Zwiebel, frisch";\n' + ) + recipes = parse_fddb_lists_csv(text) + assert len(recipes) == 2 + assert recipes[0]["name_raw"] == "!PorridgeBreakfast" + assert recipes[0]["name_normalized"] == normalize_food_name("PorridgeBreakfast") + assert len(recipes[0]["ingredients"]) == 6 + assert recipes[1]["portions"] == 4.0 + assert recipes[1]["ingredients"][0]["source_name_raw"] == "Rapso Rapsöl" + + +def test_normalize_strips_list_bang_prefix(): + assert normalize_food_name("!PorridgeBreakfast") == normalize_food_name("PorridgeBreakfast") diff --git a/backend/version.py b/backend/version.py index 487ab0d..3fc6955 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9v" BUILD_DATE = "2026-09-12" -DB_SCHEMA_VERSION = "20260912" # 062 BLS catalog + nutrition items/marks +DB_SCHEMA_VERSION = "20260912" # 063 FDDB recipes MODULE_VERSIONS = { "auth": "1.2.0", @@ -20,7 +20,7 @@ MODULE_VERSIONS = { "circumference": "1.0.1", "caliper": "1.0.1", "activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement - "nutrition": "1.1.0", # BLS mapping, items, day marks, import policy + "nutrition": "1.2.0", # FDDB-Listen/Rezepte + Katalog-Suchpopup "bls": "1.0.1", "photos": "1.0.0", "insights": "1.3.0", @@ -45,6 +45,8 @@ CHANGELOG = [ "Lernendes FDDB-Mapping ohne KI, änder- und löschbar", "Optionale nutrition_items, Import-Policy, Fasten-/Lücken-Marken", "BLS-Import als Hintergrundjob (kein Proxy-504)", + "Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code", + "FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen", ], }, { diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index ecfdb7d..41d2586 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -14,3 +14,5 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - FDDB-Import speichert Zeilen; Makro-Konflikt laut Policy - Fasten-/Lücken-Marken unabhängig vom Import - Einzelerfassung nur Makros unverändert +- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer) +- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx new file mode 100644 index 0000000..7bd624e --- /dev/null +++ b/frontend/src/components/FoodSearchModal.jsx @@ -0,0 +1,114 @@ +import { useEffect, useRef, useState } from 'react' +import { api } from '../utils/api' + +export default function FoodSearchModal({ title, initialQuery, onSelect, onClose }) { + const [q, setQ] = useState(initialQuery || '') + const [hits, setHits] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const inputRef = useRef(null) + const timer = useRef(null) + + const runSearch = async (term) => { + const query = (term || '').trim() + if (query.length < 2) { + setHits([]) + return + } + setLoading(true) + setError(null) + try { + setHits(await api.searchBlsFoods(query, 30)) + } catch (e) { + setError(e.message) + setHits([]) + } finally { + setLoading(false) + } + } + + useEffect(() => { + inputRef.current?.focus() + inputRef.current?.select() + if ((initialQuery || '').trim().length >= 2) runSearch(initialQuery) + const onKey = (e) => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => { + window.removeEventListener('keydown', onKey) + clearTimeout(timer.current) + } + }, []) + + const onChange = (value) => { + setQ(value) + clearTimeout(timer.current) + timer.current = setTimeout(() => runSearch(value), 250) + } + + return ( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 640px)', + background: 'var(--surface)', borderRadius: 16, + boxShadow: '0 8px 32px rgba(0,0,0,0.18)', + display: 'flex', flexDirection: 'column', + }} + > +
+

{title || 'Lebensmittel suchen'}

+ +
+
+ onChange(e.target.value)} + /> +

+ Suche nach dem Namen. Den BLS-Code brauchst du nicht. +

+
+
+ {loading &&

Suche…

} + {error &&

{error}

} + {!loading && q.trim().length >= 2 && hits.length === 0 && ( +

Kein Treffer. Anderen Suchbegriff versuchen.

+ )} + {hits.map((h) => ( + + ))} +
+
+
+ ) +} diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx index 5003490..acee0a5 100644 --- a/frontend/src/components/NutritionFoodMap.jsx +++ b/frontend/src/components/NutritionFoodMap.jsx @@ -1,19 +1,101 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' +import FoodSearchModal from './FoodSearchModal' + +function suggestQuery(raw) { + let s = (raw || '').replace(/^\s*[!]?\s*\d+(?:[.,]\d+)?\s*(?:g|kg|ml|l|stück|stk)?\s*/i, '').trim() + s = s.replace(/^!+/, '').trim() + const comma = s.indexOf(',') + if (comma > 2) s = s.slice(0, comma).trim() + return s +} + +function RecipePickModal({ recipes, sourceName, onPick, onClose }) { + const [q, setQ] = useState(suggestQuery(sourceName)) + const filtered = recipes.filter((r) => { + const hay = `${r.name_raw || ''} ${r.name_normalized || ''}`.toLowerCase() + return !q.trim() || hay.includes(q.trim().toLowerCase()) + }) + return ( +
+
e.stopPropagation()} + style={{ + width: '100%', maxWidth: 480, maxHeight: 'min(88vh, 560px)', + background: 'var(--surface)', borderRadius: 16, + boxShadow: '0 8px 32px rgba(0,0,0,0.18)', + display: 'flex', flexDirection: 'column', + }} + > +
+

Eigenes Rezept wählen

+ +
+
+ setQ(e.target.value)} + /> +
+
+ {filtered.length === 0 &&

Kein passendes Rezept. Zuerst Listen-CSV importieren.

} + {filtered.map((r) => ( + + ))} +
+
+
+ ) +} export default function NutritionFoodMap({ onChanged }) { const [unmapped, setUnmapped] = useState([]) const [learned, setLearned] = useState([]) + const [recipes, setRecipes] = useState([]) const [error, setError] = useState(null) - const [query, setQuery] = useState({}) - const [hits, setHits] = useState({}) + const [notice, setNotice] = useState(null) const [saving, setSaving] = useState(null) + const [searchFor, setSearchFor] = useState(null) + const [recipeFor, setRecipeFor] = useState(null) + const [importing, setImporting] = useState(false) + const listRef = useRef(null) const load = async () => { try { - const [u, m] = await Promise.all([api.listUnmappedFoods(), api.listMyFoodMappings()]) + const [u, m, r] = await Promise.all([ + api.listUnmappedFoods(), + api.listMyFoodMappings(), + api.listNutritionRecipes().catch(() => []), + ]) setUnmapped(u) setLearned(m) + setRecipes(Array.isArray(r) ? r : []) } catch (e) { setError(e.message) } @@ -21,26 +103,27 @@ export default function NutritionFoodMap({ onChanged }) { useEffect(() => { load() }, []) - const search = async (key, q) => { - setQuery((s) => ({ ...s, [key]: q })) - if (!q || q.length < 2) { - setHits((s) => ({ ...s, [key]: [] })) - return - } - try { - const rows = await api.searchBlsFoods(q) - setHits((s) => ({ ...s, [key]: rows })) - } catch (e) { - setError(e.message) - } - } - - const assign = async (sourceName, foodId, key) => { - setSaving(key) + const assign = async (sourceName, foodId) => { + setSaving(sourceName) setError(null) try { await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId }) - setHits((s) => ({ ...s, [key]: [] })) + setSearchFor(null) + await load() + onChanged?.() + } catch (e) { + setError(e.message) + } finally { + setSaving(null) + } + } + + const applyRecipe = async (sourceName, recipeId) => { + setSaving(sourceName) + setError(null) + try { + await api.applyNutritionRecipe(recipeId, sourceName) + setRecipeFor(null) await load() onChanged?.() } catch (e) { @@ -61,45 +144,97 @@ export default function NutritionFoodMap({ onChanged }) { } } + const importLists = async (file) => { + if (!file) return + setImporting(true) + setError(null) + setNotice(null) + try { + const res = await api.importFddbLists(file) + await load() + onChanged?.() + setNotice(`${res.recipes} Listen importiert, ${res.items_linked || 0} Tagebuchzeilen als Rezept verknüpft. Offene Zeilen sind jetzt die Zutaten.`) + } catch (e) { + setError(e.message) + } finally { + setImporting(false) + } + } + return (
Lebensmittel zuordnen

- Einmal bestätigt, bleibt die Zuordnung erhalten und gilt für spätere Importe. - Du kannst sie jederzeit ändern oder löschen. Keine automatische KI-Zuordnung. + Tippe auf „Im Katalog suchen“ — du suchst nach dem Namen, nicht nach einem Code. + Eigene FDDB-Listen zuerst importieren, dann wird die Tagebuchzeile in Zutaten aufgelöst.

{error &&
{error}
} + {notice &&
{notice}
} -

Offen ({unmapped.length})

- {unmapped.length === 0 &&

Keine ungemappten Bezeichner.

} + { + const f = e.target.files?.[0] + e.target.value = '' + if (f) importLists(f) + }} + /> + + {recipes.length > 0 && ( +

{recipes.length} eigene Listen geladen

+ )} + +

Offen ({unmapped.length})

+ {unmapped.length === 0 &&

Keine offenen Bezeichner.

} {unmapped.map((u) => { - const key = u.source_name_normalized + const key = `${u.kind || 'diary'}-${u.source_name_normalized}` return (
{u.source_name_raw}
- {u.count}× · {u.first_date} – {u.last_date} + {u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`} + {u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
- search(key, e.target.value)} - /> - {(hits[key] || []).map((h) => ( +
- ))} + {u.kind !== 'recipe_ingredient' && u.matching_recipe_id && ( + + )} + {u.kind !== 'recipe_ingredient' && recipes.length > 0 && ( + + )} +
) })} @@ -110,13 +245,30 @@ export default function NutritionFoodMap({ onChanged }) {
{m.source_name_raw}
- → {m.bls_code ? `${m.bls_code} · ` : ''}{m.food_name_de} + → {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''} {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
))} + + {searchFor && ( + setSearchFor(null)} + onSelect={(food) => assign(searchFor.source_name_raw, food.id)} + /> + )} + {recipeFor && ( + setRecipeFor(null)} + onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)} + /> + )} ) } @@ -132,7 +284,7 @@ export function DayMarkButtons({ date, markType, onChanged }) { } } return ( - + diff --git a/frontend/src/pages/NutritionPage.jsx b/frontend/src/pages/NutritionPage.jsx index 15a0d8f..b62fab8 100644 --- a/frontend/src/pages/NutritionPage.jsx +++ b/frontend/src/pages/NutritionPage.jsx @@ -934,7 +934,7 @@ export default function NutritionPage() {

Ernährung

{unmappedCount > 0 && (
- {unmappedCount} Lebensmittel noch ohne BLS-Zuordnung.{' '} + {unmappedCount} Lebensmittel noch ohne Katalog-Zuordnung.{' '} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index a6d1d32..7d32500 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -241,6 +241,13 @@ export const api = { }, listNutritionItems: (date) => req(date ? `/nutrition/items?date=${date}` : '/nutrition/items'), listUnmappedFoods: () => req('/nutrition/unmapped'), + listNutritionRecipes: () => req('/nutrition/recipes'), + importFddbLists: async (file) => { + const fd = new FormData(); fd.append('file', file) + const r = await fetch(`${BASE}/nutrition/recipes/import-fddb-lists`, { method: 'POST', body: fd, headers: hdrs() }) + return readJsonResponse(r) + }, + applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })), listNutritionMarks: () => req('/nutrition/marks'), putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)), deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 7a11b35..cb2bcd6 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -65,6 +65,7 @@ test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen', await expect(page.getByText(/Vorhandene Tagesmakros überschreiben/)).toBeVisible(); await page.getByRole('button', { name: /Zuordnen/i }).click(); await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible(); + await expect(page.getByRole('button', { name: /FDDB-Listen/ })).toBeVisible(); }); test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {