fix: Zuordnungen zuverlässig speichern und Mengen-Varianten zusammenfassen
All checks were successful
Deploy Development / deploy (push) Successful in 1m4s
Build Test / pytest-backend (push) Successful in 9s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 22s

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 <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-09-12 15:49:14 +02:00
parent 86f15e6957
commit c1873a47b1
17 changed files with 875 additions and 54 deletions

View File

@ -8,12 +8,14 @@ Optionale Grundlage für verlässliche Nährwerte: offizieller Bundeslebensmitte
## Zuordnung (UX) ## 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-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. 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 ## Fachliche Regeln
- BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports. - BLS-Code (`bls_code`, Stoff-`attr_key`) bleibt die stabile Identität bei Reimports.

View File

@ -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 - `/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` - `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 - 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).

View File

@ -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. - **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. - **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 - **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. - **Doku:** `.claude/docs/functional/BLS_FOOD_REFERENCE.md`, `.claude/docs/technical/BLS_FOOD_REFERENCE.md`, `docs/issues/issue-bls-food-mapping.md`. Folge #75.

View File

@ -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,
}

View File

@ -13,24 +13,126 @@ MULTISPACE_RE = re.compile(r"\s+")
DECIMAL_IN_NAME_RE = re.compile(r"(\d),(\d)") 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: if not raw:
return "" return ""
s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'") s = unicodedata.normalize("NFKC", str(raw)).strip().strip('"').strip("'")
s = s.lstrip("!") s = s.lstrip("!")
s = LEADING_QTY_RE.sub("", s) 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 = DECIMAL_IN_NAME_RE.sub(r"\1.\2", s)
s = MULTISPACE_RE.sub(" ", s).strip().lower() return s.lower()
return s
def parse_quantity_g(raw: str | None) -> float | None: def merge_unmapped_rows(rows: list[dict]) -> list[dict]:
if raw is None or str(raw).strip() == "": 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 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(",", ".") text = str(raw).strip().replace(",", ".")
m = re.match(r"^\s*(\d+(?:\.\d+)?)\s*(g|gramm)?\s*$", text, re.IGNORECASE) m = QTY_PARSE_RE.match(text)
if m: if not m:
return round(float(m.group(1)), 3) 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 return None
@ -47,6 +149,7 @@ def get_food_mapping_with_cursor(
cur.execute( cur.execute(
""" """
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, 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 f.bls_code, f.name_de, f.catalog_kind
FROM food_name_mappings m FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id JOIN food_catalog f ON f.id = m.food_id
@ -62,6 +165,7 @@ def get_food_mapping_with_cursor(
cur.execute( cur.execute(
""" """
SELECT m.id AS mapping_id, m.food_id, m.profile_id, m.source, 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 f.bls_code, f.name_de, f.catalog_kind
FROM food_name_mappings m FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id JOIN food_catalog f ON f.id = m.food_id
@ -83,6 +187,8 @@ def upsert_food_mapping(
profile_id: str | None, profile_id: str | None,
source: str = "bulk", source: str = "bulk",
source_system: str = "fddb", source_system: str = "fddb",
grams_per_unit: float | None = None,
source_unit: str | None = None,
) -> int: ) -> int:
norm = normalize_food_name(source_name_raw) norm = normalize_food_name(source_name_raw)
if not norm: if not norm:
@ -105,28 +211,55 @@ def upsert_food_mapping(
) )
existing = cur.fetchone() existing = cur.fetchone()
raw = source_name_raw.strip() raw = source_name_raw.strip()
unit = _canon_unit(source_unit) if source_unit else None
if existing: if existing:
cur.execute( cur.execute(
""" """
UPDATE food_name_mappings 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 WHERE id = %s
""", """,
(food_id, raw, source, existing["id"]), (food_id, raw, source, grams_per_unit, unit, existing["id"]),
) )
return int(existing["id"]) return int(existing["id"])
cur.execute( cur.execute(
""" """
INSERT INTO food_name_mappings INSERT INTO food_name_mappings
(source_system, source_name_raw, source_name_normalized, food_id, profile_id, source, updated_at) (source_system, source_name_raw, source_name_normalized, food_id, profile_id,
VALUES (%s, %s, %s, %s, %s, %s, NOW()) source, grams_per_unit, source_unit, updated_at)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW())
RETURNING id 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"]) 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: 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) origin = _value_origin_for_food(cur, food_id)
cur.execute( 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), (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: def clear_mapping_from_items(cur, profile_id: str, source_name_normalized: str) -> int:

View File

@ -311,7 +311,10 @@ def replace_csv_items_for_dates(
if not name: if not name:
continue continue
qty_raw = raw.get("quantity_raw") 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) mapping = get_food_mapping_with_cursor(cur, name, profile_id)
logged_at = raw.get("logged_at") logged_at = raw.get("logged_at")
cur.execute( cur.execute(

View File

@ -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 $$;

View File

@ -1,14 +1,16 @@
"""Authenticated catalog search and user-owned foods / mappings.""" """Authenticated catalog search and user-owned foods / mappings."""
from __future__ import annotations from __future__ import annotations
import logging
from typing import Optional from typing import Optional
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel from pydantic import BaseModel
from auth import require_auth from auth import require_auth
from data_layer.food_mapping import ( from data_layer.food_mapping import (
apply_mapping_to_items, apply_mapping_to_items,
apply_quantities_to_items,
clear_mapping_from_items, clear_mapping_from_items,
normalize_food_name, normalize_food_name,
suggest_catalog_foods, 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 db import get_cursor, get_db, r2d
from routers.profiles import get_pid from routers.profiles import get_pid
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/bls", tags=["bls"]) router = APIRouter(prefix="/api/bls", tags=["bls"])
@ -31,6 +35,17 @@ class MappingUpsert(BaseModel):
source_name: str source_name: str
food_id: str food_id: str
source_system: str = "fddb" 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") @router.get("/foods")
@ -46,10 +61,15 @@ def search_foods(
@router.post("/foods/manual") @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 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() name = (body.name_de or "").strip()
if not name: if not name:
raise HTTPException(400, "Name fehlt") 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) VALUES (%s, %s, 'manual_user', %s, 'manual', %s)
RETURNING * 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()) food = r2d(cur.fetchone())
_write_manual_macros(cur, food["id"], body.macros_per_100g) _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") @router.get("/mappings")
def list_my_mappings(session: dict = Depends(require_auth)): def list_my_mappings(
pid = session["profile_id"] x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth),
):
pid = _pid(session, x_profile_id)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
""" """
SELECT m.id, m.source_name_raw, m.source_name_normalized, m.food_id, m.source, 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 f.name_de AS food_name_de, f.bls_code, f.catalog_kind
FROM food_name_mappings m FROM food_name_mappings m
JOIN food_catalog f ON f.id = m.food_id 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") @router.post("/mappings")
def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)): def upsert_my_mapping(
pid = session["profile_id"] 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
@ -110,17 +138,28 @@ def upsert_my_mapping(body: MappingUpsert, session: dict = Depends(require_auth)
profile_id=pid, profile_id=pid,
source="bulk", source="bulk",
source_system=body.source_system, source_system=body.source_system,
grams_per_unit=body.grams_per_unit,
source_unit=body.source_unit,
) )
norm = normalize_food_name(body.source_name) norm = normalize_food_name(body.source_name)
n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid) n = apply_mapping_to_items(cur, pid, norm, body.food_id, mid)
for d in dates_for_normalized_name(cur, pid, norm): apply_quantities_to_items(cur, pid, norm, body.grams_per_unit)
rebuild_daily_nutrients(cur, pid, d) 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} return {"mapping_id": mid, "items_updated": n, "source_name_normalized": norm}
@router.delete("/mappings/{mapping_id}") @router.delete("/mappings/{mapping_id}")
def delete_my_mapping(mapping_id: int, session: dict = Depends(require_auth)): def delete_my_mapping(
pid = session["profile_id"] 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( 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) dates = dates_for_normalized_name(cur, pid, norm)
clear_mapping_from_items(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)) cur.execute("DELETE FROM food_name_mappings WHERE id = %s AND profile_id = %s", (mapping_id, pid))
for d in dates: try:
rebuild_daily_nutrients(cur, pid, d) 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} return {"ok": True}

View File

@ -11,6 +11,7 @@ from typing import Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends from fastapi import APIRouter, HTTPException, UploadFile, File, Header, Depends
from fastapi.responses import Response
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from auth import require_auth, check_feature_access, increment_feature_usage 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), x_profile_id: Optional[str] = Header(default=None),
session: dict = Depends(require_auth), 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: with get_db() as conn:
cur = get_cursor(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( cur.execute(
""" """
SELECT i.source_name_raw, i.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, 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 FROM nutrition_items i
LEFT JOIN food_recipes r LEFT JOIN food_recipes r
ON r.profile_id = i.profile_id AND r.name_normalized = i.source_name_normalized 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( cur.execute(
""" """
SELECT i.source_name_raw, i.source_name_normalized, 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 FROM food_recipe_ingredients i
JOIN food_recipes r ON r.id = i.recipe_id JOIN food_recipes r ON r.id = i.recipe_id
LEFT JOIN food_name_mappings m LEFT JOIN food_name_mappings m
@ -383,13 +397,15 @@ def list_unmapped_foods(
(pid,), (pid,),
) )
ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()] ings = [r2d(r) | {"kind": "recipe_ingredient"} for r in cur.fetchall()]
seen = {d["source_name_normalized"] for d in diary} merged = merge_unmapped_rows(diary + ings)
for ing in ings: out = []
if ing["source_name_normalized"] not in seen: for row in merged:
diary.append(ing) key = row.get("source_name_normalized") or normalize_food_name(row.get("source_name_raw"))
seen.add(ing["source_name_normalized"]) if key in mapped:
diary.sort(key=lambda x: (-int(x.get("count") or 0), x.get("source_name_normalized") or "")) continue
return diary 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") @router.get("/recipes")
@ -459,6 +475,50 @@ def apply_recipe_name(
return {"ok": True, "items_updated": n} 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") @router.post("/import-conflicts/resolve")
def resolve_import_conflicts( def resolve_import_conflicts(
body: dict, body: dict,

View File

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

View File

@ -1,17 +1,35 @@
from csv_parser.executor import guess_nutrition_item_fields 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 from data_layer.nutrition_items import macros_differ
def test_normalize_strips_leading_quantity(): def test_normalize_strips_leading_quantity():
assert normalize_food_name("50 g Hähnchen") == "hähnchen" assert normalize_food_name("50 g Hähnchen") == "hähnchen"
assert normalize_food_name(" Vollmilch 3,5% ") == "vollmilch 3.5%" 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(): def test_parse_quantity_g():
assert parse_quantity_g("150 g") == 150.0 assert parse_quantity_g("150 g") == 150.0
assert parse_quantity_g("150") == 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") 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(): def test_guess_fddb_bezeichnung_without_template_mapping():

View File

@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH
APP_VERSION = "0.9v" APP_VERSION = "0.9v"
BUILD_DATE = "2026-09-12" BUILD_DATE = "2026-09-12"
DB_SCHEMA_VERSION = "20260912" # 063 FDDB recipes DB_SCHEMA_VERSION = "20260912" # 064 mapping units
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.0", "auth": "1.2.0",
@ -20,7 +20,7 @@ MODULE_VERSIONS = {
"circumference": "1.0.1", "circumference": "1.0.1",
"caliper": "1.0.1", "caliper": "1.0.1",
"activity": "1.2.1", # Legacy CSV import: activity_entries feature enforcement "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", "bls": "1.0.1",
"photos": "1.0.0", "photos": "1.0.0",
"insights": "1.3.0", "insights": "1.3.0",
@ -47,6 +47,8 @@ CHANGELOG = [
"BLS-Import als Hintergrundjob (kein Proxy-504)", "BLS-Import als Hintergrundjob (kein Proxy-504)",
"Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code", "Zuordnen: Katalog-Suche nach Name (Popup), nicht nach BLS-Code",
"FDDB-Listen/Rezepte importieren und Tagebuchzeilen in Zutaten auflösen", "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",
], ],
}, },
{ {

View File

@ -16,3 +16,5 @@ Verlässliche Lebensmittel-Stammdaten (BLS 4.0 + manuelle Erweiterung), lernende
- Einzelerfassung nur Makros unverändert - Einzelerfassung nur Makros unverändert
- Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer) - Zuordnen über **Namenssuche im Popup** (kein BLS-Code-Lookup durch den Nutzer)
- FDDB-Listen-CSV importieren; Tagebuch-Rezeptzeilen in Zutaten auflösen - 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

View File

@ -1,14 +1,53 @@
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { api } from '../utils/api' 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 [q, setQ] = useState(initialQuery || '')
const [hits, setHits] = useState([]) const [hits, setHits] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState(null) 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 inputRef = useRef(null)
const timer = 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 runSearch = async (term) => {
const query = (term || '').trim() const query = (term || '').trim()
if (query.length < 2) { if (query.length < 2) {
@ -45,6 +84,34 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
timer.current = setTimeout(() => runSearch(value), 250) 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 ( return (
<div <div
style={{ style={{
@ -60,7 +127,7 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
aria-labelledby="food-search-title" aria-labelledby="food-search-title"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 640px)', width: '100%', maxWidth: 520, maxHeight: 'min(88vh, 720px)',
background: 'var(--surface)', borderRadius: 16, background: 'var(--surface)', borderRadius: 16,
boxShadow: '0 8px 32px rgba(0,0,0,0.18)', boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
display: 'flex', flexDirection: 'column', display: 'flex', flexDirection: 'column',
@ -80,14 +147,30 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
/> />
<p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}> <p style={{ fontSize: 12, color: 'var(--text3)', margin: '8px 0 0' }}>
Suche nach dem Namen. Den BLS-Code brauchst du nicht. Suche nach dem Namen. Fehlt der Treffer, legst du unten einen eigenen Eintrag an.
</p> </p>
{suggestedUnit && (
<label style={{ display: 'block', marginTop: 10, fontSize: 13 }}>
1 {unitLabel} ={' '}
<input
className="form-input"
type="number"
min="0.1"
step="0.1"
style={{ width: 90, display: 'inline-block', textAlign: 'left' }}
value={gramsPerUnit}
onChange={(e) => setGramsPerUnit(e.target.value)}
placeholder={COUNT_UNITS.has(suggestedUnit) ? 'z. B. 60' : ''}
/>
{' '}g
</label>
)}
</div> </div>
<div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}> <div style={{ overflowY: 'auto', padding: '0 16px 16px', flex: 1 }}>
{loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche</p>} {loading && <p style={{ fontSize: 13, color: 'var(--text2)' }}>Suche</p>}
{error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>} {error && <p style={{ color: 'var(--danger)', fontSize: 13 }}>{error}</p>}
{!loading && q.trim().length >= 2 && hits.length === 0 && ( {!loading && q.trim().length >= 2 && hits.length === 0 && (
<p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer. Anderen Suchbegriff versuchen.</p> <p style={{ fontSize: 13, color: 'var(--text3)' }}>Kein Treffer im Katalog.</p>
)} )}
{hits.map((h) => ( {hits.map((h) => (
<button <button
@ -95,7 +178,7 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
type="button" type="button"
className="btn btn-secondary btn-full" className="btn btn-secondary btn-full"
style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }} style={{ marginTop: 8, justifyContent: 'flex-start', textAlign: 'left', height: 'auto', padding: '10px 12px' }}
onClick={() => onSelect(h)} onClick={() => onSelect(h, extras())}
> >
<span> <span>
<strong style={{ display: 'block' }}>{h.name_de}</strong> <strong style={{ display: 'block' }}>{h.name_de}</strong>
@ -107,6 +190,53 @@ export default function FoodSearchModal({ title, initialQuery, onSelect, onClose
</span> </span>
</button> </button>
))} ))}
<div style={{ marginTop: 16, paddingTop: 12, borderTop: '1px solid var(--border)' }}>
{!showCreate ? (
<button type="button" className="btn btn-secondary btn-full" onClick={() => {
setShowCreate(true)
setManual((s) => ({ ...s, name_de: s.name_de || q || initialQuery || '' }))
}}>
Eigenes Lebensmittel anlegen
</button>
) : (
<div>
<p style={{ fontSize: 13, fontWeight: 600, marginBottom: 8 }}>Eigener Eintrag (pro 100 g)</p>
<input
className="form-input"
style={{ width: '100%', textAlign: 'left', marginBottom: 8 }}
placeholder="Name"
value={manual.name_de}
onChange={(e) => setManual({ ...manual, name_de: e.target.value })}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8 }}>
{[['kcal', 'kcal'], ['protein_g', 'Protein g'], ['fat_g', 'Fett g'], ['carbs_g', 'Kohlenhydrate g']].map(([key, label]) => (
<label key={key} style={{ fontSize: 12, color: 'var(--text2)' }}>
{label}
<input
className="form-input"
type="number"
min="0"
step="0.1"
style={{ width: '100%', textAlign: 'left', marginTop: 4 }}
value={manual[key]}
onChange={(e) => setManual({ ...manual, [key]: e.target.value })}
/>
</label>
))}
</div>
<button
type="button"
className="btn btn-primary btn-full"
style={{ marginTop: 10 }}
disabled={creating}
onClick={createManual}
>
{creating ? 'Speichere…' : 'Anlegen und zuordnen'}
</button>
</div>
)}
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -84,31 +84,43 @@ export default function NutritionFoodMap({ onChanged }) {
const [searchFor, setSearchFor] = useState(null) const [searchFor, setSearchFor] = useState(null)
const [recipeFor, setRecipeFor] = useState(null) const [recipeFor, setRecipeFor] = useState(null)
const [importing, setImporting] = useState(false) const [importing, setImporting] = useState(false)
const [busy, setBusy] = useState(false)
const listRef = useRef(null) const listRef = useRef(null)
const bundleRef = useRef(null)
const loadGen = useRef(0)
const load = async () => { const load = async () => {
const gen = ++loadGen.current
try { try {
const [u, m, r] = await Promise.all([ const [u, m, r] = await Promise.all([
api.listUnmappedFoods(), api.listUnmappedFoods(),
api.listMyFoodMappings(), api.listMyFoodMappings(),
api.listNutritionRecipes().catch(() => []), api.listNutritionRecipes().catch(() => []),
]) ])
if (gen !== loadGen.current) return
setUnmapped(u) setUnmapped(u)
setLearned(m) setLearned(m)
setRecipes(Array.isArray(r) ? r : []) setRecipes(Array.isArray(r) ? r : [])
} catch (e) { } catch (e) {
if (gen !== loadGen.current) return
setError(e.message) setError(e.message)
} }
} }
useEffect(() => { load() }, []) useEffect(() => { load() }, [])
const assign = async (sourceName, foodId) => { const assign = async (sourceName, foodId, extras = {}) => {
setSaving(sourceName) setSaving(sourceName)
setError(null) setError(null)
try { 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) setSearchFor(null)
setNotice(`Zuordnung gespeichert: ${sourceName}`)
await load() await load()
onChanged?.() onChanged?.()
} catch (e) { } 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) => { const importLists = async (file) => {
if (!file) return if (!file) return
setImporting(true) setImporting(true)
@ -167,6 +212,7 @@ export default function NutritionFoodMap({ onChanged }) {
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}> <p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 12 }}>
Tippe auf Im Katalog suchen du suchst nach dem Namen, nicht nach einem Code. 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. 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.
</p> </p>
{error && <div style={{ color: 'var(--danger)', fontSize: 13, marginBottom: 10 }}>{error}</div>} {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>} {notice && <div style={{ fontSize: 13, color: 'var(--accent-dark)', marginBottom: 10 }}>{notice}</div>}
@ -193,6 +239,25 @@ export default function NutritionFoodMap({ onChanged }) {
{recipes.length > 0 && ( {recipes.length > 0 && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p> <p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8 }}>{recipes.length} eigene Listen geladen</p>
)} )}
<input
ref={bundleRef}
type="file"
accept=".json,application/json"
style={{ display: 'none' }}
onChange={(e) => {
const f = e.target.files?.[0]
e.target.value = ''
if (f) importBundle(f)
}}
/>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={exportBundle}>
Zuordnungen & Listen exportieren
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => bundleRef.current?.click()}>
Zuordnungen & Listen importieren
</button>
</div>
<h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3> <h3 style={{ fontSize: 14, margin: '16px 0 8px' }}>Offen ({unmapped.length})</h3>
{unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>} {unmapped.length === 0 && <p className="muted">Keine offenen Bezeichner.</p>}
@ -200,9 +265,10 @@ export default function NutritionFoodMap({ onChanged }) {
const key = `${u.kind || 'diary'}-${u.source_name_normalized}` const key = `${u.kind || 'diary'}-${u.source_name_normalized}`
return ( return (
<div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}> <div key={key} style={{ borderTop: '1px solid var(--border)', padding: '10px 0' }}>
<div style={{ fontWeight: 600 }}>{u.source_name_raw}</div> <div style={{ fontWeight: 600 }}>{suggestQuery(u.source_name_raw) || u.source_name_raw}</div>
<div style={{ fontSize: 12, color: 'var(--text3)' }}> <div style={{ fontSize: 12, color: 'var(--text3)' }}>
{u.kind === 'recipe_ingredient' ? 'Rezeptzutat' : `${u.count}×`} {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}` : ''} {u.first_date ? ` · ${u.first_date} ${u.last_date}` : ''}
</div> </div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}> <div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, marginTop: 8 }}>
@ -247,6 +313,7 @@ export default function NutritionFoodMap({ onChanged }) {
<div style={{ fontSize: 12, color: 'var(--text3)' }}> <div style={{ fontSize: 12, color: 'var(--text3)' }}>
{m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''} {m.food_name_de}{m.bls_code ? ` · ${m.bls_code}` : ''}
{m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''} {m.catalog_kind !== 'official_bls' ? ' (manuell)' : ''}
{m.grams_per_unit ? ` · 1 ${m.source_unit || 'Einheit'} = ${m.grams_per_unit} g` : ''}
</div> </div>
</div> </div>
<button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button> <button type="button" className="btn btn-secondary" onClick={() => remove(m.id)}>Löschen</button>
@ -257,8 +324,9 @@ export default function NutritionFoodMap({ onChanged }) {
<FoodSearchModal <FoodSearchModal
title={`Suchen: ${searchFor.source_name_raw}`} title={`Suchen: ${searchFor.source_name_raw}`}
initialQuery={suggestQuery(searchFor.source_name_raw)} initialQuery={suggestQuery(searchFor.source_name_raw)}
quantityHint={searchFor.sample_quantity_raw || searchFor.source_name_raw}
onClose={() => setSearchFor(null)} onClose={() => setSearchFor(null)}
onSelect={(food) => assign(searchFor.source_name_raw, food.id)} onSelect={(food, extras) => assign(searchFor.source_name_raw, food.id, extras)}
/> />
)} )}
{recipeFor && ( {recipeFor && (

View File

@ -248,6 +248,27 @@ export const api = {
return readJsonResponse(r) return readJsonResponse(r)
}, },
applyNutritionRecipe: (recipeId, sourceName) => req(`/nutrition/recipes/${recipeId}/apply`, json({ source_name: sourceName })), 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'), listNutritionMarks: () => req('/nutrition/marks'),
putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)), putNutritionDayMark: (date, d) => req(`/nutrition/days/${date}/mark`, jput(d)),
deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}), deleteNutritionDayMark: (date) => req(`/nutrition/days/${date}/mark`, {method:'DELETE'}),

View File

@ -66,6 +66,7 @@ test('FEATURE: Ernährung — Einzelerfassung, Import-Policy-Hinweis, Zuordnen',
await page.getByRole('button', { name: /Zuordnen/i }).click(); await page.getByRole('button', { name: /Zuordnen/i }).click();
await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible(); await expect(page.getByText(/Lebensmittel zuordnen/)).toBeVisible();
await expect(page.getByRole('button', { name: /FDDB-Listen/ })).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 }) => { test('FEATURE: Settings — Ernährungs-Import-Policy', async ({ page }) => {