From c1873a47b17d4b6765761db95e2ec6617368cb55 Mon Sep 17 00:00:00 2001 From: Lars Date: Sat, 12 Sep 2026 15:49:14 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20Zuordnungen=20zuverl=C3=A4ssig=20speiche?= =?UTF-8?q?rn=20und=20Mengen-Varianten=20zusammenfassen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mapping bleibt erhalten, auch wenn der Nährwert-Rebuild länger dauert. Offene Liste führt 1 ml/2 ml Olivenöl als ein Lebensmittel. Dazu JSON-Sicherung, eigener Katalogeintrag und Gramm-pro-Einheit. Co-authored-by: Cursor --- .claude/docs/functional/BLS_FOOD_REFERENCE.md | 4 +- .claude/docs/technical/BLS_FOOD_REFERENCE.md | 5 +- CLAUDE.md | 2 +- backend/data_layer/food_knowledge.py | 253 ++++++++++++++++++ backend/data_layer/food_mapping.py | 184 ++++++++++++- backend/data_layer/nutrition_items.py | 5 +- backend/migrations/064_food_mapping_units.sql | 18 ++ backend/routers/bls.py | 70 ++++- backend/routers/nutrition.py | 80 +++++- backend/tests/test_food_knowledge.py | 42 +++ backend/tests/test_food_mapping.py | 20 +- backend/version.py | 6 +- docs/issues/issue-bls-food-mapping.md | 2 + frontend/src/components/FoodSearchModal.jsx | 140 +++++++++- frontend/src/components/NutritionFoodMap.jsx | 76 +++++- frontend/src/utils/api.js | 21 ++ tests/dev-smoke-test.spec.js | 1 + 17 files changed, 875 insertions(+), 54 deletions(-) create mode 100644 backend/data_layer/food_knowledge.py create mode 100644 backend/migrations/064_food_mapping_units.sql create mode 100644 backend/tests/test_food_knowledge.py diff --git a/.claude/docs/functional/BLS_FOOD_REFERENCE.md b/.claude/docs/functional/BLS_FOOD_REFERENCE.md index a9f42b1..1b3c779 100644 --- a/.claude/docs/functional/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/functional/BLS_FOOD_REFERENCE.md @@ -8,12 +8,14 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte ## Zuordnung (UX) -Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. +Der Nutzer sucht im **Popup nach dem Namen** (Katalogtreffer zeigen den BLS-Code nur nachrangig). Codes selbst heraussuchen ist nicht vorgesehen. Fehlt ein Treffer, kann ein **eigener Katalogeintrag** (Name + Makros/100 g) angelegt und sofort zugeordnet werden. Nicht-Gramm-Einheiten (Stück, EL, TL, …) bekommen ein **Gramm-pro-Einheit**-Feld am Mapping. ## 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. +Gelernte Zuordnungen und Listen lassen sich als **JSON sichern** und auf einer anderen Instanz (Dev → Prod) wieder einspielen. Offizielle Lebensmittel werden über den BLS-Code gefunden — der BLS-Katalog muss auf dem Ziel bereits importiert sein. + ## 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 4400b37..286e4ea 100644 --- a/.claude/docs/technical/BLS_FOOD_REFERENCE.md +++ b/.claude/docs/technical/BLS_FOOD_REFERENCE.md @@ -34,4 +34,7 @@ FDDB: Items persistieren; `nutrition_log` nur bei leerem Tag oder laut Policy / - `/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 +- Frontend: `FoodSearchModal` (Name-Suche, eigener Eintrag, Gramm/Einheit), Listen-Import auf dem Tab Zuordnen +- Mapping-Schreiben und Nährwert-Rebuild sind getrennte Transaktionen (Rebuild darf das Mapping nicht zurückrollen) +- `food_name_mappings.grams_per_unit` / `source_unit` (Migration **064**) +- `GET/POST /api/nutrition/food-knowledge` — portable JSON (`mitai-food-knowledge` v1): manuelle Foods, Mappings (über `bls_code` / Name, keine UUIDs), Listen. Import löst Katalog auf dem Zielsystem auf (BLS muss dort importiert sein). diff --git a/CLAUDE.md b/CLAUDE.md index 9029d62..3f28bc8 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 mit Namenssuche (Popup); FDDB-Listen/Rezepte; Fasten/Lücke; Import-Abgleich. +- **Nutzer:** Einzelerfassung unverändert; Tab Zuordnen mit Namenssuche (Popup); FDDB-Listen/Rezepte; JSON-Export/Import der Zuordnungen; 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/data_layer/food_knowledge.py b/backend/data_layer/food_knowledge.py new file mode 100644 index 0000000..9bff82a --- /dev/null +++ b/backend/data_layer/food_knowledge.py @@ -0,0 +1,253 @@ +"""Portable JSON for user food mappings, manual foods, and FDDB lists.""" +from __future__ import annotations + +from datetime import datetime, timezone +from decimal import Decimal +from typing import Any +from uuid import UUID + +from data_layer.food_mapping import ( + apply_mapping_to_items, + apply_quantities_to_items, + normalize_food_name, + upsert_food_mapping, +) +from data_layer.food_recipes import list_recipes, upsert_recipes +from data_layer.nutrition_items import catalog_macros_for_item, dates_for_normalized_name, rebuild_daily_nutrients + +BUNDLE_FORMAT = "mitai-food-knowledge" +BUNDLE_VERSION = 1 + + +def _jsonable(value: Any) -> Any: + if value is None: + return None + if isinstance(value, Decimal): + return float(value) + if isinstance(value, UUID): + return str(value) + if hasattr(value, "isoformat"): + return value.isoformat() + return value + + +def parse_food_knowledge_bundle(data: Any) -> dict[str, Any]: + if not isinstance(data, dict): + raise ValueError("Die Datei ist kein JSON-Objekt") + if data.get("format") != BUNDLE_FORMAT: + raise ValueError("Keine Mitai-Zuordnungsdatei — bitte die exportierte JSON verwenden") + try: + version = int(data.get("version") or 0) + except (TypeError, ValueError) as exc: + raise ValueError("Unbekannte Dateiversion") from exc + if version != BUNDLE_VERSION: + raise ValueError(f"Nicht unterstützte Dateiversion {version}") + return data + + +def portable_mapping(row: dict[str, Any]) -> dict[str, Any]: + return { + "source_system": row.get("source_system") or "fddb", + "source_name_raw": row.get("source_name_raw"), + "source_name_normalized": row.get("source_name_normalized"), + "bls_code": row.get("bls_code"), + "food_name_de": row.get("food_name_de") or row.get("name_de"), + "catalog_kind": row.get("catalog_kind"), + "external_key": row.get("external_key"), + "grams_per_unit": row.get("grams_per_unit"), + "source_unit": row.get("source_unit"), + } + + +def resolve_catalog_food(cur, profile_id: str, ref: dict[str, Any]) -> str | None: + bls = (ref.get("bls_code") or "").strip() + if bls: + cur.execute( + "SELECT id FROM food_catalog WHERE bls_code = %s AND is_active = true", + (bls,), + ) + row = cur.fetchone() + return str(row["id"]) if row else None + kind = ref.get("catalog_kind") or "manual_user" + name = (ref.get("food_name_de") or ref.get("name_de") or "").strip() + key = (ref.get("external_key") or "").strip() + if kind == "manual_user": + if key: + cur.execute( + """ + SELECT id FROM food_catalog + WHERE owner_profile_id = %s AND external_key = %s AND is_active = true + LIMIT 1 + """, + (profile_id, key), + ) + row = cur.fetchone() + if row: + return str(row["id"]) + if name: + cur.execute( + """ + SELECT id FROM food_catalog + WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' + AND lower(name_de) = lower(%s) AND is_active = true + LIMIT 1 + """, + (profile_id, name), + ) + row = cur.fetchone() + if row: + return str(row["id"]) + return None + if kind == "manual_admin" and name: + cur.execute( + """ + SELECT id FROM food_catalog + WHERE catalog_kind = 'manual_admin' AND lower(name_de) = lower(%s) + AND is_active = true + LIMIT 1 + """, + (name,), + ) + row = cur.fetchone() + return str(row["id"]) if row else None + return None + + +def export_food_knowledge(cur, profile_id: str) -> dict[str, Any]: + cur.execute( + """ + SELECT id, name_de, name_en, catalog_kind, external_key + FROM food_catalog + WHERE owner_profile_id = %s AND catalog_kind = 'manual_user' AND is_active = true + ORDER BY lower(name_de) + """, + (profile_id,), + ) + manuals = [] + for food in cur.fetchall(): + macros = catalog_macros_for_item(cur, food["id"], 100.0) + manuals.append({ + "name_de": food["name_de"], + "name_en": food.get("name_en"), + "catalog_kind": food["catalog_kind"], + "external_key": food.get("external_key"), + "macros_per_100g": macros, + }) + cur.execute( + """ + SELECT m.source_system, m.source_name_raw, m.source_name_normalized, + m.grams_per_unit, m.source_unit, + f.bls_code, f.name_de AS food_name_de, f.catalog_kind, f.external_key + FROM food_name_mappings m + JOIN food_catalog f ON f.id = m.food_id + WHERE m.profile_id = %s + ORDER BY m.source_name_normalized + """, + (profile_id,), + ) + mappings = [portable_mapping(dict(r)) for r in cur.fetchall()] + recipes = [] + for rec in list_recipes(cur, profile_id): + recipes.append({ + "name_raw": rec.get("name_raw"), + "name_normalized": rec.get("name_normalized"), + "portions": _jsonable(rec.get("portions")), + "description": rec.get("description"), + "source": rec.get("source") or "fddb_list", + "ingredients": [ + { + "source_name_raw": ing.get("source_name_raw"), + "source_name_normalized": ing.get("source_name_normalized"), + "quantity_raw": ing.get("quantity_raw"), + "quantity_g": _jsonable(ing.get("quantity_g")), + "sort_order": ing.get("sort_order") or 0, + } + for ing in rec.get("ingredients") or [] + ], + }) + return { + "format": BUNDLE_FORMAT, + "version": BUNDLE_VERSION, + "exported_at": datetime.now(timezone.utc).isoformat(), + "manual_foods": manuals, + "mappings": mappings, + "recipes": recipes, + } + + +def _upsert_manual_food(cur, profile_id: str, food: dict[str, Any]) -> str | None: + name = (food.get("name_de") or "").strip() + if not name: + return None + existing = resolve_catalog_food(cur, profile_id, {**food, "food_name_de": name, "catalog_kind": "manual_user"}) + if existing: + from routers.admin_bls import _write_manual_macros + _write_manual_macros(cur, existing, food.get("macros_per_100g")) + return existing + key = (food.get("external_key") or "").strip() or f"man-user-{normalize_food_name(name)[:40]}" + cur.execute( + """ + INSERT INTO food_catalog + (name_de, name_en, catalog_kind, owner_profile_id, source, external_key) + VALUES (%s, %s, 'manual_user', %s, 'import', %s) + RETURNING id + """, + (name, food.get("name_en"), profile_id, key), + ) + food_id = str(cur.fetchone()["id"]) + from routers.admin_bls import _write_manual_macros + _write_manual_macros(cur, food_id, food.get("macros_per_100g")) + return food_id + + +def import_food_knowledge(cur, profile_id: str, data: dict[str, Any]) -> dict[str, Any]: + bundle = parse_food_knowledge_bundle(data) + foods_upserted = 0 + for food in bundle.get("manual_foods") or []: + if _upsert_manual_food(cur, profile_id, food): + foods_upserted += 1 + mappings_ok = mappings_skipped = items_updated = 0 + skipped: list[str] = [] + dates: set[str] = set() + for raw in bundle.get("mappings") or []: + name = (raw.get("source_name_raw") or "").strip() + if not name: + mappings_skipped += 1 + continue + food_id = resolve_catalog_food(cur, profile_id, raw) + if not food_id: + mappings_skipped += 1 + label = raw.get("bls_code") or raw.get("food_name_de") or name + skipped.append(str(label)) + continue + mid = upsert_food_mapping( + cur, + source_name_raw=name, + food_id=food_id, + profile_id=profile_id, + source="import", + source_system=raw.get("source_system") or "fddb", + grams_per_unit=raw.get("grams_per_unit"), + source_unit=raw.get("source_unit"), + ) + norm = normalize_food_name(name) + items_updated += apply_mapping_to_items(cur, profile_id, norm, food_id, mid) + apply_quantities_to_items(cur, profile_id, norm, raw.get("grams_per_unit")) + dates.update(dates_for_normalized_name(cur, profile_id, norm)) + mappings_ok += 1 + recipe_stats = {"inserted": 0, "updated": 0, "ingredients": 0, "items_linked": 0} + recipes = bundle.get("recipes") or [] + if recipes: + recipe_stats = upsert_recipes(cur, profile_id, recipes) + dates.update(recipe_stats.pop("dates_linked", []) or []) + for day in dates: + rebuild_daily_nutrients(cur, profile_id, day) + return { + "ok": True, + "manual_foods": foods_upserted, + "mappings": mappings_ok, + "mappings_skipped": mappings_skipped, + "items_updated": items_updated, + "skipped_foods": skipped[:20], + **recipe_stats, + } diff --git a/backend/data_layer/food_mapping.py b/backend/data_layer/food_mapping.py index e9776bd..e729e4f 100644 --- a/backend/data_layer/food_mapping.py +++ b/backend/data_layer/food_mapping.py @@ -13,24 +13,126 @@ MULTISPACE_RE = re.compile(r"\s+") DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)") -def normalize_food_name(raw: str | None) -> str: +def strip_leading_quantity(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) + return MULTISPACE_RE.sub(" ", s).strip() + + +def normalize_food_name(raw: str | None) -> str: + s = strip_leading_quantity(raw) + if not s: + return "" s = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s) - s = MULTISPACE_RE.sub(" ", s).strip().lower() - return s + return s.lower() -def parse_quantity_g(raw: str | None) -> float | None: - if raw is None or str(raw).strip() == "": +def merge_unmapped_rows(rows: list[dict]) -> list[dict]: + merged: dict[str, dict] = {} + for row in rows: + raw = row.get("source_name_raw") or "" + key = normalize_food_name(raw) or row.get("source_name_normalized") or raw.lower() + if not key: + continue + display = strip_leading_quantity(raw) or raw + count = int(row.get("count") or 0) + if key not in merged: + item = dict(row) + item["source_name_normalized"] = key + item["source_name_raw"] = display + item["count"] = count + item["variant_count"] = 1 + merged[key] = item + continue + cur = merged[key] + cur["count"] = int(cur.get("count") or 0) + count + cur["variant_count"] = int(cur.get("variant_count") or 1) + 1 + if display and (not cur.get("source_name_raw") or len(display) < len(cur["source_name_raw"])): + cur["source_name_raw"] = display + if row.get("matching_recipe_id") and not cur.get("matching_recipe_id"): + cur["matching_recipe_id"] = row["matching_recipe_id"] + if row.get("sample_quantity_raw") and not cur.get("sample_quantity_raw"): + cur["sample_quantity_raw"] = row["sample_quantity_raw"] + first, last = row.get("first_date"), row.get("last_date") + if first and (not cur.get("first_date") or str(first) < str(cur["first_date"])): + cur["first_date"] = first + if last and (not cur.get("last_date") or str(last) > str(cur["last_date"])): + cur["last_date"] = last + return list(merged.values()) + + +UNIT_ALIASES = { + "g": "g", "gr": "g", "gramm": "g", + "kg": "kg", + "ml": "ml", + "l": "l", "liter": "l", "lt": "l", + "stück": "stück", "stk": "stück", "st": "stück", "st.": "stück", "pcs": "stück", + "el": "el", "esslöffel": "el", + "tl": "tl", "teelöffel": "tl", + "prise": "prise", + "scheibe": "scheibe", + "portion": "portion", "portionen": "portion", + "becher": "becher", + "tasse": "tasse", + "msp": "msp", "msp.": "msp", +} +MASS_VOLUME_TO_G = {"g": 1.0, "kg": 1000.0, "ml": 1.0, "l": 1000.0} +DEFAULT_UNIT_G = {"el": 15.0, "tl": 5.0, "prise": 0.3, "msp": 1.0} +COUNT_UNITS = frozenset({"stück", "scheibe", "portion", "becher", "tasse"}) +QTY_PARSE_RE = re.compile( + r"^\s*(\d+(?:[.,]\d+)?)\s*([a-zA-ZäöüÄÖÜß.]+)?\s*$", + re.IGNORECASE, +) + + +def _canon_unit(raw: str | None) -> str | None: + if not raw: return None + return UNIT_ALIASES.get(raw.strip().lower().rstrip(".")) + + +def parse_quantity(raw: str | None, grams_per_unit: float | None = None) -> dict[str, Any]: + empty = {"value": None, "unit": None, "quantity_g": None, "needs_unit_map": False} + if raw is None or str(raw).strip() == "": + return empty text = str(raw).strip().replace(",", ".") - m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(g|gramm)?\s*$", text, re.IGNORECASE) - if m: - return round(float(m.group(1)), 3) + m = QTY_PARSE_RE.match(text) + if not m: + return empty + value = float(m.group(1)) + unit = _canon_unit(m.group(2)) + if unit is None: + return {"value": value, "unit": "g", "quantity_g": round(value, 3), "needs_unit_map": False} + if unit in MASS_VOLUME_TO_G: + return { + "value": value, + "unit": unit, + "quantity_g": round(value * MASS_VOLUME_TO_G[unit], 3), + "needs_unit_map": False, + } + factor = grams_per_unit if grams_per_unit is not None else DEFAULT_UNIT_G.get(unit) + if factor is not None: + return { + "value": value, + "unit": unit, + "quantity_g": round(value * float(factor), 3), + "needs_unit_map": unit in COUNT_UNITS, + } + return {"value": value, "unit": unit, "quantity_g": None, "needs_unit_map": True} + + +def parse_quantity_g(raw: str | None, grams_per_unit: float | None = None) -> float | None: + return parse_quantity(raw, grams_per_unit)["quantity_g"] + + +def detect_quantity_unit(*texts: str | None) -> str | None: + for text in texts: + unit = parse_quantity(text).get("unit") + if unit and unit not in MASS_VOLUME_TO_G: + return unit return None @@ -47,6 +149,7 @@ def get_food_mapping_with_cursor( cur.execute( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, + m.grams_per_unit, m.source_unit, f.bls_code, f.name_de, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id @@ -62,6 +165,7 @@ def get_food_mapping_with_cursor( cur.execute( """ SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, + m.grams_per_unit, m.source_unit, f.bls_code, f.name_de, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id @@ -83,6 +187,8 @@ def upsert_food_mapping( profile_id: str | None, source: str = "bulk", source_system: str = "fddb", + grams_per_unit: float | None = None, + source_unit: str | None = None, ) -> int: norm = normalize_food_name(source_name_raw) if not norm: @@ -105,28 +211,55 @@ def upsert_food_mapping( ) existing = cur.fetchone() raw = source_name_raw.strip() + unit = _canon_unit(source_unit) if source_unit else None if existing: cur.execute( """ UPDATE food_name_mappings - SET food_id = %s, source_name_raw = %s, source = %s, updated_at = NOW() + SET food_id = %s, source_name_raw = %s, source = %s, + grams_per_unit = %s, source_unit = %s, updated_at = NOW() WHERE id = %s """, - (food_id, raw, source, existing["id"]), + (food_id, raw, source, grams_per_unit, unit, existing["id"]), ) return int(existing["id"]) cur.execute( """ INSERT INTO food_name_mappings - (source_system, source_name_raw, source_name_normalized, food_id, profile_id, source, updated_at) - VALUES (%s, %s, %s, %s, %s, %s, NOW()) + (source_system, source_name_raw, source_name_normalized, food_id, profile_id, + source, grams_per_unit, source_unit, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) RETURNING id """, - (source_system, raw, norm, food_id, profile_id, source), + (source_system, raw, norm, food_id, profile_id, source, grams_per_unit, unit), ) return int(cur.fetchone()["id"]) +def apply_quantities_to_items(cur, profile_id: str, source_name_normalized: str, grams_per_unit: float | None) -> int: + if not grams_per_unit: + return 0 + cur.execute( + """ + SELECT id, quantity_raw, source_name_raw + FROM nutrition_items + WHERE profile_id = %s AND source_name_normalized = %s + """, + (profile_id, source_name_normalized), + ) + n = 0 + for row in cur.fetchall(): + qty = parse_quantity_g(row.get("quantity_raw") or row.get("source_name_raw"), grams_per_unit) + if qty is None: + continue + cur.execute( + "UPDATE nutrition_items SET quantity_g = %s, updated_at = NOW() WHERE id = %s", + (qty, row["id"]), + ) + n += 1 + return n + + def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, food_id: str, mapping_id: int) -> int: origin = _value_origin_for_food(cur, food_id) cur.execute( @@ -138,7 +271,30 @@ def apply_mapping_to_items(cur, profile_id: str, source_name_normalized: str, fo """, (food_id, mapping_id, origin, profile_id, source_name_normalized), ) - return cur.rowcount or 0 + n = cur.rowcount or 0 + cur.execute( + """ + SELECT id, source_name_raw + FROM nutrition_items + WHERE profile_id = %s AND recipe_id IS NULL AND food_id IS NULL + """, + (profile_id,), + ) + extra = [ + row["id"] for row in cur.fetchall() + if normalize_food_name(row.get("source_name_raw")) == source_name_normalized + ] + if extra: + cur.execute( + """ + UPDATE nutrition_items + SET food_id = %s, mapping_id = %s, value_origin = %s, updated_at = NOW() + WHERE id = ANY(%s) + """, + (food_id, mapping_id, origin, extra), + ) + n += cur.rowcount or 0 + return n def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int: diff --git a/backend/data_layer/nutrition_items.py b/backend/data_layer/nutrition_items.py index 6b83798..c858e8f 100644 --- a/backend/data_layer/nutrition_items.py +++ b/backend/data_layer/nutrition_items.py @@ -311,7 +311,10 @@ def replace_csv_items_for_dates( if not name: continue qty_raw = raw.get("quantity_raw") - qty_g = parse_quantity_g(qty_raw if qty_raw is not None else name) + qty_g = parse_quantity_g( + qty_raw if qty_raw is not None else name, + mapping.get("grams_per_unit") if mapping else None, + ) mapping = get_food_mapping_with_cursor(cur, name, profile_id) logged_at = raw.get("logged_at") cur.execute( diff --git a/backend/migrations/064_food_mapping_units.sql b/backend/migrations/064_food_mapping_units.sql new file mode 100644 index 0000000..5386786 --- /dev/null +++ b/backend/migrations/064_food_mapping_units.sql @@ -0,0 +1,18 @@ +-- Migration 064: Mengeneinheiten am Mapping + source=import erlaubt + +ALTER TABLE food_name_mappings + ADD COLUMN IF NOT EXISTS grams_per_unit NUMERIC(10,3), + ADD COLUMN IF NOT EXISTS source_unit VARCHAR(20); + +ALTER TABLE food_name_mappings DROP CONSTRAINT IF EXISTS food_name_mappings_source_check; + +ALTER TABLE food_name_mappings + ADD CONSTRAINT food_name_mappings_source_check + CHECK (source IN ('manual', 'bulk', 'admin', 'import')); + +COMMENT ON COLUMN food_name_mappings.grams_per_unit IS 'Gramm pro Quell-Einheit (Stück, EL, …); NULL = Masse/Volumen bereits in g'; + +DO $$ +BEGIN + RAISE NOTICE 'Migration 064: mapping units + source import'; +END $$; diff --git a/backend/routers/bls.py b/backend/routers/bls.py index 10a6029..4bc4b0c 100644 --- a/backend/routers/bls.py +++ b/backend/routers/bls.py @@ -1,14 +1,16 @@ """Authenticated catalog search and user-owned foods / mappings.""" from __future__ import annotations +import logging from typing import Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel from auth import require_auth from data_layer.food_mapping import ( apply_mapping_to_items, + apply_quantities_to_items, clear_mapping_from_items, normalize_food_name, suggest_catalog_foods, @@ -18,6 +20,8 @@ from data_layer.nutrition_items import dates_for_normalized_name, rebuild_daily_ from db import get_cursor, get_db, r2d from routers.profiles import get_pid +logger = logging.getLogger(__name__) + router = APIRouter(prefix="/api/bls", tags=["bls"]) @@ -31,6 +35,17 @@ class MappingUpsert(BaseModel): source_name: str food_id: str source_system: str = "fddb" + grams_per_unit: Optional[float] = None + source_unit: Optional[str] = None + + +def _pid(session: dict, x_profile_id: Optional[str] = None) -> str: + return x_profile_id or session["profile_id"] + + +def _rebuild_days(cur, profile_id: str, dates: list[str]) -> None: + for d in dates: + rebuild_daily_nutrients(cur, profile_id, d) @router.get("/foods") @@ -46,10 +61,15 @@ def search_foods( @router.post("/foods/manual") -def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth)): +def create_user_food( + body: UserFoodCreate, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): from routers.admin_bls import _write_manual_macros + import uuid - pid = session["profile_id"] + pid = _pid(session, x_profile_id) name = (body.name_de or "").strip() if not name: raise HTTPException(400, "Name fehlt") @@ -62,7 +82,7 @@ def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth) VALUES (%s, %s, 'manual_user', %s, 'manual', %s) RETURNING * """, - (name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:40]}"), + (name, body.name_en, pid, f"man-user-{normalize_food_name(name)[:32]}-{uuid.uuid4().hex[:8]}"), ) food = r2d(cur.fetchone()) _write_manual_macros(cur, food["id"], body.macros_per_100g) @@ -70,13 +90,17 @@ def create_user_food(body: UserFoodCreate, session: dict = Depends(require_auth) @router.get("/mappings") -def list_my_mappings(session: dict = Depends(require_auth)): - pid = session["profile_id"] +def list_my_mappings( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = _pid(session, x_profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source, + m.grams_per_unit, m.source_unit, f.name_de AS food_name_de, f.bls_code, f.catalog_kind FROM food_name_mappings m JOIN food_catalog f ON f.id = m.food_id @@ -89,8 +113,12 @@ def list_my_mappings(session: dict = Depends(require_auth)): @router.post("/mappings") -def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)): - pid = session["profile_id"] +def upsert_my_mapping( + body: MappingUpsert, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = _pid(session, x_profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute( @@ -110,17 +138,28 @@ def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth) profile_id=pid, source="bulk", source_system=body.source_system, + grams_per_unit=body.grams_per_unit, + source_unit=body.source_unit, ) norm = normalize_food_name(body.source_name) n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid) - for d in dates_for_normalized_name(cur, pid, norm): - rebuild_daily_nutrients(cur, pid, d) + apply_quantities_to_items(cur, pid, norm, body.grams_per_unit) + dates = dates_for_normalized_name(cur, pid, norm) + try: + with get_db() as conn: + _rebuild_days(get_cursor(conn), pid, dates) + except Exception: + logger.exception("Nährwert-Rebuild nach Mapping %s fehlgeschlagen", norm) return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm} @router.delete("/mappings/{mapping_id}") -def delete_my_mapping(mapping_id: int, session: dict = Depends(require_auth)): - pid = session["profile_id"] +def delete_my_mapping( + mapping_id: int, + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + pid = _pid(session, x_profile_id) with get_db() as conn: cur = get_cursor(conn) cur.execute( @@ -137,8 +176,11 @@ def delete_my_mapping(mapping_id: int, session: dict = Depends(require_auth)): dates = dates_for_normalized_name(cur, pid, norm) clear_mapping_from_items(cur, pid, norm) cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid)) - for d in dates: - rebuild_daily_nutrients(cur, pid, d) + try: + with get_db() as conn: + _rebuild_days(get_cursor(conn), pid, dates) + except Exception: + logger.exception("Nährwert-Rebuild nach Mapping-Löschen fehlgeschlagen") return {"ok": True} diff --git a/backend/routers/nutrition.py b/backend/routers/nutrition.py index 673f04f..53cd08a 100644 --- a/backend/routers/nutrition.py +++ b/backend/routers/nutrition.py @@ -11,6 +11,7 @@ from typing import Optional from datetime import datetime from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends +from fastapi.responses import Response from db import get_db, get_cursor, r2d from auth import require_auth, check_feature_access, increment_feature_usage @@ -349,14 +350,26 @@ def list_unmapped_foods( x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth), ): - pid = get_pid(x_profile_id) + from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name + + pid = x_profile_id or session["profile_id"] with get_db() as conn: cur = get_cursor(conn) + cur.execute( + """ + SELECT source_name_normalized + FROM food_name_mappings + WHERE profile_id = %s + """, + (pid,), + ) + mapped = {r["source_name_normalized"] for r in cur.fetchall()} cur.execute( """ 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 + MIN(r.id::text) AS matching_recipe_id, + MIN(i.quantity_raw) AS sample_quantity_raw FROM nutrition_items i LEFT JOIN food_recipes r ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized @@ -370,7 +383,8 @@ def list_unmapped_foods( cur.execute( """ SELECT i.source_name_raw, i.source_name_normalized, - COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date + COUNT(*) AS count, NULL::date AS first_date, NULL::date AS last_date, + MIN(i.quantity_raw) AS sample_quantity_raw FROM food_recipe_ingredients i JOIN food_recipes r ON r.id = i.recipe_id LEFT JOIN food_name_mappings m @@ -383,13 +397,15 @@ def list_unmapped_foods( (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 + merged = merge_unmapped_rows(diary + ings) + out = [] + for row in merged: + key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw")) + if key in mapped: + continue + out.append(row) + out.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or "")) + return out @router.get("/recipes") @@ -459,6 +475,50 @@ def apply_recipe_name( return {"ok": True, "items_updated": n} +@router.get("/food-knowledge") +def export_food_knowledge_file( + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + import json + from data_layer.food_knowledge import export_food_knowledge + + pid = get_pid(x_profile_id) + with get_db() as conn: + bundle = export_food_knowledge(get_cursor(conn), pid) + body = json.dumps(bundle, ensure_ascii=False, indent=2, default=str) + stamp = datetime.now().strftime("%Y-%m-%d") + return Response( + content=body.encode("utf-8"), + media_type="application/json; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="mitai-food-knowledge-{stamp}.json"'}, + ) + + +@router.post("/food-knowledge") +async def import_food_knowledge_file( + file: UploadFile = File(...), + x_profile_id: Optional[str] = Header(default=None), + session: dict = Depends(require_auth), +): + import json + from data_layer.food_knowledge import import_food_knowledge, parse_food_knowledge_bundle + + pid = get_pid(x_profile_id) + raw = await file.read() + if not raw: + raise HTTPException(400, "Leere Datei") + try: + data = json.loads(raw.decode("utf-8-sig")) + parse_food_knowledge_bundle(data) + except ValueError as e: + raise HTTPException(400, str(e)) from e + except Exception as e: + raise HTTPException(400, f"Ungültiges JSON: {e}") from e + with get_db() as conn: + return import_food_knowledge(get_cursor(conn), pid, data) + + @router.post("/import-conflicts/resolve") def resolve_import_conflicts( body: dict, diff --git a/backend/tests/test_food_knowledge.py b/backend/tests/test_food_knowledge.py new file mode 100644 index 0000000..caa5188 --- /dev/null +++ b/backend/tests/test_food_knowledge.py @@ -0,0 +1,42 @@ +from data_layer.food_knowledge import ( + BUNDLE_FORMAT, + parse_food_knowledge_bundle, + portable_mapping, +) + + +def test_parse_rejects_unknown_format(): + try: + parse_food_knowledge_bundle({"format": "other", "version": 1}) + except ValueError as e: + assert "Mitai-Zuordnungsdatei" in str(e) + else: + raise AssertionError("expected ValueError") + + +def test_parse_accepts_bundle(): + data = parse_food_knowledge_bundle({ + "format": BUNDLE_FORMAT, + "version": 1, + "mappings": [], + "recipes": [], + }) + assert data["version"] == 1 + + +def test_portable_mapping_drops_ids(): + row = portable_mapping({ + "id": 99, + "food_id": "uuid-here", + "source_system": "fddb", + "source_name_raw": "Haferflocken, Großblatt", + "source_name_normalized": "haferflocken großblatt", + "bls_code": "C131000", + "food_name_de": "Hafer roh", + "catalog_kind": "official_bls", + "external_key": None, + }) + assert "id" not in row + assert "food_id" not in row + assert row["bls_code"] == "C131000" + assert row["source_name_raw"] == "Haferflocken, Großblatt" diff --git a/backend/tests/test_food_mapping.py b/backend/tests/test_food_mapping.py index df7d7bd..8efae34 100644 --- a/backend/tests/test_food_mapping.py +++ b/backend/tests/test_food_mapping.py @@ -1,17 +1,35 @@ from csv_parser.executor import guess_nutrition_item_fields -from data_layer.food_mapping import normalize_food_name, parse_quantity_g +from data_layer.food_mapping import merge_unmapped_rows, normalize_food_name, parse_quantity, parse_quantity_g from data_layer.nutrition_items import macros_differ def test_normalize_strips_leading_quantity(): assert normalize_food_name("50 g Hähnchen") == "hähnchen" assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%" + assert normalize_food_name("1 ml Olivenöl") == normalize_food_name("2 ml Olivenöl") == "olivenöl" + + +def test_merge_unmapped_collapses_quantity_variants(): + rows = merge_unmapped_rows([ + {"source_name_raw": "1 ml Olivenöl", "source_name_normalized": "1 ml olivenöl", "count": 3, "kind": "diary"}, + {"source_name_raw": "2 ml Olivenöl", "source_name_normalized": "2 ml olivenöl", "count": 1, "kind": "diary"}, + ]) + assert len(rows) == 1 + assert rows[0]["source_name_normalized"] == "olivenöl" + assert rows[0]["count"] == 4 + assert rows[0]["source_name_raw"].lower() == "olivenöl" def test_parse_quantity_g(): assert parse_quantity_g("150 g") == 150.0 assert parse_quantity_g("150") == 150.0 + assert parse_quantity_g("1 kg") == 1000.0 + assert parse_quantity_g("5 ml") == 5.0 assert parse_quantity_g("1 Stück") is None + assert parse_quantity_g("1 Stück", 60) == 60.0 + assert parse_quantity_g("2 EL") == 30.0 + assert parse_quantity("1 Scheibe")["needs_unit_map"] is True + assert parse_quantity("2 EL")["unit"] == "el" def test_guess_fddb_bezeichnung_without_template_mapping(): diff --git a/backend/version.py b/backend/version.py index 3fc6955..8894eba 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" # 063 FDDB recipes +DB_SCHEMA_VERSION = "20260912" # 064 mapping units 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.2.0", # FDDB-Listen/Rezepte + Katalog-Suchpopup + "nutrition": "1.2.2", # mapping save + manual food + units "bls": "1.0.1", "photos": "1.0.0", "insights": "1.3.0", @@ -47,6 +47,8 @@ CHANGELOG = [ "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", + "Zuordnungen und Listen als JSON exportieren/importieren (Dev → Prod)", + "Zuordnen: Mapping unabhängig vom Nährwert-Rebuild; eigener Katalogeintrag; Mengeneinheiten", ], }, { diff --git a/docs/issues/issue-bls-food-mapping.md b/docs/issues/issue-bls-food-mapping.md index 41d2586..473ae25 100644 --- a/docs/issues/issue-bls-food-mapping.md +++ b/docs/issues/issue-bls-food-mapping.md @@ -16,3 +16,5 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende - 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 +- Zuordnungen und Listen als JSON zwischen Instanzen übertragen +- Eigener Katalogeintrag im Suchpopup; Gramm pro Stück/EL/… am Mapping diff --git a/frontend/src/components/FoodSearchModal.jsx b/frontend/src/components/FoodSearchModal.jsx index 7bd624e..fcb8d18 100644 --- a/frontend/src/components/FoodSearchModal.jsx +++ b/frontend/src/components/FoodSearchModal.jsx @@ -1,14 +1,53 @@ import { useEffect, useRef, useState } from 'react' import { api } from '../utils/api' -export default function FoodSearchModal({ title, initialQuery, onSelect, onClose }) { +const COUNT_UNITS = new Set(['stück', 'scheibe', 'portion', 'becher', 'tasse']) +const UNIT_LABEL = { + stück: 'Stück', scheibe: 'Scheibe', portion: 'Portion', + becher: 'Becher', tasse: 'Tasse', el: 'EL', tl: 'TL', prise: 'Prise', +} + +function detectUnit(...texts) { + for (const text of texts) { + const m = String(text || '').match( + /(\d+(?:[.,]\d+)?)\s*(stück|stk|st\.?|scheibe|portion(?:en)?|becher|tasse|el|esslöffel|tl|teelöffel|prise)\b/i + ) + if (!m) continue + const raw = m[2].toLowerCase().replace(/\.$/, '') + const unit = raw.startsWith('st') && !raw.startsWith('scheibe') ? 'stück' + : raw.startsWith('ess') || raw === 'el' ? 'el' + : raw.startsWith('tee') || raw === 'tl' ? 'tl' + : raw.startsWith('portion') ? 'portion' + : raw + return unit + } + return null +} + +export default function FoodSearchModal({ title, initialQuery, quantityHint, onSelect, onClose }) { const [q, setQ] = useState(initialQuery || '') const [hits, setHits] = useState([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + const [creating, setCreating] = useState(false) + const [showCreate, setShowCreate] = useState(false) + const [manual, setManual] = useState({ + name_de: initialQuery || '', + kcal: '', protein_g: '', fat_g: '', carbs_g: '', + }) + const suggestedUnit = detectUnit(quantityHint, initialQuery) + const [gramsPerUnit, setGramsPerUnit] = useState( + suggestedUnit === 'el' ? '15' : suggestedUnit === 'tl' ? '5' : '' + ) const inputRef = useRef(null) const timer = useRef(null) + const extras = () => { + const g = parseFloat(String(gramsPerUnit).replace(',', '.')) + if (!suggestedUnit || !g || g <= 0) return {} + return { grams_per_unit: g, source_unit: suggestedUnit } + } + const runSearch = async (term) => { const query = (term || '').trim() if (query.length < 2) { @@ -45,6 +84,34 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose timer.current = setTimeout(() => runSearch(value), 250) } + const createManual = async () => { + const name = (manual.name_de || q || '').trim() + if (!name) { + setError('Name für den Eintrag fehlt') + return + } + setCreating(true) + setError(null) + try { + const food = await api.createUserFood({ + name_de: name, + macros_per_100g: { + kcal: parseFloat(String(manual.kcal).replace(',', '.')) || 0, + protein_g: parseFloat(String(manual.protein_g).replace(',', '.')) || 0, + fat_g: parseFloat(String(manual.fat_g).replace(',', '.')) || 0, + carbs_g: parseFloat(String(manual.carbs_g).replace(',', '.')) || 0, + }, + }) + onSelect(food, extras()) + } catch (e) { + setError(e.message) + } finally { + setCreating(false) + } + } + + const unitLabel = UNIT_LABEL[suggestedUnit] || suggestedUnit + return (
e.stopPropagation()} style={{ - width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 640px)', + width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)', background: 'var(--surface)', borderRadius: 16, boxShadow: '0 8px 32px rgba(0,0,0,0.18)', display: 'flex', flexDirection: 'column', @@ -80,14 +147,30 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose onChange={(e) => onChange(e.target.value)} />

- Suche nach dem Namen. Den BLS-Code brauchst du nicht. + Suche nach dem Namen. Fehlt der Treffer, legst du unten einen eigenen Eintrag an.

+ {suggestedUnit && ( + + )}
{loading &&

Suche…

} {error &&

{error}

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

Kein Treffer. Anderen Suchbegriff versuchen.

+

Kein Treffer im Katalog.

)} {hits.map((h) => ( ))} + +
+ {!showCreate ? ( + + ) : ( +
+

Eigener Eintrag (pro 100 g)

+ setManual({ ...manual, name_de: e.target.value })} + /> +
+ {[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => ( + + ))} +
+ +
+ )} +
diff --git a/frontend/src/components/NutritionFoodMap.jsx b/frontend/src/components/NutritionFoodMap.jsx index acee0a5..7706508 100644 --- a/frontend/src/components/NutritionFoodMap.jsx +++ b/frontend/src/components/NutritionFoodMap.jsx @@ -84,31 +84,43 @@ export default function NutritionFoodMap({ onChanged }) { const [searchFor, setSearchFor] = useState(null) const [recipeFor, setRecipeFor] = useState(null) const [importing, setImporting] = useState(false) + const [busy, setBusy] = useState(false) const listRef = useRef(null) + const bundleRef = useRef(null) + const loadGen = useRef(0) const load = async () => { + const gen = ++loadGen.current try { const [u, m, r] = await Promise.all([ api.listUnmappedFoods(), api.listMyFoodMappings(), api.listNutritionRecipes().catch(() => []), ]) + if (gen !== loadGen.current) return setUnmapped(u) setLearned(m) setRecipes(Array.isArray(r) ? r : []) } catch (e) { + if (gen !== loadGen.current) return setError(e.message) } } useEffect(() => { load() }, []) - const assign = async (sourceName, foodId) => { + const assign = async (sourceName, foodId, extras = {}) => { setSaving(sourceName) setError(null) try { - await api.upsertMyFoodMapping({ source_name: sourceName, food_id: foodId }) + await api.upsertMyFoodMapping({ + source_name: sourceName, + food_id: foodId, + grams_per_unit: extras.grams_per_unit || null, + source_unit: extras.source_unit || null, + }) setSearchFor(null) + setNotice(`Zuordnung gespeichert: ${sourceName}`) await load() onChanged?.() } catch (e) { @@ -144,6 +156,39 @@ export default function NutritionFoodMap({ onChanged }) { } } + const exportBundle = async () => { + setBusy(true) + setError(null) + setNotice(null) + try { + await api.exportFoodKnowledge() + setNotice('Zuordnungen und Listen als JSON heruntergeladen.') + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + + const importBundle = async (file) => { + if (!file) return + setBusy(true) + setError(null) + setNotice(null) + try { + const res = await api.importFoodKnowledge(file) + await load() + onChanged?.() + const skip = res.mappings_skipped ? `, ${res.mappings_skipped} ohne Katalogtreffer` : '' + const lists = (res.inserted || 0) + (res.updated || 0) + setNotice(`${res.mappings || 0} Zuordnungen und ${lists} Listen übernommen${skip}.`) + } catch (e) { + setError(e.message) + } finally { + setBusy(false) + } + } + const importLists = async (file) => { if (!file) return setImporting(true) @@ -167,6 +212,7 @@ export default function NutritionFoodMap({ onChanged }) {

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. + Für den Umzug nach Prod: Zuordnungen und Listen als JSON exportieren und dort wieder importieren.

{error &&
{error}
} {notice &&
{notice}
} @@ -193,6 +239,25 @@ export default function NutritionFoodMap({ onChanged }) { {recipes.length > 0 && (

{recipes.length} eigene Listen geladen

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

Offen ({unmapped.length})

{unmapped.length === 0 &&

Keine offenen Bezeichner.

} @@ -200,9 +265,10 @@ export default function NutritionFoodMap({ onChanged }) { const key = `${u.kind || 'diary'}-${u.source_name_normalized}` return (
-
{u.source_name_raw}
+
{suggestQuery(u.source_name_raw) || u.source_name_raw}
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`} + {u.variant_count > 1 ? ` · ${u.variant_count} Mengen-Varianten` : ''} {u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
@@ -247,6 +313,7 @@ export default function NutritionFoodMap({ onChanged }) {
→ {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''} {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} + {m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
@@ -257,8 +324,9 @@ export default function NutritionFoodMap({ onChanged }) { setSearchFor(null)} - onSelect={(food) => assign(searchFor.source_name_raw, food.id)} + onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)} /> )} {recipeFor && ( diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 7d32500..24a4da7 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -248,6 +248,27 @@ export const api = { return readJsonResponse(r) }, applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })), + exportFoodKnowledge: async () => { + const r = await fetch(`${BASE}/nutrition/food-knowledge`, { headers: hdrs() }) + if (!r.ok) { + const text = await r.text() + throw new Error(text.trim() || `HTTP ${r.status}`) + } + const blob = await r.blob() + const url = window.URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = `mitai-food-knowledge-${new Date().toISOString().split('T')[0]}.json` + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + window.URL.revokeObjectURL(url) + }, + importFoodKnowledge: async (file) => { + const fd = new FormData(); fd.append('file', file) + const r = await fetch(`${BASE}/nutrition/food-knowledge`, { method: 'POST', body: fd, headers: hdrs() }) + return readJsonResponse(r) + }, 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 cb2bcd6..91fd610 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -66,6 +66,7 @@ test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen', await page.getByRole('button', { name: /Zuordnen/i }).click(); await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible(); await expect(page.getByRole('button', { name: /FDDB-Listen/ })).toBeVisible(); + await expect(page.getByRole('button', { name: /Zuordnungen & Listen exportieren/ })).toBeVisible(); }); test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {