feat: FDDB-Listen auflösen und Katalog nach Name suchen
Tagebuchzeilen eigener Rezepte werden über den Listen-Import in Zutaten zerlegt. Zuordnen erfolgt im Namens-Popup statt per BLS-Code. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4b1be3019d
commit
86f15e6957
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
79
backend/bls/recipe_parser.py
Normal file
79
backend/bls/recipe_parser.py
Normal file
|
|
@ -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<qty>\d+(?:[.,]\d+)?)\s*(?P<unit>g|kg|ml|l)\b\s*(?P<name>.+?)"
|
||||
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
|
||||
|
|
@ -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()]
|
||||
|
|
|
|||
192
backend/data_layer/food_recipes.py
Normal file
192
backend/data_layer/food_recipes.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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()]
|
||||
|
|
|
|||
43
backend/migrations/063_fddb_recipes.sql
Normal file
43
backend/migrations/063_fddb_recipes.sql
Normal file
|
|
@ -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 $$;
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
43
backend/tests/test_recipe_parser.py
Normal file
43
backend/tests/test_recipe_parser.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
114
frontend/src/components/FoodSearchModal.jsx
Normal file
114
frontend/src/components/FoodSearchModal.jsx
Normal file
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000,
|
||||
padding: 16,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="food-search-title"
|
||||
onClick={(e) => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h2 id="food-search-title" className="card-title" style={{ margin: 0, fontSize: 16 }}>{title || 'Lebensmittel suchen'}</h2>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
placeholder="Name eingeben, z. B. Haferflocken"
|
||||
value={q}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
|
||||
Suche nach dem Namen. Den BLS-Code brauchst du nicht.
|
||||
</p>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
||||
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche…</p>}
|
||||
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
|
||||
{!loading && q.trim().length >= 2 && hits.length === 0 && (
|
||||
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer. Anderen Suchbegriff versuchen.</p>
|
||||
)}
|
||||
{hits.map((h) => (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
||||
onClick={() => onSelect(h)}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{h.name_de}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{h.bls_code ? `BLS ${h.bls_code}` : 'ohne Code'}
|
||||
{h.food_group ? ` · Gruppe ${h.food_group}` : ''}
|
||||
{h.catalog_kind !== 'official_bls' ? ' · manuell' : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.45)',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 20000, padding: 16,
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
onClick={(e) => 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',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '14px 16px', borderBottom: '1px solid var(--border)' }}>
|
||||
<h2 className="card-title" style={{ margin: 0, fontSize: 16 }}>Eigenes Rezept wählen</h2>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>Schließen</button>
|
||||
</div>
|
||||
<div style={{ padding: '12px 16px' }}>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ width: '100%', textAlign: 'left' }}
|
||||
autoFocus
|
||||
placeholder="Rezeptname filtern"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
|
||||
{filtered.length === 0 && <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein passendes Rezept. Zuerst Listen-CSV importieren.</p>}
|
||||
{filtered.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
|
||||
onClick={() => onPick(r.id)}
|
||||
>
|
||||
<span>
|
||||
<strong style={{ display: 'block' }}>{r.name_raw}</strong>
|
||||
<span style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{(r.ingredients || []).length} Zutaten
|
||||
{r.portions ? ` · ${r.portions} Portionen` : ''}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="card section-gap">
|
||||
<div className="card-title">Lebensmittel zuordnen</div>
|
||||
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
|
||||
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.
|
||||
</p>
|
||||
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>}
|
||||
{notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
|
||||
|
||||
<h3 style={{ fontSize: 14, margin: '12px 0 8px' }}>Offen ({unmapped.length})</h3>
|
||||
{unmapped.length === 0 && <p className="muted">Keine ungemappten Bezeichner.</p>}
|
||||
<input
|
||||
ref={listRef}
|
||||
type="file"
|
||||
accept=".csv,text/csv"
|
||||
style={{ display: 'none' }}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0]
|
||||
e.target.value = ''
|
||||
if (f) importLists(f)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-full"
|
||||
disabled={importing}
|
||||
onClick={() => listRef.current?.click()}
|
||||
>
|
||||
{importing ? 'Importiere Listen…' : 'FDDB-Listen / Rezepte importieren'}
|
||||
</button>
|
||||
{recipes.length > 0 && (
|
||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>
|
||||
)}
|
||||
|
||||
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
|
||||
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
|
||||
{unmapped.map((u) => {
|
||||
const key = u.source_name_normalized
|
||||
const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
|
||||
return (
|
||||
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
|
||||
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
{u.count}× · {u.first_date} – {u.last_date}
|
||||
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`}
|
||||
{u.first_date ? ` · ${u.first_date} – ${u.last_date}` : ''}
|
||||
</div>
|
||||
<input
|
||||
className="form-input"
|
||||
style={{ marginTop: 6 }}
|
||||
placeholder="BLS-Code oder Name suchen…"
|
||||
value={query[key] || ''}
|
||||
onChange={(e) => search(key, e.target.value)}
|
||||
/>
|
||||
{(hits[key] || []).map((h) => (
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ marginTop: 6, marginRight: 6 }}
|
||||
disabled={saving === key}
|
||||
onClick={() => assign(u.source_name_raw, h.id, key)}
|
||||
className="btn btn-primary"
|
||||
disabled={saving === u.source_name_raw}
|
||||
onClick={() => setSearchFor(u)}
|
||||
>
|
||||
{h.bls_code ? `${h.bls_code} · ` : ''}{h.name_de}
|
||||
{h.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
|
||||
Im Katalog suchen
|
||||
</button>
|
||||
))}
|
||||
{u.kind !== 'recipe_ingredient' && u.matching_recipe_id && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={saving === u.source_name_raw}
|
||||
onClick={() => applyRecipe(u.source_name_raw, u.matching_recipe_id)}
|
||||
>
|
||||
Als eigenes Rezept auflösen
|
||||
</button>
|
||||
)}
|
||||
{u.kind !== 'recipe_ingredient' && recipes.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={saving === u.source_name_raw}
|
||||
onClick={() => setRecipeFor(u)}
|
||||
>
|
||||
Eigenes Rezept wählen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
@ -110,13 +245,30 @@ export default function NutritionFoodMap({ onChanged }) {
|
|||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{m.source_name_raw}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--text3)' }}>
|
||||
→ {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)' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{searchFor && (
|
||||
<FoodSearchModal
|
||||
title={`Suchen: ${searchFor.source_name_raw}`}
|
||||
initialQuery={suggestQuery(searchFor.source_name_raw)}
|
||||
onClose={() => setSearchFor(null)}
|
||||
onSelect={(food) => assign(searchFor.source_name_raw, food.id)}
|
||||
/>
|
||||
)}
|
||||
{recipeFor && (
|
||||
<RecipePickModal
|
||||
recipes={recipes}
|
||||
sourceName={recipeFor.source_name_raw}
|
||||
onClose={() => setRecipeFor(null)}
|
||||
onPick={(id) => applyRecipe(recipeFor.source_name_raw, id)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -132,7 +284,7 @@ export function DayMarkButtons({ date, markType, onChanged }) {
|
|||
}
|
||||
}
|
||||
return (
|
||||
<span style={{ display: 'inline-flex', gap: 4 }}>
|
||||
<span style={{ display: 'inline-flex', gap: 8 }}>
|
||||
<button type="button" className="btn btn-secondary" style={{ fontSize: 11, padding: '4px 8px' }} onClick={() => setMark('fasting')}>
|
||||
{markType === 'fasting' ? '✓ Fasten' : 'Fasten'}
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -934,7 +934,7 @@ export default function NutritionPage() {
|
|||
<h1 className="page-title">Ernährung</h1>
|
||||
{unmappedCount > 0 && (
|
||||
<div className="card" style={{ marginBottom: 12, padding: 12, fontSize: 13 }}>
|
||||
{unmappedCount} Lebensmittel noch ohne BLS-Zuordnung.{' '}
|
||||
{unmappedCount} Lebensmittel noch ohne Katalog-Zuordnung.{' '}
|
||||
<button type="button" className="btn btn-secondary" style={{ marginLeft: 8 }} onClick={() => setInputTab('map')}>
|
||||
Jetzt zuordnen
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -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'}),
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user